参考资料:
书籍《ASP.NET Core IN ACTION SECOND EDITION》ch14、ch15
这里我本来写了一堆吐槽公司的话,但还是不放出来了,过于负能量。
但话说回来,我们当仆人的当然要服侍好我们的资本家主人们。就这样,为了更好的被奴役,被榨取剩余价值,我今天要来了解一下 ASP.NET Core 的认证与授权。
用外行都能听懂的话来说:
用内行能听懂的话来说:
可能大家都看过官方文档的默认教程,或者用 VS 创建过包含用户系统的 Web 应用,他们都用了一个叫 Identity 的官方库,在官方文档中被翻译为“标识”,笑死,根本看不懂,不如直接叫 Identity。
要明确一下,这个 Identity 中的 User 和 ASP.NET Core 的 user 不是同一个东西,他们是可以分开使用的。例如,有的公司只有一个用户中心,所有系统都从这一个用户中心取用户信息,你当然可以在你的某个应用里用 Identity 再搭建维护一套用户信息,但更多时候是从用户中心取了直接用,没有必要再在某个小应用里搞一套用户信息了,也就没有必要用 Identity 的 User 了。但你这个应用的认证和授权还是要进行,所以 ASP.NET Core 中的 user 和 claim 这时候就可以不依赖 Identity 而独立地起作用了。
下面我们再来了解一下几个东西:
不知道这玩意怎么翻译,就不翻译了。
可以直接把 Principal 当成你应用的用户。
每一个对你应用的请求都会有一个 HttpContext,当前的 Principal 就会变成你 HttpContext 的 User 属性。当你的应用需要知道当前的用户是谁,他可以做什么,的时候,可以这样访问:HttpContext.User.Claims
或者HttpContext.User.HasClaim(xxx)
,我也还没试过,不确定是不是这个属性,等学完理论去实战的时候试试。
在 ASP.NET Core 中,Principals 的实现是 ClaimsPrincipals,它是一组跟它关联的 claims 的集合。
截图来自参考资料《ASP.NET Core IN ACTION SECOND EDITION》
就算我看不懂上图里的字,我也能看懂这个图。这显然是说一个 ClaimsPrincipals 里包含了一组信息,包括用户的 Email,家庭电话,FirstName,LastName,以及一个 HasAdminAccess 表示了该用户拥有管理员权限。
这个图一看我们就能明白,显然 ClaimsPrincipals 里面放哪些项,是我们可以自定义的,毕竟上面连家庭电话这种项都出来了,现在谁家还用这玩意,还是不是90后啊?因此,我们可以定义一堆用户信息和用户权限存到数据库里,与每个用户在数据库里的账号对应起来,每次用户登录的时候就把这一堆东西从数据库查出来,放进 ClaimsPrincipals 去。后面用户的每一个请求都会把这个东西再以某种形式(cookie,header 等都行)带回来,我们会在中间件管道中把这些东西添加到HttpContext.User
,可能是HttpContext.User.Claims
这个属性,供我们的应用后面取用。
另外再提一嘴,上面图里,把 ClaimsPrincipals 中的一项一项称为 Claims,它包含一个类型(type)和一个值(value)。Claims 描述了这个 Principal 的属性。所以我们可以把 Claims 理解成当前用户的属性。
上面的 Claims 里既有用户信息如 Email,又有用户的权限如 HasAdminAccess。由此我们确定权限和认证都跟这些 Claims 直接相关。
看到这里,不管是用 ASP.NET Core 还是用什么别的语言别的框架,我们都能大体上知道怎么实现一个认证和授权的流程了。
信息太长,或者担心信息泄露怎么办?我觉得可以直接存缓存里,比如 redis 里,随便生成个 GUID 当 key,value 就是这套用户信息和权限,当然要加密一下再存。把 key 返回给前端,前端以后每次请求都把这个 key 放 header 里带过来,我们再从缓存里取出用户信息和权限,这样搞。缓存过期时间就是用户这次登录的失效时间。如果确定 key 被其它恶意用户拿到,然后用它来冒充合法用户,我们还能直接把他缓存清了,踢他下线。
上面的 2、3、4 点应该是发生在 Authentication 中间件中,所以它后面的中间件能用到用户信息。这就是负责授权的 Authorization 中间件要放在 Authentication 中间件后面的原因。放前面它压根拿不到用户信息和权限,拿什么去判断用户有没有权限?
另外需要注意的是,Authentication 中间件不负责将未经身份验证的请求重定向到登录页面,或者拒绝未经授权的请求。这两个操作是由 Authorization 中间件处理的。
未经授权的请求大概分为两种:
未登录的用户发送的请求。这种请求没有携带 Principal,说明用户没有登录
用户已经登录,但没有权限进行这项操作,或者调用这个 Web API。这种情况就值得讨论一下了。可能要返回 401?或者其它的信息?
现在我们讨论一下,可能有两种响应(response):
注意:在 ASP.NET Core 中,大家可能或多或少都知道可以用一个 Attribute 来给 Controller 或者 Action 加授权,这个 Attribute 就是[Authorize]
。如果我们只用[Authorize]
来修饰一个 Controller 或 Action,那么它只会校验用户是否登录,只要登录了的用户,都可以执行操作。如何针对权限校验?这个就更实战一点了,本文主要是说理论和逻辑,这个后面会稍微提一嘴,剩下的等到实战的文章里再说。
再看一下上面的两种情况的处理方式。一般在 Web 应用中,触发了 Challenge,用户会被重定向到登录页面。而触发了 Forbid,用户可能要被重定向到应用定义的“禁止”或“访问被拒绝”的页面。
而在客户端网页应用,如用前后端分离的方法开发的基于 React 的前端 SPA,或者 Andriod、IOS 的移动端 APP,它们需要调用后端的 Web API 。这种情况下如果触发了 Challenge 或者 Forbid,一般会被重定向到一个第三方的应用,这个应用能给 SPA 或者 APP 发 Token。例如你用某“小而美”的应用的账号登录某个网站,会跳转回这个“小而美”的应用里,让你点一下授权按钮。但重定向到其他页面不是由后端的 Web API 来做,Web API 可以对 Challenge 返回 401 Unauthorized,对 Forbid 返回 403 Forbidden。然后由客户端来决定该怎么做。
认证结束后,我们开始考虑授权。我们现在可以想到两种授权方式,一种是判断用户的 Principal 有没有某个 Claim,另一种是判断用户的 Principal 中的某个 Claim 的值是不是某个特定的值。
在 ASP.NET Core 中,定义用户是否获得授权的规则被封装在 policy 中。policy 定义了请求获得授权必须满足的要求。
而 policy 在 ASP.NET Core 中可以直接被加到一个 Controller 上或者一个 Action 上,还是用 [Authorize]
这个 Attribute。差不多就像下面这样:
[Authorize("CanHelloWorld")] public class HelloWorldController : ControllerBase {...}
上面那个CanHelloWorld
的 policy 肯定不是凭空变出来的。他是在 Startup.cs 中的 ConfigureServices 方法中注册的。
截图来自参考资料《ASP.NET Core IN ACTION SECOND EDITION》
可以看到AddPolicy
第一个参数是 policy 的名字,第二个委托的RequireClaim
参数是这个 Policy 的规则规定了它需要哪些 claim。上面示例里它似乎只定义了一个 claim 的名字,claim 还支持键值对的定义方式。当然一个 policy 肯定能设置多个 claim。就是感觉用字符串比较不优雅,不知道有没有更好的办法。这个后面再看。
policyBuilder 支持的方法:
截图来自参考资料《ASP.NET Core IN ACTION SECOND EDITION》
不知道全不全,书上就这些,感觉这些已经足够定义一个简单的权限系统了。
大体上翻译一下,第一个方法表示只要登录的用户都满足该 policy;第二个是给 policy 设置 claim,支持键值对的方式;第三个是需要 username 为方法参数里指定的 username 的用户才满足该 policy;第四个支持你用参数传一个返回值为 bool 类型的委托进去,来定义更健壮的 policy,下面我们简单讲一下。
我们可以使用RequireAssertion
方法定义更健壮的 policy。
结合时事和参考书里的例子,我们现在需要定义一个规则为用户年满 18 周岁的 policy。我们就可以传一个委托进RequireAssertion
方法,该委托判断今天的日期减去用户的 claim——DateOfBirth
的 value 大于等于 18,就 return true,否则 return false, 即可实现这个稍微复杂的 policy 的规则。
写到这里我发现,就看看这些理论,基本没什么用,还得实战一波。等我有空实战一波再搞一篇博客。
我们再引入两个概念,Requirements 和 Handlers。
每一个 policy 都包含一个或多个 requirenments,每一个 requirements 可以有一个或多个 handlers。参考书里讲了一个例子。
如果你在飞机上,你想去洗手间。我们给洗手间定义一个 policy :("CanAccessLounge")
,还有两个 requirements : (MinimumAgeRequirement 和 AllowedInLoungeRequirement)
,还有几个 handlers:
截图来自参考资料《ASP.NET Core IN ACTION SECOND EDITION》
("CanAccessLounge")
(能够进入洗手间)这个 policy 有两个 requirements,其中AllowedInLoungeRequirement
(被允许进入洗手间)有三个 handlers,FrequentFlyerHandler
需要你是乘机频率很高的乘客,类似于航空公司的熟客;IsAirlineEmployeeHandler
需要你是航空公司的员工;BannedFromLoungeHandler
表示你是否被禁止使用飞机上的洗手间。而MinimumAgeRequirement
(最小年龄限制)有一个 handler MinimumAgeRequirement
,表示洗手间有最小的年龄限制。
所以,CanAccessLounge
(能够进入洗手间)必须AllowedInLoungeRequirement
(被允许进入洗手间),而且满足MinimumAgeRequirement
(最小年龄限制)。
而AllowedInLoungeRequirement
(被允许进入洗手间),需要你满足FrequentFlyerHandler
(高频率乘客)或IsAirlineEmployeeHandler
(航空公司员工)。你不需要全部满足这两个 handlers,你只需要满足其中一项。BannedFromLoungeHandler
(被禁止使用洗手间)这个我们先不管。
现在,我们可以用 OR 来组合每个 requirement 的 handler,即只要满足任何一个 handler,就满足这个 requirement。我们也可以用 AND 来组合 requirenment,必须满足所有的 requirement 才满足这个 policy。而在 ASP.NET Core 中,我们可以给 Controller 或者 Action 设置多个 policy,就像这样: [Authorize ("Policy1"), Authorize("Policy2")]
,必须满足所有 policy 才能被授权。
所以现在的逻辑就如下图:
截图来自参考资料《ASP.NET Core IN ACTION SECOND EDITION》
简单放几段书里的代码,看一下怎么创建这些 requirements 和 handlers:
创建AllowedInLoungeRequirement
,注意要实现IAuthorizationRequirement
接口。
创建MinimumAgeRequirement
,这个 requirement 稍微特殊一点,它还带有参数:
注册包含这两个 requirement 的 policy:
创建 handler 来满足 requirement,可以看到需要继承AuthorizationHandler<对应的 requirement>
,覆写它的HandleRequirementAsync
方法:
上面两个 handler 中调用context.User.HasClaim()
来取 claim 时是稍有不同的,注意区别。
注意:可以编写可用于多个 requirement 的通用的 handler,但最好每个 handler 只处理单个 requirement。如果需要提取一些通用功能,应该从两个 requirement 调用它。
context.Succeed(requirement);
,但在开发过程中可能会有判断 claims 满足了某个条件反而不能通过授权的情况,那样就要用到context.Fail();
:这些 handler 也是需要注册的:
注意:上面这几个 handler 没有什么构造函数依赖项,所以注册的生命周期全是 Singleton,如果我们的 handler 有什么生命周期为 scoped 或者 transient 的构造函数依赖项,比如依赖了 EFCore 的 DbContext,可能要考虑用 scoped 生命周期来注册 handler
懒得总结了,就这样吧。