mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 20:31:08 +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>
190 lines
8.3 KiB
C#
190 lines
8.3 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using Azaion.E2E.Helpers;
|
|
using FluentAssertions;
|
|
using Xunit;
|
|
|
|
namespace Azaion.E2E.Tests;
|
|
|
|
[Collection("E2E")]
|
|
public sealed class AuthTests
|
|
{
|
|
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 AuthTests(TestFixture fixture) => _fixture = fixture;
|
|
|
|
[Fact]
|
|
public async Task Login_with_valid_admin_credentials_returns_200_and_token()
|
|
{
|
|
// Arrange
|
|
using var client = _fixture.CreateApiClient();
|
|
|
|
// Act
|
|
using var response = await client.PostAsync("/login",
|
|
new { email = _fixture.AdminEmail, password = _fixture.AdminPassword });
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
var body = await response.Content.ReadFromJsonAsync<LoginOkResponse>(ResponseJsonOptions);
|
|
body.Should().NotBeNull();
|
|
body!.Token.Should().NotBeNullOrWhiteSpace();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Jwt_contains_expected_claims_and_lifetime()
|
|
{
|
|
// Arrange
|
|
using var client = _fixture.CreateApiClient();
|
|
|
|
// Act
|
|
using var loginResponse = await client.PostAsync("/login",
|
|
new { email = _fixture.AdminEmail, password = _fixture.AdminPassword });
|
|
var loginBody = await loginResponse.Content.ReadFromJsonAsync<LoginOkResponse>(ResponseJsonOptions);
|
|
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(loginBody!.Token);
|
|
|
|
// Assert
|
|
loginResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
jwt.Issuer.Should().Be("AzaionApi");
|
|
jwt.Audiences.Should().Contain("Annotators/OrangePi/Admins");
|
|
var iatSeconds = long.Parse(
|
|
jwt.Claims.Single(c => c.Type == JwtRegisteredClaimNames.Iat).Value,
|
|
System.Globalization.CultureInfo.InvariantCulture);
|
|
var expSeconds = long.Parse(
|
|
jwt.Claims.Single(c => c.Type == JwtRegisteredClaimNames.Exp).Value,
|
|
System.Globalization.CultureInfo.InvariantCulture);
|
|
// AZ-531 — access tokens are now 15-min (refresh-flow shortened from 4h).
|
|
TimeSpan.FromSeconds(expSeconds - iatSeconds)
|
|
.Should().BeCloseTo(TimeSpan.FromMinutes(15), TimeSpan.FromSeconds(60));
|
|
jwt.Claims.Should().Contain(c => c.Type == "role");
|
|
// AZ-531 / AZ-535 — sid and jti claims are needed for logout + per-token denylist.
|
|
jwt.Claims.Should().Contain(c => c.Type == JwtRegisteredClaimNames.Sid);
|
|
jwt.Claims.Should().Contain(c => c.Type == JwtRegisteredClaimNames.Jti);
|
|
}
|
|
|
|
// AZ-556 AC-1 — unknown email is now indistinguishable from wrong password.
|
|
[Fact]
|
|
public async Task Login_with_unknown_email_returns_401_invalid_credentials()
|
|
{
|
|
// Arrange — use a fresh per-test email so the audit assertion below cannot
|
|
// false-pass on a leftover row from another test.
|
|
var unknownEmail = $"unknown-{Guid.NewGuid():N}@authtest.example.com";
|
|
using var client = _fixture.CreateApiClient();
|
|
try
|
|
{
|
|
// Act
|
|
using var response = await client.PostAsync("/login",
|
|
new { email = unknownEmail, password = "irrelevant" });
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
var err = await response.Content.ReadFromJsonAsync<ErrorResponse>(ResponseJsonOptions);
|
|
err.Should().NotBeNull();
|
|
err!.ErrorCode.Should().Be(70, "InvalidCredentials == 70 (AZ-556)");
|
|
|
|
// AZ-556 AC-6 — audit log records the unknown-email category internally
|
|
// even though the wire response is opaque.
|
|
(await _fixture.Db.CountAuditEvents("login_failed_unknown_email", unknownEmail))
|
|
.Should().BeGreaterOrEqualTo(1, "audit must still record the unknown-email reason");
|
|
}
|
|
finally
|
|
{
|
|
await _fixture.Db.DeleteAuditEventsFor(unknownEmail);
|
|
}
|
|
}
|
|
|
|
// AZ-556 AC-2 — wrong password collapses to the same response shape as unknown email.
|
|
[Fact]
|
|
public async Task Login_with_wrong_password_returns_401_invalid_credentials()
|
|
{
|
|
// Arrange
|
|
using var client = _fixture.CreateApiClient();
|
|
|
|
// Act
|
|
using var response = await client.PostAsync("/login",
|
|
new { email = _fixture.AdminEmail, password = "DefinitelyWrongPassword" });
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
var err = await response.Content.ReadFromJsonAsync<ErrorResponse>(ResponseJsonOptions);
|
|
err.Should().NotBeNull();
|
|
err!.ErrorCode.Should().Be(70, "InvalidCredentials == 70 (AZ-556)");
|
|
}
|
|
|
|
// AZ-556 AC-1 + AC-2 — wire response (status, body) for unknown email and wrong
|
|
// password MUST be byte-equivalent except for the human-readable message text
|
|
// (which is identical too because both throw the same ExceptionEnum).
|
|
[Fact]
|
|
public async Task Login_unknown_email_and_wrong_password_produce_identical_response()
|
|
{
|
|
// Arrange
|
|
using var client = _fixture.CreateApiClient();
|
|
|
|
// Act
|
|
using var unknown = await client.PostAsync("/login",
|
|
new { email = "nonexistent@example.com", password = "irrelevant" });
|
|
using var wrong = await client.PostAsync("/login",
|
|
new { email = _fixture.AdminEmail, password = "DefinitelyWrongPassword" });
|
|
|
|
// Assert
|
|
unknown.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
wrong.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
|
|
var unknownBody = await unknown.Content.ReadAsStringAsync();
|
|
var wrongBody = await wrong.Content.ReadAsStringAsync();
|
|
unknownBody.Should().Be(wrongBody, "AZ-556 — wire payloads must be byte-identical");
|
|
}
|
|
|
|
// AZ-556 AC-3 — disabled-account response is indistinguishable from wrong password.
|
|
[Fact]
|
|
public async Task Login_with_disabled_account_returns_401_invalid_credentials_indistinguishable_from_wrong_password()
|
|
{
|
|
// Arrange — create a fresh user, then disable them via the admin endpoint.
|
|
var email = $"disabled-{Guid.NewGuid():N}@authtest.example.com";
|
|
const string password = "Correct2026!";
|
|
using (var create = await _fixture.HttpClient.PostAsJsonAsync("/users",
|
|
new { email, password, role = 10 }))
|
|
create.IsSuccessStatusCode.Should().BeTrue($"setup: create user {email}");
|
|
try
|
|
{
|
|
using (var disable = await _fixture.HttpClient.PutAsync(
|
|
$"/users/{Uri.EscapeDataString(email)}/disable", content: null))
|
|
disable.IsSuccessStatusCode.Should().BeTrue("setup: disable the user");
|
|
|
|
using var anon = _fixture.CreateApiClient();
|
|
|
|
// Act — present the correct password to the disabled account, and a wrong
|
|
// password to a known-enabled account. The wire responses must match.
|
|
using var disabledResp = await anon.PostAsync("/login", new { email, password });
|
|
using var wrongResp = await anon.PostAsync("/login",
|
|
new { email = _fixture.AdminEmail, password = "DefinitelyWrongPassword" });
|
|
|
|
// Assert
|
|
disabledResp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
wrongResp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
var disabledBody = await disabledResp.Content.ReadAsStringAsync();
|
|
var wrongBody = await wrongResp.Content.ReadAsStringAsync();
|
|
disabledBody.Should().Be(wrongBody, "AZ-556 — disabled-account body must match wrong-password body");
|
|
|
|
// AZ-556 AC-6 — audit log preserves the internal granularity even though
|
|
// the wire response was unified.
|
|
(await _fixture.Db.CountAuditEvents("login_failed_disabled", email))
|
|
.Should().BeGreaterOrEqualTo(1, "audit must still record the disabled-account reason");
|
|
}
|
|
finally
|
|
{
|
|
await _fixture.Db.DeleteAuditEventsFor(email);
|
|
await _fixture.Db.DeleteUser(email);
|
|
}
|
|
}
|
|
}
|