[AZ-536] [AZ-537] [AZ-538] Argon2id, login rate limit + lockout, CORS https-only
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status

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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 04:52:31 +03:00
parent 9679b5636f
commit 491993f9c1
31 changed files with 1327 additions and 36 deletions
+1
View File
@@ -16,6 +16,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Npgsql" Version="10.0.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.6.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
+117
View File
@@ -0,0 +1,117 @@
using System.Globalization;
using Npgsql;
namespace Azaion.E2E.Helpers;
/// <summary>
/// Thin wrapper around <see cref="NpgsqlConnection"/> for tests that must inspect
/// or seed rows directly. Used by AZ-536 (password hash format) and AZ-537
/// (lockout state, audit_events) acceptance tests.
/// </summary>
public sealed class DbHelper
{
private readonly string _connectionString;
public DbHelper(string connectionString) => _connectionString = connectionString;
public async Task<string?> GetPasswordHash(string email, CancellationToken ct = default)
{
await using var conn = await OpenAsync(ct);
await using var cmd = new NpgsqlCommand(
"SELECT password_hash FROM public.users WHERE email = @e", conn);
cmd.Parameters.AddWithValue("e", email);
var raw = await cmd.ExecuteScalarAsync(ct);
return raw == null || raw is DBNull ? null : (string)raw;
}
public async Task<(int FailedLoginCount, DateTime? LockoutUntil)> GetLockoutState(string email, CancellationToken ct = default)
{
await using var conn = await OpenAsync(ct);
await using var cmd = new NpgsqlCommand(
"SELECT failed_login_count, lockout_until FROM public.users WHERE email = @e", conn);
cmd.Parameters.AddWithValue("e", email);
await using var rd = await cmd.ExecuteReaderAsync(ct);
if (!await rd.ReadAsync(ct))
throw new InvalidOperationException($"User {email} not found.");
var failed = rd.GetInt32(0);
DateTime? lockout = rd.IsDBNull(1) ? null : DateTime.SpecifyKind(rd.GetDateTime(1), DateTimeKind.Utc);
return (failed, lockout);
}
public async Task<int> CountAuditEvents(string eventType, string email, CancellationToken ct = default)
{
await using var conn = await OpenAsync(ct);
await using var cmd = new NpgsqlCommand(
"SELECT COUNT(*) FROM public.audit_events WHERE event_type = @t AND email = @e", conn);
cmd.Parameters.AddWithValue("t", eventType);
cmd.Parameters.AddWithValue("e", email);
var raw = await cmd.ExecuteScalarAsync(ct);
return Convert.ToInt32(raw, CultureInfo.InvariantCulture);
}
/// <summary>
/// Inject a user with a known legacy SHA-384 hash so the lazy-migration path can be
/// exercised end-to-end without going through the Argon2id-using registration API.
/// </summary>
public async Task SeedLegacyShaUser(Guid id, string email, string sha384HashBase64, string role = "ResourceUploader", CancellationToken ct = default)
{
await using var conn = await OpenAsync(ct);
await using var cmd = new NpgsqlCommand(@"
INSERT INTO public.users (id, email, password_hash, role, created_at, is_enabled, failed_login_count)
VALUES (@id, @email, @hash, @role, now(), true, 0)
ON CONFLICT (email) DO UPDATE
SET password_hash = excluded.password_hash,
failed_login_count = 0,
lockout_until = NULL,
is_enabled = true", conn);
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("email", email);
cmd.Parameters.AddWithValue("hash", sha384HashBase64);
cmd.Parameters.AddWithValue("role", role);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task DeleteUser(string email, CancellationToken ct = default)
{
await using var conn = await OpenAsync(ct);
await using var cmd = new NpgsqlCommand(
"DELETE FROM public.users WHERE email = @e", conn);
cmd.Parameters.AddWithValue("e", email);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task DeleteAuditEventsFor(string email, CancellationToken ct = default)
{
await using var conn = await OpenAsync(ct);
await using var cmd = new NpgsqlCommand(
"DELETE FROM public.audit_events WHERE email = @e", conn);
cmd.Parameters.AddWithValue("e", email);
await cmd.ExecuteNonQueryAsync(ct);
}
/// <summary>
/// Force-set a lockout deadline so tests don't have to actually trip the threshold
/// 10 times to check expiry behavior (AZ-537 AC-5).
/// </summary>
public async Task SetLockoutUntil(string email, DateTime? lockoutUntilUtc, int failedCount, CancellationToken ct = default)
{
await using var conn = await OpenAsync(ct);
await using var cmd = new NpgsqlCommand(@"
UPDATE public.users
SET lockout_until = @until,
failed_login_count = @count
WHERE email = @e", conn);
cmd.Parameters.AddWithValue("until",
(object?)(lockoutUntilUtc?.ToUniversalTime()) ?? DBNull.Value);
cmd.Parameters.AddWithValue("count", failedCount);
cmd.Parameters.AddWithValue("e", email);
await cmd.ExecuteNonQueryAsync(ct);
}
private async Task<NpgsqlConnection> OpenAsync(CancellationToken ct)
{
var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
return conn;
}
}
+4
View File
@@ -13,6 +13,7 @@ public sealed class TestSettings
[Required] public string UploaderEmail { get; init; } = null!;
[Required] public string UploaderPassword { get; init; } = null!;
[Required] public string JwtSecret { get; init; } = null!;
[Required] public string TestDbConnectionString { get; init; } = null!;
}
public sealed class TestFixture : IAsyncLifetime
@@ -25,6 +26,7 @@ public sealed class TestFixture : IAsyncLifetime
public string UploaderEmail => Settings.UploaderEmail;
public string UploaderPassword => Settings.UploaderPassword;
public string JwtSecret => Settings.JwtSecret;
public DbHelper Db { get; private set; } = null!;
public IConfiguration Configuration { get; private set; } = null!;
public async Task InitializeAsync()
@@ -39,6 +41,8 @@ public sealed class TestFixture : IAsyncLifetime
?? throw new InvalidOperationException("Failed to bind TestSettings from configuration.");
Validator.ValidateObject(Settings, new ValidationContext(Settings), validateAllProperties: true);
Db = new DbHelper(Settings.TestDbConnectionString);
var baseUri = new Uri(Settings.ApiBaseUrl, UriKind.Absolute);
HttpClient = new HttpClient { BaseAddress = baseUri, Timeout = TimeSpan.FromMinutes(5) };
+91
View File
@@ -0,0 +1,91 @@
using System.Net;
using System.Net.Http;
using Azaion.E2E.Helpers;
using FluentAssertions;
using Xunit;
namespace Azaion.E2E.Tests;
// AZ-538 — CORS http-origin removal + HSTS + HTTPS redirection.
//
// The test environment runs ASPNETCORE_ENVIRONMENT=Development (matches the
// docker-compose.test.yml setting), so AC-3/AC-4 (production-only behavior) are
// verified by direct inspection of the production-side code path:
// `if (!app.Environment.IsDevelopment()) { app.UseHsts(); app.UseHttpsRedirection(); }`
// — the same gate ASP.NET Core's standard project template uses.
//
// AC-1, AC-2, and AC-5 are exercised here against the running container.
[Collection("E2E")]
public sealed class CorsHttpsTests
{
private readonly TestFixture _fixture;
public CorsHttpsTests(TestFixture fixture) => _fixture = fixture;
[Fact]
public async Task AC1_Http_origin_is_rejected_by_cors_preflight()
{
// Arrange
using var bare = new HttpClient { BaseAddress = new Uri(_fixture.Settings.ApiBaseUrl), Timeout = TimeSpan.FromSeconds(30) };
var preflight = new HttpRequestMessage(HttpMethod.Options, "/login");
preflight.Headers.Add("Origin", "http://admin.azaion.com");
preflight.Headers.Add("Access-Control-Request-Method", "POST");
preflight.Headers.Add("Access-Control-Request-Headers", "content-type");
// Act
using var response = await bare.SendAsync(preflight);
// Assert
response.Headers.Contains("Access-Control-Allow-Origin").Should().BeFalse(
"the http origin is no longer allow-listed");
}
[Fact]
public async Task AC2_Https_origin_is_accepted_with_credentials()
{
// Arrange
using var bare = new HttpClient { BaseAddress = new Uri(_fixture.Settings.ApiBaseUrl), Timeout = TimeSpan.FromSeconds(30) };
var preflight = new HttpRequestMessage(HttpMethod.Options, "/login");
preflight.Headers.Add("Origin", "https://admin.azaion.com");
preflight.Headers.Add("Access-Control-Request-Method", "POST");
preflight.Headers.Add("Access-Control-Request-Headers", "content-type");
// Act
using var response = await bare.SendAsync(preflight);
// Assert
response.Headers.GetValues("Access-Control-Allow-Origin")
.Should().ContainSingle().Which.Should().Be("https://admin.azaion.com");
response.Headers.GetValues("Access-Control-Allow-Credentials")
.Should().ContainSingle().Which.Should().Be("true");
}
// AZ-538 AC-3 — HSTS is gated to non-Development envs in Program.cs:
// if (!app.Environment.IsDevelopment()) { app.UseHsts(); ... }
// The test container runs ASPNETCORE_ENVIRONMENT=Development, so the production
// path can't be exercised here without spinning a second Production-mode SUT.
// Tracked here as a skipped Fact so the AC stays linked to a test rather than a
// file-level comment; promote to a runnable test when a prod-mode harness exists.
[Fact(Skip = "AZ-538 AC-3 requires ASPNETCORE_ENVIRONMENT=Production; test harness runs Development. Verified by code inspection of Program.cs UseHsts gate.")]
public void AC3_Hsts_header_present_in_production() { }
// AZ-538 AC-4 — same gate as AC-3: UseHttpsRedirection only fires when not
// Development. Skipped for the same prod-only reason.
[Fact(Skip = "AZ-538 AC-4 requires ASPNETCORE_ENVIRONMENT=Production; test harness runs Development. Verified by code inspection of Program.cs UseHttpsRedirection gate.")]
public void AC4_Http_request_redirects_to_https_in_production() { }
[Fact]
public async Task AC5_Development_env_does_not_redirect_or_send_hsts()
{
// Arrange
using var bare = new HttpClient { BaseAddress = new Uri(_fixture.Settings.ApiBaseUrl), Timeout = TimeSpan.FromSeconds(30) };
// Act
using var response = await bare.GetAsync("/health/live");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Headers.Contains("Strict-Transport-Security").Should().BeFalse(
"Development env must not emit HSTS so http://localhost workflows still work");
}
}
+205
View File
@@ -0,0 +1,205 @@
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}");
}
}
@@ -0,0 +1,203 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Azaion.E2E.Helpers;
using FluentAssertions;
using Xunit;
namespace Azaion.E2E.Tests;
// AZ-536 — Argon2id password hashing + lazy migration of legacy SHA-384 hashes.
[Collection("E2E")]
public sealed class PasswordHashingTests
{
private static readonly JsonSerializerOptions ResponseJsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private sealed record ErrorResponse(int ErrorCode, string Message);
private sealed record LoginOkResponse(string Token);
private readonly TestFixture _fixture;
public PasswordHashingTests(TestFixture fixture) => _fixture = fixture;
[Fact]
public async Task AC1_New_user_password_hash_uses_argon2id_phc_format()
{
// Arrange
var email = $"pwd-{Guid.NewGuid():N}@hashtest.example.com";
const string password = "FreshPassword123!";
try
{
using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
using var register = await admin.PostAsync("/users",
new { email, password, role = 10 });
register.IsSuccessStatusCode.Should().BeTrue();
// Act
var stored = await _fixture.Db.GetPasswordHash(email);
// Assert
stored.Should().NotBeNullOrEmpty();
stored!.Should().StartWith("$argon2id$v=19$m=");
// Parse PHC params and verify they meet RFC 9106 conservative bounds.
var paramSegment = stored!.Split('$')[3].Split(',');
int.Parse(paramSegment[0][2..]).Should().BeGreaterOrEqualTo(65536, "memory ≥ 64 MiB");
int.Parse(paramSegment[1][2..]).Should().BeGreaterOrEqualTo(3, "iterations ≥ 3");
int.Parse(paramSegment[2][2..]).Should().BeGreaterOrEqualTo(1, "parallelism ≥ 1");
}
finally
{
await _fixture.Db.DeleteUser(email);
}
}
[Fact]
public async Task AC2_AC3_Legacy_sha384_hash_validates_then_transparently_rehashes()
{
// Arrange
var email = $"legacy-{Guid.NewGuid():N}@hashtest.example.com";
const string password = "LegacyUserPwd99!";
var sha384Hash = Convert.ToBase64String(SHA384.HashData(Encoding.UTF8.GetBytes(password)));
try
{
await _fixture.Db.SeedLegacyShaUser(Guid.NewGuid(), email, sha384Hash);
(await _fixture.Db.GetPasswordHash(email)).Should().Be(sha384Hash, "preconditions");
using var client = _fixture.CreateApiClient();
// Act
using var login = await client.PostAsync("/login", new { email, password });
// Assert AC-2 — login succeeds against the legacy hash
login.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await login.Content.ReadFromJsonAsync<LoginOkResponse>(ResponseJsonOptions);
body!.Token.Should().NotBeNullOrWhiteSpace();
// Assert AC-3 — the row is now stored as Argon2id
var afterLogin = await _fixture.Db.GetPasswordHash(email);
afterLogin.Should().StartWith("$argon2id$v=19$m=");
// The same plaintext keeps validating after the rehash
using var login2 = await client.PostAsync("/login", new { email, password });
login2.StatusCode.Should().Be(HttpStatusCode.OK);
}
finally
{
await _fixture.Db.DeleteAuditEventsFor(email);
await _fixture.Db.DeleteUser(email);
}
}
[Fact]
public async Task AC4_Wrong_password_fails_for_both_hash_formats()
{
// Arrange
var legacyEmail = $"legacy-wrong-{Guid.NewGuid():N}@hashtest.example.com";
var argon2Email = $"argon2-wrong-{Guid.NewGuid():N}@hashtest.example.com";
const string correct = "CorrectPwd123!";
const string wrong = "WrongPwd456?";
var sha384Hash = Convert.ToBase64String(SHA384.HashData(Encoding.UTF8.GetBytes(correct)));
try
{
await _fixture.Db.SeedLegacyShaUser(Guid.NewGuid(), legacyEmail, sha384Hash);
using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
using var register = await admin.PostAsync("/users",
new { email = argon2Email, password = correct, role = 10 });
register.IsSuccessStatusCode.Should().BeTrue();
using var client = _fixture.CreateApiClient();
// Act
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
foreach (var resp in new[] { legacyResp, argon2Resp })
{
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
var err = await resp.Content.ReadFromJsonAsync<ErrorResponse>(ResponseJsonOptions);
err!.ErrorCode.Should().Be(30, "WrongPassword == 30");
}
}
finally
{
await _fixture.Db.DeleteAuditEventsFor(legacyEmail);
await _fixture.Db.DeleteAuditEventsFor(argon2Email);
await _fixture.Db.DeleteUser(legacyEmail);
await _fixture.Db.DeleteUser(argon2Email);
}
}
[Fact]
public async Task AC5_Verify_uses_constant_time_comparator_no_obvious_timing_leak()
{
// Arrange — register a user with a known Argon2id hash, then time many wrong
// attempts of varying lengths. Argon2's per-call cost (≈ 50-200 ms with m=64MiB)
// dominates any byte-compare timing, which is exactly what the constant-time
// verify guarantees. We assert the spread is a small fraction of the mean —
// a true plaintext-prefix oracle would show wildly different means per length.
var email = $"timing-{Guid.NewGuid():N}@hashtest.example.com";
const string correct = "TimingProbe2026!";
try
{
using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
(await admin.PostAsync("/users", new { email, password = correct, role = 10 }))
.IsSuccessStatusCode.Should().BeTrue();
using var client = _fixture.CreateApiClient();
// Warmup so JIT + connection pool don't skew the first measurements.
// Reset failure state after each warmup so the per-account rate limit
// (5 fails / 5 min) doesn't fire during the timed loop below.
for (var i = 0; i < 2; i++)
{
await client.PostAsync("/login", new { email, password = "warmup-bad" });
await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 0);
await _fixture.Db.DeleteAuditEventsFor(email);
}
var samples = new List<(int Len, double Ms)>();
int[] lengths = [1, 8, 32];
foreach (var len in lengths)
{
var pwd = new string('x', len);
for (var i = 0; i < 3; i++)
{
// Per-account rate limit counts failed-login audit events; clearing
// them between samples keeps the limiter from short-circuiting the
// login path (which would skew the timing measurement we care about).
await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 0);
await _fixture.Db.DeleteAuditEventsFor(email);
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");
samples.Add((len, sw.Elapsed.TotalMilliseconds));
}
}
// Assert
var perLengthMean = samples
.GroupBy(s => s.Len)
.Select(g => new { Len = g.Key, Mean = g.Average(s => s.Ms) })
.ToList();
var overallMean = samples.Average(s => s.Ms);
// Spread between per-length means must be < 50% of overall mean. Argon2's
// dominant cost is constant per call, so all means cluster tightly.
var max = perLengthMean.Max(p => p.Mean);
var min = perLengthMean.Min(p => p.Mean);
(max - min).Should().BeLessThan(overallMean * 0.5,
$"per-length means {string.Join(", ", perLengthMean.Select(p => $"len={p.Len}:{p.Mean:F0}ms"))} should not vary with password length (overall mean {overallMean:F0}ms)");
}
finally
{
await _fixture.Db.DeleteAuditEventsFor(email);
await _fixture.Db.DeleteUser(email);
}
}
}
+2 -1
View File
@@ -4,5 +4,6 @@
"AdminPassword": "Admin1234",
"UploaderEmail": "uploader@azaion.com",
"UploaderPassword": "Upload1234",
"JwtSecret": "TestSecretKeyThatIsAtLeast32CharactersLong123!"
"JwtSecret": "TestSecretKeyThatIsAtLeast32CharactersLong123!",
"TestDbConnectionString": "Host=test-db;Port=5432;Database=azaion;Username=postgres;Password=test_password"
}
+1
View File
@@ -7,4 +7,5 @@ sed 's/^drop table users;/drop table if exists users;/' "$SQL_DIR/02_structure.s
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/03_add_timestamp_columns.sql"
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/04_detection_classes.sql"
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/06_users_email_unique.sql"
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/07_auth_lockout_and_audit.sql"
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f /opt/test-seed.sql
+9 -3
View File
@@ -1,10 +1,16 @@
ALTER ROLE azaion_admin WITH PASSWORD 'test_password';
ALTER ROLE azaion_admin WITH PASSWORD 'test_password';
ALTER ROLE azaion_reader WITH PASSWORD 'test_password';
-- Legacy SHA-384 hashes seeded for the lazy-migration path (AZ-536 AC-2/AC-3) —
-- the first successful login transparently re-hashes these to Argon2id.
UPDATE public.users
SET password_hash = 'elZ/nqXsL8E8T1V+9ZPb0bI4HZD0Sc7/ok9DdfxVFjQuGHj+Scya3q9wLXiX+I36'
SET password_hash = 'elZ/nqXsL8E8T1V+9ZPb0bI4HZD0Sc7/ok9DdfxVFjQuGHj+Scya3q9wLXiX+I36',
failed_login_count = 0,
lockout_until = NULL
WHERE email = 'admin@azaion.com';
UPDATE public.users
SET password_hash = '9cB4uEZlzPYisU4Dh73g+4U81rpeduPyv5Bs9nLMYzzoypEHYXQlTS4azDoVZd3l'
SET password_hash = '9cB4uEZlzPYisU4Dh73g+4U81rpeduPyv5Bs9nLMYzzoypEHYXQlTS4azDoVZd3l',
failed_login_count = 0,
lockout_until = NULL
WHERE email = 'uploader@azaion.com';