mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 09:31:08 +00:00
[AZ-535] [AZ-533] Logout/revocation surface + UAV mission tokens
AZ-535: POST /logout (caller's session), /logout/all (all sessions for user),
admin POST /sessions/{sid}/revoke, and verifier-only GET /sessions/revoked
snapshot. New Service role gates the snapshot. Idempotent revoke; reason +
revoked_by_user_id audited per row.
AZ-533: POST /sessions/mission mints a long-lived no-refresh ES256 token bound
to one aircraft + one mission. Audience narrowed to satellite-provider, hard
12 h cap, persisted as class='mission' so the existing logout/revoke surface
covers it. Successful CompanionPC /login or /token/refresh auto-revokes that
aircraft's open mission session (post-flight reconnect).
Schema: 09_sessions_logout_and_mission.sql adds revoked_by_user_id, class,
aircraft_id; drops NOT NULL on refresh_hash for mission rows; adds two partial
indexes for the auto-revoke and snapshot hot paths.
Tests: 13 new e2e tests, all green; full suite 75/76 (1 pre-existing flake in
PasswordHashingTests AC5 timing assertion, unrelated to this batch).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+107
-1
@@ -1,3 +1,4 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Threading.RateLimiting;
|
||||
using Azaion.Common;
|
||||
using Azaion.Common.Configs;
|
||||
@@ -85,9 +86,16 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
var apiAdminPolicy = new AuthorizationPolicyBuilder()
|
||||
.RequireRole(RoleEnum.ApiAdmin.ToString()).Build();
|
||||
|
||||
// AZ-535 — verifiers (satellite-provider, gps-denied, ui) authenticate as
|
||||
// service-role identities and are the only callers (besides ApiAdmin) allowed
|
||||
// to read the global revocation snapshot.
|
||||
var revocationReaderPolicy = new AuthorizationPolicyBuilder()
|
||||
.RequireRole(RoleEnum.Service.ToString(), RoleEnum.ApiAdmin.ToString()).Build();
|
||||
|
||||
builder.Services.AddAuthorization(o =>
|
||||
{
|
||||
o.AddPolicy(nameof(apiAdminPolicy), apiAdminPolicy);
|
||||
o.AddPolicy(nameof(apiAdminPolicy), apiAdminPolicy);
|
||||
o.AddPolicy(nameof(revocationReaderPolicy), revocationReaderPolicy);
|
||||
});
|
||||
|
||||
#endregion Policies
|
||||
@@ -131,6 +139,8 @@ builder.Services.AddSingleton<IJwtSigningKeyProvider>(jwtSigningKeyProvider);
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
||||
builder.Services.AddScoped<ISessionService, SessionService>();
|
||||
builder.Services.AddScoped<IMissionTokenService, MissionTokenService>();
|
||||
builder.Services.AddScoped<IResourcesService, ResourcesService>();
|
||||
builder.Services.AddScoped<IDetectionClassService, DetectionClassService>();
|
||||
builder.Services.AddScoped<IAuditLog, AuditLog>();
|
||||
@@ -258,11 +268,18 @@ app.MapPost("/login",
|
||||
IUserService userService,
|
||||
IAuthService authService,
|
||||
IRefreshTokenService refreshTokens,
|
||||
ISessionService sessionService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var user = await userService.ValidateUser(request, ct: cancellationToken);
|
||||
var (refreshToken, session) = await refreshTokens.IssueForNewLogin(user.Id, cancellationToken);
|
||||
var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid());
|
||||
|
||||
// AZ-533 AC-4 — post-flight reconnect: if the just-authenticated user is an
|
||||
// aircraft (CompanionPC), kill any open mission session bound to it.
|
||||
if (user.Role == RoleEnum.CompanionPC)
|
||||
await sessionService.RevokeMissionsForAircraft(user.Id, cancellationToken);
|
||||
|
||||
return Results.Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = access.Jwt,
|
||||
@@ -281,12 +298,18 @@ app.MapPost("/token/refresh",
|
||||
IRefreshTokenService refreshTokens,
|
||||
IUserService userService,
|
||||
IAuthService authService,
|
||||
ISessionService sessionService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var (newRefresh, session) = await refreshTokens.Rotate(request.RefreshToken, cancellationToken);
|
||||
var user = await userService.GetById(session.UserId, cancellationToken);
|
||||
if (user == null) throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
|
||||
var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid());
|
||||
|
||||
// AZ-533 AC-4 — same auto-revoke trigger as /login.
|
||||
if (user.Role == RoleEnum.CompanionPC)
|
||||
await sessionService.RevokeMissionsForAircraft(user.Id, cancellationToken);
|
||||
|
||||
return Results.Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = access.Jwt,
|
||||
@@ -298,6 +321,89 @@ app.MapPost("/token/refresh",
|
||||
.AllowAnonymous()
|
||||
.WithSummary("Rotate a refresh token; returns a fresh access + refresh pair");
|
||||
|
||||
// AZ-535 — logout: revoke the caller's current session (the sid claim on their
|
||||
// access token). Idempotent.
|
||||
app.MapPost("/logout",
|
||||
async (HttpContext http, ISessionService sessions, CancellationToken ct) =>
|
||||
{
|
||||
var sid = ParseSidClaim(http.User);
|
||||
var caller = ParseUserIdClaim(http.User);
|
||||
var alreadyRevoked = await sessions.RevokeBySid(sid, caller, SessionRevokedReasons.LoggedOut, ct);
|
||||
return Results.Ok(new { alreadyRevoked });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithSummary("AZ-535 — revoke the caller's current session");
|
||||
|
||||
// AZ-535 AC-2 — sign out everywhere: revoke every active session for the caller.
|
||||
app.MapPost("/logout/all",
|
||||
async (HttpContext http, ISessionService sessions, CancellationToken ct) =>
|
||||
{
|
||||
var caller = ParseUserIdClaim(http.User);
|
||||
var revoked = await sessions.RevokeAllForUser(caller, caller, SessionRevokedReasons.LoggedOutAll, ct);
|
||||
return Results.Ok(new { revoked });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithSummary("AZ-535 — revoke every session for the caller's user");
|
||||
|
||||
// AZ-535 AC-3 — admin-only revoke-by-sid.
|
||||
app.MapPost("/sessions/{sid:guid}/revoke",
|
||||
async (Guid sid, HttpContext http, ISessionService sessions, CancellationToken ct) =>
|
||||
{
|
||||
var admin = ParseUserIdClaim(http.User);
|
||||
var alreadyRevoked = await sessions.RevokeBySid(sid, admin, SessionRevokedReasons.AdminRevoked, ct);
|
||||
return Results.Ok(new { alreadyRevoked });
|
||||
})
|
||||
.RequireAuthorization(apiAdminPolicy)
|
||||
.WithSummary("AZ-535 — admin revoke-by-session-id");
|
||||
|
||||
// AZ-535 AC-4 — verifier-poll snapshot of revoked-but-not-yet-expired sessions.
|
||||
app.MapGet("/sessions/revoked",
|
||||
async (DateTime? since, HttpContext http, ISessionService sessions, CancellationToken ct) =>
|
||||
{
|
||||
// Cap "since" to the longest plausible token TTL (12 h, matches mission cap)
|
||||
// so a buggy verifier asking for "everything since 1970" doesn't cost us a
|
||||
// multi-million-row table scan.
|
||||
var floor = DateTime.UtcNow.AddHours(-12);
|
||||
var effective = since.HasValue && since.Value > floor ? since.Value : floor;
|
||||
|
||||
var rows = await sessions.GetRevokedSince(effective, ct);
|
||||
http.Response.Headers.CacheControl = "no-cache";
|
||||
return Results.Ok(rows.Select(r => new
|
||||
{
|
||||
sid = r.Sid,
|
||||
exp = r.Exp,
|
||||
revokedAt = r.RevokedAt,
|
||||
reason = r.Reason
|
||||
}));
|
||||
})
|
||||
.RequireAuthorization(revocationReaderPolicy)
|
||||
.WithSummary("AZ-535 — verifier snapshot of revoked sessions still within their TTL");
|
||||
|
||||
// AZ-533 — mission token issuance for offline UAV ops. Pilot calls with their
|
||||
// interactive access token; admin returns a long-lived no-refresh token bound
|
||||
// to one aircraft + one mission.
|
||||
app.MapPost("/sessions/mission",
|
||||
async (MissionSessionRequest request, HttpContext http, IMissionTokenService missions, CancellationToken ct) =>
|
||||
{
|
||||
var pilot = ParseUserIdClaim(http.User);
|
||||
// TODO (AZ-534): require amr=["pwd","mfa"]; until MFA ships this is a code
|
||||
// comment per the AZ-533 spec, not an enforced gate.
|
||||
var resp = await missions.Issue(pilot, request, ct);
|
||||
return Results.Ok(resp);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithSummary("AZ-533 — issue a long-lived mission token for one UAV flight");
|
||||
|
||||
static Guid ParseSidClaim(System.Security.Claims.ClaimsPrincipal user) =>
|
||||
Guid.TryParse(user.FindFirst(JwtRegisteredClaimNames.Sid)?.Value, out var s)
|
||||
? s
|
||||
: throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
|
||||
|
||||
static Guid ParseUserIdClaim(System.Security.Claims.ClaimsPrincipal user) =>
|
||||
Guid.TryParse(user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var u)
|
||||
? u
|
||||
: throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
|
||||
|
||||
// AZ-532 — JWKS endpoint. Verifiers cache for 1 h (Cache-Control: public, max-age=3600).
|
||||
app.MapGet("/.well-known/jwks.json",
|
||||
(IJwtSigningKeyProvider keys, HttpContext http) =>
|
||||
|
||||
Reference in New Issue
Block a user