[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
+6 -4
View File
@@ -117,12 +117,13 @@ public sealed class PasswordHashingTests
using var legacyResp = await client.PostAsync("/login", new { email = legacyEmail, password = wrong });
using var argon2Resp = await client.PostAsync("/login", new { email = argon2Email, password = wrong });
// Assert
// Assert — AZ-556 unified the wire response across all rejection categories;
// both hash formats now return the same opaque InvalidCredentials.
foreach (var resp in new[] { legacyResp, argon2Resp })
{
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
var err = await resp.Content.ReadFromJsonAsync<ErrorResponse>(ResponseJsonOptions);
err!.ErrorCode.Should().Be(30, "WrongPassword == 30");
err!.ErrorCode.Should().Be(70, "InvalidCredentials == 70 (AZ-556)");
}
}
finally
@@ -176,7 +177,8 @@ public sealed class PasswordHashingTests
var sw = System.Diagnostics.Stopwatch.StartNew();
using var r = await client.PostAsync("/login", new { email, password = pwd });
sw.Stop();
r.StatusCode.Should().Be(HttpStatusCode.Conflict, "wrong password");
r.StatusCode.Should().Be(HttpStatusCode.Unauthorized,
"AZ-556 — wrong-password is now InvalidCredentials (401)");
samples.Add((len, sw.Elapsed.TotalMilliseconds));
}
}