Files
admin/e2e/Azaion.E2E/Tests/PasswordHashingTests.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

204 lines
8.8 KiB
C#

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);
}
}
}