| | | 1 | | using Anichron.API.Endpoints; |
| | | 2 | | using Anichron.API.Security; |
| | | 3 | | using Anichron.API.Services; |
| | | 4 | | using Anichron.API.Settings; |
| | | 5 | | using Anichron.Core.Data; |
| | | 6 | | using Anichron.Core.Data.Repository; |
| | | 7 | | using Anichron.Infrastructure.Configuration; |
| | | 8 | | using Microsoft.AspNetCore.Authentication.JwtBearer; |
| | | 9 | | using Microsoft.AspNetCore.HttpOverrides; |
| | | 10 | | using Microsoft.EntityFrameworkCore; |
| | | 11 | | using Microsoft.IdentityModel.Tokens; |
| | | 12 | | using System.IO.Abstractions; |
| | | 13 | | using System.Text; |
| | | 14 | | using System.Threading.RateLimiting; |
| | | 15 | | using static System.Globalization.CultureInfo; |
| | | 16 | | |
| | | 17 | | namespace Anichron.API.Infrastructure; |
| | | 18 | | |
| | | 19 | | public static class ServiceCollectionExtensions |
| | | 20 | | { |
| | | 21 | | extension(IServiceCollection services) |
| | | 22 | | { |
| | | 23 | | public IServiceCollection AddDatabase(IConfiguration configuration) |
| | 0 | 24 | | { |
| | 0 | 25 | | var connectionString = DatabaseConfiguration.GetConnectionString(configuration, new FileSystem()); |
| | 0 | 26 | | return services.AddDbContext<AnichronDbContext>(options => |
| | 0 | 27 | | options.UseNpgsql(connectionString, o => o.UseNodaTime())); |
| | | 28 | | } |
| | | 29 | | |
| | | 30 | | public IServiceCollection AddForwardedHeadersSupport() |
| | 0 | 31 | | { |
| | 0 | 32 | | return services.Configure<ForwardedHeadersOptions>(options => |
| | 0 | 33 | | { |
| | 0 | 34 | | options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; |
| | 0 | 35 | | // Clearing defaults ensures the Docker Compose gateway is trusted without |
| | 0 | 36 | | // listing its IP explicitly, while blocking header injection from the internet. |
| | 0 | 37 | | options.KnownIPNetworks.Clear(); |
| | 0 | 38 | | options.KnownProxies.Clear(); |
| | 0 | 39 | | }); |
| | | 40 | | } |
| | | 41 | | |
| | | 42 | | public IServiceCollection AddRateLimiting() |
| | 0 | 43 | | { |
| | 0 | 44 | | return services.AddRateLimiter(options => |
| | 0 | 45 | | { |
| | 0 | 46 | | options.OnRejected = async (context, token) => |
| | 0 | 47 | | { |
| | 0 | 48 | | context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; |
| | 0 | 49 | | if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) |
| | 0 | 50 | | { |
| | 0 | 51 | | context.HttpContext.Response.Headers.RetryAfter = |
| | 0 | 52 | | ((int)retryAfter.TotalSeconds).ToString(InvariantCulture); |
| | 0 | 53 | | } |
| | 0 | 54 | | |
| | 0 | 55 | | await context.HttpContext.Response.WriteAsJsonAsync( |
| | 0 | 56 | | new { error = AuthMessages.TooManyRequests }, token); |
| | 0 | 57 | | }; |
| | 0 | 58 | | |
| | 0 | 59 | | // RemoteIpAddress is populated by UseForwardedHeaders before this middleware runs. |
| | 0 | 60 | | // Requests with no resolvable IP share a tight "unresolved" bucket to prevent IP-hiding abuse. |
| | 0 | 61 | | options.AddPolicy(AuthRateLimitPolicies.Sensitive, httpContext => |
| | 0 | 62 | | RateLimitPartition.GetSlidingWindowLimiter( |
| | 0 | 63 | | partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unresolved", |
| | 0 | 64 | | factory: _ => new SlidingWindowRateLimiterOptions |
| | 0 | 65 | | { |
| | 0 | 66 | | PermitLimit = AppDefaults.RateLimit.Sensitive.PermitLimit, |
| | 0 | 67 | | Window = TimeSpan.FromSeconds(AppDefaults.RateLimit.Sensitive.WindowSeconds), |
| | 0 | 68 | | SegmentsPerWindow = AppDefaults.RateLimit.Sensitive.Segments, |
| | 0 | 69 | | QueueProcessingOrder = QueueProcessingOrder.OldestFirst, |
| | 0 | 70 | | QueueLimit = 0, |
| | 0 | 71 | | })); |
| | 0 | 72 | | |
| | 0 | 73 | | // Refresh tokens rotate on every use. Tighter policy required. |
| | 0 | 74 | | options.AddPolicy(AuthRateLimitPolicies.Refresh, httpContext => |
| | 0 | 75 | | RateLimitPartition.GetSlidingWindowLimiter( |
| | 0 | 76 | | partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unresolved", |
| | 0 | 77 | | factory: _ => new SlidingWindowRateLimiterOptions |
| | 0 | 78 | | { |
| | 0 | 79 | | PermitLimit = AppDefaults.RateLimit.Refresh.PermitLimit, |
| | 0 | 80 | | Window = TimeSpan.FromMinutes(AppDefaults.RateLimit.Refresh.WindowMinutes), |
| | 0 | 81 | | SegmentsPerWindow = AppDefaults.RateLimit.Refresh.Segments, |
| | 0 | 82 | | QueueProcessingOrder = QueueProcessingOrder.OldestFirst, |
| | 0 | 83 | | QueueLimit = 0, |
| | 0 | 84 | | })); |
| | 0 | 85 | | }); |
| | | 86 | | } |
| | | 87 | | |
| | | 88 | | public IServiceCollection AddCorsPolicy(IConfiguration configuration) |
| | 0 | 89 | | { |
| | 0 | 90 | | var allowedOrigins = configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? []; |
| | | 91 | | |
| | | 92 | | // Always register CORS services so UseCors() is valid in the middleware pipeline. |
| | | 93 | | // With no configured origins the default policy allows nothing (same-origin behavior). |
| | 0 | 94 | | services.AddCors(options => |
| | 0 | 95 | | { |
| | 0 | 96 | | if (allowedOrigins.Length > 0) |
| | 0 | 97 | | { |
| | 0 | 98 | | options.AddDefaultPolicy(policy => |
| | 0 | 99 | | policy.WithOrigins(allowedOrigins) |
| | 0 | 100 | | .AllowAnyHeader() |
| | 0 | 101 | | .AllowAnyMethod() |
| | 0 | 102 | | .AllowCredentials()); |
| | 0 | 103 | | } |
| | 0 | 104 | | }); |
| | | 105 | | |
| | 0 | 106 | | return services; |
| | | 107 | | } |
| | | 108 | | |
| | | 109 | | public IServiceCollection AddAuthServices(IConfiguration configuration) |
| | 0 | 110 | | { |
| | 0 | 111 | | services.Configure<JwtSettings>(configuration.GetSection("Jwt")); |
| | 0 | 112 | | services.Configure<PasswordPolicy>(configuration.GetSection("PasswordPolicy")); |
| | 0 | 113 | | services.Configure<UsernamePolicy>(configuration.GetSection("UsernamePolicy")); |
| | 0 | 114 | | services.Configure<CorsSettings>(configuration.GetSection("Cors")); |
| | 0 | 115 | | services.AddSingleton<IClock>(SystemClock.Instance); |
| | 0 | 116 | | services.AddSingleton<IGuidFactory, TimeOrderedGuidFactory>(); |
| | 0 | 117 | | services.AddSingleton<IJwtFactory, JwtFactory>(); |
| | 0 | 118 | | services.AddSingleton<IPasswordHasher, Argon2PasswordHasher>(); |
| | 0 | 119 | | services.AddSingleton<IAuthResponseMapper, AuthResponseMapper>(); |
| | 0 | 120 | | services.AddScoped<IUserRepository, EfUserRepository>(); |
| | 0 | 121 | | services.AddScoped<IRefreshTokenRepository, EfRefreshTokenRepository>(); |
| | 0 | 122 | | services.AddScoped<IInviteRepository, EfInviteRepository>(); |
| | 0 | 123 | | services.AddScoped<IUserStorageConfigRepository, EfUserStorageConfigRepository>(); |
| | | 124 | | // AnichronDbContext is already scoped via AddDbContext; reuse the same instance for IUnitOfWork |
| | 0 | 125 | | services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<AnichronDbContext>()); |
| | 0 | 126 | | services.AddScoped<IRegistrationValidator, RegistrationValidator>(); |
| | 0 | 127 | | services.AddScoped<ILockoutService, LockoutService>(); |
| | 0 | 128 | | services.AddScoped<ITokenService, TokenService>(); |
| | 0 | 129 | | services.AddScoped<IAuthService, AuthService>(); |
| | 0 | 130 | | services.AddTransient<IBootstrapSeeder, BootstrapSeeder>(); |
| | 0 | 131 | | services.AddScoped<IAdminResetService, AdminResetService>(); |
| | 0 | 132 | | services.AddScoped<IAdminUserService, AdminUserService>(); |
| | 0 | 133 | | services.AddScoped<IAdminStorageConfigService, AdminStorageConfigService>(); |
| | 0 | 134 | | services.AddTransient<IBootstrapResetService, BootstrapResetService>(); |
| | | 135 | | |
| | | 136 | | // SameSite=None is required when the UI and API are on different origins so browsers |
| | | 137 | | // send the cookie on cross-origin requests. SameSite=Strict is safer for same-origin. |
| | 0 | 138 | | var allowedOrigins = configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? []; |
| | 0 | 139 | | services.AddSingleton(new AuthCookieSettings |
| | 0 | 140 | | { |
| | 0 | 141 | | SameSite = allowedOrigins.Length > 0 ? SameSiteMode.None : SameSiteMode.Strict, |
| | 0 | 142 | | RefreshTokenDays = configuration.GetValue("Jwt:RefreshTokenDays", AppDefaults.Jwt.RefreshTokenDays), |
| | 0 | 143 | | }); |
| | | 144 | | |
| | 0 | 145 | | services.AddHttpClient<IPwnedPasswordClient, PwnedPasswordClient>(client => |
| | 0 | 146 | | { |
| | 0 | 147 | | client.BaseAddress = new Uri(AppDefaults.Pwned.Url); |
| | 0 | 148 | | client.DefaultRequestHeaders.Add("Add-Padding", "true"); |
| | 0 | 149 | | client.Timeout = TimeSpan.FromSeconds(AppDefaults.Pwned.TimeoutInSeconds); |
| | 0 | 150 | | }).AddStandardResilienceHandler(); |
| | | 151 | | |
| | 0 | 152 | | var jwtSecret = configuration["Jwt:Secret"] |
| | 0 | 153 | | ?? throw new InvalidOperationException("Jwt:Secret configuration is missing."); |
| | 0 | 154 | | var jwtIssuer = configuration["Jwt:Issuer"] |
| | 0 | 155 | | ?? throw new InvalidOperationException("Jwt:Issuer is missing."); |
| | 0 | 156 | | var jwtAudience = configuration["Jwt:Audience"] |
| | 0 | 157 | | ?? throw new InvalidOperationException("Jwt:Audience is missing."); |
| | | 158 | | |
| | 0 | 159 | | if (Encoding.UTF8.GetByteCount(jwtSecret) < 32) |
| | 0 | 160 | | throw new InvalidOperationException("Jwt:Secret must be at least 32 bytes."); |
| | | 161 | | |
| | 0 | 162 | | services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) |
| | 0 | 163 | | .AddJwtBearer(options => |
| | 0 | 164 | | { |
| | 0 | 165 | | options.TokenValidationParameters = new TokenValidationParameters |
| | 0 | 166 | | { |
| | 0 | 167 | | ValidateIssuerSigningKey = true, |
| | 0 | 168 | | IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)), |
| | 0 | 169 | | ValidateIssuer = true, |
| | 0 | 170 | | ValidIssuer = jwtIssuer, |
| | 0 | 171 | | ValidateAudience = true, |
| | 0 | 172 | | ValidAudience = jwtAudience, |
| | 0 | 173 | | ValidateLifetime = true, |
| | 0 | 174 | | ClockSkew = TimeSpan.Zero, |
| | 0 | 175 | | }; |
| | 0 | 176 | | }); |
| | | 177 | | |
| | 0 | 178 | | return services; |
| | | 179 | | } |
| | | 180 | | |
| | | 181 | | public IServiceCollection AddAuthorizationPolicies() |
| | 0 | 182 | | { |
| | 0 | 183 | | return services.AddAuthorization(options => |
| | 0 | 184 | | options.AddPolicy(AuthPolicies.Admin, |
| | 0 | 185 | | policy => policy.RequireClaim(AppClaimTypes.IsAdmin, "true"))); |
| | | 186 | | } |
| | | 187 | | |
| | | 188 | | public IServiceCollection AddApiHealthChecks() |
| | 0 | 189 | | { |
| | 0 | 190 | | services.AddSingleton<IFileSystem, FileSystem>(); |
| | 0 | 191 | | services.AddHealthChecks() |
| | 0 | 192 | | .AddDbContextCheck<AnichronDbContext>("database") |
| | 0 | 193 | | .AddCheck<ProxyStorageHealthCheck>("proxyStorage"); |
| | 0 | 194 | | return services; |
| | | 195 | | } |
| | | 196 | | } |
| | | 197 | | } |