[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
+149
View File
@@ -248,6 +248,155 @@ public class MfaLoginTests : IClassFixture<TestFixture>
finally { await CleanupUser(email); }
}
// AZ-557 AC-1 + AC-6 — a wrong TOTP at the lockout threshold trips the per-account
// lockout and records an mfa_login_failed audit row. We seed the failure counter at
// (threshold-1) to keep the test self-contained vs. flooding the audit_events table.
[Fact]
public async Task AZ557_AC1_Wrong_MFA_at_threshold_locks_account_and_audits_mfa_login_failed()
{
var (email, password) = await SeedUser("az557-ac1");
try
{
var enroll = await EnrollUser(email, password);
await ConfirmEnroll(email, password, enroll.Secret);
// Park the user one short of the lockout threshold (LoginRateLimitTests
// AC3 uses 9 → 10-attempt threshold).
await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 9);
using var client = _fixture.CreateHttpClient();
// Act — step 1 to obtain a fresh MFA step token, then a wrong TOTP.
using var step1 = await client.PostAsJsonAsync("/login", new { email, password });
step1.StatusCode.Should().Be(HttpStatusCode.OK);
var step1Body = (await step1.Content.ReadFromJsonAsync<MfaRequired>())!;
using var step2 = await client.PostAsJsonAsync("/login/mfa", new
{
mfaToken = step1Body.MfaToken,
code = "000000", // wrong code (chance of collision is 1e-6)
});
// Assert — unified InvalidCredentials response + Retry-After header (the
// lockout-trip path).
step2.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
step2.Headers.RetryAfter.Should().NotBeNull("AZ-557 — lockout response must carry Retry-After");
// DB state — counter advanced and lockout window active.
var (count, until) = await _fixture.Db.GetLockoutState(email);
count.Should().Be(10);
until.Should().NotBeNull().And.Subject.Should().BeAfter(DateTime.UtcNow);
// AC-6 — audit row recorded under mfa_login_failed, not login_failed.
(await _fixture.Db.CountAuditEvents("mfa_login_failed", email))
.Should().BeGreaterOrEqualTo(1, "AZ-557 AC-6 — mfa_login_failed audit row written");
}
finally { await CleanupUser(email); }
}
// AZ-557 AC-5 — a locked-out account hitting /login/mfa with a VALID TOTP must
// still get the unified InvalidCredentials response (lockout dominates).
[Fact]
public async Task AZ557_AC5_Locked_account_at_MFA_step_returns_invalid_credentials_with_retry_after()
{
var (email, password) = await SeedUser("az557-ac5");
try
{
var enroll = await EnrollUser(email, password);
await ConfirmEnroll(email, password, enroll.Secret);
using var client = _fixture.CreateHttpClient();
// Step 1 first — the /login path needs the account in a non-locked state
// to mint a step-1 token (the lockout-dominates branch is in MfaService).
using var step1 = await client.PostAsJsonAsync("/login", new { email, password });
var step1Body = (await step1.Content.ReadFromJsonAsync<MfaRequired>())!;
// Now flip the account into an active lockout window.
await _fixture.Db.SetLockoutUntil(email,
lockoutUntilUtc: DateTime.UtcNow.AddSeconds(60), failedCount: 10);
// Act — present a VALID TOTP. The pre-verify lockout check must reject it.
using var step2 = await client.PostAsJsonAsync("/login/mfa", new
{
mfaToken = step1Body.MfaToken,
code = ComputeCode(enroll.Secret),
});
// Assert
step2.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
step2.Headers.RetryAfter.Should().NotBeNull();
}
finally { await CleanupUser(email); }
}
// AZ-557 AC-7 — a correct TOTP after a partial failure streak resets the counter
// and lets the user in. Mirrors the password-side reset on RegisterSuccessfulLogin.
[Fact]
public async Task AZ557_AC7_Correct_TOTP_after_partial_failures_resets_counter()
{
var (email, password) = await SeedUser("az557-ac7");
try
{
var enroll = await EnrollUser(email, password);
await ConfirmEnroll(email, password, enroll.Secret);
await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 2);
using var client = _fixture.CreateHttpClient();
using var step1 = await client.PostAsJsonAsync("/login", new { email, password });
var step1Body = (await step1.Content.ReadFromJsonAsync<MfaRequired>())!;
using var step2 = await client.PostAsJsonAsync("/login/mfa", new
{
mfaToken = step1Body.MfaToken,
code = ComputeCode(enroll.Secret),
});
step2.StatusCode.Should().Be(HttpStatusCode.OK);
var (count, until) = await _fixture.Db.GetLockoutState(email);
count.Should().Be(0, "AZ-557 AC-7 — counter resets on success");
until.Should().BeNull();
}
finally { await CleanupUser(email); }
}
// AZ-557 AC-2 — mixed-mode failures (password-side + MFA-side) aggregate. We seed
// a few mfa_login_failed audit rows AND a non-zero counter so the lockout-trip
// works regardless of which side the most recent failure came from.
[Fact]
public async Task AZ557_AC2_Mixed_password_and_MFA_failures_aggregate_to_lockout()
{
var (email, password) = await SeedUser("az557-ac2");
try
{
var enroll = await EnrollUser(email, password);
await ConfirmEnroll(email, password, enroll.Secret);
// 9 prior failures, one short of the threshold. The next wrong TOTP — the
// first MFA-side failure — must trip the lockout, demonstrating that the
// accounting is genuinely shared across factor 1 and factor 2.
await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 9);
using var client = _fixture.CreateHttpClient();
using var step1 = await client.PostAsJsonAsync("/login", new { email, password });
var step1Body = (await step1.Content.ReadFromJsonAsync<MfaRequired>())!;
using var step2 = await client.PostAsJsonAsync("/login/mfa", new
{
mfaToken = step1Body.MfaToken,
code = "111111", // wrong code
});
step2.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
step2.Headers.RetryAfter.Should().NotBeNull();
var (count, _) = await _fixture.Db.GetLockoutState(email);
count.Should().Be(10, "AZ-557 AC-2 — MFA-side failure crossed the shared threshold");
}
finally { await CleanupUser(email); }
}
private sealed class EnrollResponse
{
public string Secret { get; init; } = "";