单点登录方案与实现总结

单点登录方案与实现总结

何为登录?

众所周知,http是一个无状态协议。何为无状态?举几个有状态协议的例子,譬如TCP协议,需要三次握手,握手完成后双方及建立了连接,可以互相传输数据,传输完成后会断开连接。在譬如websocket,是一个全双工协议,也需要双方建立连接与断开连接。可曾听说过断开http连接?好像没有这回事吧,只有这次http请求200了,还是500报错了这种说法,因为http是无状态的,这次发的http请求与下一次并没有联系,也就不存在断开的概念。

看这样的场景,我需要访问某个网站的属于我的数据了,我发了一个http请求,这次http带有了用户凭据(用户密码),被认证为有权限访问资源,ok,获取到了,那下次再访问这个资源,难道我还需要再输一遍密码么,那也太麻烦了。我怎么样才能让对方知道,我输过密码认证成功是我本人了,就算做我进入“登录状态”了呢?

于是,有了这样一个方案,认证成功后,服务端(对方网站服务器)与客户端(我的浏览器)都记录下同一个值,这个值一般就叫做”JSESSIONID”。如何记录?服务端认证成功,便生成这个值存于其本地,并将其塞入我的Cookie,返回给我,我每次后续的http请求,Cookie都带上这个”JSESSIONID”,服务端找到本地对应的Session信息校验这个Cookie的有效性(真伪/是否到期),有效则返回我的受保护资源。这样,我和目标网站之间就在多个无状态的http请求之间维护出一个登陆状态,靠的就是这个Session值。

然而,这样的方案是有局限性的,Cookie只在同域名下有效,而对方名下有多个网站,都用的一套的用户名密码,我在切换网站时却要重新输入密码,因为域名不同,Cookie失效了,”JSESSIONID”无法带过去了。怎么办?单点登录得出马了。

何为单点登录?

单点登录(Single Sign On),简称为SSO,即在用户登录一次,即可直接获取多个相互信任的应用(服务)的受保护资源,而无需再次进行登录认证。

单点登录方案

单点登录常见的几种方案:

  1. 同域名
  2. 共享Session
  3. CAS
  4. oauth2
  5. SAML
  6. ……

同域名

前面说的,Session存在于服务端,并将对应的”JSESSIONID”存于用户的Cookie。这就带来了两个问题:

  1. 不同域名,Cookie会失效
  2. 一台应用登录后,另一台并没有对应的Session

第一个问题好办啊,把所有应用都放在同一个域名下不就可以了么,要知道,Cookie是可以在子域名下共享的,譬如,aaa.com下的Cookie可以在app1.aaa.com何app2.aaa.com下共存,这样,如果多个域名都指向同一台服务器,也没有问题二,那么多域名单点登录问题就解决了。

image-20211022143454444

可是,一般后端应用都不止一台,域名与应用也至少是1:1的关系,这样,问题二的存在还是不能实现单点登录。接下来看第二个方案,使用token的方式。

我们判断用户认证信息不再直接通过Session对象,在Main模块,也就是认证模块,会生成一个Token放到Cookie中,其他子应用则负责解析这个Token,如果是用的Spring Security,一般这个工作会交给RememberMeAuthenticationFilter去做,它会根据配置的token key自动解析出对应token值,并生成对应用户的局部Session。

这里的token可以采用JWT,携带一部分用户信息,减轻服务器查询用户信息的压力,可以使用JWS保证JWT的安全性。

image-20211025160630024

单点登出

由于用户登录信息存储在Cookie,所以直接Set-Cookie对应字段为空即可。

共享Session

应用不止一台,那么在某一台登录,生成的Session也同步到其他的应用,可行么?当然可以,Session一般是存在内存当中的,只要把它从内存迁移出来,放到一个统一的地方,所有应用都从这一处来获取Session,与Cookie带过来的JSESSIONID信息对比不久可以了么。

Spring已经造好了轮子,Spring-Session就是一个共享Session的组件,可以把Session存到Redis中去,就可以实现一处生成Session多处使用了。

这样,前面提到的两个问题就都解决了。

image-20211022164616538

但是,如果是不同域名下的应用呢,二级域名也不一样,比如app1.aaa.com和app2.bbb.com,如何实现单点登录?目前来说有下面几种方案。有很多种协议,比如Token,CAS,OpenID,OAuth2,SAML等等。

单点登出

清楚用户对应的Session对象即可。

CAS

CAS是一套企业范围的单点登录方案,即单点登录应用与受信任应用都是属于一个企业的,登录范围也是在该企业下的所有应用。

相关概念

CAS协议下,存在几个概念先明确一下。

User

这个好理解,就是受保护资源的所有者。

CAS Server

负责验证用户并授予用户访问应用程序的权限。一般只存在一个CAS服务器。

CAS Client

一般会存在多个CAS客户端。其负责保护所在的CAS应用,并从CAS服务器检索授权用户的身份信息。

存在于用户与CAS服务器之间的Cookie,用于存放用户认证凭证。

TGT(Ticket Granting Ticket)

一个Cookie值,存于TGC中,用于维持用户与CAS服务器之间的会话,一般叫这个会话为SSO session

ST(Service Ticket)

用户可以访问某个受保护服务的凭据(直译过来的话是服务票据)。这个ST是生成TGT之后,由CAS Server颁发的。

关键逻辑

首先只维持用户与CAS Server之间的登录状态(SSO Session)。

然后,由于子应用都信任于CAS Server,所以CAS Server颁发一个TGT和一个ST给用户,TGT给用户用来维持其与CAS之间的登录状态。

用户拿到了ST,使用ST去访问子应用(app1,app2),子应用去与关联的CAS Server核验该ST是否有效,如果有效,从CAS Server获取用户身份信息,并于用户生成局部Session,设置自己域名的JSESSIONID。

用户Cookie使用子应用的JSESSION访问子应用受保护资源。

整个流程时序图如下:

https://apereo.github.io/cas/4.2.x/images/cas_flow_diagram.png

流程图获取有些复杂,需要耐心地一步步过一遍,有个印象。其实实现起来并非那么麻烦,有现成的CAS框架,比如Yale大学发起的一个单点登录项目,全名为Central Authentication Service,也简称CAS。其中提供了多种单点登录协议的实现,CAS协议就是其中一种。具体可见https://www.apereo.org/projects/cas。

或者,我们可以借鉴一下CAS的逻辑,实现一套自己的CAS登录逻辑。关键点,就是流程图中提到的几个EndPoint:

  1. GET {casServer}/cas/login?service=app1

    从受保护app1重定向到CAS服务器的登录界面。注意,service内容是URL encoded的。

  2. POST {casServer}/cas/login

    用于CAS服务器作为SSO登录的post接口。

  3. GET {casClient}?ticket=ST-123456

    可以在CAS客户端定义一个Filter,去捕捉当前URI是否存在ST,如果存在,则到CAS Server去验证它。

  4. GET {casServer}/serviceValidate?service=app1&ticket=ST-123456

    CAS服务器通过这个endPoint来验证ST是否有效,有效则通过XML-Document方式返回用户信息给CAS客户端。

这个ST是可以存储在LocalStorage中的,可以绕过跨域问题。

这三个实现的差不多的话,基本的CAS逻辑也就完成了。

单点登出

CAS单点登出相较于之前的略微麻烦些,由于用户在每个CAS Client所处应用上都有局部Session以及对应Cookie,所以需要CAS Server调用所有CAS Client提供的登出API,实现全部登出。

OAuth2

关于OAuth2协议,详细的介绍可以参考https://datatracker.ietf.org/doc/html/rfc6749

相关概念

OAuth2下下有以下几个角色:

resource owner(资源所有者)

官方定义为能够授予对受保护资源的访问权限的实体,这个实体不一定是指代人,或许他也是台后端服务器。如果resource owner指代的是人的话,又有个别名,叫做end-user

resource server(资源服务器)

受保护资源所在的服务器端,它能够接收用户传过来的access_token,返回受保护资源。

client(客户端)

client是经过了resource owner授权的,用来访问resource server的应用。其持有的是用户的授权信息。

authorization server(授权服务器)

验证用户凭证成功并且接收用户授权后,authorization server将向client发放access_token。

关键逻辑

OAuth2本身其实不是用来做单点登录的,而是用于将用户权限授予客户端。用它也可以实现单点登录,将多个子应用均作为受信任的resource server,只要用户拥有了access_token,就可以访问所有子应用资源。

image-20211025151012509

OAuth2有几种模式,包括授权码模式(Authorization Code Grant)隐式授权模式(Implicit Grant)资源所有者密码模式(Resource Owner Password Credentials Grant),以及客户端凭证模式(Client Credentials Grant)。这里简单讲下授权码模式,也是最复杂、使用度最高的一种模式。大致流程如下:

image-20211025151215270

简单来说:

  1. client不属于我,不能直接访问我受Authorization Server保护的资源。

  2. 于是,client把它的身份信息(e.g. client-id,client-secret etc.)在authorization server进行了注册。

  3. 并且在我第一次使用此client时,client会向authorization server发送认证请求。

  4. authorization server一般会通过重定向的方式,把我的浏览器重定向到它的登录页。我在这个登录页提交我的身份凭据(用户名、密码)。

  5. authorization server通过了认证,把我的页面重定向到授权页,我可以选择授予client哪些权限(头像、联系方式等)。

  6. 我向authorization server提交了授权请求,授权服务器给我一个授权码,并重定向到client的认证url。

  7. client获取到了授权码,向Authorization Server索要access_token。

  8. client获得并存储access_token,使用它来访问resource server。

整个过程就是resource owner把受保护权限赋予client的过程。

获得了access_token,也就可以用来访问所有的子应用了。这里token并非存储在Cookie,而是一般存储在浏览器Local Storage中,所以并无跨域问题。

resource server一般会读取authorization server库中存储的token,看是否过期来验证token,亦或者是token使用JWS,自带过期时间,且共要加密与签名的存在保证了JWS不可伪造,可以直接判断token是否有效。

以下时序图引用自理解 OAuth 2.0 认证流程

image-20211025202327358

单点登出

相较于CAS,OAuth2的单点登出更为麻烦,因为client一般都是第三方的,client会注册到authorization server,但authorization server并无法去调用client。如果access_token被持久化了倒还好,将用户对应持久层的token置为失效即可,下次验证token即不通过。但如果并没有持久化,比如使用的是JWS,就只能由客户端自己去清除当前client的access_token,也无法访问到其他所有client。所以比较棘手,只能等对应的access_token自己失效。

与CAS对比

CAS下,受保护资源是属于客户端的。用户访问客户端,客户端通过CAS服务器来验证该用户是否有权访问自己的资源。而在OAuth2下,受保护资源是属于服务端的。用户需要让客户端知道,这个客户端是否有权访问用户位于服务端的数据。

这是CAS的大致流程,CAS Client用于保护所在服务器的资源,一般也是位于服务端。

image-20211025160151996

而OAuth2下,Client是独立的,它可以在手机上,浏览器上,PC软件上(经常用到的,用网易账号登录有道云笔记pc端,这里有道云笔记软件就是client,访问的网易云authorization server,获取我在网易云的头像等信息),又或者是另一个独立的后端服务器上,纯纯的客户端。

img

遗憾的是,OAuth2是一个授权协议,client最终拿到的,是用户的权限范围,而非用户本身的详细信息。如果client就需要用户详细信息了,该怎么办?

于是,在OAuth2的基础上,又出现了另一个协议:OIDC。

OIDC(OpenID Connection)

看起来好像很高大上,其实就是在OAuth2的基础上再封装了一层,可以理解为加强版OAuth2,使OAuth2具备了认证能力。其中新增了几个概念:

相关概念

OpenID Provider

负责签发ID token。也就是authorization server。

ID token

一个JWT格式的字符串,存储了用户的一些基本信息.

end-user

不同于OAuth2的resource owner,这里具体到了,资源所有者就是人。

userinfo endpoint

用户信息端点,位于authorization server,用于返回用户更加详细的认证信息。

scope标准化

scope中必须带有openid。

Claim

终端用户信息字段。

单点登出

同OAuth2的问题,token未持久化,且分布在用户代理,难以在authorization server统一清除,需要一些额外的开发量来实现。

总结

大概列举了几种实现SSO的方式,各有千秋,还是要具体情况具体分析,适合业务场景的才是最好的方案。

方案 优点 缺点
同域名 开发成本低,理解难度小,维护成本低。 分布式场景支持性较差,很多应用非同域名。
存放于Cookie,安全性低。
用户如果关闭Cookie则直接无法使用。
共享Session 降低服务器内存开销。分布式支持性较好。 维护成本较高,需要管理缓存集群。
CAS 分布式架构下支持性很好,适合企业内部应用间互相认证。 架构较为复杂,开发成本较高。
OAuth2/OIDC 分布式架构下支持性很好,基于JWS可减轻服务器压力。
适用于需要将企业内部应用资源开发给第三方时使用。
架构较为复杂,开发成本高,单点退出比较难搞。

还有其他的SSO Protocol,例如SAML,还没研究过,所以暂时不总结了。。。以上内容大多是最近学习的内容,包含了不少自己的理解,所以可能会有些错误,实际开发时还需要多翻阅官方手册。