[AZ-534] TOTP-based 2FA at credential login
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status

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:
Oleksandr Bezdieniezhnykh
2026-05-14 06:21:28 +03:00
parent 8e7c602f51
commit 1e1ded73f5
24 changed files with 1188 additions and 57 deletions
@@ -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
View File
@@ -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-4post-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-3MFA-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.