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; /// Sprint 13 — управление сессиями текущего пользователя. /// Сейчас один endpoint: POST /api/me/sessions/revoke-all /// гасит все живые refresh-токены и authorizations, выданные этому /// юзеру через OpenIddict. После вызова все его клиенты (web, POS, /// мобайл) при первом же refresh получают 400 и форсированно /// разлогиниваются. /// /// Поток: фронт делает запрос с текущим access-токеном /// (Authorization header), сервер вытаскивает sub claim, идёт в /// OpenIddict authorization-manager, ставит каждой авторизации /// status=revoked. Все привязанные к ним токены наследуют статус. /// /// Текущий access-токен остаётся валидным до своей expiration /// (по умолчанию 1 час, см. SetAccessTokenLifetime). Это компромисс: /// дёргать «прямо сейчас все access» через OpenIddict /// введения - сложнее и требует нестандартного хука. На практике /// 1 час с момента revoke-all допустим — за это время атакующий /// уже не может получить новый refresh. /// /// Audit: пишет SensitiveOpsAudit с action="RevokeAllSessions" /// и количеством погашенных authorizations в payload'е. [ApiController] [Authorize] [Route("api/me/sessions")] public class MeSessionsController : ControllerBase { private readonly UserManager _users; private readonly IOpenIddictAuthorizationManager _authMgr; private readonly IOpenIddictTokenManager _tokenMgr; private readonly foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit _audit; private readonly ILogger _log; public MeSessionsController( UserManager users, IOpenIddictAuthorizationManager authMgr, IOpenIddictTokenManager tokenMgr, foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit audit, ILogger log) { _users = users; _authMgr = authMgr; _tokenMgr = tokenMgr; _audit = audit; _log = log; } public record RevokeAllResult(int RevokedAuthorizations, int RevokedTokens); /// Гасит все refresh-токены текущего юзера. Использовать когда /// есть подозрение на угон cookies/пароля. [HttpPost("revoke-all")] public async Task> 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)); } }