- 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.
345 lines
13 KiB
C#
345 lines
13 KiB
C#
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<MemberCenterDbContext>();
|
|
|
|
builder.Services.AddDbContext<MemberCenterDbContext>(options =>
|
|
{
|
|
var connectionString = builder.Configuration.GetConnectionString("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.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<MemberCenterDbContext>();
|
|
})
|
|
.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<ForwardedHeadersOptions>(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<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<INewsletterService, NewsletterService>();
|
|
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
|
|
builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
|
|
builder.Services.AddScoped<ITenantService, TenantService>();
|
|
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
|
|
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
|
|
builder.Services.AddScoped<IProfileService, ProfileService>();
|
|
builder.Services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
|
|
builder.Services.AddHttpContextAccessor();
|
|
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
|
|
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
|
|
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
|
|
|
|
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<UserManager<ApplicationUser>>();
|
|
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<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();
|
|
}
|