You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

C#/.NET6:基于AccessToken范围构建API控制器授权策略遇阻

问题:Bearer令牌授权验证通过但Scope策略失效,租户声明无法获取

我有一个API控制器,希望使用Bearer令牌对调用服务进行授权,要求API端点验证令牌包含指定scope,但目前功能异常。

令牌负载示例

{
  "iss": "https://someauthority.com",
  "nbf": 1699891816,
  "iat": 1699891816,
  "exp": 1699895416,
  "aud": "https://myapi.com",
  "scope": [
    "myapi:user-read"
  ],
  "client_id": "MyApiConsumer",
  "tenant_id": "fcbebe85-5e17-4986-dffd-ede94e9b6a07",
  "tenant_external_id": "7123",
  "tenant_owner_client_id": "SomeTenantOwnerApp",
  "jti": "ADE83169F38F3EA14B5E99AF998821EF"
}

当前实现代码

1. JWT验证配置

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
    .AddJwtBearer(options =>
    {
        options.Authority = "https://someauthority.com";
        options.Audience = "https://myapi.com";
        options.SaveToken = true; // 两种情况都试过
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.Zero,
            IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
            {
                var json = new WebClient().DownloadString("https://myapi.com/.well-known/openid-configuration/jwks");
                var keys = JsonConvert.DeserializeObject<JwksKeys>(json);
                return keys?.Keys;
            }
        };
        options.Events = new JwtBearerEvents
        {
            OnAuthenticationFailed = context =>
            {
                var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("AuthenticationFailed");
                logger.LogError("Token validation failed", context.Exception);
                return Task.CompletedTask;
            },
            OnTokenValidated = context =>
            {
                var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("TokenValidated");
                logger.LogInformation("Token validated successfully.");
                logger.LogInformation("Claims:");
                foreach (var claim in context.Principal.Claims)
                {
                    logger.LogInformation($"{claim.Type}: {claim.Value}");
                }
                return Task.CompletedTask;
            }
        };
    });

2. 授权策略配置

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ScopeUserRead", policy => policy.RequireClaim("scope", "myapi:user-read"));
    options.AddPolicy("ScopeUserCreate", policy => policy.RequireClaim("scope", "myapi:user-create"));
    options.AddPolicy("ScopeUserWrite", policy => policy.RequireClaim("scope", "myapi:user-read-write"));
});

3. 中间件检查代码

app.Use(async (context, next) =>
{
    var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("ClaimsMiddleware");
    var user = context.User;

    if (user.Identity.IsAuthenticated)
    {
        logger.LogInformation("Authenticated User Claims:");
        foreach (var claim in user.Claims)
        {
            logger.LogInformation($"{claim.Type}: {claim.Value}");
        }
    }
    else
    {
        logger.LogInformation("User is not authenticated.");
    }
    await next.Invoke();
});

问题现象

  • 日志显示令牌验证成功,OnTokenValidated中能看到scope声明
  • 控制器类加[Authorize],方法加[Authorize("ScopeUserRead")],调用后返回401错误
  • 中间件检查发现HttpContext.User中没有scope和tenant_id等声明

疑问

  1. 问题出在哪里?是否方案有误?
  2. 能否手动将令牌声明复制到HttpContext.User?
  3. 多租户场景下,如何便捷获取tenant_id声明?是否有类似[FromToken]的注入方式?

解决方案

核心问题分析

问题出在JWT令牌中数组类型的scope声明在转换为Claims时的处理方式,以及自定义声明(如tenant_id)未被正确映射到ClaimsPrincipal。

1. 修复Scope授权失效问题

JWT中的scope是数组,但默认JwtBearer中间件会将其转换为单个Claim,值为空格分隔的字符串(而非数组)。而RequireClaim("scope", "myapi:user-read")是精确匹配Claim的完整值,导致匹配失败。

解决方法一:修改授权策略,自定义Scope验证

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ScopeUserRead", policy =>
        policy.RequireAssertion(context =>
            context.User.HasClaim(c => c.Type == "scope" && c.Value.Split(' ').Contains("myapi:user-read"))));
    
    // 其他Scope策略同理
    options.AddPolicy("ScopeUserCreate", policy =>
        policy.RequireAssertion(context =>
            context.User.HasClaim(c => c.Type == "scope" && c.Value.Split(' ').Contains("myapi:user-create"))));
});

解决方法二:在Token验证后拆分Scope为多个Claim

在OnTokenValidated事件中手动处理数组类型的scope,拆分为独立Claim:

options.Events = new JwtBearerEvents
{
    OnTokenValidated = context =>
    {
        var jwtToken = context.SecurityToken as JwtSecurityToken;
        if (jwtToken != null && jwtToken.Payload.TryGetValue("scope", out var scopeValue))
        {
            var claimsIdentity = context.Principal.Identity as ClaimsIdentity;
            if (claimsIdentity != null)
            {
                // 兼容数组或空格分隔的字符串格式
                var scopes = scopeValue switch
                {
                    string s => s.Split(' ', StringSplitOptions.RemoveEmptyEntries),
                    IEnumerable<string> arr => arr,
                    _ => Array.Empty<string>()
                };
                
                foreach (var scope in scopes)
                {
                    claimsIdentity.AddClaim(new Claim("scope", scope));
                }
            }
        }
        
        // 同时处理tenant_id等自定义声明
        if (jwtToken?.Payload.TryGetValue("tenant_id", out var tenantId) == true)
        {
            var claimsIdentity = context.Principal.Identity as ClaimsIdentity;
            claimsIdentity?.AddClaim(new Claim("tenant_id", tenantId.ToString()));
        }
        
        return Task.CompletedTask;
    }
};

修改后,授权策略可继续使用RequireClaim:

options.AddPolicy("ScopeUserRead", policy => policy.RequireClaim("scope", "myapi:user-read"));

2. 修复自定义声明(如tenant_id)无法获取的问题

默认JwtBearer中间件只会映射标准Claim类型,自定义声明需要手动添加到ClaimsPrincipal中。上面的OnTokenValidated事件处理代码已经包含了tenant_id的映射逻辑,处理后即可通过HttpContext.User.FindFirst("tenant_id")?.Value获取。

3. 实现类似[FromToken]的参数注入

可以自定义绑定属性实现令牌声明的直接注入:

步骤1:创建FromTokenAttribute

[AttributeUsage(AttributeTargets.Parameter)]
public class FromTokenAttribute : Attribute, IBindingSourceMetadata
{
    public string ClaimName { get; }

    public FromTokenAttribute(string claimName)
    {
        ClaimName = claimName;
    }

    public BindingSource BindingSource => BindingSource.Custom;
}

步骤2:创建自定义模型绑定器

public class TokenClaimModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var attribute = bindingContext.ParameterInfo.GetCustomAttribute<FromTokenAttribute>();
        if (attribute == null)
        {
            bindingContext.Result = ModelBindingResult.Failed();
            return Task.CompletedTask;
        }

        var claim = bindingContext.HttpContext.User.FindFirst(attribute.ClaimName);
        if (claim == null)
        {
            bindingContext.Result = ModelBindingResult.Failed();
            return Task.CompletedTask;
        }

        // 转换参数类型
        try
        {
            var value = Convert.ChangeType(claim.Value, bindingContext.ModelType);
            bindingContext.Result = ModelBindingResult.Success(value);
        }
        catch
        {
            bindingContext.Result = ModelBindingResult.Failed();
        }

        return Task.CompletedTask;
    }
}

步骤3:注册模型绑定提供器

builder.Services.AddControllers(options =>
{
    options.ModelBinderProviders.Insert(0, new TokenClaimModelBinderProvider());
});

public class TokenClaimModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context.ParameterInfo.GetCustomAttribute<FromTokenAttribute>() != null)
        {
            return new TokenClaimModelBinder();
        }
        return null;
    }
}

步骤4:在控制器中使用

[Authorize("ScopeUserRead")]
[HttpGet("users")]
public IActionResult GetUsers([FromToken("tenant_id")] Guid tenantId)
{
    // 直接使用tenantId
    return Ok($"Tenant ID: {tenantId}");
}

额外注意事项

  • 确保中间件顺序正确:app.UseAuthentication()必须在app.UseAuthorization()和自定义中间件之前
  • 避免重复设置TokenValidationParameters,options.Authority已自动配置Issuer、Audience和签名密钥解析,无需手动实现IssuerSigningKeyResolver(除非有特殊需求)
  • 建议使用HttpClient而非WebClient获取JWKS密钥,WebClient已被标记为过时

内容的提问来源于stack exchange,提问作者Aileron79

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.06 09:59:51