ASP.NET Core 授权的扩展:使用 IAuthorizationPolicyProvider 的自定义授权策略提供程序

一、概述

去年写过一篇《ASP.NET Core 授权的扩展:自定义 Authorize Attribute 和 IApplicationModelProvide》,由于在 ASP.NET Core 3 中,Microsoft.AspNetCore.Mvc.Internal 命名空间下的 AuthorizationApplicationModelProvider 类由 public 被改为了 internal,使得无法方便地将其从容器中 DI 容器中移除,所以不得不回到 IAuthorizationPolicyProvider 上来。

ASP.NET Core 提供了基于角色( Role )、声明( Chaim ) 和策略 ( Policy ) 等的授权方式。在实际应用中,可能采用部门( Department , 本文采用用户组 Group )、职位 ( 可继续沿用 Role )、权限( Permission )的方式进行授权。本文通过自定义 IAuthorizationPolicyProvider 进行扩展。

二、PermissionAuthorizeAttribute : IPermissionAuthorizeData

AuthorizeAttribute 类实现了 IAuthorizeData 接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
namespace Microsoft.AspNetCore.Authorization
{
/// <summary>
/// Defines the set of data required to apply authorization rules to a resource.
/// </summary>
public interface IAuthorizeData
{
/// <summary>
/// Gets or sets the policy name that determines access to the resource.
/// </summary>
string Policy { get; set; }
/// <summary>
/// Gets or sets a comma delimited list of roles that are allowed to access the resource.
/// </summary>
string Roles { get; set; }
/// <summary>
/// Gets or sets a comma delimited list of schemes from which user information is constructed.
/// </summary>
string AuthenticationSchemes { get; set; }
}
}

使用 AuthorizeAttribute 不外乎如下几种形式:

1
2
3
4
[Authorize]
[Authorize("SomePolicy")]
[Authorize(Roles = "角色1,角色2")]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]

当然,参数还可以组合起来。另外,RolesAuthenticationSchemes 的值以半角逗号分隔,是 Or 的关系;多个 AuthorizeAnd 的关系;PolicyRolesAuthenticationSchemes 如果同时使用,也是 And 的关系。

如果要扩展 AuthorizeAttribute,先扩展 IAuthorizeData 增加新的属性:

1
2
3
4
5
public interface IPermissionAuthorizeData : IAuthorizeData
{
string Groups { get; set; }
string Permissions { get; set; }
}

然后定义 AuthorizeAttribute:

1
2
3
4
5
6
7
8
9
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public class PermissionAuthorizeAttribute : Attribute, IPermissionAuthorizeData
{
public string Policy { get; set; }
public string Roles { get; set; }
public string AuthenticationSchemes { get; set; }
public string Groups { get; set; }
public string Permissions { get; set; }
}

现在,在 ControllerAction 上就可以这样使用了:

1
2
3
[PermissionAuthorize(Roles = "经理,副经理")] // 经理或副经理
[PermissionAuthorize(Groups = "研发部,生产部", Roles = "经理"] // 研发部或生产部, 或角色是经理。Groups 和 Roles 是 `Or` 的关系。
[PermissionAuthorize(Groups = "研发部,生产部", Roles = "经理", Permissions = "请假审批"] // 研发部经理或生产部经理,或者有请假审批的权限。Groups 、Roles 和 Permissions 是 `Or` 的关系。

备注:这和《ASP.NET Core 授权的扩展:自定义 Authorize Attribute 和 IApplicationModelProvide》一文不同的是,之前 GroupsRolesPermissionsAnd 的关系,而本文是 Or 的关系。原因在于 AuthorizationPolicy.CombineAsync 方法也会用到 Roles,从而达不到 And 的目的。

数据已经准备好,下一步就是怎么提取出来。通过扩展 AuthorizationApplicationModelProvider 来实现。

三、PermissionAuthorizeData : IPermissionAuthorizeData

1
2
3
4
5
6
7
8
public class PermissionAuthorizeData : IPermissionAuthorizeData
{
public string Policy { get; set; }
public string Roles { get; set; }
public string AuthenticationSchemes { get; set; }
public string Groups { get; set; }
public string Permissions { get; set; }
}

PermissionAuthorizeDataPermissionAuthorizeAttribute 的唯一区别是,后者是 Attribute

四、PermissionAuthorizationRequirement

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
public class PermissionAuthorizationRequirement : AuthorizationHandler<PermissionAuthorizationRequirement>, IAuthorizationRequirement
{
public PermissionAuthorizeData AuthorizeData { get; }

public PermissionAuthorizationRequirement(PermissionAuthorizeData authorizeData)
{
AuthorizeData = authorizeData;
}

protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionAuthorizationRequirement requirement)
{
if (context.User == null)
{
return Task.CompletedTask;
}

// 以半角逗号分隔的权限满足"需要"的其中之一即可,角色和分组也类似。
// 分组、角色和权限三者在此也是 Or 的关系,所以是在尽力去找任一匹配。
var found = false;
if (requirement.AuthorizeData.Permissions != null)
{
var permissionsClaim = context.User.Claims.FirstOrDefault(c => string.Equals(c.Type, PermissionClaimTypes.Permission, StringComparison.OrdinalIgnoreCase));
if (permissionsClaim?.Value != null && permissionsClaim.Value.Length > 0)
{
var permissionsClaimSplit = SafeSplit(permissionsClaim.Value);
var permissionsDataSplit = SafeSplit(requirement.AuthorizeData.Permissions);
found = permissionsDataSplit.Intersect(permissionsClaimSplit).Any();
}
}

if (!found && requirement.AuthorizeData.Roles != null)
{
var rolesClaim = context.User.Claims.FirstOrDefault(c => string.Equals(c.Type, ClaimTypes.Role, StringComparison.OrdinalIgnoreCase));
if (rolesClaim?.Value != null && rolesClaim.Value.Length > 0)
{
var rolesClaimSplit = SafeSplit(rolesClaim.Value);
var rolesDataSplit = SafeSplit(requirement.AuthorizeData.Roles);
found = rolesDataSplit.Intersect(rolesClaimSplit).Any();
}
}

if (!found && requirement.AuthorizeData.Groups != null)
{
var groupsClaim = context.User.Claims.FirstOrDefault(c => string.Equals(c.Type, PermissionClaimTypes.Group, StringComparison.OrdinalIgnoreCase));
if (groupsClaim?.Value != null && groupsClaim.Value.Length > 0)
{
var groupsClaimSplit = SafeSplit(groupsClaim.Value);
var groupsDataSplit = SafeSplit(requirement.AuthorizeData.Groups);
found = groupsDataSplit.Intersect(groupsClaimSplit).Any();
}
}

if (found)
{
context.Succeed(requirement);
}

return Task.CompletedTask;
}

private IEnumerable<string> SafeSplit(string source)
{
return source.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(m => m.Trim()).Where(m => !m.IsNullOrWhiteSpace());
}
}

五、PermissionAuthorizationPolicyProvider : IAuthorizationPolicyProvider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class PermissionAuthorizationPolicyProvider : IAuthorizationPolicyProvider
{
const string PolicyPrefix = "Permission:";

public DefaultAuthorizationPolicyProvider FallbackPolicyProvider { get; }

public PermissionAuthorizationPolicyProvider(IOptions<AuthorizationOptions> options)
{
// ASP.NET Core only uses one authorization policy provider, so if the custom implementation
// doesn't handle all policies (including default policies, etc.) it should fall back to an
// alternate provider.
//
// In this sample, a default authorization policy provider (constructed with options from the
// dependency injection container) is used if this custom provider isn't able to handle a given
// policy name.
//
// If a custom policy provider is able to handle all expected policy names then, of course, this
// fallback pattern is unnecessary.
FallbackPolicyProvider = new DefaultAuthorizationPolicyProvider(options);
}

public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => FallbackPolicyProvider.GetDefaultPolicyAsync();

// For ASP.NET Core 3.0
//public Task<AuthorizationPolicy> GetFallbackPolicyAsync() => FallbackPolicyProvider.GetFallbackPolicyAsync();

public Task<AuthorizationPolicy> GetPolicyAsync(string policyName)
{
if (policyName.StartsWith(PolicyPrefix, StringComparison.OrdinalIgnoreCase))
{
var policyValue = policyName.Substring(PolicyPrefix.Length);
var authorizeData = JsonConvert.DeserializeObject<PermissionAuthorizeData>(policyValue);
var policy = new AuthorizationPolicyBuilder();
policy.AddRequirements(new PermissionAuthorizationRequirement(authorizeData));
return Task.FromResult(policy.Build());
}

// If the policy name doesn't match the format expected by this policy provider,
// try the fallback provider. If no fallback provider is used, this would return
// Task.FromResult<AuthorizationPolicy>(null) instead.
return FallbackPolicyProvider.GetPolicyAsync(policyName);
}
}

六、Startup

注册 PermissionAuthorizationPolicyProvider 为单例,以替换内置的 DefaultAuthorizationPolicyProvider

1
services.AddSingleton<IAuthorizationPolicyProvider, PermissionAuthorizationPolicyProvider>();

七、Jwt 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
private readonly JwtSecurityTokenHandler _tokenHandler = new JwtSecurityTokenHandler();
[HttpGet]
[Route("SignIn")]
public async Task<ActionResult<string>> SignIn()
{
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
// 备注:Claim Type: Group 和 Permission 这里使用的是硬编码,应该定义为类似于 ClaimTypes.Role 的常量;另外,下列模拟数据不一定合逻辑。
new Claim(ClaimTypes.Name, "Bob"),
new Claim(ClaimTypes.Role, "经理"), // 注意:不能使用逗号分隔来达到多个角色的目的,下同。
new Claim(ClaimTypes.Role, "副经理"),
new Claim("Group", "研发部"),
new Claim("Group", "生产部"),
new Claim("Permission", "请假审批"),
new Claim("Permission", "权限1"),
new Claim("Permission", "权限2"),
}, JwtBearerDefaults.AuthenticationScheme));
var token = new JwtSecurityToken(
"SignalRAuthenticationSample",
"SignalRAuthenticationSample",
user.Claims,
expires: DateTime.UtcNow.AddDays(30),
signingCredentials: SignatureHelper.GenerateSigningCredentials("1234567890123456"));
return _tokenHandler.WriteToken(token);
}
[HttpGet]
[Route("Test")]
[PermissionAuthorize(Groups = "研发部,生产部", Roles = "经理", Permissions = "请假审批"] // 研发部或生产部,或者有请假审批的权限。Groups 、Roles 和 Permission 是 `Or` 的关系。
public async Task<ActionResult<IEnumerable<string>>> Test()
{
var user = HttpContext.User;
return new string[] { "value1", "value2" };
}
}

八、下一步

[PermissionAuthorize(Groups = "研发部,生产部", Roles = "经理", Permissions = "请假审批"] 这种形式还是不够灵活,哪怕用多个 Attribute, AndOr 的逻辑组合不一定能满足需求。可以在 IPermissionAuthorizeData 新增一个 Rule 属性,实现类似的效果:

1
[PermissionAuthorize(Rule = "(Groups:研发部,生产部)&&(Roles:请假审批||Permissions:超级权限)"]

通过 Rule 计算复杂的授权。

九、其他

话说,将规则保存在 Policy 名称中,略显丑陋,虽说官方文档在 ASP.NET Core 中使用 IAuthorizationPolicyProvider 的自定义授权策略提供程序也用类似方式实现了个 Sample 。

参考资料

https://docs.microsoft.com/zh-cn/aspnet/core/security/authorization/iauthorizationpolicyprovider?view=aspnetcore-3.0
https://www.cnblogs.com/RainingNight/p/authorize-how-to-work-in-asp-net-core.html