using GameList.Data; using GameList.Endpoints; using GameList.Infrastructure; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using System.Net; using System.Security.Claims; using System.Globalization; using System.Threading.RateLimiting; using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); var dataDirectory = Path.Combine(builder.Environment.ContentRootPath, "App_Data"); Directory.CreateDirectory(dataDirectory); var dataProtectionDirectory = Path.Combine(dataDirectory, "keys"); Directory.CreateDirectory(dataProtectionDirectory); var configuredConnection = builder.Configuration.GetConnectionString("Default"); var dbPath = Path.Combine(dataDirectory, "gamelist.db"); var connectionBuilder = new SqliteConnectionStringBuilder(string.IsNullOrWhiteSpace(configuredConnection) ? $"Data Source={dbPath}" : configuredConnection); if (connectionBuilder.DataSource.Contains("App_Data", StringComparison.OrdinalIgnoreCase)) { var fileName = Path.GetFileName(connectionBuilder.DataSource); connectionBuilder.DataSource = Path.Combine(dataDirectory, fileName); } else if (!Path.IsPathRooted(connectionBuilder.DataSource)) { connectionBuilder.DataSource = Path.GetFullPath(connectionBuilder.DataSource, dataDirectory); } var connectionString = connectionBuilder.ToString(); builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); builder.Services.AddHttpClient("imageValidation").ConfigurePrimaryHttpMessageHandler(EndpointHelpers.CreateImageValidationHandler); builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(dataProtectionDirectory)); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.OnRejected = async (context, token) => { var monitor = context.HttpContext.RequestServices.GetRequiredService(); monitor.RecordRateLimited(context.HttpContext); if (context.HttpContext.Response.HasStarted) return; context.HttpContext.Response.ContentType = "application/problem+json"; var problem = new ProblemDetails { Status = StatusCodes.Status429TooManyRequests, Title = "Too Many Requests", Detail = "Too many requests. Please try again shortly.", Extensions = { ["error"] = "Too many requests. Please try again shortly." } }; await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken: token); }; options.AddPolicy("auth-sensitive", context => RateLimitPartition.GetFixedWindowLimiter( partitionKey: BuildAuthRateLimitKey(context), factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 6, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true })); options.AddPolicy("admin-sensitive", context => RateLimitPartition.GetSlidingWindowLimiter( partitionKey: BuildAdminRateLimitKey(context), factory: _ => new SlidingWindowRateLimiterOptions { PermitLimit = 20, Window = TimeSpan.FromMinutes(1), SegmentsPerWindow = 4, QueueLimit = 0, AutoReplenishment = true })); }); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options => { options.Cookie.Name = PlayerIdentityExtensions.PlayerCookieName; options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Strict; options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; options.SlidingExpiration = true; options.ExpireTimeSpan = TimeSpan.FromHours(12); options.Events = new CookieAuthenticationEvents { OnSigningIn = EnsureSessionStartAsync, OnValidatePrincipal = ValidateSessionLifetimeAsync, OnRedirectToLogin = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext), OnRedirectToAccessDenied = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext) }; }); builder.Services.AddAuthorization(options => { options.AddPolicy(PlayerIdentityExtensions.AdminPolicy, policy => policy.RequireClaim(PlayerIdentityExtensions.AdminClaim, "true")); }); var app = builder.Build(); app.UseForwardedHeaders(BuildForwardedHeadersOptions(builder.Configuration)); app.UseRateLimiter(); if (!app.Environment.IsDevelopment()) { app.UseHsts(); app.UseHttpsRedirection(); } app.Use(async (ctx, next) => { ctx.Response.OnStarting(() => { var headers = ctx.Response.Headers; headers["X-Content-Type-Options"] = "nosniff"; headers["X-Frame-Options"] = "DENY"; headers["Referrer-Policy"] = "no-referrer"; headers["Permissions-Policy"] = "camera=(), geolocation=(), microphone=()"; headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; return Task.CompletedTask; }); await next(); }); var basePath = builder.Configuration["BasePath"]; if (!string.IsNullOrWhiteSpace(basePath)) { app.UsePathBase(basePath); } app.UseGlobalExceptionLogging(); app.UseAuthentication(); app.UseMiddleware(); app.UseMiddleware(); app.UseAuthorization(); app.UseMiddleware(); app.UseDefaultFiles(); app.UseStaticFiles(); app.MapHealthChecks(); app.MapAuthEndpoints(); app.MapStateEndpoints(); app.MapSuggestEndpoints(); app.MapVoteEndpoints(); app.MapResultsEndpoints(); app.MapAdminEndpoints(); app.Run(); static string BuildAuthRateLimitKey(HttpContext context) { var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; return $"{context.Request.Path}|{ip}"; } static string BuildAdminRateLimitKey(HttpContext context) { var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anon"; var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; return $"{context.Request.Path}|{userId}|{ip}"; } const string SessionStartedAtKey = "session_started_at_unix"; const long AbsoluteSessionLifetimeSeconds = 7L * 24 * 60 * 60; static Task EnsureSessionStartAsync(CookieSigningInContext context) { if (!context.Properties.Items.ContainsKey(SessionStartedAtKey)) { context.Properties.Items[SessionStartedAtKey] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); } return Task.CompletedTask; } static async Task ValidateSessionLifetimeAsync(CookieValidatePrincipalContext context) { if (!context.Properties.Items.TryGetValue(SessionStartedAtKey, out var rawStart) || !long.TryParse(rawStart, out var unixStart)) { context.RejectPrincipal(); await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return; } var startedAt = DateTimeOffset.FromUnixTimeSeconds(unixStart); if ((DateTimeOffset.UtcNow - startedAt).TotalSeconds <= AbsoluteSessionLifetimeSeconds) return; var monitor = context.HttpContext.RequestServices.GetRequiredService(); monitor.RecordSessionExpired(context.HttpContext, startedAt); context.RejectPrincipal(); await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); } static ForwardedHeadersOptions BuildForwardedHeadersOptions(IConfiguration config) { var options = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost }; options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); foreach (var proxy in config.GetSection("ForwardedHeaders:KnownProxies").Get() ?? []) { if (IPAddress.TryParse(proxy, out var parsedProxy)) options.KnownProxies.Add(parsedProxy); } foreach (var network in config.GetSection("ForwardedHeaders:KnownNetworks").Get() ?? []) { var parts = network.Split('/', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 2) continue; if (!IPAddress.TryParse(parts[0], out var prefix)) continue; if (!int.TryParse(parts[1], out var prefixLength)) continue; options.KnownIPNetworks.Add(new System.Net.IPNetwork(prefix, prefixLength)); } return options; } static Task WriteUnauthorizedChallengeAsync(HttpContext context) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; if (!context.Request.Path.StartsWithSegments("/api")) return Task.CompletedTask; if (context.Response.HasStarted) return Task.CompletedTask; context.Response.ContentType = "application/problem+json"; var problem = new ProblemDetails { Status = StatusCodes.Status401Unauthorized, Title = "Unauthorized", Detail = "Unauthorized", Extensions = { ["error"] = "Unauthorized" } }; return context.Response.WriteAsJsonAsync(problem); } public partial class Program;