mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 20:41: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:
@@ -11,6 +11,11 @@ public interface IAuditLog
|
||||
Task RecordLoginLockout(string email, CancellationToken ct = default);
|
||||
Task RecordLoginSuccess(string email, CancellationToken ct = default);
|
||||
|
||||
// AZ-556 — per-category internal forensics. Wire response is uniformly
|
||||
// `InvalidCredentials`; these recorders keep SecOps's audit trail honest.
|
||||
Task RecordLoginFailedUnknownEmail(string email, CancellationToken ct = default);
|
||||
Task RecordLoginFailedDisabled (string email, CancellationToken ct = default);
|
||||
|
||||
// AZ-534 — MFA lifecycle + login auth-event audit.
|
||||
Task RecordMfaEnroll (string email, CancellationToken ct = default);
|
||||
Task RecordMfaConfirm (string email, CancellationToken ct = default);
|
||||
@@ -20,8 +25,12 @@ public interface IAuditLog
|
||||
Task RecordMfaRecoveryUsed (string email, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Number of `login_failed` rows for the given email within the last <paramref name="windowSeconds"/>.
|
||||
/// Used by the per-account sliding-window rate limit (AZ-537 AC-2).
|
||||
/// Count of failure-audit rows for the given email within the last
|
||||
/// <paramref name="windowSeconds"/> that feed the per-account sliding-window rate
|
||||
/// limit. Includes BOTH password (<c>login_failed</c>) and TOTP
|
||||
/// (<c>mfa_login_failed</c>) failures (AZ-537 AC-2 + AZ-557 AC-3). Disabled-account
|
||||
/// and unknown-email rejections are intentionally excluded — they don't reflect an
|
||||
/// account-credential attack that the lockout/rate-limit policy should escalate.
|
||||
/// </summary>
|
||||
Task<int> CountRecentFailedLogins(string email, int windowSeconds, CancellationToken ct = default);
|
||||
}
|
||||
@@ -37,6 +46,12 @@ public class AuditLog(IDbFactory dbFactory, IHttpContextAccessor httpContextAcce
|
||||
public Task RecordLoginSuccess(string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.LoginSuccess, email, ct);
|
||||
|
||||
public Task RecordLoginFailedUnknownEmail(string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.LoginFailedUnknownEmail, email, ct);
|
||||
|
||||
public Task RecordLoginFailedDisabled(string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.LoginFailedDisabled, email, ct);
|
||||
|
||||
public Task RecordMfaEnroll (string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.MfaEnroll, email, ct);
|
||||
public Task RecordMfaConfirm (string email, CancellationToken ct = default)
|
||||
@@ -54,9 +69,13 @@ public class AuditLog(IDbFactory dbFactory, IHttpContextAccessor httpContextAcce
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddSeconds(-windowSeconds);
|
||||
var normalised = email.ToLowerInvariant();
|
||||
// AZ-557 — MFA failures feed the same per-account sliding-window count as
|
||||
// password failures so an attacker who got past factor 1 can't brute-force
|
||||
// factor 2 from rotating IPs without tripping the per-account throttle.
|
||||
return await dbFactory.Run(async db =>
|
||||
await db.AuditEvents
|
||||
.Where(e => e.EventType == AuditEventTypes.LoginFailed
|
||||
.Where(e => (e.EventType == AuditEventTypes.LoginFailed
|
||||
|| e.EventType == AuditEventTypes.MfaLoginFailed)
|
||||
&& e.Email == normalised
|
||||
&& e.OccurredAt >= cutoff)
|
||||
.CountAsync(token: ct));
|
||||
|
||||
Reference in New Issue
Block a user