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

推荐订阅源

量子位
S
Secure Thoughts
S
Schneier on Security
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Cyberwarzone
Cyberwarzone
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
P
Privacy International News Feed
L
Lohrmann on Cybersecurity
Schneier on Security
Schneier on Security
PCI Perspectives
PCI Perspectives
Google DeepMind News
Google DeepMind News
C
Cybersecurity and Infrastructure Security Agency CISA
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Latest news
Latest news
H
Hacker News: Front Page
月光博客
月光博客
Forbes - Security
Forbes - Security
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
S
Security @ Cisco Blogs
WordPress大学
WordPress大学
Recent Commits to openclaw:main
Recent Commits to openclaw:main
aimingoo的专栏
aimingoo的专栏
宝玉的分享
宝玉的分享
D
Docker
U
Unit 42
Recorded Future
Recorded Future
Spread Privacy
Spread Privacy
Microsoft Security Blog
Microsoft Security Blog
Recent Announcements
Recent Announcements
云风的 BLOG
云风的 BLOG
Application and Cybersecurity Blog
Application and Cybersecurity Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
H
Heimdal Security Blog
Microsoft Azure Blog
Microsoft Azure Blog
V
Vulnerabilities – Threatpost
Vercel News
Vercel News
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
爱范儿
爱范儿
博客园 - 聂微东
Hugging Face - Blog
Hugging Face - Blog
L
LangChain Blog
G
GRAHAM CLULEY
Apple Machine Learning Research
Apple Machine Learning Research
www.infosecurity-magazine.com
www.infosecurity-magazine.com
Blog — PlanetScale
Blog — PlanetScale
博客园 - 三生石上(FineUI控件)
罗磊的独立博客
Help Net Security
Help Net Security
Google Online Security Blog
Google Online Security Blog
S
SegmentFault 最新的问题

博客园 - 邓磊Lei

为什么 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、投影与跟踪策略的边界 从 IApplicationBuilder 到 RequestDelegate:ASP.NET Core 请求管线的性能与可观测性实战 如何在 rider 中配置 SonarQube
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 负责能力边界,资源级校验负责数据归属。把这三层拆开设计,接口安全才不是停留在表面。