374 lines
15 KiB
C#
374 lines
15 KiB
C#
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
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;
|
|
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.Security;
|
|
using Tiku.Application.Tenancy;
|
|
using Tiku.Infrastructure;
|
|
using Tiku.Infrastructure.Commerce;
|
|
using Tiku.Infrastructure.Persistence;
|
|
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<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();
|
|
if (rateLimitOptions.Enabled)
|
|
{
|
|
builder.Services.AddRateLimiter(options =>
|
|
{
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
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.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);
|
|
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<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.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidIssuer = jwtOptions.Issuer,
|
|
ValidateAudience = true,
|
|
ValidAudience = jwtOptions.Audience,
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)),
|
|
ValidateLifetime = true,
|
|
ClockSkew = TimeSpan.FromMinutes(1)
|
|
};
|
|
options.Events = new JwtBearerEvents
|
|
{
|
|
OnTokenValidated = async context =>
|
|
{
|
|
var tenantIdValue = context.Principal?.FindFirst(TikuClaimTypes.TenantId)?.Value;
|
|
if (!Guid.TryParse(tenantIdValue, out var tenantId))
|
|
{
|
|
context.Fail("Missing tenant claim.");
|
|
return;
|
|
}
|
|
|
|
var tenantInitializer = context.HttpContext.RequestServices
|
|
.GetRequiredService<ITenantContextInitializer>();
|
|
try
|
|
{
|
|
tenantInitializer.Initialize(tenantId, null, TenantResolutionSource.Jwt);
|
|
}
|
|
catch (TenantContextConflictException)
|
|
{
|
|
context.HttpContext.Items["tenant_context_conflict"] = true;
|
|
context.Fail("Authenticated tenant does not match the request host.");
|
|
return;
|
|
}
|
|
|
|
if (!jwtOptions.ValidateSessions)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var sessionIdValue = context.Principal?.FindFirst(TikuClaimTypes.SessionId)?.Value;
|
|
if (!Guid.TryParse(sessionIdValue, out var sessionId))
|
|
{
|
|
context.Fail("Missing session claim.");
|
|
return;
|
|
}
|
|
|
|
var dbContext = context.HttpContext.RequestServices.GetRequiredService<TikuDbContext>();
|
|
var now = DateTimeOffset.UtcNow;
|
|
var isSessionActive = await dbContext.AuthSessions.AnyAsync(
|
|
session =>
|
|
session.Id == sessionId &&
|
|
session.RevokedAt == null &&
|
|
session.ExpiresAt > now);
|
|
|
|
if (!isSessionActive)
|
|
{
|
|
context.Fail("Session has been revoked or expired.");
|
|
}
|
|
},
|
|
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.AddAuthorization(options =>
|
|
{
|
|
options.AddPolicy(
|
|
TikuPolicies.AuthenticatedUser,
|
|
policy => policy.RequireAuthenticatedUser());
|
|
options.AddPolicy(
|
|
TikuPolicies.CurrentTenantMember,
|
|
policy => policy
|
|
.RequireAuthenticatedUser()
|
|
.RequireAssertion(context => TenantRoleAuthorization.IsTenantMember(context.User)));
|
|
options.AddPolicy(
|
|
TikuPolicies.TenantAdmin,
|
|
policy => policy
|
|
.RequireAuthenticatedUser()
|
|
.RequireAssertion(context => TenantRoleAuthorization.IsTenantAdmin(context.User)));
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.MapOpenApi();
|
|
app.MapScalarApiReference(options => options
|
|
.WithTitle("TIKU Backend API")
|
|
.AddPreferredSecuritySchemes("BearerAuth")
|
|
.EnablePersistentAuthentication());
|
|
}
|
|
|
|
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
|
|
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
|
app.UseForwardedHeaders();
|
|
app.UseHttpsRedirection();
|
|
app.UseRouting();
|
|
app.UseCors(CorsOptions.PolicyName);
|
|
app.UseMiddleware<TenantResolutionMiddleware>();
|
|
app.UseAuthentication();
|
|
if (rateLimitOptions.Enabled)
|
|
{
|
|
app.UseRateLimiter();
|
|
}
|
|
|
|
app.UseMiddleware<CurrentPrincipalMiddleware>();
|
|
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;
|