refactor(api): split startup configuration
This commit is contained in:
23
Tiku.Api/Configuration/ApiPresentationExtensions.cs
Normal file
23
Tiku.Api/Configuration/ApiPresentationExtensions.cs
Normal file
@@ -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<BearerSecuritySchemeTransformer>();
|
||||
});
|
||||
services.AddProblemDetails();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
39
Tiku.Api/Configuration/ApplicationBuilderExtensions.cs
Normal file
39
Tiku.Api/Configuration/ApplicationBuilderExtensions.cs
Normal file
@@ -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<ExceptionHandlingMiddleware>();
|
||||
app.UseForwardedHeaders();
|
||||
app.UseHttpsRedirection();
|
||||
app.UseRouting();
|
||||
app.UseCors(CorsOptions.PolicyName);
|
||||
app.UseMiddleware<TenantResolutionMiddleware>();
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<AuthRateLimitPartitionMiddleware>();
|
||||
app.UseRateLimiter();
|
||||
app.UseMiddleware<CurrentPrincipalMiddleware>();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
194
Tiku.Api/Configuration/AuthenticationExtensions.cs
Normal file
194
Tiku.Api/Configuration/AuthenticationExtensions.cs
Normal file
@@ -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<JwtOptions>()
|
||||
.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<JwtOptions>() ?? new JwtOptions();
|
||||
|
||||
services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options => ConfigureJwtBearer(options, jwtOptions));
|
||||
|
||||
services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
|
||||
.Configure<IJwtKeyRing>((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<IAuthorizationMiddlewareResultHandler,
|
||||
AuditingAuthorizationMiddlewareResultHandler>();
|
||||
|
||||
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<Microsoft.Extensions.Options.IOptions<TenantResolutionOptions>>().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<ITenantContext>();
|
||||
var tenantInitializer = context.HttpContext.RequestServices
|
||||
.GetRequiredService<ITenantContextInitializer>();
|
||||
|
||||
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<IAuthSessionStore>();
|
||||
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" }
|
||||
});
|
||||
}
|
||||
}
|
||||
57
Tiku.Api/Configuration/DataProtectionExtensions.cs
Normal file
57
Tiku.Api/Configuration/DataProtectionExtensions.cs
Normal file
@@ -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<DataProtectionKeyRingOptions>()
|
||||
.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<DataProtectionKeyRingOptions>() ?? 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<TikuDbContext>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
33
Tiku.Api/Configuration/DependencyInjection.cs
Normal file
33
Tiku.Api/Configuration/DependencyInjection.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
89
Tiku.Api/Configuration/ExternalServiceOptionsExtensions.cs
Normal file
89
Tiku.Api/Configuration/ExternalServiceOptionsExtensions.cs
Normal file
@@ -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<ObjectStorageOptions>(
|
||||
configuration.GetSection(ObjectStorageOptions.SectionName));
|
||||
services.Configure<AliyunOssOptions>(
|
||||
configuration.GetSection(AliyunOssOptions.SectionName));
|
||||
services.PostConfigure<ObjectStorageOptions>(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<AliyunOssOptions>(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<TenantSecretEncryptionOptions>()
|
||||
.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<SmsSecurityOptions>()
|
||||
.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);
|
||||
}
|
||||
79
Tiku.Api/Configuration/NetworkConfigurationExtensions.cs
Normal file
79
Tiku.Api/Configuration/NetworkConfigurationExtensions.cs
Normal file
@@ -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<TenantResolutionOptions>()
|
||||
.Bind(configuration.GetSection(TenantResolutionOptions.SectionName));
|
||||
services.Configure<ForwardedHeadersOptions>(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<TenantResolutionOptions>() ?? new TenantResolutionOptions();
|
||||
foreach (var address in resolution.TrustedProxyAddresses)
|
||||
{
|
||||
if (IPAddress.TryParse(address, out var proxy))
|
||||
{
|
||||
options.KnownProxies.Add(proxy);
|
||||
}
|
||||
}
|
||||
});
|
||||
services.AddOptions<DomainLifecycleOptions>()
|
||||
.Bind(configuration.GetSection("TenantDomains"));
|
||||
|
||||
services.AddOptions<CorsOptions>()
|
||||
.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<CorsOptions>() ?? 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;
|
||||
}
|
||||
}
|
||||
118
Tiku.Api/Configuration/RateLimitingExtensions.cs
Normal file
118
Tiku.Api/Configuration/RateLimitingExtensions.cs
Normal file
@@ -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<ApiRateLimitOptions>()
|
||||
.Bind(configuration.GetSection(ApiRateLimitOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
var rateLimitOptions = configuration
|
||||
.GetSection(ApiRateLimitOptions.SectionName)
|
||||
.Get<ApiRateLimitOptions>() ?? new ApiRateLimitOptions();
|
||||
|
||||
services.AddOptions<AuthRateLimitOptions>()
|
||||
.Bind(configuration.GetSection(AuthRateLimitOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
var authRateLimitOptions = configuration
|
||||
.GetSection(AuthRateLimitOptions.SectionName)
|
||||
.Get<AuthRateLimitOptions>() ?? new AuthRateLimitOptions();
|
||||
|
||||
services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
if (rateLimitOptions.Enabled)
|
||||
{
|
||||
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(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);
|
||||
}
|
||||
}
|
||||
@@ -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<BearerSecuritySchemeTransformer>();
|
||||
});
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddOptions<TenantResolutionOptions>()
|
||||
.Bind(builder.Configuration.GetSection(TenantResolutionOptions.SectionName));
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(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<TenantResolutionOptions>() ?? new TenantResolutionOptions();
|
||||
foreach (var address in resolution.TrustedProxyAddresses)
|
||||
{
|
||||
if (IPAddress.TryParse(address, out var proxy))
|
||||
{
|
||||
options.KnownProxies.Add(proxy);
|
||||
}
|
||||
}
|
||||
});
|
||||
builder.Services.AddOptions<DomainLifecycleOptions>()
|
||||
.Bind(builder.Configuration.GetSection("TenantDomains"));
|
||||
builder.Services.AddOptions<CorsOptions>()
|
||||
.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<CorsOptions>() ?? 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<ApiRateLimitOptions>()
|
||||
.Bind(builder.Configuration.GetSection(ApiRateLimitOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
var rateLimitOptions = builder.Configuration
|
||||
.GetSection(ApiRateLimitOptions.SectionName)
|
||||
.Get<ApiRateLimitOptions>() ?? new ApiRateLimitOptions();
|
||||
builder.Services.AddOptions<AuthRateLimitOptions>()
|
||||
.Bind(builder.Configuration.GetSection(AuthRateLimitOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
var authRateLimitOptions = builder.Configuration
|
||||
.GetSection(AuthRateLimitOptions.SectionName)
|
||||
.Get<AuthRateLimitOptions>() ?? new AuthRateLimitOptions();
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
if (rateLimitOptions.Enabled)
|
||||
{
|
||||
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(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<DataProtectionKeyRingOptions>()
|
||||
.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<DataProtectionKeyRingOptions>() ?? 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<TikuDbContext>();
|
||||
var dataProtectionCertificate = dataProtectionOptions.LoadCertificate(requireProtectedDataProtectionKeys);
|
||||
if (dataProtectionCertificate is not null)
|
||||
{
|
||||
dataProtection.ProtectKeysWithCertificate(dataProtectionCertificate);
|
||||
}
|
||||
|
||||
builder.Services.Configure<ObjectStorageOptions>(
|
||||
builder.Configuration.GetSection(ObjectStorageOptions.SectionName));
|
||||
builder.Services.Configure<AliyunOssOptions>(
|
||||
builder.Configuration.GetSection(AliyunOssOptions.SectionName));
|
||||
builder.Services.PostConfigure<ObjectStorageOptions>(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<AliyunOssOptions>(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<TenantSecretEncryptionOptions>()
|
||||
.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<SmsSecurityOptions>()
|
||||
.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<JwtOptions>()
|
||||
.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<JwtOptions>() ?? 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<Microsoft.Extensions.Options.IOptions<TenantResolutionOptions>>().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<ITenantContext>();
|
||||
var tenantInitializer = context.HttpContext.RequestServices
|
||||
.GetRequiredService<ITenantContextInitializer>();
|
||||
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<IAuthSessionStore>();
|
||||
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<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
|
||||
.Configure<IJwtKeyRing>((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<Microsoft.AspNetCore.Authorization.IAuthorizationMiddlewareResultHandler,
|
||||
AuditingAuthorizationMiddlewareResultHandler>();
|
||||
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<ExceptionHandlingMiddleware>();
|
||||
app.UseForwardedHeaders();
|
||||
app.UseHttpsRedirection();
|
||||
app.UseRouting();
|
||||
app.UseCors(CorsOptions.PolicyName);
|
||||
app.UseMiddleware<TenantResolutionMiddleware>();
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<AuthRateLimitPartitionMiddleware>();
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.UseMiddleware<CurrentPrincipalMiddleware>();
|
||||
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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user