refactor(api): split startup configuration

This commit is contained in:
2026-07-28 12:25:28 +08:00
parent 5d2248efee
commit e43ae0574c
10 changed files with 640 additions and 496 deletions

View File

@@ -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;