Files
admin/Azaion.Common/BusinessException.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

61 lines
1.7 KiB
C#

using System.ComponentModel;
using Azaion.Common.Extensions;
namespace Azaion.Common;
public class BusinessException(ExceptionEnum exEnum) : Exception(GetMessage(exEnum))
{
private static readonly Dictionary<ExceptionEnum, string> ExceptionDescriptions;
static BusinessException()
{
ExceptionDescriptions = EnumExtensions.GetDescriptions<ExceptionEnum>();
}
public ExceptionEnum ExceptionEnum { get; set; } = exEnum;
/// <summary>
/// Optional cooldown hint surfaced as a Retry-After response header by the exception
/// handler. Used by AccountLocked and LoginRateLimited (AZ-537).
/// </summary>
public int? RetryAfterSeconds { get; init; }
public BusinessException(ExceptionEnum exEnum, int retryAfterSeconds) : this(exEnum)
{
RetryAfterSeconds = retryAfterSeconds;
}
public static string GetMessage(ExceptionEnum exEnum) => ExceptionDescriptions.GetValueOrDefault(exEnum) ?? exEnum.ToString();
}
public enum ExceptionEnum
{
[Description("No such email found.")]
NoEmailFound = 10,
[Description("Email already exists.")]
EmailExists = 20,
[Description("Passwords do not match.")]
WrongPassword = 30,
[Description("Password should be at least 12 characters.")]
PasswordLengthIncorrect = 32,
[Description("Email is empty or invalid.")]
EmailLengthIncorrect = 35,
WrongEmail = 37,
[Description("User account is disabled.")]
UserDisabled = 38,
[Description("Account is temporarily locked due to too many failed login attempts.")]
AccountLocked = 50,
[Description("Too many login attempts. Try again later.")]
LoginRateLimited = 51,
[Description("No file provided.")]
NoFileProvided = 60,
}