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>
117 lines
5.2 KiB
C#
117 lines
5.2 KiB
C#
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));
|
||
}
|
||
}
|