using System.Security.Claims; using System.Text; using System.Threading.Tasks; using FinlyticBackend.Controllers; using FinlyticBackend.Database; using FinlyticBackend.Hubs; using FinlyticBackend.Middleware; using FinlyticBackend.Services; using FinlyticBackend.Util; using FinlyticCore.Database; using FinlyticCore.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Http.Connections; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); // 1. Add Controllers builder.Services.AddControllers(); // 1b. Standardized error responses (Rules.md §11): every unhandled exception becomes an RFC 7807 // Problem Details response via GlobalExceptionHandler instead of a raw stack trace or an anonymous // error object hand-rolled per controller. builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler(); // 2. Define CORS Policy builder.Services.AddCors(options => { options.AddPolicy("AllowAll", policy => { policy.SetIsOriginAllowed(_ => true) // Allows any origin including Flutter Web localhost .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials(); }); }); // 3. Configure JWT Authentication // Fail-fast: a Gateway that falls back to a repo-public signing key is worse than one that refuses to // start (Rules.md §12). JwtTokenService applies the same guard independently for defense in depth. var secretKey = builder.Configuration["JWT:SecretKey"] ?? builder.Configuration["JWT__SecretKey"]; if (string.IsNullOrWhiteSpace(secretKey) || secretKey.Length < 32) { throw new InvalidOperationException( "JWT:SecretKey (bzw. JWT__SecretKey) ist nicht konfiguriert oder kürzer als 32 Zeichen (256 Bit). " + "FinlyticBackend startet nicht ohne einen ausreichend starken, explizit konfigurierten Signing-Key."); } var issuer = builder.Configuration["JWT:Issuer"] ?? "FinlyticBackend"; var audience = builder.Configuration["JWT:Audience"] ?? "FinlyticClients"; // Fail-fast: the default Admin account must never be seeded with a hardcoded, repo-public password // (Rules.md §12). UserService.SeedDefaultAdminAsync applies the same guard for defense in depth. var adminDefaultPassword = builder.Configuration["ADMIN:DefaultPassword"] ?? builder.Configuration["ADMIN__DefaultPassword"]; if (string.IsNullOrWhiteSpace(adminDefaultPassword)) { throw new InvalidOperationException( "ADMIN:DefaultPassword (bzw. ADMIN__DefaultPassword) ist nicht konfiguriert. " + "FinlyticBackend startet nicht ohne ein explizit konfiguriertes Initial-Passwort für den Default-Admin-Account."); } builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.RequireHttpsMetadata = false; options.SaveToken = true; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)), ValidateIssuer = true, ValidIssuer = issuer, ValidateAudience = true, ValidAudience = audience, ValidateLifetime = true, ClockSkew = TimeSpan.FromMinutes(5) }; options.Events = new JwtBearerEvents { OnMessageReceived = context => { var accessToken = context.Request.Query["access_token"]; var path = context.HttpContext.Request.Path; if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) { context.Token = accessToken; } return Task.CompletedTask; } }; }); builder.Services.AddAuthorization(); builder.Services.AddSignalR(); // 4. Register DB Context & Services builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddScoped(sp => sp.GetRequiredService()); // 4b. Dynamic Settings & Channel-Based Logging (previously absent for FinlyticBackend - see BackendSettingKeys). builder.Services.AddSingleton(); builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>)); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); var app = builder.Build(); // 0. Global exception -> Problem Details handler (Rules.md §11). Placed first so it wraps every // downstream stage (static files, routing, CORS, auth, endpoints): on an unhandled exception it re-invokes // the pipeline starting immediately after itself for the generated error response, so UseCors below still // runs and still attaches CORS headers to the error response. This does NOT change the CORS-critical // ordering documented below - UseRouting/UseCors/UseAuthentication/UseAuthorization still execute in the // same relative order for both the happy path and the error path. app.UseExceptionHandler(); // 0b. Trust X-Forwarded-* from the nginx reverse proxy in front of this service, so Request.Scheme/ // Request.Host reflect the public domain (e.g. finlytic.kleidukos.me) instead of the raw loopback // connection nginx makes to Kestrel (localhost:5000). Without this, any code building an absolute URL // from Request.Host/Scheme silently produces http://localhost:5000/... links. KnownProxies/KnownNetworks // are cleared because nginx runs as a sidecar on an address Kestrel can't predict (Docker bridge network). app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost, KnownIPNetworks = { }, KnownProxies = { } }); // ---------------------------------------------------------------------- // MIDDLEWARE PIPELINE ORDER IS CRITICAL FOR CORS // ---------------------------------------------------------------------- // 1. Static & Default files app.UseDefaultFiles(); app.UseStaticFiles(); // 2. Routing MUST come before UseCors app.UseRouting(); // 3. CORS MUST come after UseRouting and before UseAuthentication/UseAuthorization app.UseCors("AllowAll"); // 4. Authentication & Authorization app.UseAuthentication(); app.UseAuthorization(); // 5. Global User DB Validation Middleware (ensures JWT userId exists in PostgreSQL users table for every authorized route) app.Use(async (context, next) => { if (context.User.Identity?.IsAuthenticated == true) { var userIdStr = context.User.FindFirstValue(ClaimTypes.NameIdentifier); if (Guid.TryParse(userIdStr, out var userId)) { var dbContext = context.RequestServices.GetRequiredService(); bool userExists = await dbContext.Users.AsNoTracking().AnyAsync(u => u.Id == userId && u.IsActive); if (!userExists) { var logger = context.RequestServices.GetRequiredService>(); logger.LogWarning("[UserValidation] Authenticated request for UserId '{UserId}' failed DB validation (User not found or inactive). Returning 401 Unauthorized.", userId); context.Response.StatusCode = Microsoft.AspNetCore.Http.StatusCodes.Status401Unauthorized; context.Response.ContentType = "application/json"; await context.Response.WriteAsync("{\"message\":\"User account does not exist or has been deactivated.\"}"); return; } } else { context.Response.StatusCode = Microsoft.AspNetCore.Http.StatusCodes.Status401Unauthorized; context.Response.ContentType = "application/json"; await context.Response.WriteAsync("{\"message\":\"Invalid user claim in authentication token.\"}"); return; } } await next(); }); // Database Migration & Seeding using (var scope = app.Services.CreateScope()) { try { var context = scope.ServiceProvider.GetRequiredService(); var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? ""; await context.MigrateWithBootstrapAsync(connStr); var userService = scope.ServiceProvider.GetRequiredService(); await userService.SeedDefaultAdminAsync(adminDefaultPassword); } catch (Exception ex) { var logger = scope.ServiceProvider.GetRequiredService>(); logger.LogError(ex, "An error occurred during database migration/seeding."); } } // Map Endpoints app.MapControllers(); app.MapHub("/hubs/trade-stream", options => { options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents; }); app.MapHub("/hubs/news", options => { options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents; }); app.MapHub("/hubs/health", options => { options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents; }); app.MapHub("/hubs/favorites-prices", options => { options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents; }); app.MapHub("/hubs/logs", options => { options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents; }); // Explicit, sanctioned exception to "every route requires authentication" (Rules.md §7): the Docker // healthcheck defined in compose.yaml sends an unauthenticated `GET /health` against 127.0.0.1:8080 and // only checks for a 200 status code. A healthcheck cannot carry a bearer token/secret, so requiring auth // here would make every container report unhealthy. The response body is limited to {status, service, // timestamp} — no business or user data — so the anonymous surface stays minimal. app.MapGet("/health", () => Results.Ok(new { status = "Healthy", service = "FinlyticBackend", timestamp = DateTime.UtcNow })) .AllowAnonymous(); // Explicit, sanctioned exception to "every route requires authentication" (Rules.md §7): this serves the // compiled Flutter Web SPA shell (index.html) for any unmatched route. It must stay anonymous, or the // browser could never load the login page in the first place - the SPA itself enforces auth client-side // once loaded, and every actual data-bearing API route below requires a JWT. app.MapFallbackToFile("index.html"); await app.RunAsync();