Files
admin/Azaion.AdminApi/BusinessExceptionHandler.cs
T
Oleksandr Bezdieniezhnykh 1e1ded73f5
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status
[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>
2026-05-14 06:21:28 +03:00

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
};
}