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; Log.Logger = new LoggerConfiguration() .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) .Enrich.FromLogContext() .WriteTo.Console() .CreateBootstrapLogger(); 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(); 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.Run(); } catch (Exception exception) { Log.Fatal(exception, "TIKU API terminated unexpectedly"); throw; } finally { Log.CloseAndFlush(); } static string[] SplitLegacyList(string? value, string[] fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); public partial class Program;