前段时间一直在调研的前后端分离单点登录认证方案终于以 Demo 的形式实现了,期间如其他前后端开发者们一样——踩过不少坑。虽然目前的方案不能说是尽善尽美,也算勉强达到了项目要求,故在此处记录一下相关情况以备日后查阅。
开始之前需要先了解一些相关的知识与概念,包括单点登录,CAS,前后端分离等。
前后端分离已在互联网项目开发业界进行了广泛应用,通过前端应用与后端服务的分布式部署可以有效进行解耦,将数据与展现彻底分离,既保证了数据安全,也给了前端开发充分的自由。
前后端分离最常见的实现方式之一是前端 HTML 页面通过 AJAX 调用后端的 RESTFUL API 接口并使用 JSON 数据进行交互(这种方式也为单点登录方案的实现挖了个大坑)。
单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
CAS Server(CAS服务端)负责完成对用户的认证工作,完成与浏览器端的用户认证和CAS客户端的票据验证。
CAS Client(CAS客户端)负责处理对受保护资源的访问请求,需要对请求方进行身份认证时,重定向到 CAS Server 进行认证。 CAS Client 与受保护的客户端应用部署在一起,以 Filter 方式保护受保护的资源。
TGT 是 CAS 为用户签发的登录票据,拥有了 TGT,用户就可以证明自己在CAS成功登录过。 TGT 封装了 Cookie 值以及此 Cookie 值对应的用户信息。用户在 CAS 认证成功后,CAS 生成 cookie(叫TGC),写入浏览器,同时生成一个 TGT 对象,放入自己的缓存,TGT 对象的 ID 就是 cookie 的值。 当 HTTP 再次请求到来时,如果传过来的有 CAS 生成的 cookie,则 CAS 以此 cookie 值为 key 查询缓存中有无 TGT,如果有,说明用户之前登录过,如果没有,则用户需要重新登录。
存放用户身份认证凭证的 cookie,在浏览器和 CAS Server 间通讯时使用,并且只能基于安全通道传输(Https),是 CAS Server 用来明确用户身份的凭证。
服务票据,服务的惟一标识码 , 由 CAS Server 发出( Http 传送),用户访问 Service 时,Service 发现用户没有 ST,则要求用户去 CAS 获取 ST。
前文已经介绍了 CAS 认证的过程,可以看出 CAS 的认证基于会话(即浏览器与服务器之间的 Session),因此终端、CAS 客户端与 CAS 服务端会组成一个三方的认证系统。登录之后的浏览器会在 CAS Server 的域名下存放 cookie,用于浏览器和 CAS Server 之间验证是否登录;而在访问 CAS Client 资源时则会在 Client 的域名下存放一个 cookie,用于下次访问资源时调取 ST 与 CAS Server 进行验证。
现在问题出现了。当前端与后端分离时,原本的 CAS Client 就不再是一方了,而是变成了两方,于是三方认证也成了四方认证。 如果是单纯的变成了两方也并没有离开 CAS 的认证框架,无非是多一个 CAS Client 罢了,然而前端常用的 Ajax 请求恰好无法处理 CAS 中最常见的重定向操作。这样一来,包括首次登录、登录成功后返回 ST、认证登录等一系列的逻辑似乎都没有办法继续进行了。
另一个尚未解决的问题就是微服务架构带来的认证问题。对于前端访问多个 CAS Client 时需要携带 ST 的需求,目前尚未设计出较好的解决方案,此问题还有待后续研究。
前面的问题网上出现了众多解决办法,在此处仅记录我自己实现的确实可行的一个设计方案。
首先后端需要增加一个专门用来跳转页面的 Controller,只需能实现根据传入的参数(要跳转的URL)跳转到对应的页面即可。这个跳转的作用主要在于认证通过后返回前端页面,并建立会话,同时需要将会话的 JSESSIONID 放在 URL 中。
在尽量减少侵入的原则下,不对 CAS 本身的代码进行修改,而是在认证过滤之前增加一个自定义的过滤器,将原有的返回 302 重定向状态改为返回 JSON 数据。返回的数据应包括 CAS Client 中已定义的跳转 Controller 地址,用于认证通过后返回到跳转页面的方法并跳回前端页面。
前端可以封装一个发送请求并接收返回值的组件,用于拦截所有的返回结果。与后端约定判断返回的状态码,若需要跳转 CAS Server,则保存当前浏览器地址,向 CAS Server 发送 service 参数,其值为 Client 中跳转页面用的 Controller 地址并向其传入返回前端页面的参数:url。
以 axios 封装的组件为例:
service.interceptors.response.use(
response => {
if (String(response.data.returnCode) === '401') {
// 获取当前浏览器地址,用于后端回调
const href = window.location.href
// returnData 存放了用于跳转页面的 Controller 地址,最后 url 参数中是要返回的前端地址
window.location.href = 'http://cas.server.com:8443/cas/login?service=' + encodeURIComponent(response.data.returnData) + '?url=' + encodeURIComponent(encodeURIComponent(href))
}
return response.data
},
error => {
NProgress.done()
return Promise.reject(error)
}
)
登录认证成功后,Client 返回前端时在浏览器地址中会携带 JSESSIONID 参数,前端获取后需要手动存放在 cookie 中,下次请求 Client 资源时将自动携带,Client 获取到 JSESSIONID 后即可取得会话并进行认证。 以 vue-router 的导航钩子为例:
router.beforeEach((to, from, next) => {
// 路由导航钩子函数中可进行处理
if (to.query && to.query.JSESSIONID) {
Cookies.set('JSESSIONID', to.query.JSESSIONID)
// 去掉浏览器地址栏中的 JSESSIONID 参数
delete to.query.JSESSIONID
next(to)
}
})
下面记录一下整体的设计流程:
在开发过程中也存在一些具体的坑,此处只记录遇到并解决的坑以及留下的坑。
开发期间发现了 CAS Server 登录后无法保存登录状态的问题,即下次访问 CAS Server 时仍然会被认为未登录,而跳转登录页。经仔细排查发现每次访问 CAS Server 时都会生成不同的 JSESSIONID,即每次访问都会创建新的会话。 此时应排查 Request 中是否携带了 cookie(其中包含 JSESSIONID),若未携带,如果使用axios封装了请求组件,可以加上配置:
axios.defaults.withCredentials = true
若发现 Request 中已携带了 cookie,而 JSESSIONID 仍然会变,可尝试为 CAS Server 设置一个域名解决问题。
对于同一个平台下的多个子系统,如果都采用前后端分离的方式,打通了 Client1 的登录认证之后,如何让 Client2 不需要登录直接访问呢?
此处常见的解决办法之一是前端通过 iframe 手动将 JSESSIONID 写入每个子系统域下的 cookie 中。子系统每次调用接口时只需将自己保存的 JSESSIONID 带上,即可保证认证通过。
由于采用了会话机制,目前实现的版本一个前端只能访问对应的一个 CAS Client 资源,对于在同一个前端访问微服务架构多个服务的情况尚未有解决方案。因此只能设计成每个前端访问自己对应的后端。
通过此次调研,基本可以认为基于 CAS 的单点登录认证不适用于前后端分离架构,因为基于会话的单点登录对前后端分离架构天生不友好。大部分前后端分离架构最终实现单点登录都是通过共享 session,这在严格意义上说或许不能算是完善的单点登录解决方案。