diff --git a/Tiku.Api/Configuration/ApiPresentationExtensions.cs b/Tiku.Api/Configuration/ApiPresentationExtensions.cs new file mode 100644 index 0000000..52e31ea --- /dev/null +++ b/Tiku.Api/Configuration/ApiPresentationExtensions.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using Tiku.Api.OpenApi; + +namespace Tiku.Api.Configuration; + +internal static class ApiPresentationExtensions +{ + internal static IServiceCollection AddApiPresentation(this IServiceCollection services) + { + services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); + services.AddOpenApi(options => + { + options.AddDocumentTransformer(); + }); + services.AddProblemDetails(); + + return services; + } +} diff --git a/Tiku.Api/Configuration/ApplicationBuilderExtensions.cs b/Tiku.Api/Configuration/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000..a188984 --- /dev/null +++ b/Tiku.Api/Configuration/ApplicationBuilderExtensions.cs @@ -0,0 +1,39 @@ +using Scalar.AspNetCore; +using Serilog; +using Tiku.Api.Logging; +using Tiku.Api.Middleware; +using Tiku.Api.Options; + +namespace Tiku.Api.Configuration; + +public static class ApplicationBuilderExtensions +{ + public static WebApplication UseApiPipeline(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi().AllowAnonymous(); + app.MapScalarApiReference(options => options + .WithTitle("TIKU Backend API") + .AddPreferredSecuritySchemes("BearerAuth") + .EnablePersistentAuthentication()) + .AllowAnonymous(); + } + + app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging); + app.UseMiddleware(); + app.UseForwardedHeaders(); + app.UseHttpsRedirection(); + app.UseRouting(); + app.UseCors(CorsOptions.PolicyName); + app.UseMiddleware(); + app.UseAuthentication(); + app.UseMiddleware(); + app.UseRateLimiter(); + app.UseMiddleware(); + app.UseAuthorization(); + app.MapControllers(); + + return app; + } +} diff --git a/Tiku.Api/Configuration/AuthenticationExtensions.cs b/Tiku.Api/Configuration/AuthenticationExtensions.cs new file mode 100644 index 0000000..989283b --- /dev/null +++ b/Tiku.Api/Configuration/AuthenticationExtensions.cs @@ -0,0 +1,194 @@ +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using Tiku.Api.Options; +using Tiku.Api.Security; +using Tiku.Application.Auth; +using Tiku.Application.Security; +using Tiku.Domain.Tenancy; + +namespace Tiku.Api.Configuration; + +internal static class AuthenticationExtensions +{ + internal static IServiceCollection AddApiAuthenticationAndAuthorization( + this IServiceCollection services, + IConfiguration configuration, + IHostEnvironment environment) + { + services.AddOptions() + .Bind(configuration.GetSection("Security:Jwt")) + .ValidateDataAnnotations() + .Validate( + options => OptionsValidation.BeValidJwtOptions(options, environment.IsProduction()), + "Production JWT signing key must be explicitly configured and cannot use the development key.") + .ValidateOnStart(); + var jwtOptions = configuration + .GetSection("Security:Jwt") + .Get() ?? new JwtOptions(); + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => ConfigureJwtBearer(options, jwtOptions)); + + services.AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure((options, keyRing) => + { + options.TokenValidationParameters.IssuerSigningKeys = keyRing.ValidationKeys; + options.TokenValidationParameters.TryAllIssuerSigningKeys = false; + options.TokenValidationParameters.IssuerSigningKeyResolver = (_, _, kid, _) => + string.IsNullOrWhiteSpace(kid) + ? [] + : keyRing.ValidationKeys.Where(key => + string.Equals(key.KeyId, kid, StringComparison.Ordinal)); + }); + + services.AddAuthorization(options => + { + options.FallbackPolicy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); + options.AddPolicy( + TikuPolicies.AuthenticatedUser, + policy => policy.RequireAuthenticatedUser()); + options.AddPolicy( + TikuPolicies.TenantAdmin, + policy => policy.RequireAuthenticatedUser()); + }); + services.AddTikuRbacAuthorization(); + services.AddSingleton(); + + return services; + } + + private static void ConfigureJwtBearer(JwtBearerOptions options, JwtOptions jwtOptions) + { + options.MapInboundClaims = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtOptions.Issuer, + ValidateAudience = true, + ValidAudience = jwtOptions.Audience, + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + ValidAlgorithms = [SecurityAlgorithms.RsaSha256], + ValidateLifetime = true, + RequireExpirationTime = true, + ClockSkew = TimeSpan.FromMinutes(1), + NameClaimType = TikuClaimTypes.UserId + }; + options.Events = new JwtBearerEvents + { + OnTokenValidated = ValidateTokenAsync, + OnChallenge = WriteTenantConflictChallengeAsync + }; + } + + private static async Task ValidateTokenAsync(TokenValidatedContext context) + { + var principal = context.Principal; + if (!Guid.TryParse(principal?.FindFirst(TikuClaimTypes.UserId)?.Value, out var userId) || + !Guid.TryParse(principal?.FindFirst(TikuClaimTypes.SessionId)?.Value, out var sessionId) || + string.IsNullOrWhiteSpace(principal?.FindFirst(JwtRegisteredClaimNames.Jti)?.Value) || + !long.TryParse( + principal?.FindFirst(JwtRegisteredClaimNames.Iat)?.Value, + NumberStyles.None, + CultureInfo.InvariantCulture, + out _)) + { + context.Fail("Missing or invalid subject/session/jti/iat claim."); + return; + } + + var realmValue = principal.FindFirst(TikuClaimTypes.Realm)?.Value; + var realm = string.Equals(realmValue, "tenant", StringComparison.Ordinal) + ? AuthRealm.Tenant + : string.Equals(realmValue, "platform", StringComparison.Ordinal) + ? AuthRealm.Platform + : (AuthRealm?)null; + var tenantIdValue = principal.FindFirst(TikuClaimTypes.TenantId)?.Value; + var tenantId = Guid.TryParse(tenantIdValue, out var parsedTenantId) + ? parsedTenantId + : (Guid?)null; + if (realm is null || (realm == AuthRealm.Tenant) != tenantId.HasValue) + { + context.Fail("Token scope and tenant claims are inconsistent."); + return; + } + + var resolutionOptions = context.HttpContext.RequestServices + .GetRequiredService>().Value; + var requestHost = context.HttpContext.Request.Host.Host.Trim().TrimEnd('.'); + var isPlatformHost = resolutionOptions.PlatformHosts.Any(host => + string.Equals(host.Trim().TrimEnd('.'), requestHost, StringComparison.OrdinalIgnoreCase)); + var resolvedTenantContext = context.HttpContext.RequestServices.GetRequiredService(); + var tenantInitializer = context.HttpContext.RequestServices + .GetRequiredService(); + + if (realm == AuthRealm.Platform) + { + if (!isPlatformHost || resolvedTenantContext.IsResolved) + { + context.HttpContext.Items["tenant_context_conflict"] = true; + context.Fail("Platform tokens are only valid on a platform host."); + return; + } + } + else + { + if (isPlatformHost && !resolvedTenantContext.IsResolved) + { + context.HttpContext.Items["tenant_context_conflict"] = true; + context.Fail("Tenant tokens on a platform host require a matching tenant code."); + return; + } + + try + { + tenantInitializer.Initialize(tenantId!.Value, null, TenantResolutionSource.Jwt); + } + catch (TenantContextConflictException) + { + context.HttpContext.Items["tenant_context_conflict"] = true; + context.Fail("Authenticated tenant does not match the request host."); + return; + } + } + + var sessionStore = context.HttpContext.RequestServices.GetRequiredService(); + var session = await sessionStore.ValidateAccessSessionAsync( + sessionId, + userId, + realm.Value, + tenantId, + context.HttpContext.RequestAborted); + var tokenMfaSatisfied = principal.FindAll(TikuClaimTypes.Mfa) + .Any(claim => string.Equals(claim.Value, "mfa", StringComparison.Ordinal)); + if (session is null || session.MfaSatisfied != tokenMfaSatisfied) + { + context.Fail("Session, identity, membership, tenant, role or MFA state is no longer valid."); + } + } + + private static async Task WriteTenantConflictChallengeAsync(JwtBearerChallengeContext context) + { + if (!context.HttpContext.Items.ContainsKey("tenant_context_conflict")) + { + return; + } + + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsJsonAsync(new ProblemDetails + { + Title = "Authenticated tenant does not match the request host.", + Status = StatusCodes.Status403Forbidden, + Extensions = { ["code"] = "tenant_context_conflict" } + }); + } +} diff --git a/Tiku.Api/Configuration/DataProtectionExtensions.cs b/Tiku.Api/Configuration/DataProtectionExtensions.cs new file mode 100644 index 0000000..d5eee4b --- /dev/null +++ b/Tiku.Api/Configuration/DataProtectionExtensions.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.DataProtection; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Api.Configuration; + +internal static class DataProtectionExtensions +{ + internal static IServiceCollection AddApiDataProtection( + this IServiceCollection services, + IConfiguration configuration, + IHostEnvironment environment) + { + var requireProtectedKeys = !environment.IsDevelopment(); + services.AddOptions() + .Bind(configuration.GetSection(DataProtectionKeyRingOptions.SectionName)) + .PostConfigure(options => ApplyEnvironmentOverrides(options, configuration)) + .Validate( + options => DataProtectionKeyRingOptions.BeValid(options, requireProtectedKeys), + "Data Protection requires an application name and, outside Development, an X509 certificate path.") + .ValidateOnStart(); + + var keyRingOptions = configuration + .GetSection(DataProtectionKeyRingOptions.SectionName) + .Get() ?? new DataProtectionKeyRingOptions(); + ApplyEnvironmentOverrides(keyRingOptions, configuration); + if (!DataProtectionKeyRingOptions.BeValid(keyRingOptions, requireProtectedKeys)) + { + throw new InvalidOperationException( + "Data Protection requires an application name and, outside Development, an X509 certificate path."); + } + + var dataProtection = services + .AddDataProtection() + .SetApplicationName(keyRingOptions.ApplicationName.Trim()) + .PersistKeysToDbContext(); + var certificate = keyRingOptions.LoadCertificate(requireProtectedKeys); + if (certificate is not null) + { + dataProtection.ProtectKeysWithCertificate(certificate); + } + + return services; + } + + private static void ApplyEnvironmentOverrides( + DataProtectionKeyRingOptions options, + IConfiguration configuration) + { + options.ApplicationName = + configuration["TIKU_DATA_PROTECTION_APPLICATION_NAME"] ?? options.ApplicationName; + options.CertificatePath = + configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PATH"] ?? options.CertificatePath; + options.CertificatePassword = + configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PASSWORD"] ?? options.CertificatePassword; + } +} diff --git a/Tiku.Api/Configuration/DependencyInjection.cs b/Tiku.Api/Configuration/DependencyInjection.cs new file mode 100644 index 0000000..e7e2a57 --- /dev/null +++ b/Tiku.Api/Configuration/DependencyInjection.cs @@ -0,0 +1,33 @@ +using Serilog; +using Tiku.Application; +using Tiku.Infrastructure; + +namespace Tiku.Api.Configuration; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddApiServices(this WebApplicationBuilder builder) + { + builder.Services.AddSerilog((services, configuration) => configuration + .ReadFrom.Configuration(builder.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext(), + preserveStaticLogger: true); + + builder.Services.AddApiPresentation(); + builder.Services.AddApplication(); + builder.Services.AddNetworkConfiguration(builder.Configuration); + builder.Services.AddApiRateLimiting(builder.Configuration); + + var connectionString = Options.OptionsValidation.ResolveDatabaseConnectionString( + builder.Configuration, + builder.Environment.IsDevelopment()); + builder.Services.AddInfrastructure(connectionString); + + builder.Services.AddApiDataProtection(builder.Configuration, builder.Environment); + builder.Services.AddExternalServiceOptions(builder.Configuration, builder.Environment); + builder.Services.AddApiAuthenticationAndAuthorization(builder.Configuration, builder.Environment); + + return builder; + } +} diff --git a/Tiku.Api/Configuration/ExternalServiceOptionsExtensions.cs b/Tiku.Api/Configuration/ExternalServiceOptionsExtensions.cs new file mode 100644 index 0000000..8ff4a25 --- /dev/null +++ b/Tiku.Api/Configuration/ExternalServiceOptionsExtensions.cs @@ -0,0 +1,89 @@ +using Tiku.Application.Auth; +using Tiku.Infrastructure.Commerce; +using Tiku.Infrastructure.Storage; + +namespace Tiku.Api.Configuration; + +internal static class ExternalServiceOptionsExtensions +{ + internal static IServiceCollection AddExternalServiceOptions( + this IServiceCollection services, + IConfiguration configuration, + IHostEnvironment environment) + { + services.Configure( + configuration.GetSection(ObjectStorageOptions.SectionName)); + services.Configure( + configuration.GetSection(AliyunOssOptions.SectionName)); + services.PostConfigure(options => + { + options.DefaultProvider = configuration["STORAGE_DEFAULT_PROVIDER"] ?? options.DefaultProvider; + options.DefaultBucket = configuration["STORAGE_DEFAULT_BUCKET"] ?? options.DefaultBucket; + options.PublicBaseUrl = configuration["STORAGE_PUBLIC_BASE_URL"] ?? options.PublicBaseUrl; + options.AllowedMimePrefixes = SplitLegacyList( + configuration["STORAGE_ALLOWED_MIME_PREFIXES"], + options.AllowedMimePrefixes); + options.AllowedMimeTypes = SplitLegacyList( + configuration["STORAGE_ALLOWED_MIME_TYPES"], + options.AllowedMimeTypes); + options.RequireTenantPrefix = bool.TryParse( + configuration["STORAGE_REQUIRE_TENANT_PREFIX"], + out var requireTenantPrefix) + ? requireTenantPrefix + : options.RequireTenantPrefix; + options.MaxUploadBytes = long.TryParse( + configuration["STORAGE_MAX_UPLOAD_BYTES"], + out var maxUploadBytes) + ? maxUploadBytes + : options.MaxUploadBytes; + }); + services.PostConfigure(options => + { + options.Region = configuration["ALIYUN_OSS_REGION"] ?? options.Region; + options.Endpoint = configuration["ALIYUN_OSS_ENDPOINT"] ?? options.Endpoint; + options.AccessKeyId = configuration["ALIYUN_OSS_ACCESS_KEY_ID"] ?? options.AccessKeyId; + options.AccessKeySecret = configuration["ALIYUN_OSS_ACCESS_KEY_SECRET"] ?? options.AccessKeySecret; + options.SecurityToken = configuration["ALIYUN_OSS_STS_TOKEN"] ?? options.SecurityToken; + options.UseInternalEndpoint = bool.TryParse( + configuration["ALIYUN_OSS_INTERNAL"], + out var useInternalEndpoint) + ? useInternalEndpoint + : options.UseInternalEndpoint; + }); + + services.AddOptions() + .Bind(configuration.GetSection(TenantSecretEncryptionOptions.SectionName)) + .PostConfigure(options => + { + options.KeyId = configuration["TIKU_TENANT_SECRET_KEY_ID"] ?? options.KeyId; + options.MasterKey = configuration["TIKU_TENANT_SECRET_MASTER_KEY"] ?? options.MasterKey; + }) + .Validate( + TenantSecretEncryptionOptions.BeValid, + "Tenant secret encryption requires a key ID and a base64-encoded 32-byte master key.") + .Validate( + options => !environment.IsProduction() || + !TenantSecretEncryptionOptions.IsDevelopmentDefault(options), + "Production tenant secret encryption cannot use the development master key.") + .ValidateOnStart(); + + services.AddOptions() + .Bind(configuration.GetSection(SmsSecurityOptions.SectionName)) + .PostConfigure(options => + { + options.CodePepper = configuration["TIKU_SMS_CODE_PEPPER"] ?? options.CodePepper; + }) + .Validate( + SmsSecurityOptions.BeValid, + "SMS security requires a pepper of at least 32 characters, exactly five verification attempts, " + + "and positive tenant, phone, IP, and device rate limits.") + .ValidateOnStart(); + + return services; + } + + private static string[] SplitLegacyList(string? value, string[] fallback) => + string.IsNullOrWhiteSpace(value) + ? fallback + : value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); +} diff --git a/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs b/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs new file mode 100644 index 0000000..4057e42 --- /dev/null +++ b/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs @@ -0,0 +1,79 @@ +using System.Net; +using Microsoft.AspNetCore.HttpOverrides; +using Tiku.Api.Options; +using Tiku.Application.Tenancy; + +namespace Tiku.Api.Configuration; + +internal static class NetworkConfigurationExtensions +{ + internal static IServiceCollection AddNetworkConfiguration( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(TenantResolutionOptions.SectionName)); + services.Configure(options => + { + options.ForwardedHeaders = + ForwardedHeaders.XForwardedFor | + ForwardedHeaders.XForwardedHost | + ForwardedHeaders.XForwardedProto; + options.ForwardLimit = 1; + options.KnownProxies.Clear(); + options.KnownIPNetworks.Clear(); + + var resolution = configuration + .GetSection(TenantResolutionOptions.SectionName) + .Get() ?? new TenantResolutionOptions(); + foreach (var address in resolution.TrustedProxyAddresses) + { + if (IPAddress.TryParse(address, out var proxy)) + { + options.KnownProxies.Add(proxy); + } + } + }); + services.AddOptions() + .Bind(configuration.GetSection("TenantDomains")); + + services.AddOptions() + .Bind(configuration.GetSection(CorsOptions.SectionName)) + .ValidateDataAnnotations() + .Validate( + OptionsValidation.BeValidCorsOptions, + "CORS origins must be absolute HTTP/HTTPS origins, and credentials require explicit origins.") + .ValidateOnStart(); + + var corsOptions = configuration + .GetSection(CorsOptions.SectionName) + .Get() ?? new CorsOptions(); + services.AddCors(options => + { + options.AddPolicy(CorsOptions.PolicyName, policy => + { + var origins = corsOptions.AllowedOrigins + .Where(origin => !string.IsNullOrWhiteSpace(origin)) + .Select(origin => origin.Trim().TrimEnd('/')) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (origins.Length > 0) + { + policy.WithOrigins(origins); + } + + policy + .WithHeaders(corsOptions.AllowedHeaders) + .WithMethods(corsOptions.AllowedMethods); + + if (corsOptions.AllowCredentials) + { + policy.AllowCredentials(); + } + }); + }); + + return services; + } +} diff --git a/Tiku.Api/Configuration/RateLimitingExtensions.cs b/Tiku.Api/Configuration/RateLimitingExtensions.cs new file mode 100644 index 0000000..6bd5f3c --- /dev/null +++ b/Tiku.Api/Configuration/RateLimitingExtensions.cs @@ -0,0 +1,118 @@ +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Tiku.Api.Middleware; +using Tiku.Api.Options; +using Tiku.Application.Auth; +using Tiku.Application.Security; + +namespace Tiku.Api.Configuration; + +internal static class RateLimitingExtensions +{ + internal static IServiceCollection AddApiRateLimiting( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(ApiRateLimitOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + var rateLimitOptions = configuration + .GetSection(ApiRateLimitOptions.SectionName) + .Get() ?? new ApiRateLimitOptions(); + + services.AddOptions() + .Bind(configuration.GetSection(AuthRateLimitOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + var authRateLimitOptions = configuration + .GetSection(AuthRateLimitOptions.SectionName) + .Get() ?? new AuthRateLimitOptions(); + + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + if (rateLimitOptions.Enabled) + { + options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => + { + var partitionKey = + httpContext.User.FindFirst(TikuClaimTypes.UserId)?.Value ?? + httpContext.Connection.RemoteIpAddress?.ToString() ?? + "anonymous"; + + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey, + _ => CreateLimiterOptions( + rateLimitOptions.PermitLimit, + rateLimitOptions.QueueLimit, + rateLimitOptions.WindowSeconds)); + }); + } + + options.AddPolicy( + AuthRateLimitPolicies.Password, + httpContext => RateLimitPartition.GetFixedWindowLimiter( + AuthRateLimitPartitionKey.Resolve(httpContext, AuthRateLimitPolicies.Password), + _ => CreateLimiterOptions( + authRateLimitOptions.PasswordPermitLimit, + 0, + authRateLimitOptions.PasswordWindowSeconds))); + options.AddPolicy( + AuthRateLimitPolicies.Sms, + httpContext => RateLimitPartition.GetFixedWindowLimiter( + AuthRateLimitPartitionKey.Resolve(httpContext, AuthRateLimitPolicies.Sms), + _ => CreateLimiterOptions( + authRateLimitOptions.SmsPermitLimit, + 0, + authRateLimitOptions.SmsWindowSeconds))); + options.AddPolicy( + AuthRateLimitPolicies.Mfa, + httpContext => RateLimitPartition.GetFixedWindowLimiter( + AuthRateLimitPartitionKey.Resolve(httpContext, AuthRateLimitPolicies.Mfa), + _ => CreateLimiterOptions( + authRateLimitOptions.MfaPermitLimit, + 0, + authRateLimitOptions.MfaWindowSeconds))); + options.OnRejected = WriteRateLimitProblemAsync; + }); + + return services; + } + + private static FixedWindowRateLimiterOptions CreateLimiterOptions( + int permitLimit, + int queueLimit, + int windowSeconds) => + new() + { + AutoReplenishment = true, + PermitLimit = permitLimit, + QueueLimit = queueLimit, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + Window = TimeSpan.FromSeconds(windowSeconds) + }; + + private static async ValueTask WriteRateLimitProblemAsync( + OnRejectedContext context, + CancellationToken cancellationToken) + { + if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + { + context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString(); + } + + var problem = new ProblemDetails + { + Title = "Too many requests.", + Status = StatusCodes.Status429TooManyRequests, + Instance = context.HttpContext.Request.Path + }; + problem.Extensions["code"] = "rate_limited"; + problem.Extensions["traceId"] = context.HttpContext.TraceIdentifier; + + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken); + } +} diff --git a/Tiku.Api/Program.cs b/Tiku.Api/Program.cs index 91b1631..2904780 100644 --- a/Tiku.Api/Program.cs +++ b/Tiku.Api/Program.cs @@ -1,30 +1,6 @@ -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; -using Microsoft.AspNetCore.HttpOverrides; -using System.Net; -using Microsoft.EntityFrameworkCore; -using Microsoft.IdentityModel.Tokens; -using Scalar.AspNetCore; using Serilog; using Serilog.Events; -using System.Text.Json.Serialization; -using System.Threading.RateLimiting; -using Tiku.Api.Logging; -using Tiku.Api.Middleware; -using Tiku.Api.OpenApi; -using Tiku.Api.Options; -using Tiku.Api.Security; -using Tiku.Application; -using Tiku.Application.Auth; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Infrastructure; -using Tiku.Infrastructure.Commerce; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; -using Tiku.Infrastructure.Storage; +using Tiku.Api.Configuration; Log.Logger = new LoggerConfiguration() .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) @@ -37,470 +13,10 @@ try Log.Information("Starting TIKU API"); var builder = WebApplication.CreateBuilder(args); - builder.Services.AddSerilog((services, configuration) => configuration - .ReadFrom.Configuration(builder.Configuration) - .ReadFrom.Services(services) - .Enrich.FromLogContext(), - preserveStaticLogger: true); - - builder.Services.AddControllers() - .AddJsonOptions(options => - { - options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); - }); - builder.Services.AddOpenApi(options => - { - options.AddDocumentTransformer(); - }); - builder.Services.AddProblemDetails(); - builder.Services.AddApplication(); - builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(TenantResolutionOptions.SectionName)); - builder.Services.Configure(options => - { - options.ForwardedHeaders = - ForwardedHeaders.XForwardedFor | - ForwardedHeaders.XForwardedHost | - ForwardedHeaders.XForwardedProto; - options.ForwardLimit = 1; - options.KnownProxies.Clear(); - options.KnownIPNetworks.Clear(); - var resolution = builder.Configuration - .GetSection(TenantResolutionOptions.SectionName) - .Get() ?? new TenantResolutionOptions(); - foreach (var address in resolution.TrustedProxyAddresses) - { - if (IPAddress.TryParse(address, out var proxy)) - { - options.KnownProxies.Add(proxy); - } - } - }); - builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection("TenantDomains")); - builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(CorsOptions.SectionName)) - .ValidateDataAnnotations() - .Validate(OptionsValidation.BeValidCorsOptions, "CORS origins must be absolute HTTP/HTTPS origins, and credentials require explicit origins.") - .ValidateOnStart(); - var corsOptions = builder.Configuration - .GetSection(CorsOptions.SectionName) - .Get() ?? new CorsOptions(); - builder.Services.AddCors(options => - { - options.AddPolicy(CorsOptions.PolicyName, policy => - { - var origins = corsOptions.AllowedOrigins - .Where(origin => !string.IsNullOrWhiteSpace(origin)) - .Select(origin => origin.Trim().TrimEnd('/')) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (origins.Length > 0) - { - policy.WithOrigins(origins); - } - - policy - .WithHeaders(corsOptions.AllowedHeaders) - .WithMethods(corsOptions.AllowedMethods); - - if (corsOptions.AllowCredentials) - { - policy.AllowCredentials(); - } - }); - }); - builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(ApiRateLimitOptions.SectionName)) - .ValidateDataAnnotations() - .ValidateOnStart(); - var rateLimitOptions = builder.Configuration - .GetSection(ApiRateLimitOptions.SectionName) - .Get() ?? new ApiRateLimitOptions(); - builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(AuthRateLimitOptions.SectionName)) - .ValidateDataAnnotations() - .ValidateOnStart(); - var authRateLimitOptions = builder.Configuration - .GetSection(AuthRateLimitOptions.SectionName) - .Get() ?? new AuthRateLimitOptions(); - builder.Services.AddRateLimiter(options => - { - options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; - if (rateLimitOptions.Enabled) - { - options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => - { - var partitionKey = - httpContext.User.FindFirst(TikuClaimTypes.UserId)?.Value ?? - httpContext.Connection.RemoteIpAddress?.ToString() ?? - "anonymous"; - - return RateLimitPartition.GetFixedWindowLimiter( - partitionKey, - _ => new FixedWindowRateLimiterOptions - { - AutoReplenishment = true, - PermitLimit = rateLimitOptions.PermitLimit, - QueueLimit = rateLimitOptions.QueueLimit, - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - Window = TimeSpan.FromSeconds(rateLimitOptions.WindowSeconds) - }); - }); - } - - options.AddPolicy( - AuthRateLimitPolicies.Password, - httpContext => RateLimitPartition.GetFixedWindowLimiter( - AuthRateLimitPartitionKey.Resolve(httpContext, AuthRateLimitPolicies.Password), - _ => new FixedWindowRateLimiterOptions - { - AutoReplenishment = true, - PermitLimit = authRateLimitOptions.PasswordPermitLimit, - QueueLimit = 0, - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - Window = TimeSpan.FromSeconds(authRateLimitOptions.PasswordWindowSeconds) - })); - options.AddPolicy( - AuthRateLimitPolicies.Sms, - httpContext => RateLimitPartition.GetFixedWindowLimiter( - AuthRateLimitPartitionKey.Resolve(httpContext, AuthRateLimitPolicies.Sms), - _ => new FixedWindowRateLimiterOptions - { - AutoReplenishment = true, - PermitLimit = authRateLimitOptions.SmsPermitLimit, - QueueLimit = 0, - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - Window = TimeSpan.FromSeconds(authRateLimitOptions.SmsWindowSeconds) - })); - options.AddPolicy( - AuthRateLimitPolicies.Mfa, - httpContext => RateLimitPartition.GetFixedWindowLimiter( - AuthRateLimitPartitionKey.Resolve(httpContext, AuthRateLimitPolicies.Mfa), - _ => new FixedWindowRateLimiterOptions - { - AutoReplenishment = true, - PermitLimit = authRateLimitOptions.MfaPermitLimit, - QueueLimit = 0, - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - Window = TimeSpan.FromSeconds(authRateLimitOptions.MfaWindowSeconds) - })); - options.OnRejected = async (context, cancellationToken) => - { - if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) - { - context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString(); - } - - var problem = new ProblemDetails - { - Title = "Too many requests.", - Status = StatusCodes.Status429TooManyRequests, - Instance = context.HttpContext.Request.Path - }; - problem.Extensions["code"] = "rate_limited"; - problem.Extensions["traceId"] = context.HttpContext.TraceIdentifier; - - context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; - await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken); - }; - }); - - var connectionString = OptionsValidation.ResolveDatabaseConnectionString( - builder.Configuration, - builder.Environment.IsDevelopment()); - - builder.Services.AddInfrastructure(connectionString); - - var requireProtectedDataProtectionKeys = !builder.Environment.IsDevelopment(); - builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(DataProtectionKeyRingOptions.SectionName)) - .PostConfigure(options => - { - options.ApplicationName = - builder.Configuration["TIKU_DATA_PROTECTION_APPLICATION_NAME"] ?? options.ApplicationName; - options.CertificatePath = - builder.Configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PATH"] ?? options.CertificatePath; - options.CertificatePassword = - builder.Configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PASSWORD"] ?? options.CertificatePassword; - }) - .Validate( - options => DataProtectionKeyRingOptions.BeValid(options, requireProtectedDataProtectionKeys), - "Data Protection requires an application name and, outside Development, an X509 certificate path.") - .ValidateOnStart(); - - var dataProtectionOptions = builder.Configuration - .GetSection(DataProtectionKeyRingOptions.SectionName) - .Get() ?? new DataProtectionKeyRingOptions(); - dataProtectionOptions.ApplicationName = - builder.Configuration["TIKU_DATA_PROTECTION_APPLICATION_NAME"] ?? dataProtectionOptions.ApplicationName; - dataProtectionOptions.CertificatePath = - builder.Configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PATH"] ?? dataProtectionOptions.CertificatePath; - dataProtectionOptions.CertificatePassword = - builder.Configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PASSWORD"] ?? dataProtectionOptions.CertificatePassword; - if (!DataProtectionKeyRingOptions.BeValid(dataProtectionOptions, requireProtectedDataProtectionKeys)) - { - throw new InvalidOperationException( - "Data Protection requires an application name and, outside Development, an X509 certificate path."); - } - - var dataProtection = builder.Services - .AddDataProtection() - .SetApplicationName(dataProtectionOptions.ApplicationName.Trim()) - .PersistKeysToDbContext(); - var dataProtectionCertificate = dataProtectionOptions.LoadCertificate(requireProtectedDataProtectionKeys); - if (dataProtectionCertificate is not null) - { - dataProtection.ProtectKeysWithCertificate(dataProtectionCertificate); - } - - builder.Services.Configure( - builder.Configuration.GetSection(ObjectStorageOptions.SectionName)); - builder.Services.Configure( - builder.Configuration.GetSection(AliyunOssOptions.SectionName)); - builder.Services.PostConfigure(options => - { - options.DefaultProvider = builder.Configuration["STORAGE_DEFAULT_PROVIDER"] ?? options.DefaultProvider; - options.DefaultBucket = builder.Configuration["STORAGE_DEFAULT_BUCKET"] ?? options.DefaultBucket; - options.PublicBaseUrl = builder.Configuration["STORAGE_PUBLIC_BASE_URL"] ?? options.PublicBaseUrl; - options.AllowedMimePrefixes = SplitLegacyList( - builder.Configuration["STORAGE_ALLOWED_MIME_PREFIXES"], - options.AllowedMimePrefixes); - options.AllowedMimeTypes = SplitLegacyList( - builder.Configuration["STORAGE_ALLOWED_MIME_TYPES"], - options.AllowedMimeTypes); - options.RequireTenantPrefix = bool.TryParse(builder.Configuration["STORAGE_REQUIRE_TENANT_PREFIX"], out var requireTenantPrefix) - ? requireTenantPrefix - : options.RequireTenantPrefix; - options.MaxUploadBytes = long.TryParse(builder.Configuration["STORAGE_MAX_UPLOAD_BYTES"], out var maxUploadBytes) - ? maxUploadBytes - : options.MaxUploadBytes; - }); - builder.Services.PostConfigure(options => - { - options.Region = builder.Configuration["ALIYUN_OSS_REGION"] ?? options.Region; - options.Endpoint = builder.Configuration["ALIYUN_OSS_ENDPOINT"] ?? options.Endpoint; - options.AccessKeyId = builder.Configuration["ALIYUN_OSS_ACCESS_KEY_ID"] ?? options.AccessKeyId; - options.AccessKeySecret = builder.Configuration["ALIYUN_OSS_ACCESS_KEY_SECRET"] ?? options.AccessKeySecret; - options.SecurityToken = builder.Configuration["ALIYUN_OSS_STS_TOKEN"] ?? options.SecurityToken; - options.UseInternalEndpoint = bool.TryParse(builder.Configuration["ALIYUN_OSS_INTERNAL"], out var useInternalEndpoint) - ? useInternalEndpoint - : options.UseInternalEndpoint; - }); - builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(TenantSecretEncryptionOptions.SectionName)) - .PostConfigure(options => - { - options.KeyId = builder.Configuration["TIKU_TENANT_SECRET_KEY_ID"] ?? options.KeyId; - options.MasterKey = builder.Configuration["TIKU_TENANT_SECRET_MASTER_KEY"] ?? options.MasterKey; - }) - .Validate( - TenantSecretEncryptionOptions.BeValid, - "Tenant secret encryption requires a key ID and a base64-encoded 32-byte master key.") - .Validate( - options => !builder.Environment.IsProduction() || - !TenantSecretEncryptionOptions.IsDevelopmentDefault(options), - "Production tenant secret encryption cannot use the development master key.") - .ValidateOnStart(); - - builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(SmsSecurityOptions.SectionName)) - .PostConfigure(options => - { - options.CodePepper = builder.Configuration["TIKU_SMS_CODE_PEPPER"] ?? options.CodePepper; - }) - .Validate( - SmsSecurityOptions.BeValid, - "SMS security requires a pepper of at least 32 characters, exactly five verification attempts, " + - "and positive tenant, phone, IP, and device rate limits.") - .ValidateOnStart(); - - builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection("Security:Jwt")) - .ValidateDataAnnotations() - .Validate( - options => OptionsValidation.BeValidJwtOptions(options, builder.Environment.IsProduction()), - "Production JWT signing key must be explicitly configured and cannot use the development key.") - .ValidateOnStart(); - var jwtOptions = builder.Configuration - .GetSection("Security:Jwt") - .Get() ?? new JwtOptions(); - - builder.Services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.MapInboundClaims = false; - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = jwtOptions.Issuer, - ValidateAudience = true, - ValidAudience = jwtOptions.Audience, - ValidateIssuerSigningKey = true, - RequireSignedTokens = true, - ValidAlgorithms = [SecurityAlgorithms.RsaSha256], - ValidateLifetime = true, - RequireExpirationTime = true, - ClockSkew = TimeSpan.FromMinutes(1), - NameClaimType = TikuClaimTypes.UserId - }; - options.Events = new JwtBearerEvents - { - OnTokenValidated = async context => - { - var principal = context.Principal; - if (!Guid.TryParse(principal?.FindFirst(TikuClaimTypes.UserId)?.Value, out var userId) || - !Guid.TryParse(principal?.FindFirst(TikuClaimTypes.SessionId)?.Value, out var sessionId) || - string.IsNullOrWhiteSpace(principal?.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Jti)?.Value) || - !long.TryParse( - principal?.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Iat)?.Value, - System.Globalization.NumberStyles.None, - System.Globalization.CultureInfo.InvariantCulture, - out _)) - { - context.Fail("Missing or invalid subject/session/jti/iat claim."); - return; - } - - var realmValue = principal.FindFirst(TikuClaimTypes.Realm)?.Value; - var realm = string.Equals(realmValue, "tenant", StringComparison.Ordinal) - ? Tiku.Domain.Tenancy.AuthRealm.Tenant - : string.Equals(realmValue, "platform", StringComparison.Ordinal) - ? Tiku.Domain.Tenancy.AuthRealm.Platform - : (Tiku.Domain.Tenancy.AuthRealm?)null; - var tenantIdValue = principal.FindFirst(TikuClaimTypes.TenantId)?.Value; - var tenantId = Guid.TryParse(tenantIdValue, out var parsedTenantId) - ? parsedTenantId - : (Guid?)null; - if (realm is null || (realm == Tiku.Domain.Tenancy.AuthRealm.Tenant) != tenantId.HasValue) - { - context.Fail("Token scope and tenant claims are inconsistent."); - return; - } - - var resolutionOptions = context.HttpContext.RequestServices - .GetRequiredService>().Value; - var requestHost = context.HttpContext.Request.Host.Host.Trim().TrimEnd('.'); - var isPlatformHost = resolutionOptions.PlatformHosts.Any(host => - string.Equals(host.Trim().TrimEnd('.'), requestHost, StringComparison.OrdinalIgnoreCase)); - var resolvedTenantContext = context.HttpContext.RequestServices.GetRequiredService(); - var tenantInitializer = context.HttpContext.RequestServices - .GetRequiredService(); - if (realm == Tiku.Domain.Tenancy.AuthRealm.Platform) - { - if (!isPlatformHost || resolvedTenantContext.IsResolved) - { - context.HttpContext.Items["tenant_context_conflict"] = true; - context.Fail("Platform tokens are only valid on a platform host."); - return; - } - } - else - { - if (isPlatformHost && !resolvedTenantContext.IsResolved) - { - context.HttpContext.Items["tenant_context_conflict"] = true; - context.Fail("Tenant tokens on a platform host require a matching tenant code."); - return; - } - - try - { - tenantInitializer.Initialize(tenantId!.Value, null, TenantResolutionSource.Jwt); - } - catch (TenantContextConflictException) - { - context.HttpContext.Items["tenant_context_conflict"] = true; - context.Fail("Authenticated tenant does not match the request host."); - return; - } - } - - var sessionStore = context.HttpContext.RequestServices.GetRequiredService(); - var session = await sessionStore.ValidateAccessSessionAsync( - sessionId, userId, realm.Value, tenantId, context.HttpContext.RequestAborted); - var tokenMfaSatisfied = principal.FindAll(TikuClaimTypes.Mfa) - .Any(claim => string.Equals(claim.Value, "mfa", StringComparison.Ordinal)); - if (session is null || session.MfaSatisfied != tokenMfaSatisfied) - { - context.Fail("Session, identity, membership, tenant, role or MFA state is no longer valid."); - } - }, - OnChallenge = async context => - { - if (context.HttpContext.Items.ContainsKey("tenant_context_conflict")) - { - context.HandleResponse(); - context.Response.StatusCode = StatusCodes.Status403Forbidden; - await context.Response.WriteAsJsonAsync(new ProblemDetails - { - Title = "Authenticated tenant does not match the request host.", - Status = StatusCodes.Status403Forbidden, - Extensions = { ["code"] = "tenant_context_conflict" } - }); - } - } - }; - }); - - builder.Services.AddOptions(JwtBearerDefaults.AuthenticationScheme) - .Configure((options, keyRing) => - { - options.TokenValidationParameters.IssuerSigningKeys = keyRing.ValidationKeys; - options.TokenValidationParameters.TryAllIssuerSigningKeys = false; - options.TokenValidationParameters.IssuerSigningKeyResolver = (_, _, kid, _) => - string.IsNullOrWhiteSpace(kid) - ? [] - : keyRing.ValidationKeys.Where(key => - string.Equals(key.KeyId, kid, StringComparison.Ordinal)); - }); - - builder.Services.AddAuthorization(options => - { - options.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() - .RequireAuthenticatedUser() - .Build(); - options.AddPolicy( - TikuPolicies.AuthenticatedUser, - policy => policy.RequireAuthenticatedUser()); - options.AddPolicy( - TikuPolicies.TenantAdmin, - policy => policy.RequireAuthenticatedUser()); - }); - builder.Services.AddTikuRbacAuthorization(); - builder.Services.AddSingleton(); + builder.AddApiServices(); var app = builder.Build(); - - if (app.Environment.IsDevelopment()) - { - app.MapOpenApi().AllowAnonymous(); - app.MapScalarApiReference(options => options - .WithTitle("TIKU Backend API") - .AddPreferredSecuritySchemes("BearerAuth") - .EnablePersistentAuthentication()) - .AllowAnonymous(); - } - - app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging); - app.UseMiddleware(); - app.UseForwardedHeaders(); - app.UseHttpsRedirection(); - app.UseRouting(); - app.UseCors(CorsOptions.PolicyName); - app.UseMiddleware(); - app.UseAuthentication(); - app.UseMiddleware(); - app.UseRateLimiter(); - - app.UseMiddleware(); - app.UseAuthorization(); - - app.MapControllers(); - + app.UseApiPipeline(); app.Run(); } catch (Exception exception) @@ -513,9 +29,4 @@ finally Log.CloseAndFlush(); } -static string[] SplitLegacyList(string? value, string[] fallback) => - string.IsNullOrWhiteSpace(value) - ? fallback - : value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - public partial class Program; diff --git a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs index c78850d..c0ea66d 100644 --- a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs +++ b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs @@ -8,7 +8,7 @@ public sealed class ArchitectureBoundaryTests var root = FindRepositoryRoot(); var authorizationFiles = Directory .EnumerateFiles(Path.Combine(root, "Tiku.Api", "Security"), "*.cs", SearchOption.AllDirectories) - .Append(Path.Combine(root, "Tiku.Api", "Program.cs")); + .Append(Path.Combine(root, "Tiku.Api", "Configuration", "AuthenticationExtensions.cs")); var forbidden = new[] { "TikuClaimTypes.TenantRole", @@ -106,10 +106,11 @@ public sealed class ArchitectureBoundaryTests public void Api_uses_an_authenticated_fallback_policy() { var root = FindRepositoryRoot(); - var program = File.ReadAllText(Path.Combine(root, "Tiku.Api", "Program.cs")); + var authenticationRegistration = File.ReadAllText( + Path.Combine(root, "Tiku.Api", "Configuration", "AuthenticationExtensions.cs")); - Assert.Contains("FallbackPolicy", program, StringComparison.Ordinal); - Assert.Contains("RequireAuthenticatedUser()", program, StringComparison.Ordinal); + Assert.Contains("FallbackPolicy", authenticationRegistration, StringComparison.Ordinal); + Assert.Contains("RequireAuthenticatedUser()", authenticationRegistration, StringComparison.Ordinal); } [Fact]