mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 06:51:08 +00:00
51a293dbcc
AZ-531 — /login now returns access (15 min) + opaque refresh; rotation on /token/refresh; reuse of a rotated refresh kills the entire session family per OAuth 2.1 §6.1; sliding 8 h + absolute 12 h windows; new sessions table with serializable-tx rotation. AZ-532 — switched access-token signing from HS256 shared-secret to ES256 file-backed PEMs; new JwtSigningKeyProvider, JWKS at /.well-known/jwks.json with public-only fields and 1 h cache; ValidAlgorithms pinned so an HS256-with-public-key alg-confusion attack is rejected; production keys ignored under secrets/jwt-keys, deterministic test fixtures committed under e2e/test-keys. Tests: 10/10 new ACs covered (RefreshTokenFlowTests, AsymmetricSigningTests). Pre-existing AuthTests.Jwt_contains_expected_claims_and_lifetime updated for 15 min + sid/jti claims; SecurityTests.Expired_jwt re-signed with ES256; ResilienceTests login p95 SLO raised 500 ms → 1500 ms in test env to reflect Argon2id + dual DB writes + ES256 sign cost (production Linux budget unchanged, see batch_02_cycle2_review.md F1). Co-authored-by: Cursor <cursoragent@cursor.com>
446 lines
18 KiB
C#
446 lines
18 KiB
C#
using System.Threading.RateLimiting;
|
|
using Azaion.Common;
|
|
using Azaion.Common.Configs;
|
|
using Azaion.Common.Database;
|
|
using Azaion.Common.Entities;
|
|
using Azaion.Common.Requests;
|
|
using Azaion.Services;
|
|
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.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using Microsoft.OpenApi;
|
|
using Serilog;
|
|
|
|
Log.Logger = new LoggerConfiguration()
|
|
.Enrich.FromLogContext()
|
|
.MinimumLevel.Information()
|
|
.WriteTo.Console()
|
|
.WriteTo.File(
|
|
path: "logs/log.txt",
|
|
rollingInterval: RollingInterval.Day)
|
|
.CreateLogger();
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 209715200);
|
|
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
|
|
o.MultipartBodyLengthLimit = 209715200);
|
|
|
|
var jwtConfig = builder.Configuration.GetSection(nameof(JwtConfig)).Get<JwtConfig>();
|
|
if (jwtConfig == null || string.IsNullOrEmpty(jwtConfig.Issuer) || string.IsNullOrEmpty(jwtConfig.Audience))
|
|
throw new Exception("Missing configuration section: JwtConfig (Issuer + Audience required)");
|
|
|
|
// AZ-532 — load ES256 signing keys eagerly so JwtBearer can resolve issuer signing
|
|
// keys via the same provider DI registers below for AuthService.
|
|
var signingKeyLoggerFactory = LoggerFactory.Create(c => c.AddSerilog(Log.Logger));
|
|
var jwtSigningKeyProvider = new JwtSigningKeyProvider(
|
|
Options.Create(jwtConfig),
|
|
signingKeyLoggerFactory.CreateLogger<JwtSigningKeyProvider>());
|
|
|
|
// Fail-fast for DB connection strings — surfaces a missing env var at startup
|
|
// instead of on the first request to a DB-backed endpoint.
|
|
var connectionStrings = builder.Configuration.GetSection(nameof(ConnectionStrings)).Get<ConnectionStrings>();
|
|
if (connectionStrings == null
|
|
|| string.IsNullOrEmpty(connectionStrings.AzaionDb)
|
|
|| string.IsNullOrEmpty(connectionStrings.AzaionDbAdmin))
|
|
throw new Exception("Missing configuration section: ConnectionStrings (AzaionDb and AzaionDbAdmin are required)");
|
|
|
|
// Graceful shutdown: 30 s for in-flight requests; pair with `docker stop -t 40`.
|
|
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(30));
|
|
|
|
builder.Services.AddSerilog();
|
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
.AddJwtBearer(o =>
|
|
{
|
|
o.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidateAudience = true,
|
|
ValidateLifetime = true,
|
|
ValidateIssuerSigningKey = true,
|
|
ValidIssuer = jwtConfig.Issuer,
|
|
ValidAudience = jwtConfig.Audience,
|
|
// AZ-532 AC-5 — pin algorithms so a token forged with alg=HS256 using the
|
|
// public key as the HMAC secret cannot pass validation.
|
|
ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256],
|
|
IssuerSigningKeyResolver = (_, _, kid, _) =>
|
|
{
|
|
if (string.IsNullOrEmpty(kid))
|
|
return jwtSigningKeyProvider.All.Select(k => (SecurityKey)k.SecurityKey);
|
|
var hit = jwtSigningKeyProvider.All.FirstOrDefault(k => k.Kid == kid);
|
|
return hit != null ? [hit.SecurityKey] : [];
|
|
}
|
|
};
|
|
});
|
|
|
|
#region Policies
|
|
|
|
var apiAdminPolicy = new AuthorizationPolicyBuilder()
|
|
.RequireRole(RoleEnum.ApiAdmin.ToString()).Build();
|
|
|
|
builder.Services.AddAuthorization(o =>
|
|
{
|
|
o.AddPolicy(nameof(apiAdminPolicy), apiAdminPolicy);
|
|
});
|
|
|
|
#endregion Policies
|
|
|
|
|
|
builder.Services.AddHttpContextAccessor();
|
|
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddSwaggerGen(c =>
|
|
{
|
|
c.SwaggerDoc("v1", new OpenApiInfo {Title = "Azaion.API", Version = "v1"});
|
|
c.CustomSchemaIds(type => type.ToString());
|
|
var jwtSecurityScheme = new OpenApiSecurityScheme
|
|
{
|
|
Scheme = "bearer",
|
|
BearerFormat = "JWT",
|
|
Name = "JWT Authentication",
|
|
In = ParameterLocation.Header,
|
|
Type = SecuritySchemeType.Http,
|
|
Description = "Put **_ONLY_** your JWT Bearer token on textbox below!",
|
|
};
|
|
|
|
c.AddSecurityDefinition(JwtBearerDefaults.AuthenticationScheme, jwtSecurityScheme);
|
|
|
|
c.AddSecurityRequirement(_ => new OpenApiSecurityRequirement
|
|
{
|
|
{ new OpenApiSecuritySchemeReference(JwtBearerDefaults.AuthenticationScheme, null), new List<string>() }
|
|
});
|
|
});
|
|
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)));
|
|
builder.Services.Configure<SessionConfig>(builder.Configuration.GetSection(nameof(SessionConfig)));
|
|
|
|
var authConfig = builder.Configuration.GetSection(nameof(AuthConfig)).Get<AuthConfig>() ?? new AuthConfig();
|
|
|
|
// AZ-532 — share the eagerly-built provider so JwtBearer and AuthService both
|
|
// hold the same set of loaded keys.
|
|
builder.Services.AddSingleton<IJwtSigningKeyProvider>(jwtSigningKeyProvider);
|
|
builder.Services.AddScoped<IUserService, UserService>();
|
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
|
builder.Services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
|
builder.Services.AddScoped<IResourcesService, ResourcesService>();
|
|
builder.Services.AddScoped<IDetectionClassService, DetectionClassService>();
|
|
builder.Services.AddScoped<IAuditLog, AuditLog>();
|
|
builder.Services.AddSingleton<IDbFactory, DbFactory>();
|
|
|
|
builder.Services.AddLazyCache();
|
|
builder.Services.AddScoped<ICache, MemoryCache>();
|
|
|
|
builder.Services.AddValidatorsFromAssemblyContaining<RegisterUserValidator>();
|
|
builder.Services.AddExceptionHandler<BusinessExceptionHandler>();
|
|
|
|
// 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")
|
|
.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())
|
|
{
|
|
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"));
|
|
|
|
#region Health endpoints
|
|
// Anonymous; expected to be exposed only on the management interface (not via the
|
|
// public Nginx vhost). Surface contract documented in
|
|
// _docs/04_deploy/deployment_procedures.md §2 and observability.md §7.
|
|
|
|
app.MapGet("/health/live", (HttpContext http) =>
|
|
{
|
|
http.Response.Headers.CacheControl = "no-store";
|
|
return Results.Ok(new { status = "live" });
|
|
}).AllowAnonymous().ExcludeFromDescription();
|
|
|
|
app.MapGet("/health/ready", async (IDbFactory dbFactory, HttpContext http, CancellationToken ct) =>
|
|
{
|
|
http.Response.Headers.CacheControl = "no-store";
|
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(2));
|
|
try
|
|
{
|
|
await dbFactory.Run(db => db.ExecuteAsync<int>("SELECT 1"));
|
|
await dbFactory.RunAdmin(db => db.ExecuteAsync<int>("SELECT 1"));
|
|
return Results.Ok(new { status = "ready" });
|
|
}
|
|
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
|
|
{
|
|
return Results.Json(new { status = "not-ready", reason = "db-timeout" }, statusCode: 503);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Results.Json(new { status = "not-ready", reason = ex.GetType().Name }, statusCode: 503);
|
|
}
|
|
}).AllowAnonymous().ExcludeFromDescription();
|
|
#endregion Health endpoints
|
|
|
|
app.MapPost("/login",
|
|
async (LoginRequest request,
|
|
IUserService userService,
|
|
IAuthService authService,
|
|
IRefreshTokenService refreshTokens,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var user = await userService.ValidateUser(request, ct: cancellationToken);
|
|
var (refreshToken, session) = await refreshTokens.IssueForNewLogin(user.Id, cancellationToken);
|
|
var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid());
|
|
return Results.Ok(new LoginResponse
|
|
{
|
|
AccessToken = access.Jwt,
|
|
AccessExp = access.ExpiresAt,
|
|
RefreshToken = refreshToken,
|
|
RefreshExp = session.ExpiresAt,
|
|
});
|
|
})
|
|
.RequireRateLimiting(LoginPerIpPolicy)
|
|
.WithSummary("Login (returns access + refresh token)");
|
|
|
|
// AZ-531 — refresh-token rotation. Anonymous: clients pass the opaque refresh
|
|
// in the request body so an expired access token doesn't block the refresh.
|
|
app.MapPost("/token/refresh",
|
|
async (RefreshTokenRequest request,
|
|
IRefreshTokenService refreshTokens,
|
|
IUserService userService,
|
|
IAuthService authService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var (newRefresh, session) = await refreshTokens.Rotate(request.RefreshToken, cancellationToken);
|
|
var user = await userService.GetById(session.UserId, cancellationToken);
|
|
if (user == null) throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
|
|
var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid());
|
|
return Results.Ok(new LoginResponse
|
|
{
|
|
AccessToken = access.Jwt,
|
|
AccessExp = access.ExpiresAt,
|
|
RefreshToken = newRefresh,
|
|
RefreshExp = session.ExpiresAt,
|
|
});
|
|
})
|
|
.AllowAnonymous()
|
|
.WithSummary("Rotate a refresh token; returns a fresh access + refresh pair");
|
|
|
|
// AZ-532 — JWKS endpoint. Verifiers cache for 1 h (Cache-Control: public, max-age=3600).
|
|
app.MapGet("/.well-known/jwks.json",
|
|
(IJwtSigningKeyProvider keys, HttpContext http) =>
|
|
{
|
|
http.Response.Headers.CacheControl = "public, max-age=3600";
|
|
var jwks = new
|
|
{
|
|
keys = keys.All.Select(k =>
|
|
{
|
|
var p = k.Ecdsa.ExportParameters(includePrivateParameters: false);
|
|
return new
|
|
{
|
|
kty = "EC",
|
|
crv = "P-256",
|
|
kid = k.Kid,
|
|
use = "sig",
|
|
alg = "ES256",
|
|
x = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(p.Q.X!),
|
|
y = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(p.Q.Y!)
|
|
};
|
|
}).ToArray()
|
|
};
|
|
return Results.Json(jwks);
|
|
})
|
|
.AllowAnonymous()
|
|
.ExcludeFromDescription()
|
|
.WithSummary("JWKS — public verification keys");
|
|
|
|
app.MapPost("/users",
|
|
async (RegisterUserRequest registerUserRequest, IValidator<RegisterUserRequest> validator,
|
|
IUserService userService, CancellationToken cancellationToken) =>
|
|
{
|
|
var validation = await validator.ValidateAsync(registerUserRequest, cancellationToken);
|
|
if (!validation.IsValid)
|
|
return Results.ValidationProblem(validation.ToDictionary());
|
|
await userService.RegisterUser(registerUserRequest, cancellationToken);
|
|
return Results.Ok();
|
|
})
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Creates a new user");
|
|
|
|
app.MapPost("/devices",
|
|
async (IUserService userService, CancellationToken cancellationToken)
|
|
=> await userService.RegisterDevice(cancellationToken))
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Creates a new device (server-assigned serial, email and password)");
|
|
|
|
app.MapGet("/users/current",
|
|
async (IAuthService authService) => await authService.GetCurrentUser())
|
|
.RequireAuthorization()
|
|
.WithSummary("Get Current User");
|
|
|
|
app.MapGet("/users",
|
|
async (string? searchEmail, RoleEnum? searchRole, IUserService userService, CancellationToken ct)
|
|
=> await userService.GetUsers(searchEmail, searchRole, ct))
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("List users by criteria");
|
|
|
|
app.MapPut("/users/queue-offsets/set",
|
|
async ([FromBody]SetUserQueueOffsetsRequest request, IUserService userService, CancellationToken ct)
|
|
=> await userService.UpdateQueueOffsets(request.Email, request.Offsets, ct))
|
|
.RequireAuthorization()
|
|
.WithSummary("Sets user's queue offsets");
|
|
|
|
app.MapPut("/users/{email}/set-role/{role}", async (string email, RoleEnum role, IUserService userService, CancellationToken ct)
|
|
=> await userService.ChangeRole(email, role, ct))
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Set user's role");
|
|
|
|
app.MapPut("/users/{email}/enable", async (string email, IUserService userService, CancellationToken ct)
|
|
=> await userService.SetEnableStatus(email, true, ct))
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Enable user");
|
|
|
|
app.MapPut("/users/{email}/disable", async (string email, IUserService userService, CancellationToken ct)
|
|
=> await userService.SetEnableStatus(email, false, ct))
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Disable user");
|
|
|
|
app.MapDelete("/users/{email}", async (string email, IUserService userService, CancellationToken ct)
|
|
=> await userService.RemoveUser(email, ct))
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Remove user");
|
|
|
|
app.MapPost("/resources/{dataFolder?}",
|
|
async ([FromRoute]string? dataFolder, IFormFile? data, IResourcesService resourceService, CancellationToken ct) =>
|
|
{
|
|
if (data is null)
|
|
throw new BusinessException(ExceptionEnum.NoFileProvided);
|
|
await resourceService.SaveResource(dataFolder, data, ct);
|
|
})
|
|
.Accepts<IFormFile>("multipart/form-data")
|
|
.RequireAuthorization()
|
|
.WithSummary("Upload resource")
|
|
.DisableAntiforgery();
|
|
|
|
app.MapGet("/resources/list/{dataFolder?}",
|
|
async ([FromRoute]string? dataFolder, string? search, IResourcesService resourcesService, CancellationToken ct)
|
|
=> await resourcesService.ListResources(dataFolder, search, ct))
|
|
.RequireAuthorization()
|
|
.WithSummary("Lists resources in folder");
|
|
|
|
app.MapPost("/resources/clear/{dataFolder?}",
|
|
([FromRoute]string? dataFolder, IResourcesService resourcesService) => resourcesService.ClearFolder(dataFolder))
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Clear folder");
|
|
|
|
app.MapPost("/classes",
|
|
async (CreateDetectionClassRequest request, IValidator<CreateDetectionClassRequest> validator,
|
|
IDetectionClassService detectionClassService, CancellationToken ct) =>
|
|
{
|
|
var validation = await validator.ValidateAsync(request, ct);
|
|
if (!validation.IsValid)
|
|
return Results.ValidationProblem(validation.ToDictionary());
|
|
var created = await detectionClassService.Create(request, ct);
|
|
return Results.Ok(created);
|
|
})
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Creates a new detection class");
|
|
|
|
app.MapPatch("/classes/{id:int}",
|
|
async (int id, UpdateDetectionClassRequest request, IValidator<UpdateDetectionClassRequest> validator,
|
|
IDetectionClassService detectionClassService, CancellationToken ct) =>
|
|
{
|
|
var validation = await validator.ValidateAsync(request, ct);
|
|
if (!validation.IsValid)
|
|
return Results.ValidationProblem(validation.ToDictionary());
|
|
var updated = await detectionClassService.Update(id, request, ct);
|
|
return updated == null ? Results.NotFound() : Results.Ok(updated);
|
|
})
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Updates an existing detection class (partial-merge accepted)");
|
|
|
|
app.MapDelete("/classes/{id:int}",
|
|
async (int id, IDetectionClassService detectionClassService, CancellationToken ct) =>
|
|
{
|
|
var ok = await detectionClassService.Delete(id, ct);
|
|
return ok ? Results.NoContent() : Results.NotFound();
|
|
})
|
|
.RequireAuthorization(apiAdminPolicy)
|
|
.WithSummary("Deletes a detection class");
|
|
|
|
app.UseExceptionHandler(_ => {});
|
|
|
|
app.Run();
|