[AZ-556] [AZ-557] Unify login errors + share MFA lockout pipeline

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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 09:56:00 +03:00
parent ebde2b2d25
commit 4bf2e689cb
16 changed files with 537 additions and 100 deletions
+19
View File
@@ -30,12 +30,18 @@ public class BusinessException(ExceptionEnum exEnum) : Exception(GetMessage(exEn
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,
@@ -47,12 +53,17 @@ public enum ExceptionEnum
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,
@@ -85,4 +96,12 @@ public enum ExceptionEnum
[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,
}