using System.Text; 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.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.Secret)) throw new Exception("Missing configuration section: JwtConfig"); var signingKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtConfig.Secret)); // 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, IssuerSigningKey = signingKey }; }); #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() } }); }); 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))); var authConfig = builder.Configuration.GetSection(nameof(AuthConfig)).Get() ?? new AuthConfig(); 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, CancellationToken cancellationToken) => { var user = await userService.ValidateUser(request, ct: cancellationToken); return Results.Ok(new { Token = authService.CreateToken(user)}); }) .RequireRateLimiting(LoginPerIpPolicy) .WithSummary("Login"); 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();