前后端分离或AJAX下的CAS-SSO跨域流程分析

前后端分离或AJAX下的CAS-SSO跨域流程分析

公司要做用户中心,不同的产品模块要通过用户中心接通单点登录和单点退出。项目各种各样,有的是传统的单体应用,有的是现在很多前后分离REST的微服务。传统的单体服务通过CAS简单的部署很容易就能接通,但是前后分离的REST方式,需要对cas-client进行一些改造,同时,对接流程也需要根据约定发生相应的改变。下面,把我关于此问题的思考与项目的实现分享如下。

1 名词说明

  • cas-server 单点登录服务,负责管理TGT,给cas-service颁发ST
  • cas-service 需要接入cas-server(单点登录服务)的应用服务。
  • cas-client 单点登录客户端,cas-service接入cas-server需要依赖cas-client

2 问题引入

CAS-SSO简介请参考文章CAS单点登录(一)——初识SSO,使用官方流程图描述了cas-service接入cas-server的一般流程。通过浏览器发送HTTP请求,服务端发送302跳转指令进行登录验证。这种方式适合传统的单体项目,前后不分离,前端使用JSP或者其他模板引擎的方式。在这种情况下,前后不分离,不用做特殊处理,浏览器就会把Cookie中的登录状态信息等带入到cas-service中。但是目前我们的开发方式中,开发阶段使用dev-server,生产阶段是打包成静态文件放入单独的静态资源服务器中,如Nginx。

前端对后端的调用方式,通常也是采用AJAX(XMLHttpRequest)请求:这是浏览器内部的XMLHttpRequest对象发起的请求,浏览器会禁止其发起跨域的请求,主要是为了防止跨站脚本伪造的攻击(CSRF)。不但如此,上面提到过传统的HTTP请求,cas-service中的cas filter 过滤到没有登录的会给浏览器发送一个302重定向到cas-server进行登录,登录成功后cas-server会再给浏览器发送一个302重定向到cas-service中继续完成业务操作。 但是前后分离的AJAX的调用方式,是不支持302重定向的。

对于前后不分离,也有一些操作,是AJAX调用,当登录过期的时候,再发送AJAX请求,cas-service发送的重定向指令,AJAX是识别不了的。

3 问题的解决思路

基于以上问题,分别给出了一下几点解决思路。

3.1 跨域问题

基于跨域问题,以SpringBoot为例,后端开启可以跨域,允许跨域携带认证方式请求。AJAX在发送跨域请求时也做相同配置,跨域的时候既可以把Cookie中存的cas-service中的session回话信息传入后端,维持登录到会话。例如:js 的AJAX可以这么写,对接的时候应该去掉下面的 timeout: 60000,

$.ajax({
      type: type,
      url: url,
      data: newParam,
      dataType: 'json',
      xhrFields: {
        withCredentials: true,
      },
      crossDomain: true,
      headers: {
        tempToken: tempToken,
        token:token,
      },
      timeout: 60000,
      success: function(data) {
        deferred.resolve(data);
      },
})

上面的代码中,关于跨域,主要的配置是:

      xhrFields: {
        withCredentials: true
      },
      crossDomain: true

以Spring Boot为例的后端的跨域配置如下:

@Configuration
public class GlobalCorsConfig {
    @Bean
    public FilterRegistrationBean<CorsFilter> corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();

        config.setAllowCredentials(true);
        config.addAllowedOrigin("http://localhost:8000");
        config.addAllowedHeader("Cookie");
        config.addAllowedHeader("tempToken");
        config.addAllowedHeader("token");
        config.addAllowedMethod(RequestMethod.GET.name());
        config.addAllowedMethod(RequestMethod.POST.name());
        config.addAllowedMethod(RequestMethod.PUT.name());
        config.addAllowedMethod(RequestMethod.DELETE.name());

        // CORS 配置对所有接口都有效
        source.registerCorsConfiguration("/**", config);
        FilterRegistrationBean<CorsFilter> filter = new FilterRegistrationBean<>(new CorsFilter(source));
        // 跨域的过滤器应该比较靠前
        filter.setOrder(Integer.MIN_VALUE + 1);
        return filter;
    }
}

以上代码便描述了跨域的处理,cas-service中的跨域配置中,允许的请求源头,不要设置为星号 *

3.2 AJAX无法识别302问题

关于AJAX无法识别302问题,cas-service可以自定义个过滤器替换cas-service的默认鉴权的过滤器,当发现客户端请求需要的登录的时候,不发送302跳转。发送一个json串,浏览器通过获取的数据,javascript主动通过window.localtion.href=https://sso.example.com跳转到cas的登录页面,cas-service登录成功后跳转到cas-client的后端地址去进行登录处理的方法中,在这个方法中主要就是产生Session,和前端需要与后端鉴权的token。由于是浏览器主动发起的对后端的Http get请求,cas-service后端可以把session信息写入到后端的域名的浏览器Cookie中,可以在下一次调用的时候进行会话保持。
同时在这个后端处理方法中,发送一个302请求给浏览器,这个302重定向携带token或者临时token放到url参数中,跳转到前端的进行登录处理的路由中,在前端处理中把token信息或者临时的token信息存入到前端域名的Cookie中,再次发送AJAX请求的时候token信息作为Header信息,从后端的域名下获取Cookie信息进行请求。

关于cas-service 中的过滤器,已经在uc-cas-client中进行封装,直接引入依赖即可,至于返回的json数据,目前是以下数据

{
	"success": false,
	"code": -1,
	"message": "请登录",
	"data": {
		"targetUrl": "https://sso.wuss.com:8443/cas/login?service=http%3A%2F%2Fapp1.wuss.com%3A10080%2Fdemo-singleton%2Faccount%2FloginProcess"
	}
}

如上json串中,通过一个code判断需要登录,targetUrl作为需要重定向的url,可以看到,有一个service的url参数,这个参数就是你的cas-service的后端地址host+contextPath+后端处理方法的路径。前端的js接受的这个串,可以在AJAX的回调函数中做以下类似操作

 if (response.code === -1) {
      let originUrl = encodeURI(window.location.href) ;
      let redirectUrl = response.data.targetUrl.concat('?originUrl=').concat(originUrl);
      console.log(redirectUrl);
      window.location.href = redirectUrl;
    }

上述代码中,可以看到变量 originUrl 记录了当前的请求的路由,并且作为url参数传入到了重定向的地址。那cas-server登录成功后,重定向后端处理方法的时候就会把这个originUrl给带上。后端重定向到前端登录处理路由的时候,不但要携带token或者临时token作为url参数,还有originUrl,前端处理完登录后重新重定向到originUrl,这样就回到了登录之前操作的界面。

另外,在后端的登录后处理方法中,你可以通过以下代码获取登录后的用户信息。

AttributePrincipal principal = AssertionHolder.getAssertion().getPrincipal();
//获取username
String username = principal.getName();
//获取cas-server登录成功后返回的用户属性
principal.getAttributes();

代码获取到线程变量中的已经登录到username和一些设置好的登录后返回的用户属性。

下面我以React为例,给出前端的登录处理路由代码:

import React, { PureComponent } from 'react';
import { setCookie } from '@/utils/utils';

class LoginProcess extends PureComponent {
    
  componentWillMount() {
    const { location } = this.props;
    const { query } = location;
    const d = new Date();
    d.setTime(d.getTime() + (30 * 24 * 60 * 60 * 1000));
    const expires = 'expires='.concat(d.toGMTString());

    setCookie('tempToken', query.tempToken);
    setCookie('test', 'wuss');

    window.location.href = query.originUrl;
  }
  
  render() {
    return (
      <div>
        登录后处理
      </div>
    );
  }
}

export default LoginProcess;

react 通过以上代码,在this.props.location中获取浏览器url参数,并且把临时token,或者token存入Cookie,然后获取originUrl并且重定向到。等AJAX再次向后端发送请求,根据以上配置,就会携带token放到Header中作为用户鉴权,携带后端域名下的Cookie中的Session信息,保持登录会话。

如果你第一次使用的是一个临时token,那么,需要做一次通过临时token换取真实token的操作。

4 总体流程

基于上面的问题描述与解决思路,cas-service与cas-server对接的总体流程如下图所示。下图中app1-backend 就是cas-service1的后端。

前后分离的cas-service和cas-server的对接

通过上图可知,如果同一个前端的域名对接了多个后端的域名,多个后端中至少有两个需要登录的话,cas-service1登录后会给前端返回token,cas-service2登录成功之后也会给前端返回token,两个同名的话,前端就无法区分到底是哪一个。在这种情况下,建议后端返回的token加上一个自己service的前缀,方便前端去维护。

上面提到的tempToken ,可以理解为一个校验码的概念,如果你觉得麻烦,可以直接返回前端token。加上tempToken只是避免了token在302跳转的一瞬间出现在浏览器的地址栏上。

5 ST与Session的存储与单点退出

上图以及上文,当登录成功cas-server给浏览器发送302指令跳转到cas-service的后端的时候,会携带一个名字为ticket的URL参数,这个ticket就是由TGT生成的ST,事实上,当cas-service的filter发现有一个叫ticket的URL参数的时候,就会拿这个ST去cas-server做校验,校验通过后会生成一个保持登录状态的Session,这个Session中封装了成功登录后返回的一些用户信息,当前配置的ST只能去校验一次,再次去cas-server中去校验的话将不会被通过。那么,这个ST就与Session做了唯一关联,事实上,当发起单点退出的时候,cas-server会给cas-service发送一个logout的HTTP POST请求,这个请求会被cas-service filter拦截,使用这个这个请求中携带的ST清除已经登录产生的Session,便达到了单点退出的目的。

那么ST与Session这个关系时如何存储的呢?这里,分两种情况:

  • cas-service单机部署:单机部署的时候,是通过一个Map<String, HttpSession>对象存储,这个map的key就是ST。
  • cas-service集群部署:当你采用集群部署的时候,目前代码实现是通过Redis做了一个Session共享的服务器,在Redis存储了ST与SessionID的关系。当logout请求发出来,就是从ST中获取SessionID,然后通过SessionID清除Session。

以上,之所以做ST与Session关系的存储,主要是为了应对单点退出。当cas-server接到单点退出的请求的时候,它会尝试向SSO会话期间请求对CAS进行身份验证的每个应用程序发送注销消息。

关於单点退出,请查看demo中的AccountController类,url为logout的方法

6 关于过期

  • TGT过期,cas-server会向每个已经登录的cas-service发送一个logout请求,并且清除已经登录的Session,见上文。目前cas-server设置的TGT过期为最近两个小时没有使用则过期。如果TGT过期,则跳转重新登录即可

  • Session过期,跳转重新登录。

  • 建议取消JWTFilter对token过期的验证,以Session过期为准。另外,请保证cas-service的filter执行优先于你的JWTFilter

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章