.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 的泛型的抽象类。

其实它还有一个我们一直没用到的泛型重载,可以接受 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;
}
Contract 和 WorkOrder 是完全不同的业务表,但是因为都实现了 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 的机制,可以把 SameTenantRequirement、OwnedResourceRequirement、AdminOverrideRequirement 像积木一样拼装。
扩展 AuthorizationHandler 不是为了解决"能不能判断"的问题,而是为了解决当判断规则膨胀到上 10 种甚至更多,如何让代码不屎山的问题。它以组合不会有太大侵入,手写公共方法基本无法做到的。
如果你只有 1 张表需要判断归属,用公共方法;如果你有 20 张表,且权限规则很多,可以尝试使用 AuthorizationHandler。

浙公网安备 33010602011771号