265 lines
12 KiB
C#
265 lines
12 KiB
C#
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<GlobalExceptionHandler>();
|
|
|
|
// 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<BackendDbContext>(options =>
|
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
|
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<BackendDbContext>());
|
|
|
|
// 4b. Dynamic Settings & Channel-Based Logging (previously absent for FinlyticBackend - see BackendSettingKeys).
|
|
builder.Services.AddSingleton<ISettingsService, SettingsService>();
|
|
builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
|
|
|
|
builder.Services.AddHttpClient<UserTradesController>();
|
|
builder.Services.AddHttpClient<UserFavoritesController>();
|
|
builder.Services.AddHttpClient<NewsController>();
|
|
builder.Services.AddHttpClient<AssetsController>();
|
|
builder.Services.AddHttpClient<IFirebaseNotificationService, FirebaseNotificationService>();
|
|
|
|
builder.Services.AddSingleton<IJwtTokenService, JwtTokenService>();
|
|
builder.Services.AddSingleton<IUserService, UserService>();
|
|
builder.Services.AddSingleton<IFirebaseNotificationService, FirebaseNotificationService>();
|
|
|
|
builder.Services.AddSingleton<BackendMqttBridge>();
|
|
builder.Services.AddHostedService<BackendMqttBridge>(sp => sp.GetRequiredService<BackendMqttBridge>());
|
|
builder.Services.AddSingleton<WebMqttClient>();
|
|
builder.Services.AddHostedService<SystemHealthBackgroundService>();
|
|
builder.Services.AddHostedService<FavoritesPriceBackgroundService>();
|
|
|
|
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,
|
|
KnownNetworks = { },
|
|
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<BackendDbContext>();
|
|
bool userExists = await dbContext.Users.AsNoTracking().AnyAsync(u => u.Id == userId && u.IsActive);
|
|
if (!userExists)
|
|
{
|
|
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
|
|
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<BackendDbContext>();
|
|
var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? "";
|
|
await context.MigrateWithBootstrapAsync(connStr);
|
|
|
|
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
|
|
await userService.SeedDefaultAdminAsync(adminDefaultPassword);
|
|
}
|
|
|
|
catch (Exception ex)
|
|
{
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
|
|
logger.LogError(ex, "An error occurred during database migration/seeding.");
|
|
}
|
|
}
|
|
|
|
// Map Endpoints
|
|
app.MapControllers();
|
|
|
|
app.MapHub<TradeStreamHub>("/hubs/trade-stream", options =>
|
|
{
|
|
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
|
|
});
|
|
app.MapHub<NewsHub>("/hubs/news", options =>
|
|
{
|
|
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
|
|
});
|
|
app.MapHub<SystemHealthHub>("/hubs/health", options =>
|
|
{
|
|
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
|
|
});
|
|
app.MapHub<FavoritesPriceHub>("/hubs/favorites-prices", options =>
|
|
{
|
|
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
|
|
});
|
|
app.MapHub<LogStreamHub>("/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(); |