上篇文章已经介绍了很多概念性的东西,这篇文章就不用那么累了。和上一讲一样,这一讲,我们也从手撸简陋代码开始,体会一下什么是授权,再循序渐进的给大家说框架中有关授权的基础设施都是怎么设计的。
新开一个项目dotnet new web --use-program-main -o HelloAuthZ
,然后把Program.cs
改成下面这样:
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
namespace HelloAuthZ;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication("school_scheme").AddCookie("school_scheme");
var app = builder.Build();
app.UseAuthentication();
app.MapGet("/Login", LoginEndpoint);
app.MapGet("/Logout", LogoutEndpoint);
app.MapGet("/", RootEndpoint);
app.Run();
}
public static async Task RootEndpoint(HttpContext ctx)
{
StringBuilder responseContent = new StringBuilder();
int count = 0;
foreach(var identity in ctx.User.Identities)
{
responseContent.AppendLine($"Identity # {count++}: ");
responseContent.AppendLine($" Identity.IsAuthenticated == {identity.IsAuthenticated}");
responseContent.AppendLine($" Identity.AuthenticationType == {identity.AuthenticationType}");
responseContent.AppendLine($" Identity.Claims :");
foreach(var c in identity.Claims)
{
responseContent.AppendLine($" {c.Type} : {c.Value}");
}
}
await ctx.Response.WriteAsync(responseContent.ToString());
}
public static async Task LoginEndpoint(HttpContext ctx, string name, string gender, string age, string role, string scheme)
{
await ctx.SignInAsync(scheme, new ClaimsPrincipal(new ClaimsIdentity(new Claim[]
{
new Claim("Name", name),
new Claim("Gender", gender),
new Claim("Age", age),
new Claim("Role", role)
}, scheme)));
await ctx.Response.WriteAsync($"You've just logged in as {name} through \"{scheme}\" scheme");
}
public static async Task LogoutEndpoint(HttpContext ctx)
{
await ctx.SignOutAsync();
await ctx.Response.WriteAsync("You've just logged out");
}
}
如果你仔细阅读了上一篇文章的话,上面的代码就非常好懂。唯一可能有点陌生的就是LoginEndpoint
方法的参数是怎么回事,这里紧急插播一下这个小知识点:
MapGet
, MapPost
之类的方法,是.net 6.0之后推出的,用来快速定义endpoint的方法MapGet
, MapPost
之类的方法来定义endpoint时,框架在调用endpoint执行体函数时,会智能的从多个地方来解析参数的值,在没有特殊说明的情况下,框架会倾向于去请求携带的query string中去解析参数比如在下面的例子中:
var builder = WebApplication.CreateBuilder(args);
// Added as service
builder.Services.AddSingleton<Service>();
var app = builder.Build();
app.MapGet("/{id}", (int id,
int page,
[FromHeader(Name = "X-CUSTOM-HEADER")] string customHeader,
Service service) => { });
class Service { }
id
是从路由路径中取的,框架之所以会去路由路径中去取这个参数的值,是因为MapGet
的第一个参数,即路由匹配规则,使用了{xxx}
这种语法page
没有额外信息,DI池中也没有匹配的对象,框架就会去去query string中去拿这个参数,拿不到还会抛异常customerHeader
有明确的修饰:即[FromHeader(Name = "X-CUSTOM-HEADER")]
,框架就会去HTTP请求头部,将指定的头部字段的值解析出来当成参数值service
虽然也没有额外信息,但DI池中有匹配的对象,框架就会拿DI池中的对象做参数值以上代码的实现只有认证功能,登录用户有四个Claim,下面我们来新增两个endpoint:一个只允许Role
为Admin
的登录用户访问,另一个只允许“年龄大于18岁的男性用户”访问。
代码改动如下:
// ...
app.MapGet("/Login", LoginEndpoint);
app.MapGet("/Logout", LogoutEndpoint);
+
+ app.MapGet("/OnlyForAdmin", OnlyForAdminEndpoint);
+ app.MapGet("/OnlyForAdultMales", OnlyForAdultMalesEndpoint);
+
app.MapGet("/", RootEndpoint);
// ...
// ...
+ public static async Task OnlyForAdminEndpoint(HttpContext ctx)
+ {
+ IEnumerable<ClaimsIdentity> authenticatedIdentities = ctx.User.Identities.Where(i => i.IsAuthenticated);
+
+ bool isAuthenticated = authenticatedIdentities.Any();
+ bool isAdmin = authenticatedIdentities.Where(i => i.Claims.Where(c => c.Type == "Role" && c.Value == "Admin").Any()).Any();
+
+ if(!isAuthenticated)
+ {
+ ctx.Response.StatusCode = 401;
+ return;
+ }
+
+ if(!isAdmin)
+ {
+ ctx.Response.StatusCode = 403;
+ return;
+ }
+
+ await ctx.Response.WriteAsync("you're AUTHENTICATED and ADMIN !");
+ return;
+ }
+
+ public static async Task OnlyForAdultMalesEndpoint(HttpContext ctx)
+ {
+ IEnumerable<ClaimsIdentity> authenticatedIdentities = ctx.User.Identities.Where(i => i.IsAuthenticated);
+
+ bool isAuthenticated = authenticatedIdentities.Any();
+ Func<ClaimsIdentity, bool> identityIsAdult = identity => identity.Claims.Where(c => c.Type == "Age" && int.TryParse(c.Value, out int age) && age >= 18).Any();
+ Func<ClaimsIdentity, bool> identityIsMale = identity => identity.Claims.Where(c => c.Type == "Gender" && c.Value == "Male").Any();
+ bool isAdultMales = authenticatedIdentities.Where(i => identityIsAdult(i) && identityIsMale(i)).Any();
+
+ if(!isAuthenticated)
+ {
+ ctx.Response.StatusCode = 401;
+ return;
+ }
+
+ if(!isAdultMales)
+ {
+ ctx.Response.StatusCode = 403;
+ return;
+ }
+
+ await ctx.Response.WriteAsync("you're ADULT and MALE for sure !");
+ return;
+ }
// ...
就目前这个代码,跑起来,以下是几种运行结果:
第一种:不登录,两个额外页面是无法访问的,都会返回401
第二种:以Alice的身份登录,我们假定Alice是一个20岁的女性,且是管理员身份。那么她就能访问OnlyForAdmin
页面,但无法访问OnlyForAdultMales
页面:会返回403
第三种:以Bob的身份登录,假定Bob是一个19岁的男性,但不是管理员,是一个学生,那么他就只能访问OnlyForAdultMales
页面,不能访问OnlyForAdmin
页面:会返回403
上面的代码实现很简单,两个特殊的endpoint看起来代码差不多,但从概念的角度来讲,他们其实使用了两种不同的鉴权思路:
咱现在说的是思路,是概念,和任何框架、任何实现都没关系。
所谓的“role based authZ”其实非常好理解,也非常自然:即在用户的信息中,单独开个字段来说明这个用户的权限是什么,然后在代码实现的时候,只通过这一个单一字段来判断用户的请求是否合法。这种设计思路非常简单,也非常实用,大规模的应用在这个世界上80%的交互式网站上:大多数网站的权限规则都非常简单,不需要考虑过多的幺蛾子。
我们以一个网络论坛为例,如果一个网络论坛决定采用这种方式来设计整个认证授权用户体系,那么很简单,给用户信息表中单加个字段,就叫“等级”就好了:
新注册用户0级,达到某种活跃度升一级,级别的什就是"role"。
对于论坛的各级管理员,可以设定一些奇怪的级别:
这几乎就能搞定所有问题了,但有一种场景,这种设计实现应对起来比较吃力。比如这个论坛的例子中,忽然有一天站长决定开发一个少儿不宜区,问题就出来了:如何把年龄判断,加进现在的鉴权过程中呢?
两种实现思路:
一种是缝缝补补,尽量少改代码,而是发挥聪明才智,把年龄信息编码到已经存在的“等级”字段中去,然后适当更新权限配置文件或者配置表。
另一种是去他妈的,把当前系统中的,基于单一字段判断权限的逻辑砍了,重新写一套更灵活的鉴权代码。
这第二种,就是业界总结出来的:claims based authZ
这玩意说白了,其实就是把原来基于单一字段的判断逻辑,改成了多个字段的判断逻辑。
我们上面的/OnlyForAdminEndpoint
的鉴权逻辑是我们自己写的,现在我们把它改造一下,使用框架现成的基础设施来实现role based authZ
首先,与认证Authentication一样,授权,或者说鉴权,需要向DI中注册一些对象,这些对象承载着鉴权逻辑的具体实现。
其次,与认证Authentication一样,鉴权在asp .net core中也被设计成了一个middleware
所以我们要对主函数做如下修改:
// ...
builder.Services.AddAuthentication("school_scheme").AddCookie("school_scheme");
+ builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
+ app.UseRouting();
+ app.UseAuthorization();
app.MapGet("/Login", LoginEndpoint);
app.MapGet("/Logout", LogoutEndpoint);
// ...
而之所以我们要在app.UseAuthorization
之前加上app.UseRouting
的调用,如果你之前看过我们第十二课前的番外篇,会有一点朦胧的印象。如果你没看过,或者印象不深,不重要,这里简单的解释一下:
app.UseRouting
添加的middleware,会执行“路由”,所谓的“路由”,就是搞明白这个请求将被哪个endpoint执行体处理现在我们向DI中注册了一揽子对象,用来干鉴权的活。又配置了一个middleware,去实际执行鉴权动作。我们要实现的,是role based authZ,那么自然的,就需要把这个规则,写在endpoint定义的地方,如下:
// ...
app.MapGet("/Logout", LogoutEndpoint);
- app.MapGet("/OnlyForAdmin", OnlyForAdminEndpoint);
+ app.MapGet("/OnlyForAdmin", OnlyForAdminEndpoint).RequireAuthorization(new AuthorizeAttribute { Roles = "Admin" });
app.MapGet("/OnlyForAdultMales", OnlyForAdultMalesEndpoint);
app.MapGet("/", RootEndpoint);
// ...
我们在MapGet
之后链式调用的这个方法,就是在向框架说:
Admin
才行目前不必过分纠结RequireAuthorization
方法的细节,以及AuthorizeAttribute
的细节:比如设定Role的地方为什么是复数形式Roles
(其实是因为可以允许多种Role的用户来访问这个endpoint,多个Role以逗号分开),只需要大致明白这个逻辑就可以了。关键的点在:我们是把鉴权规则和endpoint写在了一起。
这正应了上面说的:鉴权所需要的信息,只有在“路由”工作结束后才能拿到。
再接下来,框架会根据鉴权信息去分析某个请求是否合法,那我们在endpoint执行体中就没必要写条件判断了,所以OnlyForAdminEndpoint
就可以改写成下面这样:
public static async Task OnlyForAdminEndpoint(HttpContext ctx)
{
await ctx.Response.WriteAsync("you're AUTHENTICATED and ADMIN !");
return;
}
之前一大坨的,用来做权限判断的代码,都可以扔了:现在框架会帮我们做这些事,更具体一点,app.UseAuthorization
注册的middleware会做那些事
最后一件事:我们还没有告诉框架,我们的用户信息中,哪个字段代表着role!
这很关键!!我们虽然用英文单词定义了Name, Gender, Age, Role
四个claim,但框架又不会讲英语,它哪知道所谓的Role,指的是哪个Claim呢?
那么我们来思考一下:我们应当如何告诉框架,去哪里拿Role?或者说,我们把我们假定为asp .net core框架的开发者,我们应当在哪里开放一个口子,让程序员可以自定义Role的来源?
有两个地方:
ClaimsIdentity
里诸多的Claims中,你去找那个Type == "Role"
的Claim,它的值,就是Role
ClaimsIdentity
实例的时候,都手动指定:对于目前这个用户的这个身份,请把其中Type == "Role"
的Claim的值,作为这个用户,这个身份的Role对于前者,可以如下在代码中指定:
builder.Services.AddAuthentication("school_scheme").AddCookie("school_scheme");
+ builder.Services.Configure<ClaimsIdentityOptions>(options =>
+ {
+ options.RoleClaimType = "Role";
+ });
builder.Services.AddAuthorization();
但是,这种方法是无效的,接下来这个没用的知识点比较有意思:
上面的代码虽然看起来做了我们想做的事,但实际上,框架在判断登录用户是否与某个Role相契合的时候,调用的是ClaimsPrincipal.IsInRole()
方法,而这个方法的默认实现,长下面这样:
public virtual bool IsInRole(string role)
{
for (int i = 0; i < _identities.Count; i++)
{
if (_identities[i] != null)
{
if (_identities[i].HasClaim(_identities[i].RoleClaimType, role))
{
return true;
}
}
}
return false;
}
这里有两个关键信息:
ClaimsPrincipal
中,IsInRole
的实现,完全没有去看,哪怕一眼,DI池中配置的ClaimsIdentityOptions
配置对象:逻辑纯写死的,配置对象你随便配置,我看一眼算我输ClaimsPrincipal
这一层没有尊重ClaimsIdentityOptions
中的配置,但各种其它类库、框架中的子类,则有可能改写这个IsInRole
的具体实现既然这个方法是无效的,那么我们就只能用第2个,看起来蠢一点的办法了:在构造ClaimsIdentity
的时候,指定一个Claim为Role。
去查阅ClaimsIdentity
的文档,会发现有个属性叫RoleClaimType
,它就是我们想要的那个属性,不过麻烦的是,这个属性是只读的,只能通过构造函数,在构造的时候设定值。翻遍所有的构造函数重载,我们能找到下面这个重载:
public ClaimsIdentity (IEnumerable<Claim>? claims, string? authenticationType, string? nameType, string? roleType);
完美!那我们就可以把我们登录的代码改写成下面这样:
public static async Task LoginEndpoint(HttpContext ctx, string name, string gender, string age, string role, string scheme)
{
await ctx.SignInAsync(scheme, new ClaimsPrincipal(new ClaimsIdentity(new Claim[]
{
new Claim("Name", name),
new Claim("Gender", gender),
new Claim("Age", age),
new Claim("Role", role)
- }, scheme)));
+ }, scheme, null, "Role")));
await ctx.Response.WriteAsync($"You've just logged in as {name} through \"{scheme}\" scheme");
}
嵌套太多了不太好阅读,不好意思,下面是清爽版:
public static async Task LoginEndpoint(HttpContext ctx, string name, string gender, string age, string role, string scheme)
{
IEnumerable<Claim> claims = new List<Claim>
{
new Claim("Name", name),
new Claim("Gender", gender),
new Claim("Age", age),
new Claim("Role", role)
};
ClaimsIdentity identity = new ClaimsIdentity(claims, scheme, null, "Role");
ClaimsPrincipal principal = new ClaimsPrincipal(identity);
await ctx.SignInAsync(scheme, principal);
await ctx.Response.WriteAsync($"You've just logged in as {name} through \"{scheme}\" scheme");
}
现在我们把程序运行起来,有以下效果:
如果是匿名用户去访问/OnlyForAdmin
,会被重定向至/Account/Login?ReturnUrl=%2FOnlyForAdmin
:其中/Account/Login
显然是意图重定向至登录页面,而附带的query string则是在记录登录成功后应当跳转的地址,也是我们本意欲访问但没有权限的地址:/OnlyForAdmin
如果是认证用户Bob去访问/OnlyForAdmin
,会被重定向至/Account/AccessDenied?ReturnUrl=%2FOnlyForAdmin
,这个/Account/AcccessDenied
就不是登录页面了,而显然是一个通知页面,应当在这个页面向Bob展示“你没有权限访问”等提示信息
如果是认证用户Alice去访问的话,自然和我们之前的效果一样,并没有什么区别:
我们先忽略掉401
变/Acccount/Login
和403
变/Account/AccessDenied
这两个细节的话,就可以说:我们已经从思路上掌握了所谓的role based authZ,同时也掌握了如何在asp .net core框架中,去实现role based authZ。
为了防止大家刺挠,我也简单的说一下这个重定向的问题:
问题一:重定向合理吗?合理。那返回401和403合理吗?也合理。那到底应该重定向,还是返回401/403呢?
如果请求是用户从浏览器发过来的,请求路径对应的endpoint的渲染结果是一个给人看的网页的话,那么返回重定向,让没认证的用户去登录,让没权限的用户死心,是合理的
如果请求不是浏览器发送来的,或者说虽然是浏览器发送来的,但请求路径对应的endpoint本身就不是个页面,而是一个API接口的话,那么返回重定向是很滑稽的,这时候就应该返回401/403
还有一种重要的知识点大家需要意识到:只有基于Cookie的认证机制,才会存在这个问题,才需要在服务端纠结到底是返回401/403,还是重定向。
你仔细琢磨这个问题,它意味着,如果存在一种Http请求,它:
那么这个认证信息,这个纸条,一定是写在cookie里的,没有其它技术方案。换言之,如果浏览器没有执行脚本的能力,那么它唯一能在HTTP这个无状态协议上实现“纸条”的技术方案,就是使用cookie,并且只有这一种可能。
而如果认证是基于其它非cookie的手段实现的,比如把认证纸条放在HTTP请求的Authorization
字段中,那么,能发送这种请求的东西,一定不是浏览器本身,只能是浏览器身上的脚本语言,或者直接就是非浏览器环境的其它程序。浏览器本身是不会给HTTP请求填充Authorization
字段的
问题二:那怎么实现,在渲染页面的endpoint鉴权时,返回重定向,而对API的调用进行鉴权的时候,返回401/403呢?
这个问题有三个层次的回答:
最低层次的答案是:
有一个HTTP请求头部字段叫X-Requested-With
,这不是一个标准的头部字段,这只是一个约定俗成的行业习惯,用来标识当前请求是对API的请求,而不是页面的请求。
大多数前端开发框架,以及前端网络库,在向后端发送API请求的时候,都会加上这个头部字段,并把字段值设定为XMLHttpRequest
。
而如果请求发送方遵守了这个行业习惯的话,asp .net core中,基于cookie机制实现的认证功能,是能自动识别请求类型,从而自动的去判断,应当发送重定向,还是401/403。
至于非cookie机制实现的认证功能,则压根不会碰到这个问题:浏览器要在请求页面的HTTP请求中插入认证纸条的话,不借助JS的话,cookie几乎就是唯一选择。
中间层次的答案是:
你要理解到,无论是重定向,还是401/403,都是authentication middleware做的,而不是authorization middleware。
你可以简单的理解为:authZ middleware只是鉴别出一个能不能访问的结果,而这个鉴别结果到底是要怎么处理,是authN middleware的事情。
我们可以对authN的相关对象进行额外配置,可以做到:
改写重定向的地址,可以使用CookieAuthenticationOptions
中的如下字段:
builder.Services.AddAuthentication("school_scheme").AddCookie("school_scheme", cookieAuthNOptions =>
{
cookieAuthNOptions.LoginPath = "/customizedLoginPath";
cookieAuthNOptions.AccessDeniedPath = "/customizedUnauthorizedPagePath";
});
如果要禁用重定向,或者按需禁用重定向,可以通过CookieAuthenticationOptions.Events
中的事件回调,来自定义用户在未登录或权限不够时的行为,下面是个例子,来按请求路径是否以/api
开头,来决定在未认证的情况下,是重定向到登录页面,还是直接返回401。
builder.Services.AddAuthentication("school_scheme").AddCookie("school_scheme", cookieAuthNOptions =>
{
cookieAuthNOptions.Events.OnRedirectToLogin = ctx =>
{
if(ctx.HttpContext.Request.Path.StartsWithSegments("/api"))
{
ctx.Response.Clear();
ctx.Response.StatusCode = 401;
return Task.CompletedTask;
}
ctx.Response.Redirect(ctx.RedirectUri);
return Task.CompletedTask;
};
});
最高层次的答案
对于API Endpoint,你就不应该使用基于cookie的认证方案!
HTTP协议当初设计Cookie这个东西,这套机制,就是专门给浏览器用的,就不是给程序用的!除非你写的那个程序,就叫“浏览器”,那当我没说!
在role based authZ中,其实我们可以把鉴权的逻辑,抽象成下面的“权限表”
endpoint | 允许访问的Role |
---|---|
/OnlyForAdmin |
Admin |
/OnlyForStudent |
Student, Admin |
(我们的代码示例中并没有写/OnlyForStudent
这个Endpoint,但你应该能明白我想表达什么)
上面这个表最大的问题是,列名“允许访问的Role”本身就暗含了鉴权的逻辑。如果我们写得详细一点,上面的权限表其实应该写成下面这样
endpoint | 鉴权逻辑函数 | 调用鉴权逻辑时的参数 |
---|---|---|
/OnlyForAdmin |
(string[] roles) => roles.Select(r => ctx.User.IsInRole(r)).Where(isInRole => isInRole).Any() |
new [] {"Admin"} |
/OnlyForAdmin |
(string[] roles) => roles.Select(r => ctx.User.IsInRole(r)).Where(isInRole => isInRole).Any() |
new [] {"Admin", "Student"} |
注意啊,上面的代码,都是伪代码,就说那么个意思而已。我们现在假设,在某个平行宇宙中,asp .net core官方人员最初就是这样设计鉴权系统的,那么对于role based authZ来说,鉴权逻辑函数都是一样的,不同的只是调用函数时的参数不同,这个参数,其实就是我们在MapGet
后面调用RequireAuthorization
时告诉框架的信息:
app.MapGet("/OnlyForAdmin", OnlyForAdminEndpoint).RequireAuthorization(new AuthorizeAttribute { Roles = "Admin" });
也就是总结一下,在role based authZ中
鉴权逻辑函数是框架内置的,这个函数的逻辑也很简单,就是去轮ctx.User
中的Claims,去看那个RoleClaim里面的值与参数是不是能匹配上
鉴权逻辑函数的参数是由程序员书写的,其实就是IAuthorizeData.Roles
字段
AuthorizeAttribute
是一个对接口IAuthorizeData
的实现我们上面也说了,只用一个Claim来判断权限限制太大,框架是万万不能这样设计的,所以很自然的,在那个平行宇宙中,asp .net core官方人员的下一步改进目标,就是要支持用多种claim来判断权限,如果延续上面的思路,就可以将鉴权表改造成下面这样:
endpoint | 鉴权逻辑函数 | 调用鉴权逻辑时的参数 |
---|---|---|
/OnlyForAdmin |
/* ... */ |
new Dictionary<string, Func<string, bool>> { { "Role", r => r == "Admin" } } |
/OnlyForStudent |
/* ... */ |
new Dictionary<string, Func<string, bool>> { { "Role", r => r == "Admin" || r == "Student" } } |
固化在框架内部的鉴权逻辑函数则可以改造成下面这样:
public static bool ClaimsBasedAuthZ(ClaimsPrincipal user, Dictionary<string, Func<string, bool>> rules)
{
foreach(var kvp in rules)
{
if(!UserIsMatchRule(user, kvp.Key, kvp.Value))
{
return false;
}
}
return true;
}
public static bool UserIsMatchRule(ClaimsPrincipal user, string claimType, Func<string, bool> isValueAllowed)
{
foreach(Claim c in user中的所有已认证Identity下的Claims)
{
if(c.Type == claimType && isValueAllowed(c.Value))
{
return true;
}
}
return false;
}
这样的设计理论上确实可以实现claim based authZ,但没必要:因为上面的权限表只需要再进一步,就可以进化成一种更为灵活的方式
即,为什么不直接让程序员写出下面这样的代码呢?
// ...
app.MapGet("/OnlyForAdmin", OnlyForAdminEndpoint)
.RequireAuthorization(OnlyForAdminAuthorizationFunc);
// ...
public static bool OnlyForAdminAuthorizationFunc(HttpContext ctx)
{
if(/* 各种细节逻辑判断,想看claim就看claim,想看什么就看什么 */)
{
return true
}
else
{
return false;
}
}
有没有感觉豁然开朗?返璞归真大道至简?
事实上也差不多是这样(其实细节差得挺多的,但你可以这样去理解):asp .net core创造了一个概念,叫policy
,这个policy
的意思,其实就是“用户自定义的鉴权函数”,只不过在代码中,它的形式并不是C#函数。
也就是说,从历史发展的角度来看,在发现role based authZ不好用之后,应该有一个阶段,是去实现claims based authZ。但框架直接跳过了这个阶段,直接实现了最有自由度的那种鉴权设计,然后为它起了个名字,叫policy based authZ。
首先,policy的本质其实就是自定义的鉴权函数,但它的形式又不是纯C#函数:要用以下的代码,在AddAuthorization
的时候定义policy。这也很好理解:policy的定义从逻辑上来说也算是鉴权系统中的一部分,通过配置方式去定义policy也很合理
builder.Services.AddAuthorization(authorizationOptions =>
{
authorizationOptions.AddPolicy("AdultMalesPolicy", ???);
});
上面的代码中,通过AuthorizationOptions.AddPolicy
方法来定义,或者说向当前鉴权系统中添加一个policy时,
第一个参数,是policy的名字:这将是这个policy的全局唯一的身份证号,后续任何使用到这个policy的地方,都是用这个名字来指代它
第二个参数,是policy的具体实现:它可以是一个AuthorizationPolicy
对象,也可以是一个Action<AuthorizationPolicyBuilder>
使用AuthorizationPolicy
当然最直接,这也是policy在框架内部的真实实现:每一个所谓的policy,其实在框架内部都是一个AuthorizationPolicy
对象。但手动构造这个对象其实有点复杂
使用Action<AuthorizationPolicyBuilder>
其实是一种间接方法:框架根据你传入的函数,来构造出一个AuthorizationPolicy
对象,最终在框架内部的policy,其实还是一个AuthorizationPolicy
对象
由于是间接构造,不可避免的是有一些限制的,不能像直接构造AuthorizationPolicy
那样随心所欲,但好处也是非常明显的:AuthorizationPolicyBuilder
有一些工具方法,设计出来就是为了让我们快速描述鉴权逻辑的,代码写起来清爽得多。
builder.Services.AddAuthorization(authZOptions =>
{
authZOptions.AddPolicy("AdultMalesPolicy", policyBuilder =>
{
policyBuilder.AddAuthenticationSchemes("school_scheme");
policyBuilder.RequireAuthenticatedUser();
policyBuilder.RequireClaim("Gender", "Male");
// ...
});
});
AuthorizationBuilderPolicy
有四个常用方法
RequireAuthenticatedUser
: 添加“仅认证用户可通过鉴权”的逻辑RequireRole
: 添加“仅Role为指定值才能通过鉴权”的逻辑RequireClaim
: 有两个语义,通过重载区分。添加“必须存在某个指定Claim”,或“必须存在某个指定Claim,且Claim的值必须为指定值”的逻辑RequireUserName
: 和RequireRole
类似这四个基本方法里,又以RequireAuthenticatedUser
和RequireClaim
最为基本,仅使用这两个方法,其实就可以实现所谓的claim based authZ了。
但RequireClaim
的几个重载,都最多只能实现“指定的Claim的值必须在某几个指定的值里面”这种逻辑,没法做更复杂的逻辑:比如我们要实现的,把claim的值转化为数值,再去做逻辑判断。
这时候就得使用一个叫RequireAssertion
的方法了:它接受一个Func<AuthorizationHandlerContext, bool>
作为参数,在里面,可以通过AuthorizationHandlerContext.User
直接拿到ClaimsPrincipal
对象,做任何想做的逻辑判断,如下:
builder.Services.AddAuthorization(authZOptions =>
{
authZOptions.AddPolicy("AdultMalesPolicy", policyBuilder =>
{
policyBuilder.AddAuthenticationSchemes("school_scheme");
policyBuilder.RequireAuthenticatedUser();
policyBuilder.RequireClaim("Gender", "Male");
policyBuilder.RequireAssertion(authZHandlerCtx =>
{
IEnumerable<ClaimsIdentity> authNedIdentities = authZHandlerCtx.User.Identities.Where(i => i.IsAuthenticated);
foreach(var identity in authNedIdentities)
{
Claim? ageClaim = identity.Claims.Where(c => c.Type == "Age").FirstOrDefault();
if(ageClaim is not null && int.TryParse(ageClaim.Value, out int age))
{
return age >= 18;
}
}
return false;
});
});
});
当你如上构造好policy后,就可以如下使用它了:
app.MapGet("/OnlyForAdultMales", OnlyForAdultMalesEndpoint).RequireAuthorization("AdultMalesPolicy");
而endpoint执行体中,也就可以把原先的鉴权逻辑删掉了:
public static async Task OnlyForAdultMalesEndpoint(HttpContext ctx)
{
- IEnumerable<ClaimsIdentity> authenticatedIdentities = ctx.User.Identities.Where(i => i.IsAuthenticated);
-
- bool isAuthenticated = authenticatedIdentities.Any();
- Func<ClaimsIdentity, bool> identityIsAdult = identity => identity.Claims.Where(c => c.Type == "Age" && int.TryParse(c.Value, out int age) && age >= 18).Any();
- Func<ClaimsIdentity, bool> identityIsMale = identity => identity.Claims.Where(c => c.Type == "Gender" && c.Value == "Male").Any();
- bool isAdultMales = authenticatedIdentities.Where(i => identityIsAdult(i) && identityIsMale(i)).Any();
-
- if(!isAuthenticated)
- {
- ctx.Response.StatusCode = 401;
- return;
- }
-
- if(!isAdultMales)
- {
- ctx.Response.StatusCode = 403;
- return;
- }
-
await ctx.Response.WriteAsync("you're ADULT and MALE for sure !");
return;
}
小小的总结一下:
asp .net core框架中并没有所谓的claims based authZ,而是直接进化到了所谓的policy based authZ
policy的理论本质是一个自定义的鉴权函数,policy的实际本质是 一个AuthorizationPolicy
对象 + 一个独一无二的字符串名字
定义policy的地方通常是在AddAuthorization
时,通过配置参数Action<AuthorizationOptions>
内部,调用options.AddPolicy("name", xxx)
定义的
一般情况我们并不会真的创建一个AuthorizationPolicy
对象,传递给AddPolicy
作为第二个参数,而是选择使用Action<AuthorizationPolicyBuilder>
的方式间接定义policy
AuthorizationPolicyBuilder
有几个常用的Requirexxx
方法可以描述一些基本的鉴权逻辑,更复杂一些的逻辑,需要用RequireAssertion
来实现
RequireAssertion
中,鉴权逻辑虽然可以更灵活一点,但有个限制是:鉴权能拿到的数据,也仅局限于框架经过Authentication Middleware后,得到的ClaimsPrincipal
对象。而像诸如:请求源IP之类的信息,是拿不到的其实到目前为止,你已经基本学会了policy based authZ,虽然你能定义的policy都是通过Action<AuthorizationPolicyBuilder>
间接创建的,能做的无非就是拿点claim出来判断,但就这么点知识,已经足够应付日常开发了。
你不需要知道DI里有多少鉴权相关的对象被注册在那里,也不需要知道UseAuthorization
注册的middleware背后的运行逻辑,你只需要知道怎么定义policy,然后怎么应用policy就行了。
哦,不太对,我们上面只讲了在MapXXX
定义的endpoint上怎么应用policy,但没说在Controller中,在Razor Page中怎么应用policy,这里补充上:
核心很简单:就是AuthorizeAttribute
,它可以直接修饰Controller类,或类中的方法,如下:
[Authorize(Policy = "xxx")]
public class xxxController : Controller
{
// ...
}
当然除了policy外,还可以使用它实现role based authZ:[Authorize(Roles = "xxx,xxx")]
。或者什么参数都不加,代表“只要登录了就行”,跟什么参数都不加去在MapGet()
后面调用RequireAuthorization()
一样。
那么如何在Razor Page中,对指定页面指定policy呢?坏消息是:AuthorizeAttribute
是不能用来修饰Razor Page的model方法的,比如你在Razor Page后面的OnGet
, OnPost
方法上去使用,是没有效果的。
那怎么办呢?官方推荐:把你的Razor Page项目改造成MVC项目,给中间加一层Controller
// To be continued: 授权之块的内部实现还是有点复杂,有点纠结要不要仔细从源代码角度来接着介绍 // 暂时决定就先讲到这里吧,对于日常开发来说,policy based authZ已经完全足够了