mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 09:31:08 +00:00
1e1ded73f5
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>
65 lines
2.7 KiB
C#
65 lines
2.7 KiB
C#
using System.Globalization;
|
|
using Microsoft.AspNetCore.Diagnostics;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
using Newtonsoft.Json;
|
|
|
|
namespace Azaion.Common;
|
|
|
|
public class BusinessExceptionHandler(ILogger<BusinessExceptionHandler> logger) : IExceptionHandler
|
|
{
|
|
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
|
|
{
|
|
if (exception is BusinessException ex)
|
|
{
|
|
logger.LogWarning(exception, ex.Message);
|
|
httpContext.Response.StatusCode = MapStatusCode(ex.ExceptionEnum);
|
|
httpContext.Response.ContentType = "application/json";
|
|
|
|
if (ex.RetryAfterSeconds is { } retry && retry > 0)
|
|
httpContext.Response.Headers.RetryAfter = retry.ToString(CultureInfo.InvariantCulture);
|
|
|
|
var err = JsonConvert.SerializeObject(new
|
|
{
|
|
ErrorCode = ex.ExceptionEnum,
|
|
ex.Message
|
|
});
|
|
await httpContext.Response.WriteAsync(err, cancellationToken).ConfigureAwait(false);
|
|
return true;
|
|
}
|
|
|
|
if (exception is BadHttpRequestException badReq)
|
|
{
|
|
logger.LogWarning(exception, badReq.Message);
|
|
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
|
|
httpContext.Response.ContentType = "application/json";
|
|
|
|
var err = JsonConvert.SerializeObject(new
|
|
{
|
|
ErrorCode = 0,
|
|
badReq.Message
|
|
});
|
|
await httpContext.Response.WriteAsync(err, cancellationToken).ConfigureAwait(false);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static int MapStatusCode(ExceptionEnum kind) => kind switch
|
|
{
|
|
ExceptionEnum.AccountLocked => StatusCodes.Status423Locked,
|
|
ExceptionEnum.LoginRateLimited => StatusCodes.Status429TooManyRequests,
|
|
ExceptionEnum.InvalidRefreshToken => StatusCodes.Status401Unauthorized,
|
|
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
|
|
};
|
|
}
|