mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 08:41:09 +00:00
[AZ-534] TOTP-based 2FA at credential login
Add RFC 6238 TOTP enrollment, two-step /login flow, recovery codes, and
the amr=["pwd","mfa"] claim that propagates through refresh-token rotation.
- New endpoints: /users/me/mfa/{enroll,confirm,disable} and /login/mfa.
- /login short-circuits to a 5-min ES256 step-1 token (audience-pinned
azaion-mfa-step2) when the user has MFA enabled; real access+refresh
pair is minted only after /login/mfa.
- mfa_secret encrypted at rest via ASP.NET Core IDataProtector
(purpose=Azaion.Mfa.Secret.v1; key folder configurable via
DataProtection:KeysFolder for production persistence).
- Recovery codes (10 single-use, base32, ~80-bit entropy) hashed with
SHA-256 and stored as JSONB; constant-time compare on lookup.
- RFC 6238 §5.2 replay defense via mfa_last_used_window per user.
- Sessions carry mfa_authenticated so /token/refresh re-stamps the
amr claim correctly across the entire 30-day refresh window.
- New audit events: enroll, confirm, disable, login-success/failed,
recovery-used.
- Schema: env/db/10_users_mfa.sql adds users.mfa_* columns and
sessions.mfa_authenticated; mfa_recovery_codes mapped as BinaryJson
in AzaionDbSchemaHolder; disable path uses raw parameterised SQL to
avoid LinqToDB null-literal type-inference on jsonb columns.
E2E: 6 new tests in MfaLoginTests cover all six AC; full suite
82 passed / 0 failed / 3 intentional skips.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -54,6 +54,11 @@ public class BusinessExceptionHandler(ILogger<BusinessExceptionHandler> logger)
|
||||
ExceptionEnum.SessionNotFound => StatusCodes.Status404NotFound,
|
||||
ExceptionEnum.InvalidMissionRequest => StatusCodes.Status400BadRequest,
|
||||
ExceptionEnum.AircraftNotFound => StatusCodes.Status400BadRequest,
|
||||
ExceptionEnum.MfaAlreadyEnabled => StatusCodes.Status409Conflict,
|
||||
ExceptionEnum.MfaNotEnrolling => StatusCodes.Status409Conflict,
|
||||
ExceptionEnum.MfaNotEnabled => StatusCodes.Status409Conflict,
|
||||
ExceptionEnum.InvalidMfaCode => StatusCodes.Status401Unauthorized,
|
||||
ExceptionEnum.InvalidMfaToken => StatusCodes.Status401Unauthorized,
|
||||
_ => StatusCodes.Status409Conflict
|
||||
};
|
||||
}
|
||||
|
||||
+115
-15
@@ -1,5 +1,6 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Azaion.Common;
|
||||
using Azaion.Common.Configs;
|
||||
using Azaion.Common.Database;
|
||||
@@ -141,6 +142,22 @@ builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
||||
builder.Services.AddScoped<ISessionService, SessionService>();
|
||||
builder.Services.AddScoped<IMissionTokenService, MissionTokenService>();
|
||||
builder.Services.AddScoped<IMfaService, MfaService>();
|
||||
|
||||
// AZ-534 — DataProtection encrypts mfa_secret at rest. Default key storage
|
||||
// (per-machine, ephemeral inside containers) is fine for a single-instance SUT.
|
||||
// Production deployments MUST set DataProtection__KeysFolder to a persistent
|
||||
// volume so encrypted secrets survive restarts and rolling deploys.
|
||||
{
|
||||
var dpBuilder = builder.Services.AddDataProtection();
|
||||
dpBuilder.SetApplicationName("Azaion.AdminApi");
|
||||
var keyFolder = builder.Configuration["DataProtection:KeysFolder"];
|
||||
if (!string.IsNullOrWhiteSpace(keyFolder))
|
||||
{
|
||||
Directory.CreateDirectory(keyFolder);
|
||||
dpBuilder.PersistKeysToFileSystem(new DirectoryInfo(keyFolder));
|
||||
}
|
||||
}
|
||||
builder.Services.AddScoped<IResourcesService, ResourcesService>();
|
||||
builder.Services.AddScoped<IDetectionClassService, DetectionClassService>();
|
||||
builder.Services.AddScoped<IAuditLog, AuditLog>();
|
||||
@@ -269,27 +286,76 @@ app.MapPost("/login",
|
||||
IAuthService authService,
|
||||
IRefreshTokenService refreshTokens,
|
||||
ISessionService sessionService,
|
||||
IMfaService mfaService,
|
||||
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
|
||||
// AZ-534 AC-3 — MFA-enabled users get short-circuited to a step-1 token; the
|
||||
// real access+refresh pair is minted only after /login/mfa.
|
||||
if (user.MfaEnabled)
|
||||
{
|
||||
AccessToken = access.Jwt,
|
||||
AccessExp = access.ExpiresAt,
|
||||
RefreshToken = refreshToken,
|
||||
RefreshExp = session.ExpiresAt,
|
||||
});
|
||||
return Results.Ok(new MfaRequiredResponse
|
||||
{
|
||||
MfaRequired = true,
|
||||
MfaToken = mfaService.IssueMfaStepToken(user.Id),
|
||||
ExpiresIn = 300,
|
||||
});
|
||||
}
|
||||
|
||||
return await IssueDualTokens(user, authService, refreshTokens, sessionService, amr: null, cancellationToken);
|
||||
})
|
||||
.RequireRateLimiting(LoginPerIpPolicy)
|
||||
.WithSummary("Login (returns access + refresh token)");
|
||||
.WithSummary("Login (returns access + refresh token, OR mfa_required if MFA is enabled)");
|
||||
|
||||
// AZ-534 AC-3 / AC-4 — second factor at credential login. Anonymous because the
|
||||
// step-1 mfa_token is itself the proof the caller is mid-flow.
|
||||
app.MapPost("/login/mfa",
|
||||
async (MfaLoginRequest request,
|
||||
IMfaService mfaService,
|
||||
IUserService userService,
|
||||
IAuthService authService,
|
||||
IRefreshTokenService refreshTokens,
|
||||
ISessionService sessionService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var userId = mfaService.ValidateMfaStepToken(request.MfaToken);
|
||||
var user = await userService.GetById(userId, cancellationToken)
|
||||
?? throw new BusinessException(ExceptionEnum.NoEmailFound);
|
||||
|
||||
var amr = await mfaService.VerifyForLogin(userId, request.Code, cancellationToken);
|
||||
return await IssueDualTokens(user, authService, refreshTokens, sessionService, amr, cancellationToken);
|
||||
})
|
||||
.AllowAnonymous()
|
||||
.RequireRateLimiting(LoginPerIpPolicy)
|
||||
.WithSummary("AZ-534 — second-factor verification; returns access + refresh token");
|
||||
|
||||
static async Task<IResult> IssueDualTokens(
|
||||
User user,
|
||||
IAuthService authService,
|
||||
IRefreshTokenService refreshTokens,
|
||||
ISessionService sessionService,
|
||||
string[]? amr,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// AZ-534 — pin AMR strength to the session so refresh rotation inherits it.
|
||||
var mfaAuthenticated = amr != null && amr.Contains("mfa");
|
||||
var (refreshToken, session) = await refreshTokens.IssueForNewLogin(user.Id, mfaAuthenticated, ct);
|
||||
var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid(), amr: amr);
|
||||
|
||||
// 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, ct);
|
||||
|
||||
return Results.Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = access.Jwt,
|
||||
AccessExp = access.ExpiresAt,
|
||||
RefreshToken = refreshToken,
|
||||
RefreshExp = session.ExpiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
// AZ-531 — refresh-token rotation. Anonymous: clients pass the opaque refresh
|
||||
// in the request body so an expired access token doesn't block the refresh.
|
||||
@@ -304,7 +370,10 @@ app.MapPost("/token/refresh",
|
||||
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-534 — preserve the original AMR strength across rotations.
|
||||
var amr = session.MfaAuthenticated ? new[] { "pwd", "mfa" } : new[] { "pwd" };
|
||||
var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid(), amr: amr);
|
||||
|
||||
// AZ-533 AC-4 — same auto-revoke trigger as /login.
|
||||
if (user.Role == RoleEnum.CompanionPC)
|
||||
@@ -379,6 +448,37 @@ app.MapGet("/sessions/revoked",
|
||||
.RequireAuthorization(revocationReaderPolicy)
|
||||
.WithSummary("AZ-535 — verifier snapshot of revoked sessions still within their TTL");
|
||||
|
||||
// AZ-534 — TOTP MFA enrollment + management.
|
||||
app.MapPost("/users/me/mfa/enroll",
|
||||
async (MfaEnrollRequest request, HttpContext http, IMfaService mfa, CancellationToken ct) =>
|
||||
{
|
||||
var userId = ParseUserIdClaim(http.User);
|
||||
var resp = await mfa.Enroll(userId, request.Password, ct);
|
||||
return Results.Ok(resp);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithSummary("AZ-534 — start TOTP enrollment (pre-confirm)");
|
||||
|
||||
app.MapPost("/users/me/mfa/confirm",
|
||||
async (MfaConfirmRequest request, HttpContext http, IMfaService mfa, CancellationToken ct) =>
|
||||
{
|
||||
var userId = ParseUserIdClaim(http.User);
|
||||
await mfa.Confirm(userId, request.Code, ct);
|
||||
return Results.Ok(new { mfaEnabled = true });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithSummary("AZ-534 — confirm TOTP enrollment with a valid code");
|
||||
|
||||
app.MapPost("/users/me/mfa/disable",
|
||||
async (MfaDisableRequest request, HttpContext http, IMfaService mfa, CancellationToken ct) =>
|
||||
{
|
||||
var userId = ParseUserIdClaim(http.User);
|
||||
await mfa.Disable(userId, request.Password, request.Code, ct);
|
||||
return Results.Ok(new { mfaEnabled = false });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithSummary("AZ-534 — disable MFA (requires password + valid code)");
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user