using System.Security.Claims; using System.Threading.RateLimiting; using MemberCenter.Infrastructure.Configuration; using MemberCenter.Infrastructure.Identity; using MemberCenter.Infrastructure.Persistence; using MemberCenter.Infrastructure.Services; using MemberCenter.Application.Abstractions; using MemberCenter.Application.Constants; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; using OpenIddict.Validation.AspNetCore; EnvLoader.LoadDotEnvIfDevelopment(); var builder = WebApplication.CreateBuilder(args); var pathBase = NormalizePathBase(builder.Configuration["PathBase"]); var issuer = builder.Configuration["Auth:Issuer"]; var issuerUri = ParseAbsoluteUriOrThrow(issuer, "Auth:Issuer"); var allowInsecureHttp = builder.Configuration.GetValue("Auth:AllowInsecureHttp", false); builder.Services.AddDataProtection() .SetApplicationName("MemberCenter") .PersistKeysToDbContext(); builder.Services.AddDbContext(options => { var connectionString = builder.Configuration.GetConnectionString("Default") ?? "Host=localhost;Database=member_center;Username=postgres;Password=postgres"; options.UseNpgsql(connectionString); options.UseOpenIddict(); }); builder.Services .AddIdentity(options => { options.User.RequireUniqueEmail = true; options.Password.RequireDigit = true; options.Password.RequireLowercase = true; options.Password.RequireUppercase = true; options.Password.RequireNonAlphanumeric = false; options.Password.RequiredLength = 8; options.Lockout.AllowedForNewUsers = true; options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); }) .AddEntityFrameworkStores() .AddDefaultTokenProviders(); builder.Services.AddAuthentication(options => { options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; }); builder.Services.ConfigureApplicationCookie(options => { options.Cookie.Path = "/"; options.Cookie.SameSite = SameSiteMode.None; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; }); builder.Services.AddOpenIddict() .AddCore(options => { options.UseEntityFrameworkCore() .UseDbContext(); }) .AddServer(options => { options.SetAuthorizationEndpointUris(WithPathBase(pathBase, "/oauth/authorize")); options.SetTokenEndpointUris( WithPathBase(pathBase, "/oauth/token"), WithPathBase(pathBase, "/auth/login"), WithPathBase(pathBase, "/auth/refresh")); options.SetLogoutEndpointUris(WithPathBase(pathBase, "/auth/logout")); if (issuerUri is not null) { options.SetIssuer(issuerUri); } options.AllowAuthorizationCodeFlow() .RequireProofKeyForCodeExchange(); options.AllowRefreshTokenFlow(); options.AllowPasswordFlow(); options.AllowClientCredentialsFlow(); options.AcceptAnonymousClients(); options.RegisterScopes( OpenIddictConstants.Scopes.OpenId, OpenIddictConstants.Scopes.Email, OpenIddictConstants.Scopes.Profile, "profile:basic.read", "profile:basic.write", "profile:addresses.read", "profile:addresses.write", "profile:subscriptions.read", "profile:subscriptions.write", "newsletter:list.read", "newsletter:send.write", "newsletter:send.read", "newsletter:events.read", "newsletter:events.write", "newsletter:events.write.global", "files:upload.write", "files:download.read", "files:download.delegate", "files:metadata.read", "files:delete"); options.AddDevelopmentEncryptionCertificate(); options.AddDevelopmentSigningCertificate(); options.DisableAccessTokenEncryption(); var aspNetCore = options.UseAspNetCore() .EnableAuthorizationEndpointPassthrough() .EnableTokenEndpointPassthrough() .EnableLogoutEndpointPassthrough() .EnableStatusCodePagesIntegration(); if (builder.Environment.IsDevelopment() || allowInsecureHttp) { // Allows OIDC/OAuth endpoints to operate behind non-HTTPS internal networks/proxies. aspNetCore.DisableTransportSecurityRequirement(); } }) .AddValidation(options => { options.UseLocalServer(); options.UseAspNetCore(); }); builder.Services.AddAuthorization(options => { options.AddPolicy("Admin", policy => policy.RequireRole("admin", "superuser")); options.AddPolicy("Superuser", policy => policy.RequireRole("superuser")); options.AddPolicy("ProfileBasicRead", policy => policy.RequireAssertion(context => context.User.HasScope("profile:basic.read"))); options.AddPolicy("ProfileBasicWrite", policy => policy.RequireAssertion(context => context.User.HasScope("profile:basic.write"))); options.AddPolicy("ProfileAddressesRead", policy => policy.RequireAssertion(context => context.User.HasScope("profile:addresses.read"))); options.AddPolicy("ProfileAddressesWrite", policy => policy.RequireAssertion(context => context.User.HasScope("profile:addresses.write"))); options.AddPolicy("ProfileSubscriptionsRead", policy => policy.RequireAssertion(context => context.User.HasScope("profile:subscriptions.read"))); options.AddPolicy("ProfileSubscriptionsWrite", policy => policy.RequireAssertion(context => context.User.HasScope("profile:subscriptions.write"))); options.AddPolicy("FilesDownloadDelegate", policy => policy.RequireAssertion(context => context.User.HasScope("files:download.delegate"))); options.AddPolicy("FilesDownloadRead", policy => policy.RequireAssertion(context => context.User.HasScope("files:download.read"))); }); builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownNetworks.Clear(); options.KnownProxies.Clear(); }); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.OnRejected = static async (context, token) => { if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) { context.HttpContext.Response.Headers.RetryAfter = Math.Ceiling(retryAfter.TotalSeconds).ToString(); } await context.HttpContext.Response.WriteAsync("Too many requests.", token); }; options.AddPolicy(RateLimitPolicyNames.PublicAuthRegister, context => CreateFixedWindowLimiter(context, "api-auth-register", permitLimit: 5, TimeSpan.FromMinutes(15))); options.AddPolicy(RateLimitPolicyNames.PublicAuthRecovery, context => CreateFixedWindowLimiter(context, "api-auth-recovery", permitLimit: 5, TimeSpan.FromMinutes(15))); options.AddPolicy(RateLimitPolicyNames.PublicNewsletterSubscribe, context => CreateFixedWindowLimiter(context, "api-newsletter-subscribe", permitLimit: 20, TimeSpan.FromMinutes(10))); options.AddPolicy(RateLimitPolicyNames.PublicNewsletterUnsubscribeToken, context => CreateFixedWindowLimiter(context, "api-newsletter-unsubscribe-token", permitLimit: 10, TimeSpan.FromMinutes(10))); }); builder.Services.AddControllers(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); builder.Services.Configure(builder.Configuration.GetSection("SendEngine")); builder.Services.AddHttpClient(); builder.Services.AddScoped(); var app = builder.Build(); await EnsureAuthRegistryDefaultsAsync(app.Services); app.UseForwardedHeaders(); if (!string.IsNullOrWhiteSpace(pathBase)) { app.UsePathBase(pathBase); } app.Use(async (context, next) => { if (issuerUri is not null && IsOpenIddictRequest(context.Request.Path)) { context.Request.Scheme = issuerUri.Scheme; context.Request.Host = HostString.FromUriComponent(issuerUri); } await next(); }); app.UseRouting(); app.UseRateLimiter(); app.UseAuthentication(); app.Use(async (context, next) => { if (context.User.Identity?.IsAuthenticated == true) { if (context.User.HasClaim(claim => claim.Type == "client_usage")) { await next(); return; } var subject = context.User.FindFirstValue(OpenIddictConstants.Claims.Subject) ?? context.User.FindFirstValue(ClaimTypes.NameIdentifier); if (Guid.TryParse(subject, out var userId)) { var userManager = context.RequestServices.GetRequiredService>(); var user = await userManager.FindByIdAsync(userId.ToString()); if (user is null || user.DisabledAt.HasValue) { context.Response.StatusCode = StatusCodes.Status403Forbidden; await context.Response.WriteAsync("Account is disabled."); return; } var tokenSecurityStamp = context.User.FindFirst("AspNet.Identity.SecurityStamp")?.Value; if (!string.IsNullOrWhiteSpace(tokenSecurityStamp) && !string.Equals(tokenSecurityStamp, user.SecurityStamp, StringComparison.Ordinal)) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; await context.Response.WriteAsync("Session has been invalidated."); return; } } } await next(); }); app.UseAuthorization(); app.MapControllers(); app.Run(); static string? NormalizePathBase(string? pathBase) { if (string.IsNullOrWhiteSpace(pathBase)) { return null; } var normalized = pathBase.StartsWith('/') ? pathBase : $"/{pathBase}"; return normalized.Length > 1 ? normalized.TrimEnd('/') : normalized; } static string WithPathBase(string? pathBase, string relativePath) { var normalizedRelativePath = relativePath.StartsWith('/') ? relativePath : $"/{relativePath}"; return string.IsNullOrWhiteSpace(pathBase) ? normalizedRelativePath : $"{pathBase}{normalizedRelativePath}"; } static Uri? ParseAbsoluteUriOrThrow(string? uri, string configKey) { if (string.IsNullOrWhiteSpace(uri)) { return null; } if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsed)) { throw new InvalidOperationException($"{configKey} must be an absolute URI."); } return parsed; } static bool IsOpenIddictRequest(PathString path) { return path.StartsWithSegments("/.well-known", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/oauth", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/auth", StringComparison.OrdinalIgnoreCase); } static RateLimitPartition CreateFixedWindowLimiter( HttpContext context, string policyPrefix, int permitLimit, TimeSpan window) { var identifier = context.User.Identity?.IsAuthenticated == true ? context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? context.User.Identity?.Name ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown" : context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; var partitionKey = $"{policyPrefix}:{identifier}"; return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions { PermitLimit = permitLimit, Window = window, QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueLimit = 0, AutoReplenishment = true }); } static async Task EnsureAuthRegistryDefaultsAsync(IServiceProvider services) { await using var scope = services.CreateAsyncScope(); var registry = scope.ServiceProvider.GetRequiredService(); await registry.EnsureDefaultsAsync(); }