公司要做用户中心,不同的产品模块要通过用户中心接通单点登录和单点退出。项目各种各样,有的是传统的单体应用,有的是现在很多前后分离REST的微服务。传统的单体服务通过CAS简单的部署很容易就能接通,但是前后分离的REST方式,需要对cas-client进行一些改造,同时,对接流程也需要根据约定发生相应的改变。下面,把我关于此问题的思考与项目的实现分享如下。
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是识别不了的。
基于以上问题,分别给出了一下几点解决思路。
基于跨域问题,以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中的跨域配置中,允许的请求源头,不要设置为星号 *
关于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的操作。
基于上面的问题描述与解决思路,cas-service与cas-server对接的总体流程如下图所示。下图中app1-backend 就是cas-service1的后端。
通过上图可知,如果同一个前端的域名对接了多个后端的域名,多个后端中至少有两个需要登录的话,cas-service1登录后会给前端返回token,cas-service2登录成功之后也会给前端返回token,两个同名的话,前端就无法区分到底是哪一个。在这种情况下,建议后端返回的token加上一个自己service的前缀,方便前端去维护。
上面提到的tempToken ,可以理解为一个校验码的概念,如果你觉得麻烦,可以直接返回前端token。加上tempToken只是避免了token在302跳转的一瞬间出现在浏览器的地址栏上。
上图以及上文,当登录成功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这个关系时如何存储的呢?这里,分两种情况:
以上,之所以做ST与Session关系的存储,主要是为了应对单点退出。当cas-server接到单点退出的请求的时候,它会尝试向SSO会话期间请求对CAS进行身份验证的每个应用程序发送注销消息。
关于单点退出,请查看demo中的AccountController类,url为logout的方法
TGT过期,cas-server会向每个已经登录的cas-service发送一个logout请求,并且清除已经登录的Session,见上文。目前cas-server设置的TGT过期为最近两个小时没有使用则过期。如果TGT过期,则跳转重新登录即可
Session过期,跳转重新登录。
建议取消JWTFilter对token过期的验证,以Session过期为准。另外,请保证cas-service的filter执行优先于你的JWTFilter