从零搭建一个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可参考: http://www.javashuo.com/article/p-zdezhxqr-ed.html
  • 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仍然有效)后,来判断该用户是否已经后端登出,若是已经登出则主动拒绝访问。
 
PS.  这篇文章写的时间跨度有点大,文章内容相对较多,而且有大量的文件和代码修改,但文中代码均已图片形式展示,本系列文章完结后会上传相关代码文件,若有问题可随时联系做者。
 
参考:
 
相关文章
相关标签/搜索