feat: enforce tenant isolation and shared question bank

This commit is contained in:
2026-07-27 16:59:12 +08:00
parent 28e9a9fa41
commit db4c7b4496
137 changed files with 6402 additions and 112274 deletions

View File

@@ -1,6 +1,8 @@
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;
@@ -16,6 +18,7 @@ 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;
@@ -49,6 +52,30 @@ try
});
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()
@@ -218,6 +245,26 @@ try
{
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;
@@ -242,6 +289,20 @@ try
{
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" }
});
}
}
};
});
@@ -276,9 +337,11 @@ try
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)
{