mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 11:41:09 +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:
@@ -0,0 +1,138 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Azaion.Common;
|
||||
using Azaion.Common.Configs;
|
||||
using Azaion.Common.Database;
|
||||
using Azaion.Common.Entities;
|
||||
using Azaion.Common.Requests;
|
||||
using LinqToDB;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Azaion.Services;
|
||||
|
||||
/// <summary>
|
||||
/// AZ-533 — issues long-lived single-use access tokens for offline UAV missions.
|
||||
/// Distinct from <see cref="IAuthService"/> because:
|
||||
/// <list type="bullet">
|
||||
/// <item>Lifetime is per-mission (≤ 12 h), not per-session policy.</item>
|
||||
/// <item>Audience is narrowed to <c>satellite-provider</c>, not the broad admin audience.</item>
|
||||
/// <item>No refresh: a single token covers the entire flight, then dies.</item>
|
||||
/// <item>Carries mission-specific claims (mission_id, aircraft_id, valid_region).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public interface IMissionTokenService
|
||||
{
|
||||
Task<MissionSessionResponse> Issue(Guid pilotUserId, MissionSessionRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class MissionTokenService(
|
||||
IDbFactory dbFactory,
|
||||
IJwtSigningKeyProvider signingKeys,
|
||||
IOptions<JwtConfig> jwtConfig) : IMissionTokenService
|
||||
{
|
||||
private const string MissionAudience = "satellite-provider";
|
||||
private const double MaxDurationHours = 12.0;
|
||||
private const double MinDurationHours = 0.1;
|
||||
private const double LifetimeBufferHours = 1.0; // covers post-flight reconnect grace
|
||||
|
||||
private static readonly Regex MissionIdPattern =
|
||||
new(@"^M-\d{4}-\d{2}-\d{2}-\d{3}$", RegexOptions.Compiled);
|
||||
|
||||
private readonly JwtConfig _jwt = jwtConfig.Value;
|
||||
|
||||
public async Task<MissionSessionResponse> Issue(Guid pilotUserId, MissionSessionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Validate(request);
|
||||
|
||||
// Aircraft must exist with Role=CompanionPC. Anything else is a config error.
|
||||
var aircraft = await dbFactory.Run(async db =>
|
||||
await db.Users.FirstOrDefaultAsync(u => u.Id == request.AircraftId, token: ct));
|
||||
if (aircraft == null || aircraft.Role != RoleEnum.CompanionPC)
|
||||
throw new BusinessException(ExceptionEnum.AircraftNotFound);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var expAt = now.AddHours(request.PlannedDurationH + LifetimeBufferHours);
|
||||
var sid = Guid.NewGuid();
|
||||
var jti = Guid.NewGuid();
|
||||
|
||||
// Persist the session BEFORE we mint the token so revocation lookups can
|
||||
// never miss a token that's already in the wild.
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
await db.InsertAsync(new Session
|
||||
{
|
||||
Id = sid,
|
||||
UserId = pilotUserId,
|
||||
FamilyId = sid, // mission sessions are their own family — no rotation
|
||||
IssuedAt = now,
|
||||
LastUsedAt = now,
|
||||
ExpiresAt = expAt,
|
||||
FamilyStartedAt = now,
|
||||
Class = SessionClasses.Mission,
|
||||
AircraftId = request.AircraftId,
|
||||
// RefreshHash null — no refresh value backs a mission token.
|
||||
}, token: ct));
|
||||
|
||||
var token = MintToken(pilotUserId, request, sid, jti, expAt);
|
||||
|
||||
return new MissionSessionResponse
|
||||
{
|
||||
AccessToken = token,
|
||||
AccessExp = expAt,
|
||||
TokenClass = SessionClasses.Mission,
|
||||
SessionId = sid,
|
||||
};
|
||||
}
|
||||
|
||||
private static void Validate(MissionSessionRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.MissionId) || !MissionIdPattern.IsMatch(request.MissionId))
|
||||
throw new BusinessException(ExceptionEnum.InvalidMissionRequest);
|
||||
|
||||
if (request.PlannedDurationH < MinDurationHours || request.PlannedDurationH > MaxDurationHours)
|
||||
throw new BusinessException(ExceptionEnum.InvalidMissionRequest);
|
||||
}
|
||||
|
||||
private string MintToken(Guid pilotUserId, MissionSessionRequest request, Guid sid, Guid jti, DateTime expAt)
|
||||
{
|
||||
var active = signingKeys.Active;
|
||||
var creds = new SigningCredentials(active.SecurityKey, SecurityAlgorithms.EcdsaSha256);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, pilotUserId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Sid, sid.ToString()),
|
||||
new(JwtRegisteredClaimNames.Jti, jti.ToString()),
|
||||
new("mission_id", request.MissionId),
|
||||
new("aircraft_id", request.AircraftId.ToString()),
|
||||
new("token_class", SessionClasses.Mission),
|
||||
};
|
||||
|
||||
if (request.RequestedScope is { Count: > 0 })
|
||||
foreach (var p in request.RequestedScope)
|
||||
claims.Add(new Claim("permissions", p));
|
||||
|
||||
if (request.ValidRegion != null)
|
||||
claims.Add(new Claim(
|
||||
"valid_region",
|
||||
JsonSerializer.Serialize(request.ValidRegion),
|
||||
JsonClaimValueTypes.Json));
|
||||
|
||||
var descriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = expAt,
|
||||
Issuer = _jwt.Issuer,
|
||||
// AZ-533 — narrowed audience: satellite-provider only, not the broad
|
||||
// interactive audience. Verifiers downstream gate on this claim.
|
||||
Audience = MissionAudience,
|
||||
SigningCredentials = creds
|
||||
};
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var token = handler.CreateToken(descriptor);
|
||||
return handler.WriteToken(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Azaion.Common;
|
||||
using Azaion.Common.Database;
|
||||
using Azaion.Common.Entities;
|
||||
using LinqToDB;
|
||||
|
||||
namespace Azaion.Services;
|
||||
|
||||
/// <summary>
|
||||
/// AZ-535 — logout/revocation surface. Distinct from <see cref="IRefreshTokenService"/>:
|
||||
/// refresh-token service rotates and reuse-detects; this service expresses the
|
||||
/// human / admin / system intent to kill a session and exposes the verifier-poll
|
||||
/// snapshot that powers cross-service denylists.
|
||||
/// </summary>
|
||||
public interface ISessionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Revoke a single session by id. Returns the revocation status BEFORE this
|
||||
/// call: <c>true</c> if it was already revoked (idempotent no-op),
|
||||
/// <c>false</c> if this call is the one that revoked it.
|
||||
/// </summary>
|
||||
Task<bool> RevokeBySid(Guid sessionId, Guid? byUserId, string reason, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revoke every active session for a user. Returns the count of rows newly
|
||||
/// revoked by this call.
|
||||
/// </summary>
|
||||
Task<int> RevokeAllForUser(Guid userId, Guid? byUserId, string reason, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// AZ-533 — auto-revoke every open mission session belonging to <paramref name="aircraftId"/>.
|
||||
/// Fired on successful /login or /token/refresh from the aircraft's own user.
|
||||
/// </summary>
|
||||
Task<int> RevokeMissionsForAircraft(Guid aircraftId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// AZ-535 AC-4 — verifier-poll snapshot. Returns sessions revoked since
|
||||
/// <paramref name="since"/> whose <c>exp</c> is still in the future, so the
|
||||
/// list stays bounded.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<RevokedSession>> GetRevokedSince(DateTime since, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record RevokedSession(Guid Sid, DateTime Exp, DateTime RevokedAt, string? Reason);
|
||||
|
||||
public class SessionService(IDbFactory dbFactory) : ISessionService
|
||||
{
|
||||
public async Task<bool> RevokeBySid(Guid sessionId, Guid? byUserId, string reason, CancellationToken ct = default)
|
||||
{
|
||||
return await dbFactory.RunAdmin(async db =>
|
||||
{
|
||||
var existing = await db.Sessions.FirstOrDefaultAsync(s => s.Id == sessionId, token: ct);
|
||||
if (existing == null)
|
||||
throw new BusinessException(ExceptionEnum.SessionNotFound);
|
||||
|
||||
if (existing.RevokedAt.HasValue)
|
||||
return true; // idempotent — already revoked, no DB write needed
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
await db.Sessions
|
||||
.Where(s => s.Id == sessionId && s.RevokedAt == null)
|
||||
.Set(s => s.RevokedAt, now)
|
||||
.Set(s => s.RevokedReason, reason)
|
||||
.Set(s => s.RevokedByUserId, byUserId)
|
||||
.UpdateAsync(token: ct);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<int> RevokeAllForUser(Guid userId, Guid? byUserId, string reason, CancellationToken ct = default) =>
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return await db.Sessions
|
||||
.Where(s => s.UserId == userId && s.RevokedAt == null)
|
||||
.Set(s => s.RevokedAt, now)
|
||||
.Set(s => s.RevokedReason, reason)
|
||||
.Set(s => s.RevokedByUserId, byUserId)
|
||||
.UpdateAsync(token: ct);
|
||||
});
|
||||
|
||||
public async Task<int> RevokeMissionsForAircraft(Guid aircraftId, CancellationToken ct = default) =>
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return await db.Sessions
|
||||
.Where(s => s.AircraftId == aircraftId
|
||||
&& s.Class == SessionClasses.Mission
|
||||
&& s.RevokedAt == null)
|
||||
.Set(s => s.RevokedAt, now)
|
||||
.Set(s => s.RevokedReason, SessionRevokedReasons.PostFlightReconnect)
|
||||
.UpdateAsync(token: ct);
|
||||
});
|
||||
|
||||
public async Task<IReadOnlyList<RevokedSession>> GetRevokedSince(DateTime since, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return await dbFactory.Run(async db =>
|
||||
(await db.Sessions
|
||||
.Where(s => s.RevokedAt != null
|
||||
&& s.RevokedAt > since
|
||||
&& s.ExpiresAt > now) // AZ-535 AC-4: prune expired
|
||||
.Select(s => new RevokedSession(s.Id, s.ExpiresAt, s.RevokedAt!.Value, s.RevokedReason))
|
||||
.ToListAsync(token: ct)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user