从零搭建一个IdentityServer——资源与访问控制

  IdentityServer作为授权服务器它的最终目的是用于对资源进行管控,这里所说的资源有两种,其一是API资源,实际上也就是OIDC协议中客户端(RP)所需要访问的一系列受保护的资源(API),授权服务器通过对终端用户完成身份验证后发放相应Token,然后可以使用Token来完成受保护资源的访问。
  另外就是对用户资源进行管控,简单来说就是授权服务器存储了用户相关信息,客户端应用无需也无权来管理,如有需要可以通过授权服务器获取,这样的好处就是将用户信息统一管理,可以保证用户数据一致性、安全性也可以减少客户端程序的开发量。
  随着软件或者信息化的不断发展,现在一个常见的软件使用场景就是,很多软件都可以支持第三方账号登录,登陆时首先会有一个授权登录XXX应用的提示,当用户同意且登录成功后软件可以获取到第三方账号的相关信息,如头像、昵称等,甚至还可以申请并获取账号的手机号码等隐私信息,最常见的例子就是微信公众号/小程序。
  本文的主题就是如何通过IdentityServer4来对资源进行管控,最后实现访问第三方应用程序(客户端,RP)时授权提示及用户信息申请的过程。
  本文内容有:

Resource定义

  借用IdentityServer4官方文档的一句话“OpenID Connect或OAuth Token服务的最终目的就是控制资源的访问”,而这里的资源类别有两种,其一就是API资源,可以把它看成一系列受保护的可远程调用的内容,甚至可以直接狭义的理解为基于Http协议的Web API。另外就是用户信息资源,如用户昵称、头像、手机号码等等。
  在IdentityServer4中,使用IdentityResource来定义一个用户资源,一个用户资源除了有名称、展示名称等属性外还包含一系列的属性,将这一系列的用户属性统称为ClaimType,举个例子官网文档自定义profile资源的例子(注:默认的profile资源包含了name, family_name, given_name, middle_name, nickname等ClaimType信息,具体参考文档:https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims):
  
  从图中可以看到这个自定义资源设置了名称、展示名称及一个ClaimTypes列表,简单来说就是这个用户资源包含了用户名、邮箱和状态,当客户端(RP)拥有这个资源的访问权限后,它就可以通过授权服务器获得用户的相关信息。更多IdentityResource定义参考文档:https://identityserver4.readthedocs.io/en/release/reference/identity_resource.html
  在IdentityServer4中,使用ApiResource来定义一个API资源,它的基础结构与用户资源类似,也是包含名称、展示名称,只是不同的是它拥有一个scope列表,一个scope可以按照字面意思理解,就是这个资源的范围,这个范围由人来定义,可大可小,并且scope可以独立于资源单独存在,一个应用程序可以只有一个scope,换句话说就是当用户拥有这个scope的权限,那么就可以访问这个应用程序的所有内容,也可以细粒度的一个Api就对应一个Api资源,一个Api资源中包含多个scope,如将这个api的每一个子功能或权限都定义为一个scope。
  下图为一个ApiResource定义的基本结构,它是针对Api级别定义的,这个资源下面有两个scope分别对应这个api的完全访问和只读访问两个权限:
  

Client定义

  Client就是代表之前文章中提到的客户端(RP)应用程序,那么定义Client实际上就是应用程序的一些特性及应用程序的功能。
  下图为一个Client的定义信息,它包含了Client的Id、名称、授权方式等,但本文主要关注资源控制,所以主要关注的是Client的AllowedScopes属性,它包含了所允许访问的用户资源和Api资源信息,下图Client的Scope定义中我们可以看出,该应用程序可以访问用户的id(OpenId)、用户基本信息(Profile)及邮箱,同时定义了该应用程序有api1、api2.read_only两个api资源:
  

Identity Resource与Asp.net Core Identity

  前面了解了Identity Resource包含了用户的基本信息,而在我们常用的asp.net core应用程序中,用户信息都通过Asp.net core Identity进行管理,包括本系列文章也是通过Identity来完成用户信息管理的,但是一般情况下Asp.net core Identity通过UserManager等类型来完成用户信息管理(主要是指获取),而现在情况比较特殊IdentityServer4的UserInfo EndPoint是用来获取用户信息的,关键问题是用户信息存储仍然通过Asp.net core Identity实现,从而引出一个它们之间如何互相关联工作的问题。
  关于Identity Resource与Identity组件的关联主要有以下两方面内容:
  • Profile Service
  • ClaimTypes
  • IdentityServer4与Asp.net Core Identity的集成

Profile Service

  Profile Service是IdentityServer4中用于提供用户信息的服务,在IdentityServer4核心类库中它定义了一个IProfileService的接口,这个接口定义了两个无返回值的方法,分别用于获取用户信息和判断账户是否可用,接口定义如下图所示。
  
  这里要注意的是因为没有返回值,所以实际上两个方法所需返回的数据都是通过填充传入参数来实现数据传递,其中用户数据请求上下文(ProfileDataRequestContext)通过其它相关参数,如用户id(Subject Id)、请求的claimTypes(RequestedClaimTypes,这个参数的意义在于这个服务不是每次都将用户的所有信息都进行返回,而是只返回需要的,如通过UserInfo EndPoint来获取用户信息时,这个参数就会携带email、profile等claimTypes,而生成Access Token时还会携带如api1、api2.read_only之类的api scope),来获取用户信息,最终将用户信息填充到IssuedClaims这个列表中:
  
  简单来说IdentityServer4用户信息获取就依赖于这个接口,想要获取特定存储的用户信息,那么根据情况实现该接口即可,那么我们可以猜测IdentityServer4与Asp.net core Identity的集成实际上是实现了一个基于Asp.net core Identity的ProfileService。

ClaimType

  了解了数据的获取问题之后,还有一个问题就是数据之间的映射,假设现在有两个系统,系统A和系统B,系统A中存在一个名为身份证号码的数据,系统B中存在一个Id Card No.的数据,人们可以很容易知道两个数据虽然名称不一样,但是内容是一样的,但是计算机不行,我们需要在它们之间建立一个映射关系,建立映射关系之前首先得了解它们对数据的命名规则。
  无论是asp.net core identity还是OIDC的用户数据,实际上都是用Claim来表示用户信息的,这是它们之间的一个共同点,即数据结构一致,简单来说只要名称能对上那么就能互相交换数据了,这里需要引出两个Claim的定义,其一是.Net的ClaimTypes,它位于System.Security.Claims命名空间下,定义了一个用户常用的claim type,具体信息如下图所示:
  
  另外一个是Jwt的ClaimTypes,它的定义可以参考文档:https://www.iana.org/assignments/jwt/jwt.xhtml,在.Net中可以使用IdentityModel类库来直接使用相关定义,具体内容如下图所示:
  
  在上面两张图片中分别用红框标明了ClaimTypes的NameIdentifier、Name和JwtClaimTypes的Subject、Name,两个值分别对应了用户的Id和用户名,可以看出它们的claim名称(及相同名称的值)并不一致。
  如果想要实现数据互通,那么只需要将相同意义的Claim进行对应即可。
  我们知道OIDC或者说Oauth2.0中涉及的Token基本使用jwt来作为规范,但是从上面System.Security.Claims命名空间下对ClaimType的定义中可以看到它和jwt的Claim定义有很大的区别,那么.Net体系中有没有针对jwt的实现呢?(注意这里指的是.Net体系中而非基于.Net或者C#代码的实现)答案是肯定的,因为在.Net体系(甚至可以说微软体系)中也提供OIDC服务,它同时兼顾了jwt规范以及System.Security.Claims命名空间下对ClaimTypes定义。
  下图为System.IdentityModel.Tokens.Jwt中定义的Jwt中的Claim名称:
  
  同时该程序集中定义了JwtRegisteredClaimNames与ClaimTypes的映射关系,从图中可以看出Jwt中的sub和nameid都将与ClaimTypes的NameIdentifier对应:
  
  注:System.IdentiyModel.Token.Jwt是AzureAD(微软的身份验证云服务)对.net core的一个拓展类库,具体参考: https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/,而IdentityModel这个类库是一个.net 基金会的开源项目,具体参考:https://github.com/IdentityModel/IdentityModel。
  另外需要注意的是在我们后续的内容中或者说identityServer4与Asp.net core Identity的集成中会用到以上两个类库,即会存在三个Claim名称的相互映射关系。

IdentityServer4与Asp.net Core Identity的集成

  经过前面内容的介绍,如果要实现IdentityServer4与Asp.net Core Identity的集成那么只需要实现基于Asp.net core Identity的Profile Service同时完成相关Claim名称映射即可。
  关于前者在IdentityServer4.AspnetIdentity中提供了相应的实现,它依赖Identity的UserManager和一个ClaimsFactory,具体如下图所示:
  
  其中该类型通过UserManager来获取用户信息:
  
  而ClamsFactory它更是于UserManager息息相关,它通过UserManager来获取用户、Email、电话号码等相关信息:
  更多细节可直接查看相关源码:
  最后就是Claim的映射问题,在介绍它们的Claim映射之前,我们先通过一个图来介绍一些相关关系:
  上图中包含两个主体:基于is4的授权应用和基于OIDC的客户端应用(红色框),分别用于发布Token和验证Token并获取用户信息,它们都是Asp.net Core应用程序,分别通过依赖IdentityServer4和Microsoft.AspNetCore.Authentication.OpenIdConnect来实现相应功能。
  三个Claim定义(文章前面提到过):IdentityServer4的Token和用户信息都是基于JwtClaimTypes来生成的,实际上应该说IdentityServer4实现了Jwt、Oauth2.0、OIDC协议。
  而Asp.net core应用程序默认使用System.Security.Claims.ClaimTypes。它的定义没有jwt那么简洁,比如Jwt中的sub一般代表用户的Id,而ClaimTypes中使用NameIdentifier表示(一串很长的uri)。
  JwtRegisteredClaimNames是微软身份云服务的一个实现,它与JwtClaimTypes存在一些差异,同时它为了能够与Asp.net Core应用集成,自己包含了一个与ClaimTypes的映射关系。
  最后还有两个最重要的产物ID Token、UserInfoEndpoint返回的用户信息以及.Net Core应用中的User信息,这也是IdentityServer4与Asp.net Core Identity的集成的关键,换句话说只要将ID Token及UserInfo“翻译”为.Net Core应用的User实例就认为它们集成成功了(用户信息的获取或者说ID Token及UserInfo生成时用户数据的来源不一定是asp.net identity,所以它不是集成的关键)。
  下面来做一个简单的实验,首先通过授权码流程对应用程序进行身份验证,并获得相应ID Token以及UserInfo(详见:https://www.cnblogs.com/selimsong/p/14355150.html#oidc_code_flow,另外需要注意的是本实验将客户端程序oidc身份验证的GetClaimsFromUserInfoEndpoint配置设为true,这样才能拿到用户的name信息):
User信息如下图所示:
  
  从图中可以看到Claims列表中包含了用户名信息(name),但是User中的Name属性却为null,实际上从图中就能看出原因,是因为Claims列表中的用户名属性Claim名称为“name”,而User所需要的是“System.Security.Claims.ClaimTypes.Name”,所以无法正确匹配。这里需要注意的就是ID Token中包含的sub信息却能正确的被“System.Security.Claims.ClaimTypes.NameIdentifier”匹配。
  ID Token中的sub信息:
  
  首先需要明确的一点是IdentityServer4生成ID Token或者UserInforEndPoint获取的用户信息均基于jwt规范(https://www.iana.org/assignments/jwt/jwt.xhtml),而.Net Core中oidc身份验证组件是基于System.IdentityModel.Tokens.Jwt.ClaimTypeMapping来进行匹配的,从下图中可以看到sub匹配了NameIdentifier,所以用户Id能够被转换,但是该映射类型中没有定义用户名(name)的映射信息,所以导致用户名无法被正确匹配:
  
  为了能够正确映射,我们只需要再客户端程序将oidc Token验证选项中NameClaimType属性变更为JwtClaimTypes.Name(name)即可:
  
  再次获取的用户信息,数据已经成功匹配上了:
  

Asp.net core基于Scope的访问授权

  上面内容通过Identity Resources用户身份信息来引出了Claim的概念,通过Claim来对用户信息属性进行映射和管理,对于API Resources来说也是一样的,仍然是通过Claim来对API资源进行声明,下面就来演示一下如何通过Claim定义API Resource以及如何使用这些被定义的Claim保护真实的API资源。
  首先我们假设有一系列用户管理功能API资源,包含了用户信息查看和修改。那么根据API资源的定义,我们将该用户管理功能定义为一个API资源,同时将用户信息查看和修改以Claim的方式体现:
  资源中Scope的定义:
  

   

  然后新建一个API项目,在API项目中定义用户管理的两个API:
  
  然后在Startup类型的ConfigureServices方法中添加基于声明的身份验证策略(参考:https://docs.microsoft.com/en-us/aspnet/core/security/authorization/claims?view=aspnetcore-5.0):
  
  并把身份验证策略添加到API的授权特性上:
  
  最后我们将相应的Scope配置到Client信息上,并且Client在发起授权请求时添加相应的Claim信息:
  
  Client的OIDC身份验证配置添加需要请求的scope,这里需要注意的是代码中仅添加了user_read这个scope,虽然当前client信息包含user_read和user_edit两个scope,但是如果不进行主动请求,那么最终获得的结果中不会包含user_edit声明信息:
  
  最后在client中添加测试代码:
  
  尝试运行通过client来调用被保护的API,获得以下结果:
  
  为什么修改用户信息授权被拒绝呢?对access token进行解析,可以看到token中的scope信息仅包含user_read,没有包含user_edit这是因为在授权请求中没有请求user_edit的原因:
  

IdentityServer4启用Consent

  同意(Consent),是最终用户授予客户端程序访问资源权限的应允。举个简单的例子来说手机号码是最终用户的隐私信息,一般应用程序没有权限直接获取,如果需要获取那么需要征得用户同意,用户同意这个过程就是Consent。
  本文中上面的内容都是由Client本身来获取相关资源访问权限(包括用户资源和API资源),并没有用户的参与,或者说用户的允许,Consent就是引入用户来对Client能够获取的权限进行授权的功能。
  IdentityServer4的Consent是在进行授权请求之前向用户征求允许的权限,下面就基于IdentityServer4实现一个简单的Consent功能,实现Consent功能主要有以下几个步骤:(注:identityServer4模板中有默认的基于MVC的Consent实现,以下内容可以看作一个简版的Razor Page的实现,主要是仅给出了关键代码,并没有处理代码中可能出现的异常,仅作为演示使用)
  1. 修改Client信息让相应Client支持Consent。
  2. 为IdentityServer应用添加Consent页面,页面主要功能是将当前Client支持的资源列出给用户选择并将选择结果传递给后续的授权请求。
  3. 对IdentityServer4进行配置,将Consent连接指向我们添加的页面。
 
  1. 通过修改ClientRequireConsent设为true:
  
  2. 添加Consent页面:
  
  2.1 获取当前授权请求上下文,通过上下文获取当前请求Client所拥有的资源并展示:
  
  这段代码主要目的是在授权请求过程中(由于设置了需要授权Require Consent)跳转到同意(Consent)页面,并展现出当前Client所有可选的Scope(包括IdentityScopes和ApiScopes)供用户进行选择并同意当前Client访问。
  2.2 添加页面用于展示并选择提交用户同意的权限或拒绝授权:
  首先定义一个用于存放用户提交内容的模型:
  
  根据模型编写页面展示/提交代码(APIScopes部分展示代码与IdentityScopes部分类似):
  
  处理提交内容,如果点击no按钮直接拒绝授权,如果点击yes则完成授权,并跳转完成后续授权请求工作:
  
  3.配置IdentityServer4的Consent页面路径:
  
  4. 运行程序进行测试(使用上一章的UserManage功能进行测试):
  首先访问受保护资源UserManage时先跳转到登录页面,完成登录后就可以看到刚刚创建的Consent页面:
  点击同意按钮后得到以下结果,注意修改用户的状态码是200:
  
  如果取消修改用户信息权限:
  那么就会看到修改用户信息被403拒绝的信息:
  
  5. 添加一个电话号码的身份资源,并赋予到相应的Client后:
  首先定义资源(phone资源定义参考:https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims):
  资源下包含“phone_number”Claim:
  
  将phone这个资源作为Client允许的Scope:
  
  为用户数据添加电话号码信息:
  
  运行程序后可以看到Consent页面已经有“电话号码”这个用户信息资源授权:
  但是点击同意后Client中的UserClaims中并没有电话号码相关的信息:
  
  是因为数据没生效吗?我们知道这里的用户信息来自于UserInfoEndpoint,它是通过携带access token来完成用户信息请求的,那么首先我们来看看生成的access token包含哪些信息?
  
  已经看见它有权访问phone这个scope信息了,但是为什么没有相应数据呢?我们通过这个access token尝试访问一次UserEndPoint看看:
  
  能够看到已经有phone_number这个数据了,所以最终的问题出在UserInfoEndpoint数据与Asp.net Core User对象数据映射的时候,仅需要添加以下配置即可将phone_number映射到User中:
  
  重新登录后得到以下结果:
  
  注意,由于asp.net core应用程序有一些默认的claim映射和过滤,会导致与真实返回的Token结果不一致,可以通过下面代码禁用这些映射关系:
  
  禁用这些关系后再次登录,可以看到claim信息与之前有很大的差异,现在的claim基本与jwt协议的claim定义一致了:
  

小结

  本文介绍了IdentityServer或者说OIDC协议中对资源的定义与访问控制,对比了基于jwt的Claim定义与.Net体系中Claim定义的区别,了解到OIDC协议或者IdentityServer4与Asp.net core应用集成时关键在于Claim的映射。
  同时文章最后通过IdentityServer4的Consent功能实现了用户对Client所需权限的授权。Consent功能将默认的授权变为用户主动授权,这样做更利于资源的控制和用户隐私的保护。
 
参考:
 
 

posted @ 2021-07-20 09:15  7m鱼  阅读(1351)  评论(0编辑  收藏  举报