using System.IdentityModel.Tokens.Jwt; 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(o => o.MultipartBodyLengthLimit = 209715200); var jwtConfig = builder.Configuration.GetSection(nameof(JwtConfig)).Get(); 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()); // 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(); 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(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(); // AZ-535 — verifiers (satellite-provider, gps-denied, ui) authenticate as // service-role identities and are the only callers (besides ApiAdmin) allowed // to read the global revocation snapshot. var revocationReaderPolicy = new AuthorizationPolicyBuilder() .RequireRole(RoleEnum.Service.ToString(), RoleEnum.ApiAdmin.ToString()).Build(); builder.Services.AddAuthorization(o => { o.AddPolicy(nameof(apiAdminPolicy), apiAdminPolicy); o.AddPolicy(nameof(revocationReaderPolicy), revocationReaderPolicy); }); #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() } }); }); builder.Services.Configure(builder.Configuration.GetSection(nameof(ResourcesConfig))); builder.Services.Configure(builder.Configuration.GetSection(nameof(JwtConfig))); builder.Services.Configure(builder.Configuration.GetSection(nameof(ConnectionStrings))); builder.Services.Configure(builder.Configuration.GetSection(nameof(AuthConfig))); builder.Services.Configure(builder.Configuration.GetSection(nameof(SessionConfig))); var authConfig = builder.Configuration.GetSection(nameof(AuthConfig)).Get() ?? new AuthConfig(); // AZ-532 — share the eagerly-built provider so JwtBearer and AuthService both // hold the same set of loaded keys. builder.Services.AddSingleton(jwtSigningKeyProvider); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddLazyCache(); builder.Services.AddScoped(); builder.Services.AddValidatorsFromAssemblyContaining(); builder.Services.AddExceptionHandler(); // 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("SELECT 1")); await dbFactory.RunAdmin(db => db.ExecuteAsync("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, ISessionService sessionService, 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()); // AZ-533 AC-4 — post-flight reconnect: if the just-authenticated user is an // aircraft (CompanionPC), kill any open mission session bound to it. if (user.Role == RoleEnum.CompanionPC) await sessionService.RevokeMissionsForAircraft(user.Id, cancellationToken); 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, ISessionService sessionService, 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()); // AZ-533 AC-4 — same auto-revoke trigger as /login. if (user.Role == RoleEnum.CompanionPC) await sessionService.RevokeMissionsForAircraft(user.Id, cancellationToken); 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-535 — logout: revoke the caller's current session (the sid claim on their // access token). Idempotent. app.MapPost("/logout", async (HttpContext http, ISessionService sessions, CancellationToken ct) => { var sid = ParseSidClaim(http.User); var caller = ParseUserIdClaim(http.User); var alreadyRevoked = await sessions.RevokeBySid(sid, caller, SessionRevokedReasons.LoggedOut, ct); return Results.Ok(new { alreadyRevoked }); }) .RequireAuthorization() .WithSummary("AZ-535 — revoke the caller's current session"); // AZ-535 AC-2 — sign out everywhere: revoke every active session for the caller. app.MapPost("/logout/all", async (HttpContext http, ISessionService sessions, CancellationToken ct) => { var caller = ParseUserIdClaim(http.User); var revoked = await sessions.RevokeAllForUser(caller, caller, SessionRevokedReasons.LoggedOutAll, ct); return Results.Ok(new { revoked }); }) .RequireAuthorization() .WithSummary("AZ-535 — revoke every session for the caller's user"); // AZ-535 AC-3 — admin-only revoke-by-sid. app.MapPost("/sessions/{sid:guid}/revoke", async (Guid sid, HttpContext http, ISessionService sessions, CancellationToken ct) => { var admin = ParseUserIdClaim(http.User); var alreadyRevoked = await sessions.RevokeBySid(sid, admin, SessionRevokedReasons.AdminRevoked, ct); return Results.Ok(new { alreadyRevoked }); }) .RequireAuthorization(apiAdminPolicy) .WithSummary("AZ-535 — admin revoke-by-session-id"); // AZ-535 AC-4 — verifier-poll snapshot of revoked-but-not-yet-expired sessions. app.MapGet("/sessions/revoked", async (DateTime? since, HttpContext http, ISessionService sessions, CancellationToken ct) => { // Cap "since" to the longest plausible token TTL (12 h, matches mission cap) // so a buggy verifier asking for "everything since 1970" doesn't cost us a // multi-million-row table scan. var floor = DateTime.UtcNow.AddHours(-12); var effective = since.HasValue && since.Value > floor ? since.Value : floor; var rows = await sessions.GetRevokedSince(effective, ct); http.Response.Headers.CacheControl = "no-cache"; return Results.Ok(rows.Select(r => new { sid = r.Sid, exp = r.Exp, revokedAt = r.RevokedAt, reason = r.Reason })); }) .RequireAuthorization(revocationReaderPolicy) .WithSummary("AZ-535 — verifier snapshot of revoked sessions still within their TTL"); // AZ-533 — mission token issuance for offline UAV ops. Pilot calls with their // interactive access token; admin returns a long-lived no-refresh token bound // to one aircraft + one mission. app.MapPost("/sessions/mission", async (MissionSessionRequest request, HttpContext http, IMissionTokenService missions, CancellationToken ct) => { var pilot = ParseUserIdClaim(http.User); // TODO (AZ-534): require amr=["pwd","mfa"]; until MFA ships this is a code // comment per the AZ-533 spec, not an enforced gate. var resp = await missions.Issue(pilot, request, ct); return Results.Ok(resp); }) .RequireAuthorization() .WithSummary("AZ-533 — issue a long-lived mission token for one UAV flight"); static Guid ParseSidClaim(System.Security.Claims.ClaimsPrincipal user) => Guid.TryParse(user.FindFirst(JwtRegisteredClaimNames.Sid)?.Value, out var s) ? s : throw new BusinessException(ExceptionEnum.InvalidRefreshToken); static Guid ParseUserIdClaim(System.Security.Claims.ClaimsPrincipal user) => Guid.TryParse(user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var u) ? u : throw new BusinessException(ExceptionEnum.InvalidRefreshToken); // 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 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("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 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 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();