< Summary

Information
Class: Anichron.API.Infrastructure.ServiceCollectionExtensions
Assembly: Anichron.API
File(s): /home/runner/work/anichron/anichron/src/Anichron.API/Infrastructure/ServiceCollectionExtensions.cs
Tag: 228_36821334185
Line coverage
0%
Covered lines: 0
Uncovered lines: 140
Coverable lines: 140
Total lines: 197
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 14
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
AddDatabase(...)100%210%
AddForwardedHeadersSupport(...)100%210%
AddRateLimiting(...)100%210%
AddCorsPolicy(...)0%620%
AddAuthServices(...)0%156120%
AddAuthorizationPolicies(...)100%210%
AddApiHealthChecks(...)100%210%

File(s)

/home/runner/work/anichron/anichron/src/Anichron.API/Infrastructure/ServiceCollectionExtensions.cs

#LineLine coverage
 1using Anichron.API.Endpoints;
 2using Anichron.API.Security;
 3using Anichron.API.Services;
 4using Anichron.API.Settings;
 5using Anichron.Core.Data;
 6using Anichron.Core.Data.Repository;
 7using Anichron.Infrastructure.Configuration;
 8using Microsoft.AspNetCore.Authentication.JwtBearer;
 9using Microsoft.AspNetCore.HttpOverrides;
 10using Microsoft.EntityFrameworkCore;
 11using Microsoft.IdentityModel.Tokens;
 12using System.IO.Abstractions;
 13using System.Text;
 14using System.Threading.RateLimiting;
 15using static System.Globalization.CultureInfo;
 16
 17namespace Anichron.API.Infrastructure;
 18
 19public static class ServiceCollectionExtensions
 20{
 21    extension(IServiceCollection services)
 22    {
 23        public IServiceCollection AddDatabase(IConfiguration configuration)
 024        {
 025            var connectionString = DatabaseConfiguration.GetConnectionString(configuration, new FileSystem());
 026            return services.AddDbContext<AnichronDbContext>(options =>
 027                options.UseNpgsql(connectionString, o => o.UseNodaTime()));
 28        }
 29
 30        public IServiceCollection AddForwardedHeadersSupport()
 031        {
 032            return services.Configure<ForwardedHeadersOptions>(options =>
 033            {
 034                options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
 035                // Clearing defaults ensures the Docker Compose gateway is trusted without
 036                // listing its IP explicitly, while blocking header injection from the internet.
 037                options.KnownIPNetworks.Clear();
 038                options.KnownProxies.Clear();
 039            });
 40        }
 41
 42        public IServiceCollection AddRateLimiting()
 043        {
 044            return services.AddRateLimiter(options =>
 045            {
 046                options.OnRejected = async (context, token) =>
 047                {
 048                    context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
 049                    if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
 050                    {
 051                        context.HttpContext.Response.Headers.RetryAfter =
 052                            ((int)retryAfter.TotalSeconds).ToString(InvariantCulture);
 053                    }
 054
 055                    await context.HttpContext.Response.WriteAsJsonAsync(
 056                        new { error = AuthMessages.TooManyRequests }, token);
 057                };
 058
 059                // RemoteIpAddress is populated by UseForwardedHeaders before this middleware runs.
 060                // Requests with no resolvable IP share a tight "unresolved" bucket to prevent IP-hiding abuse.
 061                options.AddPolicy(AuthRateLimitPolicies.Sensitive, httpContext =>
 062                    RateLimitPartition.GetSlidingWindowLimiter(
 063                        partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unresolved",
 064                        factory: _ => new SlidingWindowRateLimiterOptions
 065                        {
 066                            PermitLimit = AppDefaults.RateLimit.Sensitive.PermitLimit,
 067                            Window = TimeSpan.FromSeconds(AppDefaults.RateLimit.Sensitive.WindowSeconds),
 068                            SegmentsPerWindow = AppDefaults.RateLimit.Sensitive.Segments,
 069                            QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
 070                            QueueLimit = 0,
 071                        }));
 072
 073                // Refresh tokens rotate on every use. Tighter policy required.
 074                options.AddPolicy(AuthRateLimitPolicies.Refresh, httpContext =>
 075                    RateLimitPartition.GetSlidingWindowLimiter(
 076                        partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unresolved",
 077                        factory: _ => new SlidingWindowRateLimiterOptions
 078                        {
 079                            PermitLimit = AppDefaults.RateLimit.Refresh.PermitLimit,
 080                            Window = TimeSpan.FromMinutes(AppDefaults.RateLimit.Refresh.WindowMinutes),
 081                            SegmentsPerWindow = AppDefaults.RateLimit.Refresh.Segments,
 082                            QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
 083                            QueueLimit = 0,
 084                        }));
 085            });
 86        }
 87
 88        public IServiceCollection AddCorsPolicy(IConfiguration configuration)
 089        {
 090            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).
 094            services.AddCors(options =>
 095            {
 096                if (allowedOrigins.Length > 0)
 097                {
 098                    options.AddDefaultPolicy(policy =>
 099                        policy.WithOrigins(allowedOrigins)
 0100                            .AllowAnyHeader()
 0101                            .AllowAnyMethod()
 0102                            .AllowCredentials());
 0103                }
 0104            });
 105
 0106            return services;
 107        }
 108
 109        public IServiceCollection AddAuthServices(IConfiguration configuration)
 0110        {
 0111            services.Configure<JwtSettings>(configuration.GetSection("Jwt"));
 0112            services.Configure<PasswordPolicy>(configuration.GetSection("PasswordPolicy"));
 0113            services.Configure<UsernamePolicy>(configuration.GetSection("UsernamePolicy"));
 0114            services.Configure<CorsSettings>(configuration.GetSection("Cors"));
 0115            services.AddSingleton<IClock>(SystemClock.Instance);
 0116            services.AddSingleton<IGuidFactory, TimeOrderedGuidFactory>();
 0117            services.AddSingleton<IJwtFactory, JwtFactory>();
 0118            services.AddSingleton<IPasswordHasher, Argon2PasswordHasher>();
 0119            services.AddSingleton<IAuthResponseMapper, AuthResponseMapper>();
 0120            services.AddScoped<IUserRepository, EfUserRepository>();
 0121            services.AddScoped<IRefreshTokenRepository, EfRefreshTokenRepository>();
 0122            services.AddScoped<IInviteRepository, EfInviteRepository>();
 0123            services.AddScoped<IUserStorageConfigRepository, EfUserStorageConfigRepository>();
 124            // AnichronDbContext is already scoped via AddDbContext; reuse the same instance for IUnitOfWork
 0125            services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<AnichronDbContext>());
 0126            services.AddScoped<IRegistrationValidator, RegistrationValidator>();
 0127            services.AddScoped<ILockoutService, LockoutService>();
 0128            services.AddScoped<ITokenService, TokenService>();
 0129            services.AddScoped<IAuthService, AuthService>();
 0130            services.AddTransient<IBootstrapSeeder, BootstrapSeeder>();
 0131            services.AddScoped<IAdminResetService, AdminResetService>();
 0132            services.AddScoped<IAdminUserService, AdminUserService>();
 0133            services.AddScoped<IAdminStorageConfigService, AdminStorageConfigService>();
 0134            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.
 0138            var allowedOrigins = configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
 0139            services.AddSingleton(new AuthCookieSettings
 0140            {
 0141                SameSite = allowedOrigins.Length > 0 ? SameSiteMode.None : SameSiteMode.Strict,
 0142                RefreshTokenDays = configuration.GetValue("Jwt:RefreshTokenDays", AppDefaults.Jwt.RefreshTokenDays),
 0143            });
 144
 0145            services.AddHttpClient<IPwnedPasswordClient, PwnedPasswordClient>(client =>
 0146            {
 0147                client.BaseAddress = new Uri(AppDefaults.Pwned.Url);
 0148                client.DefaultRequestHeaders.Add("Add-Padding", "true");
 0149                client.Timeout = TimeSpan.FromSeconds(AppDefaults.Pwned.TimeoutInSeconds);
 0150            }).AddStandardResilienceHandler();
 151
 0152            var jwtSecret = configuration["Jwt:Secret"]
 0153                            ?? throw new InvalidOperationException("Jwt:Secret configuration is missing.");
 0154            var jwtIssuer = configuration["Jwt:Issuer"]
 0155                            ?? throw new InvalidOperationException("Jwt:Issuer is missing.");
 0156            var jwtAudience = configuration["Jwt:Audience"]
 0157                              ?? throw new InvalidOperationException("Jwt:Audience is missing.");
 158
 0159            if (Encoding.UTF8.GetByteCount(jwtSecret) < 32)
 0160                throw new InvalidOperationException("Jwt:Secret must be at least 32 bytes.");
 161
 0162            services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
 0163                .AddJwtBearer(options =>
 0164                {
 0165                    options.TokenValidationParameters = new TokenValidationParameters
 0166                    {
 0167                        ValidateIssuerSigningKey = true,
 0168                        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
 0169                        ValidateIssuer = true,
 0170                        ValidIssuer = jwtIssuer,
 0171                        ValidateAudience = true,
 0172                        ValidAudience = jwtAudience,
 0173                        ValidateLifetime = true,
 0174                        ClockSkew = TimeSpan.Zero,
 0175                    };
 0176                });
 177
 0178            return services;
 179        }
 180
 181        public IServiceCollection AddAuthorizationPolicies()
 0182        {
 0183            return services.AddAuthorization(options =>
 0184                options.AddPolicy(AuthPolicies.Admin,
 0185                    policy => policy.RequireClaim(AppClaimTypes.IsAdmin, "true")));
 186        }
 187
 188        public IServiceCollection AddApiHealthChecks()
 0189        {
 0190            services.AddSingleton<IFileSystem, FileSystem>();
 0191            services.AddHealthChecks()
 0192                    .AddDbContextCheck<AnichronDbContext>("database")
 0193                    .AddCheck<ProxyStorageHealthCheck>("proxyStorage");
 0194            return services;
 195        }
 196    }
 197}