惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Last Week in AI
Last Week in AI
D
DataBreaches.Net
腾讯CDC
Recent Announcements
Recent Announcements
有赞技术团队
有赞技术团队
A
About on SuperTechFans
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Google DeepMind News
Google DeepMind News
Microsoft Security Blog
Microsoft Security Blog
云风的 BLOG
云风的 BLOG
罗磊的独立博客
月光博客
月光博客
MyScale Blog
MyScale Blog
U
Unit 42
Martin Fowler
Martin Fowler
Stack Overflow Blog
Stack Overflow Blog
T
Tailwind CSS Blog
Engineering at Meta
Engineering at Meta
N
Netflix TechBlog - Medium
G
Google Developers Blog
博客园 - 【当耐特】
D
Docker
I
InfoQ
雷峰网
雷峰网

博客园 - 邓磊Lei

我可以双击 shift 打开 visual studio 的代码搜索 我决定把 gittoy 更名为 git blame for vs 为了可以重新拥抱 Visual Studio, 我做了一个 vs 版的 gittoolbox ABP DTO 与对象映射:AutoMapper Profile 配置与 Mapperly 源生成器 ABP 应用服务源码解读:ApplicationService 基类内置了哪些能力 ABP 仓储模式源码解读:自动过滤是如何实现的 ABP DDD 实体源码解读:7 层基类的设计逻辑和适用场景 ABP 模块系统源码学习:启动时模块是如何加载和排序的 为什么 Go 一个 HTTP 服务可以同时处理数万连接 gin怎么处理一次http请求的 我的经验:git提交信息分别用什么icon C# 也能像 Python 一样写脚本 | .NET 10 构建基于文件的应用 .NET 10 使用 Microsoft.AspNetCore.OpenApi 实现 API 版本管理 ASP.NET Core 内存缓存实战:一篇搞懂该怎么配、怎么避坑 Microsoft Agent Framework + Kimi API 实战:控制台应用跑通单次与多轮 Agent 对话 开发实战:asp.net core + ef core 实现动态可扩展的分页方案 聊聊 ASP.NET Core 中间件和过滤器的区别 Python 入门:从“其他语言”到 Pythonic 思维的完整迁移手册 .NET 进阶之路:异步、并发与内存管理的系统性认知 Redis:延迟双删的适用边界与落地细节 Serilog:从结构化日志认知到 .NET 工程落地 EF Core 原生 SQL 实战:FromSql、SqlQuery 与对象映射边界 EF Core 拦截器实战:SaveChangesInterceptor、CommandInterceptor 与审计落地 ASP.NET Core 外部依赖调用治理实战:HttpClientFactory、Polly 与幂等边界 .NET .Result 避坑指南:不同框架下的死锁与线程池饥饿 EF Core 慢查询排查实战:TagWith、OpenTelemetry、执行计划,30 分钟定位性能瓶颈 EF Core 并发冲突实战:乐观锁、RowVersion 与 DbUpdateConcurrencyException 怎么处理 EF Core 写入链路深拆:从 ChangeTracker 到 SQL Batch 的性能诊断与优化 C# 异步编程深水区:Task、ValueTask、线程池饥饿与背压设计 EF Core 查询性能黑洞:Include、投影与跟踪策略的边界
ASP.NET Core 认证鉴权实战:JWT、Policy 与权限边界怎么落地
邓磊Lei · 2026-03-11 · via 博客园 - 邓磊Lei

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

问题背景

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

根因并不复杂:

  1. 接口加了 [Authorize]
  2. 系统只校验“是否登录”
  3. 没有继续校验角色、权限和资源归属

结果就是,认证做了,授权却只做了一半。

这也是很多系统的共性问题。认证只是在回答“你是谁”,授权回答的是“你能做什么”。如果这两件事没有拆开设计,接口表面安全,实际边界会很模糊。

原理解析

认证解决身份确认

认证的目标,是确认当前请求对应的是哪个用户、哪个客户端,常见做法就是校验 JWT 的签名、过期时间、签发方和受众。

这一步做完后,系统拿到的是一个 ClaimsPrincipal。它说明“请求身份可信”,但并不说明这个身份就有所有权限。

授权解决操作范围

授权是在认证之后,对用户能力做进一步判断。

在 ASP.NET Core 里,最常见的落点是 Policy。你可以按角色、权限声明、租户、部门或业务规则定义策略,而不是在控制器里到处手写 if 判断。

角色不等于权限模型

很多系统一开始只有 AdminOperatorUser 这几个角色,后来业务一复杂,就会发现角色粒度太粗。

更稳妥的方式通常是:

  • 角色用于粗粒度分组
  • 权限声明用于精细操作控制

例如“财务”和“运营”都属于后台用户,但是否允许退款、导出、调价,应该由权限声明决定,而不是只靠角色名硬编码。

资源级授权才是真正的边界

就算用户具备 orders.refund 权限,也不代表他可以操作所有订单。

很多越权问题出在这里:接口只校验了功能权限,没有校验资源归属,比如租户是否匹配、门店是否匹配、是否只能操作自己负责的数据。

所以完整授权通常分两层:

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

示例代码

下面用一个“订单退款接口”来说明一套常见落地方式。

先配置 JWT 认证:

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateIssuerSigningKey = true,
            ValidateLifetime = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SigningKey"]!)),
            ClockSkew = TimeSpan.FromSeconds(30)
        };
    });

再定义基于权限声明的授权策略:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("OrdersRefund", policy =>
    {
        policy.RequireAuthenticatedUser();
        policy.RequireClaim("permission", "orders.refund");
    });
});

如果 token 里的声明长这样:

{
  "sub": "1001",
  "name": "alice",
  "tenant_id": "t-01",
  "permission": ["orders.read", "orders.refund"]
}

那么接口级功能授权可以这样写:

app.MapPost("/api/orders/{id:long}/refund",
    async (
        long id,
        RefundRequest request,
        IAuthorizationService authorizationService,
        ClaimsPrincipal user,
        OrderRefundService service,
        CancellationToken ct) =>
    {
        var result = await service.RefundAsync(id, request, user, ct);
        return result ? Results.Ok() : Results.Forbid();
    })
    .RequireAuthorization("OrdersRefund");

但这样还不够。因为用户即使有退款权限,也未必能退任意租户、任意门店的订单。

所以业务层还要做资源级校验:

public sealed class OrderRefundService
{
    private readonly AppDbContext _db;

    public OrderRefundService(AppDbContext db)
    {
        _db = db;
    }

    public async Task<bool> RefundAsync(
        long orderId,
        RefundRequest request,
        ClaimsPrincipal user,
        CancellationToken ct)
    {
        var tenantId = user.FindFirst("tenant_id")?.Value;
        if (string.IsNullOrWhiteSpace(tenantId))
        {
            return false;
        }

        var order = await _db.Orders.FirstOrDefaultAsync(x => x.Id == orderId, ct);
        if (order is null)
        {
            return false;
        }

        if (!string.Equals(order.TenantId, tenantId, StringComparison.Ordinal))
        {
            return false;
        }

        if (order.Status != OrderStatus.Paid)
        {
            return false;
        }

        order.Status = OrderStatus.Refunded;
        order.RefundReason = request.Reason;
        order.RefundedAt = DateTime.UtcNow;

        await _db.SaveChangesAsync(ct);
        return true;
    }
}

如果你希望把这类判断进一步收敛到授权层,也可以自定义 Requirement 和 Handler:

public sealed class SameTenantRequirement : IAuthorizationRequirement
{
}

public sealed class SameTenantHandler : AuthorizationHandler<SameTenantRequirement, Order>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        SameTenantRequirement requirement,
        Order resource)
    {
        var tenantId = context.User.FindFirst("tenant_id")?.Value;
        if (!string.IsNullOrWhiteSpace(tenantId) && tenantId == resource.TenantId)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

这种方式的价值在于:功能权限和资源权限都能被组织成一致的授权模型,而不是散落在各个接口里。

工程实践建议

[Authorize] 只能说明这个接口需要登录,不能说明权限模型已经设计正确。

真正需要明确的是:这个接口到底限制到角色、权限、租户、组织,还是具体资源。

Claim 设计要稳定,不要随业务字段漂移

JWT 里的声明一旦进入多个服务,就会变成契约。

建议优先保留稳定字段,例如用户 ID、租户 ID、权限编码,不要把频繁变化的展示信息和大块业务数据塞进 token。

权限编码要业务化

与其用 AdminManager 这种泛化概念,不如直接定义 orders.refundorders.exportproducts.adjust-price 这类权限编码。

这样做的好处是边界清晰,也更适合做前后端联动和审计。

认证失败、授权失败要能区分

401 和 403 不是一回事。

  • 401 表示身份无效或缺失
  • 403 表示身份有效,但没有权限

很多系统把两者混成一个“没权限”,最后排查问题时非常费劲。

审计日志不要缺席

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

  • 谁发起了操作
  • 操作了哪个资源
  • 操作前后的关键状态
  • 请求是否被拒绝以及原因

这样越权、误操作和合规追查才有依据。

评论区讨论

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

总结

认证鉴权最容易出问题的地方,不是 token 验不过,而是系统把“已登录”和“有权限”混成了一件事。

JWT 负责身份可信,Policy 负责能力边界,资源级校验负责数据归属。把这三层拆开设计,接口安全才不是停留在表面。