[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
+61 -2
View File
@@ -1,4 +1,5 @@
using System.Text;
using System.Threading.RateLimiting;
using Azaion.Common;
using Azaion.Common.Configs;
using Azaion.Common.Database;
@@ -9,7 +10,9 @@ using FluentValidation;
using LinqToDB.Data;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Rewrite;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
@@ -101,11 +104,15 @@ builder.Services.AddSwaggerGen(c =>
builder.Services.Configure<ResourcesConfig>(builder.Configuration.GetSection(nameof(ResourcesConfig)));
builder.Services.Configure<JwtConfig>(builder.Configuration.GetSection(nameof(JwtConfig)));
builder.Services.Configure<ConnectionStrings>(builder.Configuration.GetSection(nameof(ConnectionStrings)));
builder.Services.Configure<AuthConfig>(builder.Configuration.GetSection(nameof(AuthConfig)));
var authConfig = builder.Configuration.GetSection(nameof(AuthConfig)).Get<AuthConfig>() ?? new AuthConfig();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IResourcesService, ResourcesService>();
builder.Services.AddScoped<IDetectionClassService, DetectionClassService>();
builder.Services.AddScoped<IAuditLog, AuditLog>();
builder.Services.AddSingleton<IDbFactory, DbFactory>();
builder.Services.AddLazyCache();
@@ -114,18 +121,61 @@ builder.Services.AddScoped<ICache, MemoryCache>();
builder.Services.AddValidatorsFromAssemblyContaining<RegisterUserValidator>();
builder.Services.AddExceptionHandler<BusinessExceptionHandler>();
// Add CORS configuration
// AZ-537 — per-IP sliding window rate limit on /login. Per-account rate limit and
// account lockout live in UserService.ValidateUser (DB-backed) so they survive
// process restarts and feed the audit_events table.
const string LoginPerIpPolicy = "login-per-ip";
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = (ctx, _) =>
{
if (ctx.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
ctx.HttpContext.Response.Headers.RetryAfter =
((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture);
return ValueTask.CompletedTask;
};
options.AddPolicy(LoginPerIpPolicy, httpContext =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetSlidingWindowLimiter(ip, _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = authConfig.RateLimit.PerIpPermitLimit,
Window = TimeSpan.FromSeconds(authConfig.RateLimit.PerIpWindowSeconds),
SegmentsPerWindow = 6,
QueueLimit = 0,
AutoReplenishment = true
});
});
});
// AZ-538 — only the HTTPS origin is allowed; the legacy http:// origin combined with
// AllowCredentials() permitted credentialed cleartext traffic and is now removed.
builder.Services.AddCors(options =>
{
options.AddPolicy("AdminCorsPolicy", policy =>
{
policy.WithOrigins("https://admin.azaion.com", "http://admin.azaion.com")
policy.WithOrigins("https://admin.azaion.com")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
// AZ-538 — HSTS: 1 year, includeSubDomains, preload eligible. Only attached in
// non-Development envs; Development skips both HSTS and HTTPS redirection so
// `dotnet watch` on http://localhost keeps working.
if (!builder.Environment.IsDevelopment())
{
builder.Services.AddHsts(o =>
{
o.MaxAge = TimeSpan.FromDays(365);
o.IncludeSubDomains = true;
o.Preload = true;
});
}
var app = builder.Build();
if (app.Environment.IsDevelopment())
@@ -133,11 +183,19 @@ if (app.Environment.IsDevelopment())
app.UseSwagger();
app.UseSwaggerUI();
}
else
{
// AZ-538 — defence in depth: even if the http origin is re-added by accident
// the protocol-layer redirect kicks in first.
app.UseHsts();
app.UseHttpsRedirection();
}
app.UseCors("AdminCorsPolicy");
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseRewriter(new RewriteOptions().AddRedirect("^$", "/swagger"));
@@ -180,6 +238,7 @@ app.MapPost("/login",
var user = await userService.ValidateUser(request, ct: cancellationToken);
return Results.Ok(new { Token = authService.CreateToken(user)});
})
.RequireRateLimiting(LoginPerIpPolicy)
.WithSummary("Login");
app.MapPost("/users",