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));
}
}