mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 10:21:10 +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>
215 lines
9.1 KiB
C#
215 lines
9.1 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using Azaion.E2E.Helpers;
|
|
using FluentAssertions;
|
|
using Xunit;
|
|
|
|
namespace Azaion.E2E.Tests;
|
|
|
|
// AZ-537 — /login per-IP and per-account rate limit + account lockout.
|
|
//
|
|
// AC-1 (per-IP) is intentionally skipped at the e2e layer: every test in this
|
|
// container shares one source IP, so reliably triggering the per-IP partition
|
|
// without polluting the shared budget for other tests is impractical. The per-IP
|
|
// rate limit is implemented via ASP.NET Core's built-in SlidingWindowRateLimiter
|
|
// applied to /login via .RequireRateLimiting("login-per-ip") and is exercised by
|
|
// the framework's own tests; manual verification covers the wiring.
|
|
[Collection("E2E")]
|
|
public sealed class LoginRateLimitTests
|
|
{
|
|
private static readonly JsonSerializerOptions ResponseJsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
private sealed record ErrorResponse(int ErrorCode, string Message);
|
|
|
|
private readonly TestFixture _fixture;
|
|
|
|
public LoginRateLimitTests(TestFixture fixture) => _fixture = fixture;
|
|
|
|
[Fact(Skip = "Per-IP rate limit not testable in shared-IP container env; verified by ASP.NET Core RateLimiter unit tests + manual probe (AZ-537 AC-1).")]
|
|
public Task AC1_Per_ip_rate_limit_returns_429() => Task.CompletedTask;
|
|
|
|
[Fact]
|
|
public async Task AC2_Per_account_rate_limit_returns_429_with_retry_after()
|
|
{
|
|
// Arrange — fresh user; spec: 5 attempts / 5 min, the 6th is rate-limited.
|
|
var email = $"ratelimit-{Guid.NewGuid():N}@authtest.example.com";
|
|
const string correct = "Correct2026!";
|
|
await CreateUser(email, correct);
|
|
try
|
|
{
|
|
using var client = _fixture.CreateApiClient();
|
|
|
|
// 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.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.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(70, "InvalidCredentials == 70 (AZ-556)");
|
|
}
|
|
finally
|
|
{
|
|
await _fixture.Db.DeleteAuditEventsFor(email);
|
|
await _fixture.Db.DeleteUser(email);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AC3_Account_locks_after_threshold_consecutive_failures()
|
|
{
|
|
// Arrange — bypass per-account rate-limit by force-seeding the failure counter
|
|
// close to the lockout threshold; one more wrong attempt then trips it.
|
|
// This avoids flooding the audit_events table with 10 failures in 5 min, which
|
|
// the per-account window would block at the 6th.
|
|
var email = $"lockout-{Guid.NewGuid():N}@authtest.example.com";
|
|
const string correct = "Correct2026!";
|
|
await CreateUser(email, correct);
|
|
try
|
|
{
|
|
// Set FailedLoginCount = 9 directly; next failed login crosses the 10-attempt threshold.
|
|
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" });
|
|
|
|
// 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(70, "InvalidCredentials == 70 (AZ-556)");
|
|
trip.Headers.RetryAfter.Should().NotBeNull();
|
|
|
|
// DB state reflects the lockout
|
|
var (count, until) = await _fixture.Db.GetLockoutState(email);
|
|
count.Should().Be(10);
|
|
until.Should().NotBeNull().And.Subject.Should().BeAfter(DateTime.UtcNow);
|
|
|
|
// 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.Unauthorized);
|
|
}
|
|
finally
|
|
{
|
|
await _fixture.Db.DeleteAuditEventsFor(email);
|
|
await _fixture.Db.DeleteUser(email);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AC4_Successful_login_resets_failed_counter()
|
|
{
|
|
// Arrange
|
|
var email = $"reset-{Guid.NewGuid():N}@authtest.example.com";
|
|
const string correct = "Correct2026!";
|
|
await CreateUser(email, correct);
|
|
try
|
|
{
|
|
// Park the user with 5 prior failures (still below the 5/5min rate-limit
|
|
// count because we set them via DB, not via /login attempts).
|
|
await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 5);
|
|
|
|
using var client = _fixture.CreateApiClient();
|
|
|
|
// Act — correct password now
|
|
using var ok = await client.PostAsync("/login", new { email, password = correct });
|
|
|
|
// Assert
|
|
ok.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
var (count, until) = await _fixture.Db.GetLockoutState(email);
|
|
count.Should().Be(0);
|
|
until.Should().BeNull();
|
|
}
|
|
finally
|
|
{
|
|
await _fixture.Db.DeleteAuditEventsFor(email);
|
|
await _fixture.Db.DeleteUser(email);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AC5_Lockout_expires_after_duration_elapses()
|
|
{
|
|
// Arrange — set a lockout that already expired one second ago
|
|
var email = $"expired-{Guid.NewGuid():N}@authtest.example.com";
|
|
const string correct = "Correct2026!";
|
|
await CreateUser(email, correct);
|
|
try
|
|
{
|
|
await _fixture.Db.SetLockoutUntil(email,
|
|
lockoutUntilUtc: DateTime.UtcNow.AddSeconds(-1),
|
|
failedCount: 10);
|
|
|
|
using var client = _fixture.CreateApiClient();
|
|
|
|
// Act — correct password after lockout expiry
|
|
using var ok = await client.PostAsync("/login", new { email, password = correct });
|
|
|
|
// Assert
|
|
ok.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
var (count, until) = await _fixture.Db.GetLockoutState(email);
|
|
count.Should().Be(0);
|
|
until.Should().BeNull();
|
|
}
|
|
finally
|
|
{
|
|
await _fixture.Db.DeleteAuditEventsFor(email);
|
|
await _fixture.Db.DeleteUser(email);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AC6_Lockout_event_is_recorded_in_audit_log()
|
|
{
|
|
// Arrange — same setup as AC3 but assert audit_events
|
|
var email = $"audit-{Guid.NewGuid():N}@authtest.example.com";
|
|
const string correct = "Correct2026!";
|
|
await CreateUser(email, correct);
|
|
try
|
|
{
|
|
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" });
|
|
// AZ-556 — same opaque InvalidCredentials response now.
|
|
trip.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
|
|
// Act
|
|
var lockoutCount = await _fixture.Db.CountAuditEvents("login_lockout", email);
|
|
var failedCount = await _fixture.Db.CountAuditEvents("login_failed", email);
|
|
|
|
// Assert
|
|
lockoutCount.Should().Be(1, "exactly one login_lockout audit event must be present");
|
|
failedCount.Should().BeGreaterOrEqualTo(1, "the failing attempt is also audited");
|
|
}
|
|
finally
|
|
{
|
|
await _fixture.Db.DeleteAuditEventsFor(email);
|
|
await _fixture.Db.DeleteUser(email);
|
|
}
|
|
}
|
|
|
|
private async Task CreateUser(string email, string password)
|
|
{
|
|
using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
|
var created = await admin.PostAsync("/users", new { email, password, role = 10 });
|
|
created.IsSuccessStatusCode.Should().BeTrue($"setup: create user {email}");
|
|
}
|
|
}
|