mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 19:31:08 +00:00
491993f9c1
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>
204 lines
8.8 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|