mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 08:11: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>
107 lines
3.8 KiB
C#
107 lines
3.8 KiB
C#
using System.ComponentModel;
|
|
using Azaion.Common.Extensions;
|
|
|
|
namespace Azaion.Common;
|
|
|
|
public class BusinessException(ExceptionEnum exEnum) : Exception(GetMessage(exEnum))
|
|
{
|
|
private static readonly Dictionary<ExceptionEnum, string> ExceptionDescriptions;
|
|
|
|
static BusinessException()
|
|
{
|
|
ExceptionDescriptions = EnumExtensions.GetDescriptions<ExceptionEnum>();
|
|
}
|
|
|
|
public ExceptionEnum ExceptionEnum { get; set; } = exEnum;
|
|
|
|
/// <summary>
|
|
/// Optional cooldown hint surfaced as a Retry-After response header by the exception
|
|
/// handler. Used by AccountLocked and LoginRateLimited (AZ-537).
|
|
/// </summary>
|
|
public int? RetryAfterSeconds { get; init; }
|
|
|
|
public BusinessException(ExceptionEnum exEnum, int retryAfterSeconds) : this(exEnum)
|
|
{
|
|
RetryAfterSeconds = retryAfterSeconds;
|
|
}
|
|
|
|
public static string GetMessage(ExceptionEnum exEnum) => ExceptionDescriptions.GetValueOrDefault(exEnum) ?? exEnum.ToString();
|
|
}
|
|
|
|
public enum ExceptionEnum
|
|
{
|
|
// AZ-556 — DEPRECATED: no longer thrown by `UserService.ValidateUser`. The login
|
|
// path now uses `InvalidCredentials` (70) for all rejection categories to close the
|
|
// user-enumeration leak (F-AUTH-1 + F-AUTH-3). Kept defined for any cross-workspace
|
|
// verifier that still pattern-matches on the old codes. Removal is scheduled in a
|
|
// separate ticket after the deprecation window.
|
|
[Description("No such email found.")]
|
|
NoEmailFound = 10,
|
|
|
|
[Description("Email already exists.")]
|
|
EmailExists = 20,
|
|
|
|
// AZ-556 — DEPRECATED: see the `NoEmailFound` deprecation note above.
|
|
[Description("Passwords do not match.")]
|
|
WrongPassword = 30,
|
|
|
|
[Description("Password should be at least 12 characters.")]
|
|
PasswordLengthIncorrect = 32,
|
|
|
|
[Description("Email is empty or invalid.")]
|
|
EmailLengthIncorrect = 35,
|
|
|
|
WrongEmail = 37,
|
|
|
|
// AZ-556 — DEPRECATED: see the `NoEmailFound` deprecation note above.
|
|
[Description("User account is disabled.")]
|
|
UserDisabled = 38,
|
|
|
|
// AZ-556 — DEPRECATED: cycle-2 unifies the lockout response under
|
|
// `InvalidCredentials` + Retry-After header (AC-7). Kept defined for cross-workspace
|
|
// verifier compatibility; will be removed alongside `NoEmailFound`/`WrongPassword`.
|
|
[Description("Account is temporarily locked due to too many failed login attempts.")]
|
|
AccountLocked = 50,
|
|
|
|
// AZ-556 — DEPRECATED: see the `AccountLocked` deprecation note above.
|
|
[Description("Too many login attempts. Try again later.")]
|
|
LoginRateLimited = 51,
|
|
|
|
[Description("Refresh token is invalid, expired, or has been revoked.")]
|
|
InvalidRefreshToken = 52,
|
|
|
|
[Description("Session not found.")]
|
|
SessionNotFound = 53,
|
|
|
|
[Description("Mission token request is invalid.")]
|
|
InvalidMissionRequest = 54,
|
|
|
|
[Description("Aircraft not found or wrong role.")]
|
|
AircraftNotFound = 55,
|
|
|
|
[Description("MFA is already enabled for this user.")]
|
|
MfaAlreadyEnabled = 56,
|
|
|
|
[Description("MFA enrollment is not in progress for this user.")]
|
|
MfaNotEnrolling = 57,
|
|
|
|
[Description("MFA is not enabled for this user.")]
|
|
MfaNotEnabled = 58,
|
|
|
|
[Description("Invalid MFA code or recovery code.")]
|
|
InvalidMfaCode = 59,
|
|
|
|
[Description("MFA token is invalid or expired.")]
|
|
InvalidMfaToken = 61,
|
|
|
|
[Description("No file provided.")]
|
|
NoFileProvided = 60,
|
|
|
|
// AZ-556 — single opaque login-failure code. Replaces the wire-side use of
|
|
// `NoEmailFound`, `WrongPassword`, `UserDisabled`, `AccountLocked`, and
|
|
// `LoginRateLimited`. The audit log preserves the actual category for SecOps.
|
|
// Lockout / rate-limit responses additionally carry a Retry-After header via
|
|
// `BusinessException.RetryAfterSeconds`.
|
|
[Description("Invalid credentials.")]
|
|
InvalidCredentials = 70,
|
|
} |