Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
96 lines
3.1 KiB
C#
96 lines
3.1 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.Extensions.Options;
|
|
using System.Text.RegularExpressions;
|
|
using YarpGateway.Services;
|
|
|
|
namespace YarpGateway.Middleware;
|
|
|
|
/// <summary>
|
|
/// 租户路由中间件
|
|
///
|
|
/// 安全说明:
|
|
/// 1. 验证 X-Tenant-Id Header 与 JWT 中的 tenant claim 一致
|
|
/// 2. 防止租户隔离绕过攻击
|
|
/// 3. 只有验证通过后才进行路由查找
|
|
/// </summary>
|
|
public class TenantRoutingMiddleware
|
|
{
|
|
private readonly RequestDelegate _next;
|
|
private readonly IRouteCache _routeCache;
|
|
private readonly ILogger<TenantRoutingMiddleware> _logger;
|
|
|
|
public TenantRoutingMiddleware(
|
|
RequestDelegate next,
|
|
IRouteCache routeCache,
|
|
ILogger<TenantRoutingMiddleware> logger)
|
|
{
|
|
_next = next;
|
|
_routeCache = routeCache;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task InvokeAsync(HttpContext context)
|
|
{
|
|
var headerTenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
|
|
|
|
if (string.IsNullOrEmpty(headerTenantId))
|
|
{
|
|
await _next(context);
|
|
return;
|
|
}
|
|
|
|
// 安全验证:检查 Header 中的租户 ID 是否与 JWT 一致
|
|
if (context.User?.Identity?.IsAuthenticated == true)
|
|
{
|
|
var jwtTenantId = context.User.Claims
|
|
.FirstOrDefault(c => c.Type == "tenant" || c.Type == "tenant_id")?.Value;
|
|
|
|
if (!string.IsNullOrEmpty(jwtTenantId) && jwtTenantId != headerTenantId)
|
|
{
|
|
// 记录安全事件
|
|
_logger.LogWarning(
|
|
"Tenant ID mismatch detected! JWT tenant: {JwtTenant}, Header tenant: {HeaderTenant}, User: {User}",
|
|
jwtTenantId,
|
|
headerTenantId,
|
|
context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "unknown"
|
|
);
|
|
|
|
// 拒绝请求
|
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
await context.Response.WriteAsync("Tenant ID verification failed");
|
|
return;
|
|
}
|
|
}
|
|
|
|
var path = context.Request.Path.Value ?? string.Empty;
|
|
var serviceName = ExtractServiceName(path);
|
|
|
|
if (string.IsNullOrEmpty(serviceName))
|
|
{
|
|
await _next(context);
|
|
return;
|
|
}
|
|
|
|
var route = _routeCache.GetRoute(headerTenantId, serviceName);
|
|
if (route == null)
|
|
{
|
|
_logger.LogDebug("Route not found - Tenant: {Tenant}, Service: {Service}", headerTenantId, serviceName);
|
|
await _next(context);
|
|
return;
|
|
}
|
|
|
|
context.Items["DynamicClusterId"] = route.ClusterId;
|
|
|
|
var routeType = route.IsGlobal ? "global" : "tenant-specific";
|
|
_logger.LogDebug("Tenant routing - Tenant: {Tenant}, Service: {Service}, Cluster: {Cluster}, Type: {Type}",
|
|
headerTenantId, serviceName, route.ClusterId, routeType);
|
|
|
|
await _next(context);
|
|
}
|
|
|
|
private string ExtractServiceName(string path)
|
|
{
|
|
var match = Regex.Match(path, @"/api/(\w+)/?");
|
|
return match.Success ? match.Groups[1].Value : string.Empty;
|
|
}
|
|
} |