[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
+22 -3
View File
@@ -11,6 +11,11 @@ public interface IAuditLog
Task RecordLoginLockout(string email, CancellationToken ct = default);
Task RecordLoginSuccess(string email, CancellationToken ct = default);
// AZ-556 — per-category internal forensics. Wire response is uniformly
// `InvalidCredentials`; these recorders keep SecOps's audit trail honest.
Task RecordLoginFailedUnknownEmail(string email, CancellationToken ct = default);
Task RecordLoginFailedDisabled (string email, CancellationToken ct = default);
// AZ-534 — MFA lifecycle + login auth-event audit.
Task RecordMfaEnroll (string email, CancellationToken ct = default);
Task RecordMfaConfirm (string email, CancellationToken ct = default);
@@ -20,8 +25,12 @@ public interface IAuditLog
Task RecordMfaRecoveryUsed (string email, CancellationToken ct = default);
/// <summary>
/// Number of `login_failed` rows for the given email within the last <paramref name="windowSeconds"/>.
/// Used by the per-account sliding-window rate limit (AZ-537 AC-2).
/// Count of failure-audit rows for the given email within the last
/// <paramref name="windowSeconds"/> that feed the per-account sliding-window rate
/// limit. Includes BOTH password (<c>login_failed</c>) and TOTP
/// (<c>mfa_login_failed</c>) failures (AZ-537 AC-2 + AZ-557 AC-3). Disabled-account
/// and unknown-email rejections are intentionally excluded — they don't reflect an
/// account-credential attack that the lockout/rate-limit policy should escalate.
/// </summary>
Task<int> CountRecentFailedLogins(string email, int windowSeconds, CancellationToken ct = default);
}
@@ -37,6 +46,12 @@ public class AuditLog(IDbFactory dbFactory, IHttpContextAccessor httpContextAcce
public Task RecordLoginSuccess(string email, CancellationToken ct = default)
=> Insert(AuditEventTypes.LoginSuccess, email, ct);
public Task RecordLoginFailedUnknownEmail(string email, CancellationToken ct = default)
=> Insert(AuditEventTypes.LoginFailedUnknownEmail, email, ct);
public Task RecordLoginFailedDisabled(string email, CancellationToken ct = default)
=> Insert(AuditEventTypes.LoginFailedDisabled, email, ct);
public Task RecordMfaEnroll (string email, CancellationToken ct = default)
=> Insert(AuditEventTypes.MfaEnroll, email, ct);
public Task RecordMfaConfirm (string email, CancellationToken ct = default)
@@ -54,9 +69,13 @@ public class AuditLog(IDbFactory dbFactory, IHttpContextAccessor httpContextAcce
{
var cutoff = DateTime.UtcNow.AddSeconds(-windowSeconds);
var normalised = email.ToLowerInvariant();
// AZ-557 — MFA failures feed the same per-account sliding-window count as
// password failures so an attacker who got past factor 1 can't brute-force
// factor 2 from rotating IPs without tripping the per-account throttle.
return await dbFactory.Run(async db =>
await db.AuditEvents
.Where(e => e.EventType == AuditEventTypes.LoginFailed
.Where(e => (e.EventType == AuditEventTypes.LoginFailed
|| e.EventType == AuditEventTypes.MfaLoginFailed)
&& e.Email == normalised
&& e.OccurredAt >= cutoff)
.CountAsync(token: ct));