mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 18:21:09 +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:
@@ -71,25 +71,40 @@ public sealed class AuthTests
|
||||
jwt.Claims.Should().Contain(c => c.Type == JwtRegisteredClaimNames.Jti);
|
||||
}
|
||||
|
||||
// AZ-556 AC-1 — unknown email is now indistinguishable from wrong password.
|
||||
[Fact]
|
||||
public async Task Login_with_unknown_email_returns_409_with_error_code_10()
|
||||
public async Task Login_with_unknown_email_returns_401_invalid_credentials()
|
||||
{
|
||||
// Arrange
|
||||
// Arrange — use a fresh per-test email so the audit assertion below cannot
|
||||
// false-pass on a leftover row from another test.
|
||||
var unknownEmail = $"unknown-{Guid.NewGuid():N}@authtest.example.com";
|
||||
using var client = _fixture.CreateApiClient();
|
||||
try
|
||||
{
|
||||
// Act
|
||||
using var response = await client.PostAsync("/login",
|
||||
new { email = unknownEmail, password = "irrelevant" });
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync("/login",
|
||||
new { email = "nonexistent@example.com", password = "irrelevant" });
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
var err = await response.Content.ReadFromJsonAsync<ErrorResponse>(ResponseJsonOptions);
|
||||
err.Should().NotBeNull();
|
||||
err!.ErrorCode.Should().Be(70, "InvalidCredentials == 70 (AZ-556)");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||
var err = await response.Content.ReadFromJsonAsync<ErrorResponse>(ResponseJsonOptions);
|
||||
err.Should().NotBeNull();
|
||||
err!.ErrorCode.Should().Be(10);
|
||||
// AZ-556 AC-6 — audit log records the unknown-email category internally
|
||||
// even though the wire response is opaque.
|
||||
(await _fixture.Db.CountAuditEvents("login_failed_unknown_email", unknownEmail))
|
||||
.Should().BeGreaterOrEqualTo(1, "audit must still record the unknown-email reason");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _fixture.Db.DeleteAuditEventsFor(unknownEmail);
|
||||
}
|
||||
}
|
||||
|
||||
// AZ-556 AC-2 — wrong password collapses to the same response shape as unknown email.
|
||||
[Fact]
|
||||
public async Task Login_with_wrong_password_returns_409_with_error_code_30()
|
||||
public async Task Login_with_wrong_password_returns_401_invalid_credentials()
|
||||
{
|
||||
// Arrange
|
||||
using var client = _fixture.CreateApiClient();
|
||||
@@ -99,9 +114,76 @@ public sealed class AuthTests
|
||||
new { email = _fixture.AdminEmail, password = "DefinitelyWrongPassword" });
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
var err = await response.Content.ReadFromJsonAsync<ErrorResponse>(ResponseJsonOptions);
|
||||
err.Should().NotBeNull();
|
||||
err!.ErrorCode.Should().Be(30);
|
||||
err!.ErrorCode.Should().Be(70, "InvalidCredentials == 70 (AZ-556)");
|
||||
}
|
||||
|
||||
// AZ-556 AC-1 + AC-2 — wire response (status, body) for unknown email and wrong
|
||||
// password MUST be byte-equivalent except for the human-readable message text
|
||||
// (which is identical too because both throw the same ExceptionEnum).
|
||||
[Fact]
|
||||
public async Task Login_unknown_email_and_wrong_password_produce_identical_response()
|
||||
{
|
||||
// Arrange
|
||||
using var client = _fixture.CreateApiClient();
|
||||
|
||||
// Act
|
||||
using var unknown = await client.PostAsync("/login",
|
||||
new { email = "nonexistent@example.com", password = "irrelevant" });
|
||||
using var wrong = await client.PostAsync("/login",
|
||||
new { email = _fixture.AdminEmail, password = "DefinitelyWrongPassword" });
|
||||
|
||||
// Assert
|
||||
unknown.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
wrong.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
|
||||
var unknownBody = await unknown.Content.ReadAsStringAsync();
|
||||
var wrongBody = await wrong.Content.ReadAsStringAsync();
|
||||
unknownBody.Should().Be(wrongBody, "AZ-556 — wire payloads must be byte-identical");
|
||||
}
|
||||
|
||||
// AZ-556 AC-3 — disabled-account response is indistinguishable from wrong password.
|
||||
[Fact]
|
||||
public async Task Login_with_disabled_account_returns_401_invalid_credentials_indistinguishable_from_wrong_password()
|
||||
{
|
||||
// Arrange — create a fresh user, then disable them via the admin endpoint.
|
||||
var email = $"disabled-{Guid.NewGuid():N}@authtest.example.com";
|
||||
const string password = "Correct2026!";
|
||||
using (var create = await _fixture.HttpClient.PostAsJsonAsync("/users",
|
||||
new { email, password, role = 10 }))
|
||||
create.IsSuccessStatusCode.Should().BeTrue($"setup: create user {email}");
|
||||
try
|
||||
{
|
||||
using (var disable = await _fixture.HttpClient.PutAsync(
|
||||
$"/users/{Uri.EscapeDataString(email)}/disable", content: null))
|
||||
disable.IsSuccessStatusCode.Should().BeTrue("setup: disable the user");
|
||||
|
||||
using var anon = _fixture.CreateApiClient();
|
||||
|
||||
// Act — present the correct password to the disabled account, and a wrong
|
||||
// password to a known-enabled account. The wire responses must match.
|
||||
using var disabledResp = await anon.PostAsync("/login", new { email, password });
|
||||
using var wrongResp = await anon.PostAsync("/login",
|
||||
new { email = _fixture.AdminEmail, password = "DefinitelyWrongPassword" });
|
||||
|
||||
// Assert
|
||||
disabledResp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
wrongResp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
var disabledBody = await disabledResp.Content.ReadAsStringAsync();
|
||||
var wrongBody = await wrongResp.Content.ReadAsStringAsync();
|
||||
disabledBody.Should().Be(wrongBody, "AZ-556 — disabled-account body must match wrong-password body");
|
||||
|
||||
// AZ-556 AC-6 — audit log preserves the internal granularity even though
|
||||
// the wire response was unified.
|
||||
(await _fixture.Db.CountAuditEvents("login_failed_disabled", email))
|
||||
.Should().BeGreaterOrEqualTo(1, "audit must still record the disabled-account reason");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _fixture.Db.DeleteAuditEventsFor(email);
|
||||
await _fixture.Db.DeleteUser(email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; } = "";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,11 +182,14 @@ public sealed class SecurityTests
|
||||
// Act
|
||||
using var login = await client.PostAsync("/login", new { email, password });
|
||||
|
||||
// Assert
|
||||
login.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||
// Assert — AZ-556 unified the disabled-account response with the wrong-
|
||||
// password response. The indistinguishability check (byte-for-byte body
|
||||
// equality + audit-log granularity) lives in AuthTests
|
||||
// `Login_with_disabled_account_returns_401_invalid_credentials_indistinguishable_from_wrong_password`.
|
||||
login.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
var err = await login.Content.ReadFromJsonAsync<ErrorResponse>(ResponseJsonOptions);
|
||||
err.Should().NotBeNull();
|
||||
err!.ErrorCode.Should().Be(38);
|
||||
err!.ErrorCode.Should().Be(70, "InvalidCredentials == 70 (AZ-556)");
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user