[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
+44 -4
View File
@@ -55,6 +55,7 @@ public class MfaService(
IDataProtectionProvider dataProtectionProvider,
IJwtSigningKeyProvider signingKeys,
IOptions<JwtConfig> jwtConfig,
IOptions<AuthConfig> authConfig,
IAuditLog auditLog) : IMfaService
{
private const string MfaSecretPurpose = "Azaion.Mfa.Secret.v1";
@@ -66,6 +67,7 @@ public class MfaService(
private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector(MfaSecretPurpose);
private readonly JwtConfig _jwt = jwtConfig.Value;
private readonly AuthConfig _auth = authConfig.Value;
public async Task<MfaEnrollResponse> Enroll(Guid userId, string password, CancellationToken ct = default)
{
@@ -247,11 +249,29 @@ public class MfaService(
public async Task<string[]> VerifyForLogin(Guid userId, string code, CancellationToken ct = default)
{
var user = await userService.GetById(userId, ct)
?? throw new BusinessException(ExceptionEnum.NoEmailFound);
?? throw new BusinessException(ExceptionEnum.InvalidCredentials);
if (!user.MfaEnabled || string.IsNullOrEmpty(user.MfaSecret))
throw new BusinessException(ExceptionEnum.MfaNotEnabled);
// AZ-557 — active lockout from EITHER the password or the MFA side rejects
// the request before the TOTP verify runs, with the same wire shape the
// password path uses (`InvalidCredentials` + Retry-After).
if (user.LockoutUntil is { } until && until > DateTime.UtcNow)
{
var remaining = (int)Math.Ceiling((until - DateTime.UtcNow).TotalSeconds);
throw new BusinessException(ExceptionEnum.InvalidCredentials, Math.Max(remaining, 1));
}
// AZ-557 — per-account sliding-window rate limit applies to MFA failures too
// (CountRecentFailedLogins counts login_failed + mfa_login_failed). Without
// this an attacker with a leaked password could brute-force the 6-digit TOTP
// from rotating IPs without ever tripping the per-account throttle.
var recentFailures = await auditLog.CountRecentFailedLogins(
user.Email, _auth.RateLimit.PerAccountWindowSeconds, ct);
if (recentFailures >= _auth.RateLimit.PerAccountPermitLimit)
throw new BusinessException(ExceptionEnum.InvalidCredentials, _auth.RateLimit.PerAccountWindowSeconds);
var secret = _protector.Unprotect(user.MfaSecret);
if (VerifyTotpCode(secret, code, user.MfaLastUsedWindow, out var window))
{
@@ -262,19 +282,39 @@ public class MfaService(
u => u.Id == userId,
u => new User { MfaLastUsedWindow = window },
token: ct));
// AZ-557 — TOTP success also resets the failure counter so a user who
// fat-fingered a few codes before getting it right doesn't drift toward
// lockout. Mirrors the password-side reset in RegisterSuccessfulLogin.
await dbFactory.RunAdmin(async db =>
await db.Users.UpdateAsync(
u => u.Id == userId,
u => new User { FailedLoginCount = 0, LockoutUntil = null },
token: ct));
await auditLog.RecordMfaLoginSuccess(user.Email, ct);
return ["pwd", "mfa"];
}
// TOTP failed — try recovery code (single-use)
// TOTP failed — try recovery code (single-use). Recovery codes are
// high-entropy and intentionally NOT counted by the lockout pipeline; a
// locked-out user can still escape via a recovery code.
if (await TryConsumeRecoveryCode(user, code, ct))
{
await dbFactory.RunAdmin(async db =>
await db.Users.UpdateAsync(
u => u.Id == user.Id,
u => new User { FailedLoginCount = 0, LockoutUntil = null },
token: ct));
await auditLog.RecordMfaRecoveryUsed(user.Email, ct);
return ["pwd", "mfa", "recovery"];
}
await auditLog.RecordMfaLoginFailed(user.Email, ct);
throw new BusinessException(ExceptionEnum.InvalidMfaCode);
// AZ-557 — feed the shared failure-accounting helper. It records the audit
// row (mfa_login_failed), bumps failed_login_count, and on threshold-crossing
// throws InvalidCredentials + Retry-After (which we let propagate). If it
// does NOT throw, we fall through and throw the bare InvalidCredentials so
// the wire response is uniform with the password path.
await userService.RegisterMfaFailedLogin(user, ct);
throw new BusinessException(ExceptionEnum.InvalidCredentials);
}
private static bool VerifyTotpCode(string secretBase32, string code, long? lastUsedWindow, out long matchedWindow)