mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 12:11:09 +00:00
4bf2e689cb
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>
100 lines
4.9 KiB
C#
100 lines
4.9 KiB
C#
using Azaion.Common.Database;
|
|
using Azaion.Common.Entities;
|
|
using LinqToDB;
|
|
using Microsoft.AspNetCore.Http;
|
|
|
|
namespace Azaion.Services;
|
|
|
|
public interface IAuditLog
|
|
{
|
|
Task RecordLoginFailed (string email, CancellationToken ct = default);
|
|
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);
|
|
Task RecordMfaDisable (string email, CancellationToken ct = default);
|
|
Task RecordMfaLoginSuccess (string email, CancellationToken ct = default);
|
|
Task RecordMfaLoginFailed (string email, CancellationToken ct = default);
|
|
Task RecordMfaRecoveryUsed (string email, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// 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);
|
|
}
|
|
|
|
public class AuditLog(IDbFactory dbFactory, IHttpContextAccessor httpContextAccessor) : IAuditLog
|
|
{
|
|
public Task RecordLoginFailed (string email, CancellationToken ct = default)
|
|
=> Insert(AuditEventTypes.LoginFailed, email, ct);
|
|
|
|
public Task RecordLoginLockout(string email, CancellationToken ct = default)
|
|
=> Insert(AuditEventTypes.LoginLockout, email, ct);
|
|
|
|
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)
|
|
=> Insert(AuditEventTypes.MfaConfirm, email, ct);
|
|
public Task RecordMfaDisable (string email, CancellationToken ct = default)
|
|
=> Insert(AuditEventTypes.MfaDisable, email, ct);
|
|
public Task RecordMfaLoginSuccess (string email, CancellationToken ct = default)
|
|
=> Insert(AuditEventTypes.MfaLoginSuccess, email, ct);
|
|
public Task RecordMfaLoginFailed (string email, CancellationToken ct = default)
|
|
=> Insert(AuditEventTypes.MfaLoginFailed, email, ct);
|
|
public Task RecordMfaRecoveryUsed (string email, CancellationToken ct = default)
|
|
=> Insert(AuditEventTypes.MfaRecoveryUsed, email, ct);
|
|
|
|
public async Task<int> CountRecentFailedLogins(string email, int windowSeconds, CancellationToken ct = default)
|
|
{
|
|
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
|
|
|| e.EventType == AuditEventTypes.MfaLoginFailed)
|
|
&& e.Email == normalised
|
|
&& e.OccurredAt >= cutoff)
|
|
.CountAsync(token: ct));
|
|
}
|
|
|
|
private async Task Insert(string eventType, string email, CancellationToken ct)
|
|
{
|
|
var ip = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
|
var normalised = email.ToLowerInvariant();
|
|
await dbFactory.RunAdmin(async db =>
|
|
{
|
|
await db.InsertAsync(new AuditEvent
|
|
{
|
|
EventType = eventType,
|
|
OccurredAt = DateTime.UtcNow,
|
|
Email = normalised,
|
|
Ip = ip
|
|
}, token: ct);
|
|
});
|
|
}
|
|
}
|