找回密码
 立即注册
首页 业界区 业界 ASP.NET Core 认证鉴权实战:JWT、Policy 与权限边界怎 ...

ASP.NET Core 认证鉴权实战:JWT、Policy 与权限边界怎么落地

樊涵菡 前天 14:50
这篇文章不讨论完整身份平台建设,只聚焦 ASP.NET Core 里最常见、也最容易出错的一段:JWT 认证、Policy 授权,以及资源级权限边界该怎么落到代码里。
问题背景

真实现场:一个后台退款接口原本只允许财务角色调用,但线上排查发现,普通运营账号只要拿到有效 token,也能调用成功。
根因并不复杂:

  • 接口加了 [Authorize]
  • 系统只校验“是否登录”
  • 没有继续校验角色、权限和资源归属
结果就是,认证做了,授权却只做了一半。
这也是很多系统的共性问题。认证只是在回答“你是谁”,授权回答的是“你能做什么”。如果这两件事没有拆开设计,接口表面安全,实际边界会很模糊。
原理解析

认证解决身份确认

认证的目标,是确认当前请求对应的是哪个用户、哪个客户端,常见做法就是校验 JWT 的签名、过期时间、签发方和受众。
这一步做完后,系统拿到的是一个 ClaimsPrincipal。它说明“请求身份可信”,但并不说明这个身份就有所有权限。
授权解决操作范围

授权是在认证之后,对用户能力做进一步判断。
在 ASP.NET Core 里,最常见的落点是 Policy。你可以按角色、权限声明、租户、部门或业务规则定义策略,而不是在控制器里到处手写 if 判断。
角色不等于权限模型

很多系统一开始只有 Admin、Operator、User 这几个角色,后来业务一复杂,就会发现角色粒度太粗。
更稳妥的方式通常是:

  • 角色用于粗粒度分组
  • 权限声明用于精细操作控制
例如“财务”和“运营”都属于后台用户,但是否允许退款、导出、调价,应该由权限声明决定,而不是只靠角色名硬编码。
资源级授权才是真正的边界

就算用户具备 orders.refund 权限,也不代表他可以操作所有订单。
很多越权问题出在这里:接口只校验了功能权限,没有校验资源归属,比如租户是否匹配、门店是否匹配、是否只能操作自己负责的数据。
所以完整授权通常分两层:

  • 功能级:你有没有这个动作权限
  • 资源级:你能不能对这条具体数据执行这个动作
示例代码

下面用一个“订单退款接口”来说明一套常见落地方式。
先配置 JWT 认证:
  1. using Microsoft.AspNetCore.Authentication.JwtBearer;
  2. using Microsoft.IdentityModel.Tokens;
  3. using System.Text;
  4. builder.Services
  5.     .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  6.     .AddJwtBearer(options =>
  7.     {
  8.         options.TokenValidationParameters = new TokenValidationParameters
  9.         {
  10.             ValidateIssuer = true,
  11.             ValidateAudience = true,
  12.             ValidateIssuerSigningKey = true,
  13.             ValidateLifetime = true,
  14.             ValidIssuer = builder.Configuration["Jwt:Issuer"],
  15.             ValidAudience = builder.Configuration["Jwt:Audience"],
  16.             IssuerSigningKey = new SymmetricSecurityKey(
  17.                 Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SigningKey"]!)),
  18.             ClockSkew = TimeSpan.FromSeconds(30)
  19.         };
  20.     });
复制代码
再定义基于权限声明的授权策略:
  1. builder.Services.AddAuthorization(options =>
  2. {
  3.     options.AddPolicy("OrdersRefund", policy =>
  4.     {
  5.         policy.RequireAuthenticatedUser();
  6.         policy.RequireClaim("permission", "orders.refund");
  7.     });
  8. });
复制代码
如果 token 里的声明长这样:
  1. {
  2.   "sub": "1001",
  3.   "name": "alice",
  4.   "tenant_id": "t-01",
  5.   "permission": ["orders.read", "orders.refund"]
  6. }
复制代码
那么接口级功能授权可以这样写:
  1. app.MapPost("/api/orders/{id:long}/refund",
  2.     async (
  3.         long id,
  4.         RefundRequest request,
  5.         IAuthorizationService authorizationService,
  6.         ClaimsPrincipal user,
  7.         OrderRefundService service,
  8.         CancellationToken ct) =>
  9.     {
  10.         var result = await service.RefundAsync(id, request, user, ct);
  11.         return result ? Results.Ok() : Results.Forbid();
  12.     })
  13.     .RequireAuthorization("OrdersRefund");
复制代码
但这样还不够。因为用户即使有退款权限,也未必能退任意租户、任意门店的订单。
所以业务层还要做资源级校验:
  1. public sealed class OrderRefundService
  2. {
  3.     private readonly AppDbContext _db;
  4.     public OrderRefundService(AppDbContext db)
  5.     {
  6.         _db = db;
  7.     }
  8.     public async Task<bool> RefundAsync(
  9.         long orderId,
  10.         RefundRequest request,
  11.         ClaimsPrincipal user,
  12.         CancellationToken ct)
  13.     {
  14.         var tenantId = user.FindFirst("tenant_id")?.Value;
  15.         if (string.IsNullOrWhiteSpace(tenantId))
  16.         {
  17.             return false;
  18.         }
  19.         var order = await _db.Orders.FirstOrDefaultAsync(x => x.Id == orderId, ct);
  20.         if (order is null)
  21.         {
  22.             return false;
  23.         }
  24.         if (!string.Equals(order.TenantId, tenantId, StringComparison.Ordinal))
  25.         {
  26.             return false;
  27.         }
  28.         if (order.Status != OrderStatus.Paid)
  29.         {
  30.             return false;
  31.         }
  32.         order.Status = OrderStatus.Refunded;
  33.         order.RefundReason = request.Reason;
  34.         order.RefundedAt = DateTime.UtcNow;
  35.         await _db.SaveChangesAsync(ct);
  36.         return true;
  37.     }
  38. }
复制代码
如果你希望把这类判断进一步收敛到授权层,也可以自定义 Requirement 和 Handler:
  1. public sealed class SameTenantRequirement : IAuthorizationRequirement
  2. {
  3. }
  4. public sealed class SameTenantHandler : AuthorizationHandler<SameTenantRequirement, Order>
  5. {
  6.     protected override Task HandleRequirementAsync(
  7.         AuthorizationHandlerContext context,
  8.         SameTenantRequirement requirement,
  9.         Order resource)
  10.     {
  11.         var tenantId = context.User.FindFirst("tenant_id")?.Value;
  12.         if (!string.IsNullOrWhiteSpace(tenantId) && tenantId == resource.TenantId)
  13.         {
  14.             context.Succeed(requirement);
  15.         }
  16.         return Task.CompletedTask;
  17.     }
  18. }
复制代码
这种方式的价值在于:功能权限和资源权限都能被组织成一致的授权模型,而不是散落在各个接口里。
工程实践建议

不要把 [Authorize] 当成权限治理的终点

[Authorize] 只能说明这个接口需要登录,不能说明权限模型已经设计正确。
真正需要明确的是:这个接口到底限制到角色、权限、租户、组织,还是具体资源。
Claim 设计要稳定,不要随业务字段漂移

JWT 里的声明一旦进入多个服务,就会变成契约。
建议优先保留稳定字段,例如用户 ID、租户 ID、权限编码,不要把频繁变化的展示信息和大块业务数据塞进 token。
权限编码要业务化

与其用 Admin、Manager 这种泛化概念,不如直接定义 orders.refund、orders.export、products.adjust-price 这类权限编码。
这样做的好处是边界清晰,也更适合做前后端联动和审计。
认证失败、授权失败要能区分

401 和 403 不是一回事。

  • 401 表示身份无效或缺失
  • 403 表示身份有效,但没有权限
很多系统把两者混成一个“没权限”,最后排查问题时非常费劲。
审计日志不要缺席

高风险操作除了鉴权,还应该记录审计日志。至少要能追到:

  • 谁发起了操作
  • 操作了哪个资源
  • 操作前后的关键状态
  • 请求是否被拒绝以及原因
这样越权、误操作和合规追查才有依据。
评论区讨论


  • 你们现在的权限模型更偏角色驱动,还是权限点驱动?
  • 资源级授权你们是放在 Policy Handler,还是业务层服务里?
  • 对高风险接口,你们有没有单独做审计日志和告警?
总结

认证鉴权最容易出问题的地方,不是 token 验不过,而是系统把“已登录”和“有权限”混成了一件事。
JWT 负责身份可信,Policy 负责能力边界,资源级校验负责数据归属。把这三层拆开设计,接口安全才不是停留在表面。

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册