273 lines
10 KiB
C#
273 lines
10 KiB
C#
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<AppDbContext>(options => options.UseSqlite(connectionString));
|
|
builder.Services.AddScoped<SuggestionWorkflowService>();
|
|
builder.Services.AddScoped<VoteWorkflowService>();
|
|
builder.Services.AddScoped<AdminWorkflowService>();
|
|
builder.Services.AddScoped<ResultsWorkflowService>();
|
|
builder.Services.AddScoped<StateWorkflowService>();
|
|
builder.Services.AddSingleton<AuthAttemptMonitor>();
|
|
builder.Services.AddSingleton<StateChangeNotifier>();
|
|
|
|
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<AuthAttemptMonitor>();
|
|
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<EnsurePlayerExistsMiddleware>();
|
|
app.UseMiddleware<CsrfProtectionMiddleware>();
|
|
app.UseAuthorization();
|
|
app.UseMiddleware<StateChangeNotificationMiddleware>();
|
|
|
|
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<AuthAttemptMonitor>();
|
|
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<string[]>() ?? [])
|
|
{
|
|
if (IPAddress.TryParse(proxy, out var parsedProxy))
|
|
options.KnownProxies.Add(parsedProxy);
|
|
}
|
|
|
|
foreach (var network in config.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>() ?? [])
|
|
{
|
|
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;
|