从零搭建一个IdentityServer——会话管理与登出

  在上一篇文章中我们介绍了单页应用是如何使用IdentityServer完成身份验证的,并且在讲到静默登录以及会话监听的时候都提到会话(Session)这一概念,会话指的是用户与系统之间交互过程,反过来说就是用户与系统之间交互的状态就保存在会话(Session)中,对于HTTP协议来说,由于它本身是无状态的,所以为了能够记录用户访问系统的状态,一般使用Cookie来存放会话信息。但是现在我们需要保存的是与IdentityServer之间的会话,对于单页应用来说它一般会存在跨域问题,那IdentityServer是如何处理跨域来完成会话管理的呢?同时IdentityServer4又提供了哪些与登录登出相关的特性?本文就从会话管理开始来一一介绍。
  本文内容有:

会话管理

  首先会话本身有两个主体,即服务器和客户端,服务端就是identityServer本身,它是一个asp.net core应用程序,那么实际上它的会话机制就和普通的asp.net core应用程序是一致的,通过cookie来保存相应会话的id或信息。
  下图为登录IdentityServer后浏览器端存储的会话信息和身份信息:
  而对于客户端来说,我们知道IdentityServer4实际上是OpenIDConnect(OIDC)协议的一个实现,而OIDC协议本身是没有会话管理这一特性的,它的出现实际上是在一个补充协议中:https://openid.net/specs/openid-connect-session-1_0.html,该协议约定了客户端如何对服务端的会话信息进行管理,而协议的主要内容是以下几个点:
  • 协议定义:如何持续监控终端用户在OpenID Provider(OP,Identity Server)上提供的会话信息,以便于终端用户登出OpenID Provider(OP,IdentityServer)时能够同时登出客户端(Relying Party)。
  关于OP(IdentityServer)和RP(client)见下图:
  
  简单来说就是上一篇文章演示的“会话监控”内容,当用户直接从IdentityServer直接登出时,客户端本身能够感知到并作出相应动作(客户端登出)。
  • iframe:一个HTML的标签,它代表一个内嵌的HTML文档,如果在HTML使用iframe那就是文档中包含另一个文档,iframe可以通过src属性来设置包含文档的url地址。当iframe设置的url与主文档的url不同域时,可以使用iframe的postmessage方法实现跨域通信。
  关于iframe及postmessage可参考:https://blog.csdn.net/tang_yi_/article/details/79401280
  • RP iframe:位于客户端(Relying Party, RP)中的一个iframe,这个iframe的作用是用于向OP iframe发送及接收信息,发送的信息是用于告知OP iframe进行会话检查,接收的信息是OP iframe完成会话检查后的结果。
  下图是oidc-client.js中用于创建RP iframe的代码:
  
  下图为使用RP iframe向OP iframe发送信息的代码:
  
  下图为接收到OP iframe会话验证结果消息后的处理代码:
  
  • OP iframe:一个由OpenID Provider(OP,IdentityServer)提供的,位于客户端(Relying Party, RP)中的一个iframe,它的作用是与IdentityServer同域,保存于IdentityServer的会话信息,并提供检查接口(基于postmessage)的iframe。
  当用户身份验证成功后,oidc-client会根据配置信息来访问获取OP iframe:
  
  OP iframe请求:
  
  下图为OP iframe中监听RP iframe会话检查消息,完成检查并返回消息结果的代码:
  
  会话检查是对用户数据中包含的会话状态(session_state)信息进行核对,会话状态(session_state)信息分为两个部分,它们用“.”分隔,前部分是客户端id、客户端域名、会话id加盐计算出来的哈希值,后部分是哈希计算使用的盐(salt)。
  
  下图为会话检查的具体逻辑,获取当前的会话id并进行哈希计算后与用户信息中的哈希值进行核对,如果不一致那么认为会话发生变化。
  
  发生变化后oidc-client会自动发起授权请求来确认新会话的信息,这个也就是上一篇文章登出后发起的请求返回需要登录的原因:
  从以上内容看来oidc协议的会话管理主要是通过iframe完成的。
  下图为单页应用完成登录后发起静默登录时候的页面信息:
  
  图中存在两个iframe,第一个是OP iframe包含了会话检查相关内容,第二个是发起静默登录时,创建的一个指向授权终结点的iframe,通过跨域完成登录,需要注意的是由于RP iframe是通过js代码创建的,所以无法在页面代码中找到。
  到此为止我们了解到的仅仅是会话管理在单页应用中实现的登录与登出功能,通过会话管理它可以将浏览器与客户端(RP)及授权服务器(OP)之间的关系联系起来,简单来说就是当浏览器与授权服务器(OP)会话中断时客户端(RP)程序能够知道(会话信息改变),同时如果浏览器与客户端(RP)会话中断时授权服务器(OP)也能知道(先清除客户端身份信息,然后跳转到授权服务器登出界面)。
其次还有一个特点就是由于OIDC的会话管理协议是使用iframe来完成跨域会话检查,虽然默认检查频率是2秒一次,但是它不需要向授权服务器发送任何请求即可完成检查,所以可以节省大量的网络资源和服务器资源。
  但最后看来这个会话管理协议只适用于单页应用来完成相关功能,但是对于web应用来说,使用单页方式实现的仅仅是一部分,其它方式是如何处理客户端(RP)与授权服务器(OP)之间的登录联系的呢?

前端登出

  OIDC前端登出协议(OpenID Connect Front-Channel Logout),这个协议提供了一种登出的机制,该机制是通过浏览器的前端技术来与被登出的客户端(RP)/服务器(OP)建立通信,不再需要iframe就可以实现相关登出功能,具体协议内容参见:https://openid.net/specs/openid-connect-frontchannel-1_0.html
  接下来我们就通过asp.net core应用程序来演示一下这个协议是如何完成前端登出的。

授权服务器(OP)登出联动客户端(RP)

  1. API项目中添加一个登出页面
  API项目实际上就是我们的客户端(RP),当前的例子就是通过在该应用上添加一个登出页面来完成授权服务器登出后通知客户端登出的功能。
  注:asp.net core api项目实际上是不包含页面的,此处仅为了方便通过api项目中添加Razor页面来完成演示。
  首先添加一个Razor页面的布局:
  完成后获得相关的目录结构和必要文件:
  
  添加一个登出页面:
  
  后端代码,代码非常简单,就是通过get方法访问该页面时就直接进行登出操作:
  
  最后在Startup文件中添加Razor Page的服务和路由:
  

   

  然后运行程序即可访问到代码了:
  
  2. 授权服务器中创建一个前端登出页面,同时对Identity登出页面改造:
  在本系列文章前面我们通过IdentityServer4集成asp.net core identity实现了用户的登录登出功能,并且在使用中也暂时没发现任何问题,可以满足基础的授权服务器的登录和登出,但是如果要实现登出联动,那么就需要进行一些改造。
  主要改造有下面几个步骤:
  1)添加一个前端登出页面:
  
  2)对前端登出的Razor Page的后端Model中添加三个字段,并且用特性标明它们从Query中获取:
  
  3)在前端登出的Razor Page的前端代码中添加以下代码:
  
  4)修改Identity登出页面的后端Post请求处理方法:
  
  3. 修改客户端数据,添加uri(客户端新增的登出地址):
  
  4. 验证登出联动:
  首先通过IdentityServer完成身份验证,并可访问受保护资源:
  
  然后开启新的选项卡访问IdentityServer的登出页面,此时因为客户端程序是通过客户端完成了授权服务器的身份验证,在浏览器会话信息保存期间,它默认是登录状态:
  最后我们点击登出链接,程序将携带相关参数跳转到我们添加的前端登出页面:
  现在我们再去刷新受保护资源时得到以下结果,它跳转到授权服务器的登录页面了,这意味着我们在授权服务器(OP)登出的时候,客户端(RP)同时也完成了登出:

原理简析

  它们是如何完成联动登出的呢?我们首先来分析一下相关主体有哪些:
  • 客户端(RP)登出页面:访问该页面即可完成客户端(RP)方面的登出,这个页面用于授权服务器登出联动时访问。
  • 授权服务器(OP)登出页面:一个基于Asp.net core Identity的登出页面,用于asp.net core应用程序(这里特指授权服务器)的登出。
  • 授权服务器(OP)前端登出页面:一个用于完成OIDC前端登出协议的登出页面,负责客户端登出页面的调用及客户端应用程序跳转(该页面功能有点类似于,我们在购买火车票付款时,首先跳转到支付页面,完成支付后通知系统已支付,并且又跳转回订单页面的过程)。
   其次在整个过程中我们还使用了两个比较重要的组件:
  • IdentityServer4的交互服务(Interaction Service):这个实际上就是identityServer4提供的一组接口,这些接口约定了用户与IdentityServer4的交互方法,该接口可以通过依赖注入的方式进行使用。在本例中使用Interaction Service的目的是获取当前登录用户的登出上下文,以便完成后续登出工作(相关信息存储于Cookie中,类似基于Cookie身份验证的身份信息载体)。关于接口内容详见文档:https://identityserver4.readthedocs.io/en/latest/reference/interactionservice.html
  • 结束会话终结点(End Session Endpoint):就是字面意思,结束会话使用的终结点,在这里的作用是通过结束会话终结点来终结会话并跳转到客户端(RP)的登出页面完成客户端(RP)登出。
  它的整个登出流程如下图所示:
  
  简单来说就是当用户访问授权服务器登出页面并进行登出操作后,它进行授权服务应用登出后,跳转到前端登录页面,通过登出上下文信息渲染了一个iframe元素,通过iframe完成结束会话终结点的访问和客户端登出页面的访问,最终呈现给用户的就是前端登出页面。
  下图为登出操作后的网络请求详情:
  整个程序由登出页面携带参数重定向到请求1(前端登录页面),然后通过前端登录页面的iframe发起请求2(结束会话终结点请求),最后再由结束会话终结点请求中的iframe完成客户端登出请求3。
下图为前端登录页面在执行完成以上内容后的结果,从结果中我们可以看到两个iframe分别对应了结束会话终结点请求和客户端登出页面请求:
  
  总的来说就是三个要点:
  1. 清除授权服务器的身份信息。
  2. 结束IdentityServer4的会话状态。
  3. 清除客户端的身份信息。

客户端(RP)登出联动授权服务器(OP)

  以上面所提到的三个要点来看如何实现客户端(RP)与授权服务器(OP)的登出联动。
  首先我们在客户端添(RP)加一个登出页面:
  
  在页面后台代码中添加以下内容(主要是获取id token然后拼接授权服务器的结束会话终结点地址,另外就是退出登录):
  
  以下是页面前端代码,主要是通过iframe去访问结束会话终结点(注:使用iframe的目的是因为访问授权服务器时能够携带相关Cookie,以便进行身份验证及登出操作):
  
  最后修改一下授权服务器(OP)的登出页面后台代码,当接收到携带logoutId的Get请求时,对用户进行登出操作(注:最后一句对User赋值的代码,是因为虽然应用程序执行了登出,但是User.Identity.IsAuthenticated仍然为true,这里有找到一些资料可以进行参考:https://stackoverflow.com/questions/10663873/user-identity-isauthenticated-true-after-logout-asp-net-mvc
  
  接下来就开始验证我们的联动登出,首先确保受保护资源可访问:
  
  然后访问客户端的登出页面(https://localhost:51001/logoutwithop):
  访问登出页面时,会触发授权服务器的登出页面代码,从代码中我们可以看到相应的logoutId以及通过IdentityServer4交互服务获得的登出上下文:
  
  通过断点后,我们可以看到整个请求过程(请忽略相关404链接,是因为没有添加静态文件处理中间件导致的文件无法获取):
  iframe里面的内容,可以看到授权服务器已经成功登出:
  
  刷新受保护资源会跳转到授权服务器进行身份验证,这证明了客户端本身已经完成登出:
  
  以上内容就是客户端(RP)联动授权服务器(OP)的登出功能,总的来说还是三个要点:
  1. 清除客户端的身份信息。
  2. 结束IdentityServer4的会话状态。
  3. 清除授权服务器的身份信息。
  注:IdentityServer4中实际有两个会话结束终结点,分别是EndSessionCallbackEndPoint和EndSessionEndPoint,前者用于OP联动RP的登出,主要功能是渲染一个FrontChannelLogoutUrl的iframe来访问客户端的前端登出页面,后者是用于RP联动OP时发起的结束会话请求,这个请求identityServer会保存一个登出信息,这个操作是EndSessionCallbackEndPoint不具备的,换句话说如果在OP联动RP的场景下,客户端(RP)的登出页面(本例仅调用的HttpContext的Signout方法登出)还应该调用EndSessionEndPoint来给授权服务器保存登出信息。本文为了简化内容复杂性把两个终结点都称为了结束会话终结点。

后端登出

  前面提到的无论是会话管理还是前端登出,它都有一个共同点就是基于浏览器,因为浏览器可以通过Cookie或者H5的存储功能来保存会话/状态信息,登出实际上就是把相应的信息删除,这种情况下不管是客户端(RP)还是授权服务器(OP)它们本身都只是去验证身份信息的有效性,如果身份信息存在且有效那么身份验证通过,但是实际应用中可能会出现这么一种情况,假设身份信息过期时间足够长,那么只要用户不主动登出,那么身份信息将永久保存、永久有效,服务端没有“任何”一种方法能够主动让其失效,这是存在问题的,针对这种问题OIDC提出了后端登出这一概念。
  后端登出是什么呢?它实际上是一种授权服务器(OP)与客户端(RP)之间直接通信的登出机制,简单说来就是当通过授权服务器(OP)登出时可以直接通知到客户端(RP),不需要浏览器的支持,说个具体场景就类似于微信可以同时在PC以及移动设备上登录,但是移动设备上可以直接控制PC登出,或者是当用户修改密码后,密码修改前所有的会话都应被终止。
  后端登出虽然不再基于浏览器的会话信息,但是它毕竟需要明确知道相关登出的会话信息,所以它本身比前端登出要复杂,需要授权服务器(OP)以及客户端(RP)都支持会话管理。对于授权服务器来说可以通过访问https://localhost:5001/.well-known/openid-configuration来确定是否支持后端登出:
  
  而客户端(RP)本身就得自己实现了,在实现客户端的会话管理之前,还有一个概念需要了解一下,那就是登出令牌(Logout Token),它包含两个比较重要的信息,其一是用户id(sub),其二是会话id(sid)具体参考文档:https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken
拥有这两个信息,或者只有对这两个信息进行管理,那么在登出时我们才能知道到底是哪一个用户的哪一次会话被结束了,那么LogoutToken是怎么来的呢?
  首先我们在客户端(RP)添加一个用于接收后端请求的控制器(注:需要Post方法):
  
  然后将这个控制器的地址配置到IdentityServer的Client数据库中:
  
  运行程序并执行上面介绍过的前端登出(OP联动RP登出流程),就会触发后端登出,在相应代码设置的断点会被触发:
  
  在这个请求中我们发现Form表单中包含了logout_token:
  
  根据格式看来logout_token是一个jwt,以jwt方式解析该token获得结果如下:
  其中包含了用户id(sub)及此次会话id(sid),在此实验基础上,我们来实现一个简单的客户端会话管理。
  添加一个登出会话管理类型,该类型维护一个登出会话列表,它的功能是当接收到后端登出请求时将相应登出信息存储到列表中,用户在身份验证后来判断用户及当前会话是否存在于列表,如果存在列表中,那么证明该用户的当前会话已经被后端登出,应该被禁止:
  
  修改后端登出控制器代码(此代码仅用于测试,并未对任何异常情况进行处理,另外也未对token进行完整性验证等,如果需要了解token验证相关内容,可参考:https://github.com/IdentityServer/IdentityServer4/tree/main/samples/Clients/src/MvcHybridBackChannel):
  
  添加一个Cookie身份验证事件处理器,当用户通过身份验证时去判断sub及sid是否已经被登出:
  
  应用该事件处理器,先添加到容器,然后配置到Cookie身份验证中:
  
  为了保证能够验证后端登出有效性,我们把前端登出代码注释后,运行程序(还是按照前端登出OP联动RP流程,但前端登出代码已经被注释而失效了,所以如果登出成功,那就是后端登出的效果):
  
  当程序完成前端登出跳转后,会自动触发并进入登出流程:
  
  相应的用户及会话已经被登出,所以需要拒绝并登出用户:
  
  再次刷新受保护资源,程序将跳转到授权服务器登录页面,换句话说就是后端登出成功。
  
  以上就是后端登出内容(OP联动RP进行后端登出),为什么没有RP联动OP的后端登出?因为在非浏览器环境下客户端一般不会保存与授权服务器的身份验证信息(哪怕保存了,那么自己删除即可),所以自然就不存在RP登出需要联动OP的场景。
  另外要注意的是后端登出原本是在非浏览器环境下使用的,但上面的例子仍然是通过基于浏览器的前端登出来完成的,其目的仅仅是为了方便演示,其次后端登出请求是由结束会话回调终结点(EndSessionCallback EndPoint)发起的(只要客户端信息存在BackChannelLogoutUri信息就会自动发起),那么如果想主动发起该请求我们需要借助IBackChannelLogoutService来完成,该服务的SendLogoutNotificationsAsync方法可以通过用户id、会话id以及客户端id来发起相应客户端的后端登出请求:
  
  关于如何获取会话信息来通过该服务发起登出会在后续文章中介绍。

小结

  本文主要介绍了IdentityServer4的会话管理以及前后端登出功能。其中会话管理和前端登出都是基于浏览器,通过浏览器本身的Cookie及存储功能来保存相关身份、会话数据,同时借助Iframe来实现跨域请求、跨域会话检查等等功能。
  对于前端登出来说它主要有授权服务器(OP)与客户端(RP)互相联动两种场景,无论用户从哪一方进行登出操作都能够将两方的身份信息删除。
  对于后端登出来说它要求授权服务器(OP)与客户端(RP)双方都具备后端登出功能,IdentityServer4本身支持,而客户端就需要自己实现了,本文中实现了一个简单的登出会话管理功能,即当用户触发后端登出后,客户端会记录登出信息,当用户再次发起请求时,在身份验证(验证Cookie,此时Cookie仍然有效)后,来判断该用户是否已经后端登出,如果已经登出则主动拒绝访问。
 

关于会话Id(补充)

  关于IdentityServer的会话管理,在文章前面我们就说过只要登录到授权服务器之后就会有一个名为“idsrv.session”的cookie,它代表用户与授权服务器此次会话的id,这里有两个问题,第一就是为什么它的名称是“idsrv.session”,是因为IdentityServer4中定义了一个默认的常量,如下图所示:
  
  如果想要修改可以在IdentityServer的服务配置中,通过Authentication.CheckSessionCookieName来修改。
  
  第二个问题,这个cookie是如何出现的呢?为什么登录了就有?登出了就被删除?是因为IdentityServer4实现了或者说包装了asp.net core自有的身份验证服务,实现了自己登录、登出逻辑,举个登录例子,它先创建了会话Id然后又调用原有的登录逻辑:
  
  创建的会话Id是通过IdentityModel里面的CryptoRandom类型生成的一个16位的唯一id,然后将这个id写到cookie中:
  
  那么这个会话id(sid)出现在什么地方?又有什么作用呢?从以前的文章中,我们可以看到Id_token、Access_token以及基于oidc身份验证的用户信息、js单页应用的客户端都能看见会话id:
  Id_token:
 
  Access_token:
  
  asp.net core 应用程序用户信息:
  
  单页应用中的用户数据:
  虽然会话id存在的地方很多,但是它实际都是由首次登录的时候生成的,它们的使用过程如下:
  登录(生成会话id)→颁发ID Token/Access Token(包含会话id)→解析/验证Token(获得会话id)。解析验证Token主要是对Id_Token进行验证解析,从id_token中获取相关信息,这也是为什么asp.net core应用及单页应用中的用户数据都包含会话id的原因。
  会话Id的作用也就是字面的意思,标记了相关内容是某次会话产生的。
  目前发现会话Id的应用有以下几方面:
  1. 单页应用的会话检查:单页应用的会话检查主要是对session_state(由授权请求生成的包含sid加盐哈希值和盐的字符串)进行验证,如果会话id发生变化(在同一会话下多次进行授权请求会话id不会变,但是盐会发生变化,所以导致session_state也不一致,但由于会话检查时是获取实时的盐,所以盐的变化不会触发会话变化事件),那么就会触发会话变化事件。
  2. 后端登出时也使用了会话id进行标记,以确保授权服务器知道要登出哪一次会话,而客户端被知道哪一个会话已经被登出。
  除此之外我们还可以在数据库的persistedgrants(授权持久化)表中看到会话id的身影,标明了某一次Token的颁发情况:
  更多关于会话id的用法在后续内容中会持续关注。
 
 
PS.  这篇文章写的时间跨度有点大,文章内容相对较多,并且有大量的文件和代码修改,但文中代码均已图片形式展现,本系列文章完结后会上传相关代码文件,如有问题可随时联系作者。
 
参考:
 
posted @ 2021-04-08 11:35  7m鱼  阅读(426)  评论(2编辑  收藏