145 lines
5.8 KiB
C#
145 lines
5.8 KiB
C#
using System.Threading.RateLimiting;
|
|
using Backend.Common;
|
|
using Backend.Configuration;
|
|
using Backend.Data;
|
|
using Backend.Repositories;
|
|
using Backend.Security;
|
|
using Backend.Services;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Globalization;
|
|
|
|
namespace Backend.Extensions;
|
|
|
|
public static class ServiceCollectionExtensions
|
|
{
|
|
public static IServiceCollection AddApplicationServices(
|
|
this IServiceCollection services,
|
|
IConfiguration configuration,
|
|
IWebHostEnvironment environment)
|
|
{
|
|
services.AddProblemDetails();
|
|
services.AddEndpointsApiExplorer();
|
|
services.AddSwaggerGen();
|
|
|
|
services.Configure<FrontendOptions>(configuration.GetSection(FrontendOptions.SectionName));
|
|
services.Configure<TwitchAuthOptions>(configuration.GetSection(TwitchAuthOptions.SectionName));
|
|
services.AddMemoryCache();
|
|
services.AddHttpClient();
|
|
var allowedOrigins = ResolveAllowedOrigins(configuration, environment);
|
|
|
|
var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres");
|
|
if (string.IsNullOrWhiteSpace(connectionString))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"No PostgreSQL connection string configured. Set VTSA_POSTGRES or ConnectionStrings:Postgres.");
|
|
}
|
|
|
|
services.AddCors(options =>
|
|
{
|
|
options.AddPolicy(ApplicationDefaults.FrontendCorsPolicy, policy =>
|
|
{
|
|
policy.WithOrigins(allowedOrigins)
|
|
.WithHeaders("Authorization", "Content-Type")
|
|
.WithMethods(HttpMethods.Get, HttpMethods.Post, HttpMethods.Put, HttpMethods.Delete);
|
|
});
|
|
});
|
|
|
|
services.AddRateLimiter(options =>
|
|
{
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
options.OnRejected = async (context, cancellationToken) =>
|
|
{
|
|
context.HttpContext.Response.ContentType = "application/json";
|
|
await context.HttpContext.Response.WriteAsJsonAsync(
|
|
new { message = "Zu viele Anfragen. Bitte kurz warten und erneut versuchen." },
|
|
cancellationToken);
|
|
};
|
|
|
|
options.AddPolicy(ApplicationDefaults.AuthRateLimitPolicy, context =>
|
|
RateLimitPartition.GetFixedWindowLimiter(
|
|
partitionKey: BuildRateLimitPartitionKey(context, "auth"),
|
|
factory: _ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = 5,
|
|
Window = TimeSpan.FromMinutes(1),
|
|
QueueLimit = 0,
|
|
AutoReplenishment = true,
|
|
}));
|
|
|
|
options.AddPolicy(ApplicationDefaults.PublicWriteRateLimitPolicy, context =>
|
|
RateLimitPartition.GetFixedWindowLimiter(
|
|
partitionKey: BuildRateLimitPartitionKey(context, "public-write"),
|
|
factory: _ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = 20,
|
|
Window = TimeSpan.FromMinutes(1),
|
|
QueueLimit = 0,
|
|
AutoReplenishment = true,
|
|
}));
|
|
});
|
|
|
|
services.AddDbContext<AwardsDbContext>(options => options.UseNpgsql(connectionString));
|
|
|
|
services.AddScoped<IUserSessionRepository, UserSessionRepository>();
|
|
services.AddScoped<IRiskFlagRepository, RiskFlagRepository>();
|
|
services.AddScoped<IAdminAuditRepository, AdminAuditRepository>();
|
|
services.AddScoped<IUserSessionService, UserSessionService>();
|
|
services.AddScoped<IRiskRuleService, RiskRuleService>();
|
|
services.AddScoped<IRiskFlagService, RiskFlagService>();
|
|
services.AddScoped<IAdminAuditService, AdminAuditService>();
|
|
services.AddScoped<AdminSessionFilter>();
|
|
|
|
return services;
|
|
}
|
|
|
|
private static string BuildRateLimitPartitionKey(HttpContext context, string policyName)
|
|
{
|
|
var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
|
|
var route = context.Request.Path.Value ?? "/";
|
|
return string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"{policyName}:{ipAddress}:{route}");
|
|
}
|
|
|
|
private static string[] ResolveAllowedOrigins(IConfiguration configuration, IWebHostEnvironment environment)
|
|
{
|
|
var frontendOptions = configuration.GetSection(FrontendOptions.SectionName).Get<FrontendOptions>();
|
|
var configuredOrigins = frontendOptions?.AllowedOrigins
|
|
.Select(NormalizeCorsOrigin)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray() ?? [];
|
|
|
|
if (configuredOrigins.Length > 0)
|
|
{
|
|
return configuredOrigins;
|
|
}
|
|
|
|
if (environment.IsDevelopment())
|
|
{
|
|
return ApplicationDefaults.FrontendOrigins;
|
|
}
|
|
|
|
throw new InvalidOperationException(
|
|
"Frontend:AllowedOrigins must be configured in non-development environments.");
|
|
}
|
|
|
|
private static string NormalizeCorsOrigin(string origin)
|
|
{
|
|
var trimmedOrigin = origin.Trim();
|
|
if (string.IsNullOrWhiteSpace(trimmedOrigin) || trimmedOrigin.Contains('*', StringComparison.Ordinal))
|
|
{
|
|
throw new InvalidOperationException("CORS origins must be explicit http(s) origins. Wildcards are not allowed.");
|
|
}
|
|
|
|
if (!Uri.TryCreate(trimmedOrigin, UriKind.Absolute, out var uri)
|
|
|| uri.Scheme is not ("http" or "https")
|
|
|| string.IsNullOrWhiteSpace(uri.Host))
|
|
{
|
|
throw new InvalidOperationException($"Invalid CORS origin configured: {trimmedOrigin}");
|
|
}
|
|
|
|
return uri.GetLeftPart(UriPartial.Authority);
|
|
}
|
|
}
|