2026-04-23 00:15:53 +09:00

214 lines
7.9 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");
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.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.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();
builder.Services.AddHttpContextAccessor();
var app = builder.Build();
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)
{
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
});
}