mirror of
https://github.com/azaion/admin.git
synced 2026-06-22 06:51:10 +00:00
[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:
@@ -43,20 +43,25 @@ public sealed class LoginRateLimitTests
|
||||
{
|
||||
using var client = _fixture.CreateApiClient();
|
||||
|
||||
// Act — 5 wrong attempts seed the per-account counter.
|
||||
// Act — 5 wrong attempts seed the per-account counter. AZ-556 unifies the
|
||||
// response to InvalidCredentials (401), so every attempt — wrong, rate-
|
||||
// limited, or locked — looks the same on the wire. Retry-After is the only
|
||||
// signal that the rate-limit branch is in play.
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
using var r = await client.PostAsync("/login", new { email, password = $"wrong-{i}" });
|
||||
r.StatusCode.Should().Be(HttpStatusCode.Conflict, $"attempt {i + 1} should still get WrongPassword");
|
||||
r.StatusCode.Should().Be(HttpStatusCode.Unauthorized,
|
||||
$"attempt {i + 1} should be wrong-password / InvalidCredentials");
|
||||
}
|
||||
// The 6th attempt — even with the *correct* password — must be rate-limited.
|
||||
using var sixth = await client.PostAsync("/login", new { email, password = correct });
|
||||
|
||||
// Assert
|
||||
sixth.StatusCode.Should().Be(HttpStatusCode.TooManyRequests);
|
||||
sixth.StatusCode.Should().Be(HttpStatusCode.Unauthorized,
|
||||
"AZ-556 collapses lockout/rate-limit responses to InvalidCredentials too");
|
||||
sixth.Headers.RetryAfter.Should().NotBeNull("Retry-After should hint when to try again");
|
||||
var err = await sixth.Content.ReadFromJsonAsync<ErrorResponse>(ResponseJsonOptions);
|
||||
err!.ErrorCode.Should().Be(51, "LoginRateLimited == 51");
|
||||
err!.ErrorCode.Should().Be(70, "InvalidCredentials == 70 (AZ-556)");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -83,10 +88,12 @@ public sealed class LoginRateLimitTests
|
||||
using var client = _fixture.CreateApiClient();
|
||||
using var trip = await client.PostAsync("/login", new { email, password = "wrong-final" });
|
||||
|
||||
// Assert — 423 immediately on the threshold-crossing attempt
|
||||
trip.StatusCode.Should().Be(HttpStatusCode.Locked);
|
||||
// Assert — AZ-556 collapses the lockout-trip response into the same
|
||||
// InvalidCredentials shape as a wrong-password rejection, distinguished
|
||||
// only by the Retry-After header.
|
||||
trip.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
var err = await trip.Content.ReadFromJsonAsync<ErrorResponse>(ResponseJsonOptions);
|
||||
err!.ErrorCode.Should().Be(50, "AccountLocked == 50");
|
||||
err!.ErrorCode.Should().Be(70, "InvalidCredentials == 70 (AZ-556)");
|
||||
trip.Headers.RetryAfter.Should().NotBeNull();
|
||||
|
||||
// DB state reflects the lockout
|
||||
@@ -94,9 +101,10 @@ public sealed class LoginRateLimitTests
|
||||
count.Should().Be(10);
|
||||
until.Should().NotBeNull().And.Subject.Should().BeAfter(DateTime.UtcNow);
|
||||
|
||||
// Subsequent attempts with the *correct* password also return 423 until expiry
|
||||
// Subsequent attempts with the *correct* password also return InvalidCredentials
|
||||
// until the lockout expires.
|
||||
using var locked = await client.PostAsync("/login", new { email, password = correct });
|
||||
locked.StatusCode.Should().Be(HttpStatusCode.Locked);
|
||||
locked.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -179,7 +187,8 @@ public sealed class LoginRateLimitTests
|
||||
await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 9);
|
||||
using var client = _fixture.CreateApiClient();
|
||||
using var trip = await client.PostAsync("/login", new { email, password = "wrong-final" });
|
||||
trip.StatusCode.Should().Be(HttpStatusCode.Locked);
|
||||
// AZ-556 — same opaque InvalidCredentials response now.
|
||||
trip.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
|
||||
// Act
|
||||
var lockoutCount = await _fixture.Db.CountAuditEvents("login_lockout", email);
|
||||
|
||||
Reference in New Issue
Block a user