- Added support for data protection keys by integrating Entity Framework Core for key persistence. - Updated `Program.cs` and `MemberCenterDbContext.cs` to configure data protection services. - Introduced new migration to create `DataProtectionKeys` table in the database. - Enhanced cookie settings for authentication to enforce security policies (SameSite=None, Secure=Always). - Updated installation documentation with new authentication configuration options.
238 lines
8.8 KiB
C#
238 lines
8.8 KiB
C#
using System.Security.Claims;
|
|
using System.Threading.RateLimiting;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using MemberCenter.Application.Abstractions;
|
|
using MemberCenter.Application.Constants;
|
|
using MemberCenter.Infrastructure.Configuration;
|
|
using MemberCenter.Infrastructure.Identity;
|
|
using MemberCenter.Infrastructure.Persistence;
|
|
using MemberCenter.Infrastructure.Services;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
EnvLoader.LoadDotEnvIfDevelopment();
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Services.AddDataProtection()
|
|
.SetApplicationName("MemberCenter")
|
|
.PersistKeysToDbContext<MemberCenterDbContext>();
|
|
|
|
builder.Services.AddDbContext<MemberCenterDbContext>(options =>
|
|
{
|
|
var connectionString = builder.Configuration.GetConnectionString("Default")
|
|
?? Environment.GetEnvironmentVariable("ConnectionStrings__Default")
|
|
?? "Host=localhost;Database=member_center;Username=postgres;Password=postgres";
|
|
|
|
options.UseNpgsql(connectionString);
|
|
options.UseOpenIddict();
|
|
});
|
|
|
|
builder.Services
|
|
.AddIdentity<ApplicationUser, ApplicationRole>(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<MemberCenterDbContext>()
|
|
.AddDefaultTokenProviders();
|
|
|
|
builder.Services.Configure<SecurityStampValidatorOptions>(options =>
|
|
{
|
|
options.ValidationInterval = TimeSpan.Zero;
|
|
});
|
|
|
|
var googleClientId = builder.Configuration["Authentication:Google:ClientId"]
|
|
?? Environment.GetEnvironmentVariable("Authentication__Google__ClientId");
|
|
var googleClientSecret = builder.Configuration["Authentication:Google:ClientSecret"]
|
|
?? Environment.GetEnvironmentVariable("Authentication__Google__ClientSecret");
|
|
|
|
var authenticationBuilder = builder.Services.AddAuthentication();
|
|
if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(googleClientSecret))
|
|
{
|
|
authenticationBuilder.AddGoogle(options =>
|
|
{
|
|
options.ClientId = googleClientId;
|
|
options.ClientSecret = googleClientSecret;
|
|
});
|
|
}
|
|
|
|
builder.Services.ConfigureApplicationCookie(options =>
|
|
{
|
|
options.LoginPath = "/account/login";
|
|
options.Cookie.Path = "/";
|
|
options.Cookie.SameSite = SameSiteMode.None;
|
|
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
|
|
|
options.Events = new CookieAuthenticationEvents
|
|
{
|
|
OnRedirectToLogin = context => HandleAdminAuthRedirectAsync(context),
|
|
OnRedirectToAccessDenied = context => HandleAdminAuthRedirectAsync(context),
|
|
OnValidatePrincipal = context => ValidatePrincipalAsync(context)
|
|
};
|
|
});
|
|
|
|
builder.Services.AddAuthorization(options =>
|
|
{
|
|
options.AddPolicy("Admin", policy => policy.RequireRole("admin", "superuser"));
|
|
options.AddPolicy("Superuser", policy => policy.RequireRole("superuser"));
|
|
});
|
|
|
|
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.PublicAuthLogin, context =>
|
|
CreateFixedWindowLimiter(context, "web-auth-login", permitLimit: 10, TimeSpan.FromMinutes(5)));
|
|
|
|
options.AddPolicy(RateLimitPolicyNames.PublicAuthRegister, context =>
|
|
CreateFixedWindowLimiter(context, "web-auth-register", permitLimit: 5, TimeSpan.FromMinutes(15)));
|
|
|
|
options.AddPolicy(RateLimitPolicyNames.PublicAuthRecovery, context =>
|
|
CreateFixedWindowLimiter(context, "web-auth-recovery", permitLimit: 5, TimeSpan.FromMinutes(15)));
|
|
});
|
|
|
|
builder.Services.AddScoped<INewsletterService, NewsletterService>();
|
|
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
|
|
builder.Services.AddScoped<ITenantService, TenantService>();
|
|
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
|
|
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
|
|
builder.Services.AddScoped<IAuditLogWriter, AuditLogWriter>();
|
|
builder.Services.AddScoped<IAccountGovernanceService, AccountGovernanceService>();
|
|
builder.Services.AddScoped<IAccountEmailTemplateService, AccountEmailTemplateService>();
|
|
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
|
|
builder.Services.AddScoped<IAccountEmailService, AccountEmailService>();
|
|
builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
|
|
builder.Services.AddScoped<ISubscriptionAdminService, SubscriptionAdminService>();
|
|
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
|
|
builder.Services.AddScoped<IProfileService, ProfileService>();
|
|
builder.Services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
|
|
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
|
|
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
|
|
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
|
|
|
|
builder.Services.AddOpenIddict()
|
|
.AddCore(options =>
|
|
{
|
|
options.UseEntityFrameworkCore()
|
|
.UseDbContext<MemberCenterDbContext>();
|
|
});
|
|
|
|
builder.Services.AddControllersWithViews(options =>
|
|
{
|
|
options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
|
|
});
|
|
builder.Services.AddHttpContextAccessor();
|
|
|
|
var app = builder.Build();
|
|
|
|
await EnsureAuthRegistryDefaultsAsync(app.Services);
|
|
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
app.UseExceptionHandler("/Home/Error");
|
|
app.UseHsts();
|
|
}
|
|
|
|
app.UseRouting();
|
|
app.UseRateLimiter();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.MapControllerRoute(
|
|
name: "admin",
|
|
pattern: "admin/{controller=Home}/{action=Index}/{id?}",
|
|
defaults: new { area = "Admin" });
|
|
|
|
app.MapControllerRoute(
|
|
name: "default",
|
|
pattern: "{controller=Home}/{action=Index}/{id?}");
|
|
|
|
app.Run();
|
|
|
|
static Task HandleAdminAuthRedirectAsync(RedirectContext<CookieAuthenticationOptions> context)
|
|
{
|
|
if (context.Request.Path.StartsWithSegments("/admin", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
context.Response.Redirect(context.RedirectUri);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
static async Task ValidatePrincipalAsync(CookieValidatePrincipalContext context)
|
|
{
|
|
await SecurityStampValidator.ValidatePrincipalAsync(context);
|
|
if (context.Principal?.Identity?.IsAuthenticated != true)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var userId = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
if (!Guid.TryParse(userId, out var parsedUserId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var userManager = context.HttpContext.RequestServices.GetRequiredService<UserManager<ApplicationUser>>();
|
|
var user = await userManager.FindByIdAsync(parsedUserId.ToString());
|
|
if (user is null || user.DisabledAt.HasValue)
|
|
{
|
|
context.RejectPrincipal();
|
|
await context.HttpContext.SignOutAsync();
|
|
}
|
|
}
|
|
|
|
static RateLimitPartition<string> 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<IAuthResourceRegistryService>();
|
|
await registry.EnsureDefaultsAsync();
|
|
}
|