Files
admin/e2e/Azaion.E2E/Tests/LoginRateLimitTests.cs
T
Oleksandr Bezdieniezhnykh 491993f9c1
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status
[AZ-536] [AZ-537] [AZ-538] Argon2id, login rate limit + lockout, CORS https-only
AZ-536 — replace unsalted SHA-384 password hashing with Argon2id (RFC 9106).
Stored as PHC string with 64 MiB / 3 iter / 1 lane defaults; legacy SHA-384
hashes detected by prefix and lazily re-hashed on next successful login.
Verify uses CryptographicOperations.FixedTimeEquals on both formats.

AZ-537 — add per-IP sliding window rate limit on /login (ASP.NET Core
RateLimiter, 10/60s default — production-tight) plus DB-backed per-account
limit (5/300s) and consecutive-failure lockout (10 / 15 min) on the users
row. Adds a generic audit_events table with INSERT/SELECT-only grants for
the app role so the per-account count is queryable and admins cannot erase
their own forensic trail. BusinessExceptionHandler maps AccountLocked to
423 and LoginRateLimited to 429, both with Retry-After.

AZ-538 — drop the http://admin.azaion.com origin from CORS, gate
UseHsts() + UseHttpsRedirection() to non-Development envs (1y / preload).

Test infra: Npgsql in the e2e project + a DbHelper for direct DB
inspection used by the AZ-536/537 ACs. appsettings.Development.json
raises PerIpPermitLimit to 1000 so the suite (~270 logins from one
container IP) doesn't false-trip the limiter.

Tests: 53 pass + 3 documented skips (per-IP rate limit needs distinct
client IPs; HSTS/HTTPS redirect need ASPNETCORE_ENVIRONMENT=Production).

Code review: PASS_WITH_WARNINGS — 0 Critical, 0 High, 1 Medium, 3 Low.
See _docs/03_implementation/reviews/batch_01_cycle2_review.md.

Closes AZ-530 epic batch 1 of 4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 04:52:31 +03:00

206 lines
8.4 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.
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");
}
// 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.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");
}
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 — 423 immediately on the threshold-crossing attempt
trip.StatusCode.Should().Be(HttpStatusCode.Locked);
var err = await trip.Content.ReadFromJsonAsync<ErrorResponse>(ResponseJsonOptions);
err!.ErrorCode.Should().Be(50, "AccountLocked == 50");
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 423 until expiry
using var locked = await client.PostAsync("/login", new { email, password = correct });
locked.StatusCode.Should().Be(HttpStatusCode.Locked);
}
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" });
trip.StatusCode.Should().Be(HttpStatusCode.Locked);
// 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}");
}
}