food-market/src/food-market.api/Controllers/MeSessionsController.cs
nns 8e54e2e0d6 feat(s13): security headers + rate-limits + sensitive-ops audit + session revoke + Grafana
Sprint 13 — security + observability deep. 7 пунктов чек-листа ✓.

Подробности — docs/sprint13-progress.md и docs/food-market-server-postgres-role.md.

Главное:
- food-market-server (back.food-market.kz, legacy backend) теперь
  работает на dedicated PG-роли food_market_server_app (NOSUPERUSER /
  NOCREATEDB / NOCREATEROLE / NOREPLICATION / NOBYPASSRLS) с CRUD-only
  грантами. Раньше использовался postgres-superuser с паролем 1q2w3e4r.
  Бэкап конфига сохранён, rollback одной командой.
- SecurityHeadersMiddleware навешивает CSP / X-Frame-Options DENY /
  X-Content-Type-Options nosniff / Referrer-Policy strict-origin /
  Permissions-Policy. HSTS 365d + includeSubDomains + preload.
  Те же заголовки в deploy/nginx.conf для SPA HTML.
- Rate-limit:
  • Signup-IP — 3/час + 10/день (на stage'е переопределено через
    .env RATE_SIGNUP_HOUR=30 чтобы не ломать e2e).
  • Forgot-password — per-email 3/час + per-IP 10/час.
- SensitiveOpsAudit сервис, wired в:
  • TwoFactor enroll/disable
  • Employees.Update при смене RoleId (action=AssignRole,
    payload с prev/next role + полный RolePermissions)
  • MeAccount.ChangePassword (новый endpoint)
  • MeSessions.RevokeAll (новый endpoint)
- POST /api/me/sessions/revoke-all — через
  IOpenIddictAuthorizationManager.FindBySubjectAsync + TryRevokeAsync.
  Integration-тест: refresh после revoke → 400/401.
- Hangfire dashboard — nginx-route добавлен (раньше /hangfire ловился
  SPA-fallback'ом). Фильтр SuperAdmin'ом уже был. Тест: anon/tenant →
  401/403/404.
- Grafana dashboard JSON (deploy/grafana/dashboards/food-market.json,
  9 панелей) + инструкции импорта в docs/observability.md.

Проверено на stage'е: все 6 security-заголовков видны на /;
/hangfire → 401 (закрыт); 4-я форгот → 429; stage-smoke (5 этапов) ✓.

Тесты: 68 unit + 9 integration (включая 3 новых: SessionRevokeTests,
HangfireAccessTests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 12:30:10 +05:00

117 lines
5.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Security.Claims;
using foodmarket.Infrastructure.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
namespace foodmarket.Api.Controllers;
/// <summary>Sprint 13 — управление сессиями текущего пользователя.
/// Сейчас один endpoint: <c>POST /api/me/sessions/revoke-all</c>
/// гасит все живые refresh-токены и authorizations, выданные этому
/// юзеру через OpenIddict. После вызова все его клиенты (web, POS,
/// мобайл) при первом же refresh получают 400 и форсированно
/// разлогиниваются.
///
/// <para>Поток: фронт делает запрос с текущим access-токеном
/// (Authorization header), сервер вытаскивает sub claim, идёт в
/// OpenIddict authorization-manager, ставит каждой авторизации
/// status=revoked. Все привязанные к ним токены наследуют статус.</para>
///
/// <para>Текущий access-токен остаётся валидным до своей expiration
/// (по умолчанию 1 час, см. SetAccessTokenLifetime). Это компромисс:
/// дёргать «прямо сейчас все access» через OpenIddict
/// введения - сложнее и требует нестандартного хука. На практике
/// 1 час с момента revoke-all допустим — за это время атакующий
/// уже не может получить новый refresh.</para>
///
/// <para>Audit: пишет SensitiveOpsAudit с action="RevokeAllSessions"
/// и количеством погашенных authorizations в payload'е.</para></summary>
[ApiController]
[Authorize]
[Route("api/me/sessions")]
public class MeSessionsController : ControllerBase
{
private readonly UserManager<User> _users;
private readonly IOpenIddictAuthorizationManager _authMgr;
private readonly IOpenIddictTokenManager _tokenMgr;
private readonly foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit _audit;
private readonly ILogger<MeSessionsController> _log;
public MeSessionsController(
UserManager<User> users,
IOpenIddictAuthorizationManager authMgr,
IOpenIddictTokenManager tokenMgr,
foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit audit,
ILogger<MeSessionsController> log)
{
_users = users;
_authMgr = authMgr;
_tokenMgr = tokenMgr;
_audit = audit;
_log = log;
}
public record RevokeAllResult(int RevokedAuthorizations, int RevokedTokens);
/// <summary>Гасит все refresh-токены текущего юзера. Использовать когда
/// есть подозрение на угон cookies/пароля.</summary>
[HttpPost("revoke-all")]
public async Task<ActionResult<RevokeAllResult>> RevokeAll(CancellationToken ct)
{
var sub = User.FindFirstValue(OpenIddictConstants.Claims.Subject)
?? User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(sub))
return Unauthorized();
int revokedAuth = 0, revokedTok = 0;
// OpenIddict хранит authorization (один на пару user+client) и
// tokens (refresh / access). При revoke authorization все его
// tokens получают статус "revoked" → следующий /connect/token с
// refresh вернёт invalid_grant.
await foreach (var auth in _authMgr.FindBySubjectAsync(sub, ct))
{
try
{
await _authMgr.TryRevokeAsync(auth, ct);
revokedAuth++;
}
catch (Exception ex)
{
_log.LogWarning(ex, "RevokeAll: failed to revoke authorization for sub={Sub}", sub);
}
}
// Дополнительно ревочим каждый token явно: authorizations cascade
// помечает их revoked, но повторный TryRevoke безопасен (idempotent)
// и страхует если authorization-revoke не сработал по какой-то причине.
await foreach (var token in _tokenMgr.FindBySubjectAsync(sub, ct))
{
try
{
await _tokenMgr.TryRevokeAsync(token, ct);
revokedTok++;
}
catch (Exception ex)
{
_log.LogWarning(ex, "RevokeAll: failed to revoke token for sub={Sub}", sub);
}
}
await _audit.LogAsync(
action: "RevokeAllSessions",
entityType: "AppUser",
entityId: Guid.TryParse(sub, out var uid) ? uid : null,
payload: new { revokedAuthorizations = revokedAuth, revokedTokens = revokedTok },
ct: ct);
_log.LogInformation(
"RevokeAllSessions: sub={Sub} authorizations={Auth} tokens={Tokens}",
sub, revokedAuth, revokedTok);
return Ok(new RevokeAllResult(revokedAuth, revokedTok));
}
}