warrenchen 09589ef631 Completed API and redirect login test
feat: Update OAuth client handling and permissions, enhance authentication claims, and adjust environment settings
2026-04-23 13:56:33 +09:00

344 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");
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 =>
{
var cookieDomain = builder.Configuration["Auth:CookieDomain"];
if (!string.IsNullOrWhiteSpace(cookieDomain))
{
options.Cookie.Domain = cookieDomain;
}
});
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")));
});
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();
}