mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 06:51:08 +00:00
4bf2e689cb
AZ-556 collapses every /login rejection (unknown email, wrong password, disabled account, lockout, per-account rate limit) to a single opaque InvalidCredentials (70) → 401 response. Timing equalised by a new Security.VerifyDummy using the same Argon2id parameters. Audit log keeps the rejection category internally (login_failed_unknown_email, login_failed_disabled). AZ-557 wires /login/mfa into the existing per-account lockout + rate-limit pipeline. MFA failures now feed UserService's shared failure accounting (RegisterMfaFailedLogin → RegisterFailedLoginCore) and CountRecentFailedLogins aggregates both login_failed and mfa_login_failed rows. Successful TOTP / recovery resets the counter. Deprecated five legacy ExceptionEnum members (NoEmailFound, WrongPassword, UserDisabled, AccountLocked, LoginRateLimited) — kept defined for cross-workspace verifier compatibility during the deprecation window. E2E coverage updated: AuthTests (byte-identical body assertion + disabled-account audit row), LoginRateLimitTests, PasswordHashingTests, SecurityTests, plus four new MfaLoginTests (AC1, AC2, AC5, AC7). Code review verdict: PASS_WITH_WARNINGS (batch_06_cycle2_review.md). Co-authored-by: Cursor <cursoragent@cursor.com>
69 lines
3.0 KiB
C#
69 lines
3.0 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
|
|
{
|
|
// AZ-556 — `InvalidCredentials` covers unknown email, wrong password, disabled
|
|
// account, lockout, and per-account rate-limit. Same 401 for all five so the
|
|
// wire response carries no signal beyond the optional Retry-After header.
|
|
ExceptionEnum.InvalidCredentials => StatusCodes.Status401Unauthorized,
|
|
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
|
|
};
|
|
}
|