.NET CORE 授权进阶-数据操作权限实现及扩展

.NET Core基于资源的授权-防止水平越权的实现

这篇分享内容就是基于图中的后半部分,在功能权限验证通过后,对数据进行操作时,如何使用原生 .NET Core 的授权模块进行实现及扩展。在回顾一下授权的这个简图:

授权简图

资源权限在功能权限之上,不是替代关系。业务逻辑上,必须得先验证调用方具备目标接口的功能权限,再判断对某一条具体的业务数据我们有没有操作权限(通俗说就是能进这个门,再看进门后能不能动这个具体物品)。得先要有接口的功能权限。

举一个我之前接触过的工作订单场景。首先我们有一个接口叫 GetWorkOrderDetail(int workOrderId),它的作用是用来根据订单 id 获取订单详情数据的。功能权限层上来说,用户 A 业务、B 业务、C 业务都有查看工作订单详情的权限,所以他们都能成功调用这个接口 API 端点,不会被返回 404 或 403 功能未授权。

但是除了这个之外,还应该:

1. A 业务查自己的订单 1001 应该返回 200

2. A 业务查 B 业务的订单 1002 应该返回 403

如果 A 业务查 B 业务的订单 1002 返回了数据,其实是水平越权了。想象一下如果在你知道你同事的用户 id 条件下,换成查工资的接口,你看到了他的工资详细信息,一看比你多了几个 0,那你估计得气死,这种就属于比较严重的数据安全漏洞。

出现的根本原因就是因为,功能权限是粗粒度的,它只管能不能查询这个动作,管不到能不能看或操作这张订单。要堵住这个洞,就得引入基于资源的授权——校验时不光看用户有什么权限,还要把具体的资源对象拿出来,比一比这条数据到底允不允许他来操作。

场景 说明
多租户系统 用户只能操作所属租户的数据,SaaS 中 A 租户员工仅能管理 A 租户的订单
数据拥有权控制 用户仅能操作自己创建的内容,如博客园中作者只能编辑自己的文章
租户 + 归属2个约束 订单、合同编辑这些敏感操作要同租户,又必须是本人

上面只是举一个例子,不过对于数据的权限通常有 2 个维度,一是操作型的,二是查询型的。这里我主要介绍操作型的数据权限,并且结合 .NET Core 原生的授权扩展实现防止越权。至于查询型,例如:

角色 功能权限 资源权限(能看到谁的数据)
总经理 有「查看工作订单」 所有员工
部门领导 有「查看工作订单」 本部门下属
普通员工 有「查看工作订单」 仅自己

这种通常是通过在代码中拼接查询条件来组装 SQL 或者在 ORM 做通用拦截实现的,不做分享。


1. 实现思路

.NET Core 授权模块其实为这种场景留好了扩展点。结合第一部分的功能权限,我们扩展了 PermissionCodeHandler,它继承自 AuthorizationHandler<TRequirement>,它是一个接受类型为 IAuthorizationRequirement 的泛型的抽象类。

AuthorizationHandler

其实它还有一个我们一直没用到的泛型重载,可以接受 2 个泛型类型,一个还是 IAuthorizationRequirement,另外一个 TResource 泛型参数,但是它没有约束,就说明适用任何能实现业务的对象。

AuthorizationHandler<TRequirement, TResource>

例如自定义的 Handler 声明成 AuthorizationHandler<OwnedResourceRequirement, IOwnedResource>,框架就会把我们手动传进来的那个实现了 IOwnedResource 的实例送到 Handler 里,它就能拿资源的归属字段和当前用户比对了。

那怎么把资源对象传给 handler 呢?我们可以主动调用 IAuthorizationService.AuthorizeAsync()

var result = await _authorizationService.AuthorizeAsync(
    User,     
    order,    
    ResourceAuthorizationPolicyNames.Owner);

if (!result.Succeeded) return Forbid();

和第一部分 [Authorize(Policy = "view")] 进方法前自动拦截不太一样,业务数据授权信息,必须要先查库拿到实体才知道,没法在进方法前就判断。所以做法就是先把资源查出来,再拿着资源去问授权系统。

实现流程如下:

实现流程


2. 实现

2.1 定义资源抽象接口

先定义 IOwnedResource 作为业务数据资源的公有抽象,授权的时候才有可比的值。例如订单、合同、工单等实体实现此接口后,都能共用 OwnedResourceAuthorizationHandler,不需要每一个业务写一个 Handler。这样 Handler 也可以复用。

public interface IOwnedResource
{
    string CreateUserId { get; }
}

如果你还需要判断租户,也可以提取一个租户的共用接口作为抽象:

public interface ITenantScoped
{
    string TenantId { get; }
}

基于接口多继承的特点,你可以随意组合,例如再组合一个同时带租户与创建人的资源。需要同一个租户和本人 2 种校验的实体,实现这个接口就行了:

public interface ITenantOwnedResource : ITenantScoped, IOwnedResource
{
}

2.2 定义业务数据对象

工作订单 WorkOrder 继承自组合的接口:

public class Order : ITenantOwnedResource
{
    public int Id { get; init; }

    // 租户ID,用户只能操作自己租户的工作订单
    public string TenantId { get; init; } = string.Empty;

    // 创建用户ID,用户只能操作自己创建的订单
    public string CreateUserId { get; init; } = string.Empty;

    public int Status { get; init; }
    public string Description { get; init; } = string.Empty;
}

合同数据 Contract 也继承自组合的接口,共用同一套 OwnedResourceAuthorizationHandler

public class Contract : ITenantOwnedResource
{
    public int Id { get; init; }

    // 租户ID,用户只能操作自己租户的工作订单
    public string TenantId { get; init; } = string.Empty;

    // 创建用户ID,用户只能操作自己创建的订单
    public string CreateUserId { get; init; } = string.Empty;

    public string Title { get; init; } = string.Empty;
}

ContractWorkOrder 是完全不同的业务表,但是因为都实现了 ITenantOwnedResource,传给 AuthorizeAsync() 后会自动路由到同一对 Handler,不用单独为合同再写 ContractOwnerAuthorizationHandler

2.3 定义 Requirement

和前面一样,Requirement 是个空的标记,就为了负责让框架路由到对应 Handler。然后这里两个场景就得建 2 个 Requirement:

// 场景一:多租户隔离
public class SameTenantRequirement : IAuthorizationRequirement { }

// 场景二:数据所有权
public class OwnedResourceRequirement : IAuthorizationRequirement { }

2.4 扩展 AuthorizationHandler

SameTenantAuthorizationHandler 租户隔离

public class SameTenantAuthorizationHandler : AuthorizationHandler<SameTenantRequirement, ITenantScoped>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        SameTenantRequirement requirement, 
        ITenantScoped resource) 
    {
        // 取认证阶段写入 Claim 的当前用户所属租户
        var tenantId = context.User.FindFirst(ResourceClaimTypes.TenantId)?.Value;

        // 资源的租户 == 用户数据的租户,才通过,否则就是跨租户越权
        if (!string.IsNullOrEmpty(tenantId) && 
            string.Equals(tenantId, resource.TenantId, StringComparison.OrdinalIgnoreCase))
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

OwnedResourceAuthorizationHandler 数据拥有操作权限

public class OwnedResourceAuthorizationHandler : AuthorizationHandler<OwnedResourceRequirement, IOwnedResource>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        OwnedResourceRequirement requirement,
        IOwnedResource resource)
    {
        var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;

        if (!string.IsNullOrEmpty(userId) && 
            string.Equals(userId, resource.CreateUserId, StringComparison.OrdinalIgnoreCase))
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

这里也是 context.Succeed() 只在通过时调用,不 Succeed 就等于拒绝,框架最终会返回 403。

2.5 注册到容器中

把两个扩展的 AuthorizationHandler 和命名策略一起注册。

public static class ResourceBasedAuthorizationExtensions
{
    public static IServiceCollection AddResourceBasedAuthorization(this IServiceCollection services)
    {
        // 模拟的数据源
        services.AddSingleton<IOrderStore, InMemoryOrderStore>();
        services.AddSingleton<IContractStore, InMemoryContractStore>();

        // AuthorizationHandler<TRequirement, TResource> 框架按类型自动路由
        services.AddSingleton<IAuthorizationHandler, SameTenantAuthorizationHandler>();
        services.AddSingleton<IAuthorizationHandler, OwnedResourceAuthorizationHandler>();

        // 用原生 AddPolicy() 添加策略,一条策略可以挂多个 Requirement,他们是 AND 的关系
        services.AddAuthorization(options =>
        {
            // 同时校验租户和创建人,两个都成功才算通过
            options.AddPolicy(ResourceAuthorizationPolicyNames.OwnerInTenant, policy =>
            {
                policy.Requirements.Add(new SameTenantRequirement());
                policy.Requirements.Add(new OwnedResourceRequirement());
            });
        });

        return services;
    }
}

2.6 在控制器中使用

[ApiController]
[Route("api/resource-contracts")]
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)]
public class ResourceContractController : ControllerBase
{
    private readonly IContractStore _contractStore;
    private readonly IAuthorizationService _authorizationService;

    public ResourceContractController(
        IContractStore contractStore, 
        IAuthorizationService authorizationService)
    {
        _contractStore = contractStore;
        _authorizationService = authorizationService;
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(int id, [FromBody] string title)
    {
        var contract = _contractStore.Find(id);
        if (contract is null) 
            return NotFound(new { message = $"合同 {id} 不存在" });

        // 与订单相同,一条组合策略同时校验租户和创建人
        var result = await _authorizationService.AuthorizeAsync(
            User, 
            contract, 
            ResourceAuthorizationPolicyNames.OwnerInTenant);

        if (!result.Succeeded) 
            return Forbid();

        return Ok(new { 
            approach = "resource-based", 
            message = $"已更新合同 {id} 标题为 {title}" 
        });
    }
}

3. 总结

在 .NET Core 授权系统里,AuthorizationHandler<TRequirement, TResource> 它能让 Handler 拿到具体的资源对象,把校验从"你能不能干这件事"升级到"能不能对这条数据干这件事"。先查库拿到资源,再用注入的 IAuthorizationService.AuthorizeAsync(User, resource, policy) 去判断。

它作为功能权限的补充,不是替代。正确的姿势是功能权限(能不能操作订单)+ 资源权限(能不能操作某个订单)一起叠加。

还有作为判断依据,例如租户 id、用户 id 最好在登录认证阶段就写到 Claim,授权阶段直接从 principal identity 里边取,尽量不要在 Handler 里再查一次库。

还有小伙伴可能会问,能不能就声明一个特性就搞定,不用查库?嗯没有办法,至少我目前没发现,资源授权几乎都是代码中写的。

到这里可能也有小伙伴会问,这也太麻烦了吧,有点花里胡哨的,我直接在业务中提取公共方法,比如 IResourceAuthorizationService.CheckAsync(user, resource) 不更加简单吗?

是的,在简单的业务场景下,公共方法确实更直观。但在中大型系统中,原生 AuthorizationHandler 可以让边界更清晰,并且可以和框架原生能力整合更加优雅。当然也是仁者见仁智者见智,我这里只是做个对比,没有强行说你不用这个就是不对,具体有以下 2 个好处:

3.1 延缓代码腐化速度

听到这个别惊讶,基本业务代码都会腐化,只是时间问题。公共方法方案通常写在 Application 或 Infrastructure 层,业务 Service/Controller 会主动调用它。时间长了,开发人员容易在判断逻辑里顺手加业务字段检查,比如"状态为草稿才能删",导致授权逻辑和业务逻辑耦合。下一个人改业务时,很可能因为不敢动这个公共方法而直接换种方式。

3.2 Policy 动态组合

提取公共方法,如果你要判断创建人 + 同租户 + 管理员可操作,方法参数会变成 CheckAsync(user, resource, bool allowAdminOverride),或者写多个重载的方法。如果需求变更为财务角色可以跨租户看,你只能改方法入参。

利用框架本身 Policy 的机制,可以把 SameTenantRequirementOwnedResourceRequirementAdminOverrideRequirement 像积木一样拼装。

扩展 AuthorizationHandler 不是为了解决"能不能判断"的问题,而是为了解决当判断规则膨胀到上 10 种甚至更多,如何让代码不屎山的问题。它以组合不会有太大侵入,手写公共方法基本无法做到的。

如果你只有 1 张表需要判断归属,用公共方法;如果你有 20 张表,且权限规则很多,可以尝试使用 AuthorizationHandler。

posted @ 2026-08-11 11:50  yuyuyui  阅读(0)  评论(0)    收藏  举报