简单了解 ASP.NET Core 的认证与授权

参考资料:

书籍《ASP.NET Core IN ACTION SECOND EDITION》ch14、ch15

0. 照例吐槽

这里我本来写了一堆吐槽公司的话,但还是不放出来了,过于负能量。

但话说回来,我们当仆人的当然要服侍好我们的资本家主人们。就这样,为了更好的被奴役,被榨取剩余价值,我今天要来了解一下 ASP.NET Core 的认证与授权。

1. 什么是认证 Authentication 和授权 Authorization

用外行都能听懂的话来说:

  1. 认证 Authentication —— 为你的应用创建用户并让他们能够登录你的应用,即确认用户是谁
  2. 授权 Authorization —— 控制当前登录的用户在的应用里可以做什么,不能做什么

用内行能听懂的话来说:

  1. 认证 Authentication —— 确定是谁发出的请求
  2. 授权 Authorization —— 确定请求的行为是否被允许

2. ASP.NET Core 中的 users 和 claims

可能大家都看过官方文档的默认教程,或者用 VS 创建过包含用户系统的 Web 应用,他们都用了一个叫 Identity 的官方库,在官方文档中被翻译为“标识”,笑死,根本看不懂,不如直接叫 Identity。

要明确一下,这个 Identity 中的 User 和 ASP.NET Core 的 user 不是同一个东西,他们是可以分开使用的。例如,有的公司只有一个用户中心,所有系统都从这一个用户中心取用户信息,你当然可以在你的某个应用里用 Identity 再搭建维护一套用户信息,但更多时候是从用户中心取了直接用,没有必要再在某个小应用里搞一套用户信息了,也就没有必要用 Identity 的 User 了。但你这个应用的认证和授权还是要进行,所以 ASP.NET Core 中的 user 和 claim 这时候就可以不依赖 Identity 而独立地起作用了。

下面我们再来了解一下几个东西:

Principal

不知道这玩意怎么翻译,就不翻译了。

可以直接把 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这个属性,供我们的应用后面取用。

Claims

另外再提一嘴,上面图里,把 ClaimsPrincipals 中的一项一项称为 Claims,它包含一个类型(type)和一个值(value)。Claims 描述了这个 Principal 的属性。所以我们可以把 Claims 理解成当前用户的属性。

上面的 Claims 里既有用户信息如 Email,又有用户的权限如 HasAdminAccess。由此我们确定权限和认证都跟这些 Claims 直接相关。

3. 认证和授权的抽象过程

看到这里,不管是用 ASP.NET Core 还是用什么别的语言别的框架,我们都能大体上知道怎么实现一个认证和授权的流程了。

  1. 用户在客户端输入用户名密码登录
  2. 服务端校验用户名和密码,没啥问题的话就从数据库里取出用户信息和权限,放到 HttpContext 里,随时能取到。我们把这套东西抽象成 principal
  3. 这次请求结束时,我们把用户的 principal 序列化,作为加密的 cookie 返回给浏览器。或者把这些信息加密放到一个 token 里,如 JWT 那种方式。然后返回给前端,前端想办法存下来
  4. 前端再次发起请求时,就把这套信息继续传过来,我们解析,并放到 HttpContext 里随时取用。

信息太长,或者担心信息泄露怎么办?我觉得可以直接存缓存里,比如 redis 里,随便生成个 GUID 当 key,value 就是这套用户信息和权限,当然要加密一下再存。把 key 返回给前端,前端以后每次请求都把这个 key 放 header 里带过来,我们再从缓存里取出用户信息和权限,这样搞。缓存过期时间就是用户这次登录的失效时间。如果确定 key 被其它恶意用户拿到,然后用它来冒充合法用户,我们还能直接把他缓存清了,踢他下线。

Authentication 中间件和 Authorization 中间件在这个过程中发挥的作用

上面的 2、3、4 点应该是发生在 Authentication 中间件中,所以它后面的中间件能用到用户信息。这就是负责授权的 Authorization 中间件要放在 Authentication 中间件后面的原因。放前面它压根拿不到用户信息和权限,拿什么去判断用户有没有权限?

另外需要注意的是,Authentication 中间件不负责将未经身份验证的请求重定向到登录页面,或者拒绝未经授权的请求。这两个操作是由 Authorization 中间件处理的。

4. 如何处理未经授权的请求

未经授权的请求大概分为两种:

  1. 未登录的用户发送的请求。这种请求没有携带 Principal,说明用户没有登录

  2. 用户已经登录,但没有权限进行这项操作,或者调用这个 Web API。这种情况就值得讨论一下了。可能要返回 401?或者其它的信息?

现在我们讨论一下,可能有两种响应(response):

  1. Challenge(质询?考验?挑战?我也不知道怎么翻译)—— 此响应表示用户未被授权执行该操作,因为他们尚未登录
  2. Forbid(禁止,阻止)—— 此响应表示用户已登录但不符合执行操作的要求。比如他们没有执行这项操作需要的 claim

注意:在 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。然后由客户端来决定该怎么做。

什么是基于声明(claims)的策略(policies)授权

认证结束后,我们开始考虑授权。我们现在可以想到两种授权方式,一种是判断用户的 Principal 有没有某个 Claim,另一种是判断用户的 Principal 中的某个 Claim 的值是不是某个特定的值。

在 ASP.NET Core 中,定义用户是否获得授权的规则被封装在 policy 中。policy 定义了请求获得授权必须满足的要求。

而 policy 在 ASP.NET Core 中可以直接被加到一个 Controller 上或者一个 Action 上,还是用 [Authorize]这个 Attribute。差不多就像下面这样:

[Authorize("CanHelloWorld")]
public class HelloWorldController : ControllerBase
{...}

如何添加 policy

上面那个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,下面我们简单讲一下。

更复杂健壮的 policy

我们可以使用RequireAssertion方法定义更健壮的 policy。

结合时事和参考书里的例子,我们现在需要定义一个规则为用户年满 18 周岁的 policy。我们就可以传一个委托进RequireAssertion方法,该委托判断今天的日期减去用户的 claim——DateOfBirth的 value 大于等于 18,就 return true,否则 return false, 即可实现这个稍微复杂的 policy 的规则。

写到这里我发现,就看看这些理论,基本没什么用,还得实战一波。等我有空实战一波再搞一篇博客。

Requirements 和 Handlers

我们再引入两个概念,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:

  1. 创建AllowedInLoungeRequirement,注意要实现IAuthorizationRequirement接口。

  2. 创建MinimumAgeRequirement,这个 requirement 稍微特殊一点,它还带有参数:

  3. 注册包含这两个 requirement 的 policy:

  4. 创建 handler 来满足 requirement,可以看到需要继承AuthorizationHandler<对应的 requirement>,覆写它的HandleRequirementAsync方法:

上面两个 handler 中调用context.User.HasClaim()来取 claim 时是稍有不同的,注意区别。

注意:可以编写可用于多个 requirement 的通用的 handler,但最好每个 handler 只处理单个 requirement。如果需要提取一些通用功能,应该从两个 requirement 调用它。

  1. 上面都是判断 claims 有没有满足需要的条件,如果满足了就context.Succeed(requirement);,但在开发过程中可能会有判断 claims 满足了某个条件反而不能通过授权的情况,那样就要用到context.Fail();

  1. 这些 handler 也是需要注册的:

    注意:上面这几个 handler 没有什么构造函数依赖项,所以注册的生命周期全是 Singleton,如果我们的 handler 有什么生命周期为 scoped 或者 transient 的构造函数依赖项,比如依赖了 EFCore 的 DbContext,可能要考虑用 scoped 生命周期来注册 handler

5. 总结

懒得总结了,就这样吧。

posted @ 2021-09-05 23:21  Kit_L  阅读(968)  评论(1编辑  收藏  举报