保证一个账户在多个系统上实现单一用户的登录,跨域,跨应用,是sso解决的问题
“在服务与服务之间共享session的一种解决办法”
1、如何优雅的实现分布式单点登陆
1.1、什么是分布式单点登录,为何使用它,它有哪几种?
单点登录定义
单点登录SSO(Single Sign On)说得简单点就是在一个多系统共存的环境下,用户在一处登录后,就不用在其他系统中登录,也就是用户的一次登录能得到其他所有系统的信任。

为什么使用单点登录
单点登录在大型网站里使用得非常频繁,例如像阿里巴巴这样的网站,在网站的背后是成百上千的子系统,用户一次操作或交易可能涉及到几十个子系统的协作,如果每个子系统都需要用户认证,不仅用户会疯掉,各子系统也会为这种重复认证授权的逻辑搞疯掉。
实现单点登录的基本思路
实现单点登录说到底就是要解决如何产生和存储那个信任,再就是其他系统如何验证这个信任的有效性,因此要点也就以下两个:
- 存储信任
- 验证信任

常见实现方式
- 以cookie作为凭证媒介 (不可跨域)
最简单的单点登录实现方式,是使用cookie作为媒介,存放用户凭证。 用户登录父应用之后,应用返回一个加密的cookie,当用户访问子应用的时候,携带上这个cookie,授权应用解密cookie并进行校验,校验通过则登录当前用户。
- 以token作为凭证媒介 (可跨域)
以token作为媒介,token存放到服务器本地(token一般是有过期时间的)。
用户登录父应用之后,应用返回一个access_token,当用户访问子应用的时候,携带上这个token,授权应用拿到并进行校验,校验通过则登录当前用户。
跨域和不可跨域原因:
cookie是和域名相关的
因为cookie是和域名绑定的,所以这种实现方式不能够跨域
1.2、概述
项目简介:
该项目是一个分布式单点登录框架。只需要登录一次就可以访问所有相互信任的应用系统。
项目特性
拥有”轻量级、分布式、跨域、Cookie+Token均支持、Web+APP均支持”等特性,开箱即用。
项目使用
接着往下看即可~~~
##1.3、项目整体结构以及各个模块作用
单点登录项目结构
项目结构 作用 SSO Server 中央认证服务,支持集群 SSO WebClient 基于cookie接入SSO认证中心的Client应用 SSO TokenClient 基于token接入SSO认证中心的Client应用 SSO-Core 项目核心类,工具类
sso server 的核心模块
| 模块类 | 作用 |
|---|---|
| 拦截器:PermissionInterceptor | 设置客户端的访问接口权限 |
| 配置类:Conf,Server端的XxlSsoConfig | server的配置类(可设访问地址) |
| 登陆,登出和检测 : AppController,WebController | 登陆登出,项目的主线 |
| 工具类:SsoLoginStore,CookieUtil,SsoSessionIdHelper | 工具类 |
sso client的核心模块
| 项目结构 | 作用 |
|---|---|
| text类 | 测试 |
| 配置类 | 配置拦截器使其起作用 |
| 过滤类filter : XxlSsoTokenFilter,XxlSsoWebFilter | 验证 SSO SessionId 通过,受限资源请求放行 |
1.4、系统设计图

1.5、项目特性
- 1、简洁:API直观简洁,可快速上手
- 2、轻量级:环境依赖小,部署与接入成本较低
- 3、单点登录:只需要登录一次就可以访问所有相互信任的应用系统
- 4、分布式:接入SSO认证中心的应用,支持分布式部署
- 5、HA:Server端与Client端,均支持集群部署,提高系统可用性
- 6、跨域:支持跨域应用接入SSO认证中心
- 7、Cookie+Token均支持:支持基于Cookie和基于Token两种接入方式,并均提供Sample项目
- 8、Web+APP均支持:支持Web和APP接入
- 9、实时性:系统登陆、注销状态,全部Server与Client端实时共享
- 10、CS结构:基于CS结构,包括Server”认证中心”与Client”受保护应用”
- 11、记住密码:未记住密码时,关闭浏览器则登录态失效;记住密码时,支持登录态自动延期,在自定义延期时间的基础上,原则上可以无限延期
- 12、路径排除:支持自定义多个排除路径,支持Ant表达式,用于排除SSO客户端不需要过滤的路径
2、代码讲解
2.1、主要代码类解释
xxl-sso-core模块
-
XxlSsoTokenFilter和XxlSsoWebFilter 都是实现Filter接口 ,作为过滤器 类内部实现init方法和doFilter方法:
- init方法 : 始化我们的一些参数并打出日志
- 初始化3个属性: 1.项目名2.项目登出路径3.不拦截资源
1
2
3
4
5
6
7
8
9
@Override
public void init(FilterConfig filterConfig) throws ServletException {
ssoServer = filterConfig.getInitParameter(Conf.SSO_SERVER);
logoutPath = filterConfig.getInitParameter(Conf.SSO_LOGOUT_PATH);
excludedPaths = filterConfig.getInitParameter(Conf.SSO_EXCLUDED_PATHS);
logger.info("XxlSsoTokenFilter init.");
}
-
dofilter里面写拦截逻辑,主要包括3步如下:
- 支持ANT表达式: 对一些资源课自定义不设置拦截 (该路径是自定义配置的)
1
2
3
4
5
6
7
8
9
10
11
12
13
// 1.make url and excluded path check
String servletPath = req.getServletPath();
if (excludedPaths!=null && excludedPaths.trim().length()>0) {
for (String excludedPath:excludedPaths.split(",")) {
String uriPattern = excludedPath.trim();
// 支持ANT表达式
if (antPathMatcher.match(uriPattern, servletPath)) {
// excluded path, allow
chain.doFilter(request, response);
return;
}
}
}
1
2. 登出拦截逻辑: 如果访问的地址和登出地址一致,就登出并返回执行码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 2.logout filter
if (logoutPath!=null
&& logoutPath.trim().length()>0
&& logoutPath.equals(servletPath)) {
// logout
SsoTokenLoginHelper.logout(req);
// response
res.setStatus(HttpServletResponse.SC_OK);
res.setContentType("application/json;charset=UTF-8");
res.getWriter().println("{\"code\":"+ReturnT.SUCCESS_CODE+", \"msg\":\"\"}");
return;
}
1
3. 登陆拦截逻辑: 用户访问单点登录的客户端资源的时候,会经过该过滤器,当用户所携带令牌不正确时,会被拦截返回错误码, 否则允许访问资源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 3login filter
XxlSsoUser xxlUser = SsoTokenLoginHelper.loginCheck(req);
if (xxlUser == null) {
// response
res.setStatus(HttpServletResponse.SC_OK);
res.setContentType("application/json;charset=UTF-8");
res.getWriter().println("{\"code\":"+Conf.SSO_LOGIN_FAIL_RESULT.getCode()+", \"msg\":\""+ Conf.SSO_LOGIN_FAIL_RESULT.getMsg() +"\"}");
return;
}
// ser sso user
request.setAttribute(Conf.SSO_USER, xxlUser);
// already login, allow
chain.doFilter(request, response);
return;
-
SsoLoginStore: redis的工具类把jedis操作redis常用的方法给包装一下,具体包括:
- remove 移除元素,调用jedis客户端的del方法
1
2
3
4
public static void remove(String storeKey) {
String redisKey = redisKey(storeKey);
JedisUtil.del(redisKey);
}
- put 放入元素,调用jedis客户端的put方法,过期时间是redisExpireMinite(默认是1440)*60
1
2
3
4
5
public static void put(String storeKey, XxlSsoUser xxlUser) {
String redisKey = redisKey(storeKey);
JedisUtil.setObjectValue(redisKey, xxlUser, redisExpireMinite * 60);
}
- get 获取元素,获取对象,并把String对象转换成bean对象(是用json序列化的)
1
2
3
4
5
6
7
8
9
public static XxlSsoUser get(String storeKey) {
String redisKey = redisKey(storeKey);
Object objectValue = JedisUtil.getObjectValue(redisKey);
if (objectValue != null) {
XxlSsoUser xxlUser = (XxlSsoUser) objectValue;
return xxlUser;
}
return null;
}
-
SsoSessionIdHelper: SessionId的设计类,包括:
- makeSessionId 生成sessionId, 是用 用户id+’_‘+uuid 组成
1
2
3
4
public static String makeSessionId(XxlSsoUser xxlSsoUser){
String sessionId = xxlSsoUser.getUserid().concat("_").concat(xxlSsoUser.getVersion());
return sessionId;
}
- parseStoreKey 生成 rediskey 使用userId+自定义字符串组成
1
2
3
4
5
6
7
8
9
10
11
12
public static String parseStoreKey(String sessionId) {
if (sessionId!=null && sessionId.indexOf("_")>-1) {
String[] sessionIdArr = sessionId.split("_");
if (sessionIdArr.length==2
&& sessionIdArr[0]!=null
&& sessionIdArr[0].trim().length()>0) {
String userId = sessionIdArr[0].trim();
return userId;
}
}
return null;
}
- parseVersion 传入sessionId,得到uuid,之后用来验证令牌的有效值
1
2
3
4
5
6
7
8
9
10
11
12
public static String parseVersion(String sessionId) {
if (sessionId!=null && sessionId.indexOf("_")>-1) {
String[] sessionIdArr = sessionId.split("_");
if (sessionIdArr.length==2
&& sessionIdArr[1]!=null
&& sessionIdArr[1].trim().length()>0) {
String version = sessionIdArr[1].trim();
return version;
}
}
return null;
}
-
CookieUtil :向用户写入和拿出cookie的工具类,包括如下方法:
- set 写入cookie
1
2
3
4
5
6
7
8
9
10
private static void set(HttpServletResponse response, String key, String value, String domain, String path, int maxAge, boolean isHttpOnly) {
Cookie cookie = new Cookie(key, value);
if (domain != null) {
cookie.setDomain(domain);
}
cookie.setPath(path);
cookie.setMaxAge(maxAge);
cookie.setHttpOnly(isHttpOnly);
response.addCookie(cookie);
}
- 删除cookie
1
2
3
4
5
6
public static void remove(HttpServletRequest request, HttpServletResponse response, String key) {
Cookie cookie = get(request, key);
if (cookie != null) {
set(response, key, "", null, COOKIE_PATH, 0, true);
}
}
- getValue: 拿到cookie
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static String getValue(HttpServletRequest request, String key) {
Cookie cookie = get(request, key);
if (cookie != null) {
return cookie.getValue();
}
return null;
}
private static Cookie get(HttpServletRequest request, String key) {
Cookie[] arr_cookie = request.getCookies();
if (arr_cookie != null && arr_cookie.length > 0) {
for (Cookie cookie : arr_cookie) {
if (cookie.getName().equals(key)) {
return cookie;
}
}
}
return null;
}
xxl-sso-server模块
-
XxlSsoConfig : 它实现了这两个接口 InitializingBean, DisposableBean,代码如下:
-
这两个接口是什么:
Spring提供了一些标志接口,用来改变BeanFactory中的bean的行为,它们包括InitializingBean和DisposableBean。
-
实现这些接口将会导致BeanFactory调用前一个接口的afterPropertiesSet()方法,调用后一个接口destroy()方法,从而使得bean可以在初始化和析构后做一些特定的动作。
- InitializingBean的afterPropertiesSet: 初始化数据库,建立数据库连接池
1
2
3
4
5
@Override
public void afterPropertiesSet() throws Exception {
SsoLoginStore.setRedisExpireMinite(redisExpireMinite);
JedisUtil.init(redisAddress);
}
- DisposableBean的destroy: 在redis使用完成之后关闭连接池
1
2
3
4
5
@Override
public void destroy() throws Exception {
JedisUtil.close();
}
-
PermissionInterceptor 授权拦截 : 该拦截器实现了HandlerInterceptor接口
实现接口后要重写它的3个方法:
-
preHandle
preHandle方法是进行处理器拦截用的,顾名思义,该方法将在Controller处理之前进行调用,SpringMVC中的Interceptor拦截器是链式的,可以同时存在 多个Interceptor,然后SpringMVC会根据声明的前后顺序一个接一个的执行,而且所有的Interceptor中的preHandle方法都会在 Controller方法调用之前调用。SpringMVC的这种Interceptor链式结构也是可以进行中断的,这种中断方式是令preHandle的返回值为false,当preHandle的返回值为false的时候整个请求就结束了。
-
1
2
3
4
5
6
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
// TODO Auto-generated method stub
return true;
}
-
postHandle
这个方法只会在当前这个Interceptor的preHandle方法返回值为true的时候才会执行。postHandle是进行处理器拦截用的,它的执行时间是在处理器进行处理之后,也就是在Controller的方法调用之后执行,但是它会在DispatcherServlet进行视图的渲染之前执行,也就是说在这个方法中你可以对ModelAndView进行操作。
1
2
3
4
5
6
7
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {
// TODO Auto-generated method stub
}
-
afterCompletion
该方法也是需要当前对应的Interceptor的preHandle方法的返回值为true时才会执行。该方法将在整个请求完成之后,也就是DispatcherServlet渲染了视图执行, 这个方法的主要作用是用于清理资源的,当然这个方法也只能在当前这个Interceptor的preHandle方法的返回值为true时才会执行。
1
2
3
4
5
6
7
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response, Object handler, Exception ex)
throws Exception {
// TODO Auto-generated method stub
}
xxl-sso-samples模块
-
客户端配置类 : XxlSsoConfig
-
使拦截器起作用: 这里用到@bean注解注入FilterRegistrationBean对象,设置web Filter
-
关于FilterRegistrationBean的使用和说明:
springBoot由于中抛弃了XML配置,所以要在启动主函数注入FilterRegistrationBean对象来实现过滤器
-
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Bean
public FilterRegistrationBean xxlSsoFilterRegistration() {
// xxl-sso, redis init
JedisUtil.init(xxlSsoRedisAddress);
// xxl-sso, filter init
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setName("XxlSsoWebFilter");
registration.setOrder(1);
registration.addUrlPatterns("/*");
registration.setFilter(new XxlSsoWebFilter());
registration.addInitParameter(Conf.SSO_SERVER, xxlSsoServer);
registration.addInitParameter(Conf.SSO_LOGOUT_PATH, xxlSsoLogoutPath);
registration.addInitParameter(Conf.SSO_EXCLUDED_PATHS, xxlSsoExcludedPaths);
return registration;
}
3、项目运用
3.1、登录认证模块分析
- 用户于Client端应用访问受限资源时,将会自动 redirect 到 SSO Server 进入统一登录界面
- 用户登录成功之后将会为用户分配 SSO SessionId 并 redirect 返回来源Client端应用,同时附带分配的 SSO SessionId
- 在Client端的SSO Filter里验证 SSO SessionId 无误,将 SSO SessionId 写入到用户浏览器Client端域名下 cookie 中
- SSO Filter验证 SSO SessionId 通过,受限资源请求放行
3.2、注销认证模块分析
- 用户与Client端应用请求注销Path时,将会 redirect 到 SSO Server 自动销毁全局 SSO SessionId,实现全局销毁
- 然后,访问接入SSO保护的任意Client端应用时,SSO Filter 均会拦截请求并 redirect 到 SSO Server 的统一登录界面
3.3、用户登录状态检查
- 用户携带token或者cookie浏览client应用时,会被过滤器拦截过滤器中会解析用户的访问地址,验证用户是否登陆,但确认是登陆状态的时候,放行
- 当用户被拦截了,可以设置让其跳转到sso- server 的登陆界面
3.4、基于cookie与token方式的不同
基于Cookie:
- 登陆凭证存储:登陆成功后,用户登陆凭证被自动存储在浏览器Cookie中
- Client端校验登陆状态:通过校验请求Cookie中的是否包含用户登录凭证判断
- 系统角色模型:
- SSO Server:认证中心,提供用户登陆、注销以及登陆状态校验等功能
- Client应用:受SSO保护的Client端Web应用,为用户浏览器访问提供服务
- 用户:发起请求的用户,使用浏览器访问
基于Token:
- 登陆凭证存储:登陆成功后,获取到登录凭证(xxl_sso_sessionid=xxx),需要主动存储,如存储在 localStorage、Sqlite 中
- Client端校验登陆状态:通过校验请求 Header参数 中的是否包含用户登录凭证(xxl_sso_sessionid=xxx)判断;因此,发送请求时需要在 Header参数 中设置登陆凭证
- 系统角色模型:
- SSO Server:认证中心,提供用户登陆、注销以及登陆状态校验等功能
- Client应用:受SSO保护的Client端Web应用,为用户请求提供接口服务
- 用户:发起请求的用户,如使用Android、IOS、桌面客户端等请求访问
4、项目总结
单点登录 SSO 的技术被越来越广泛地运用到各个领域的软件系统当中。本文从业务的角度分析了 单点登录 的需求和应用领域;从技术本身的角度分析了 单点登录 技术的内部机制和实现手段,并且给出Web-SSO 和cookie-SSO 的实现、源代码和详细讲解,还从安全和性能的角度对现有的实现技术进行进一步分析,指出相应的风险和需要改进的方面。
希望能够帮助应用架构师和系统分析人员从本质上认识单点登录,从而更好地设计出符合需要的安全架构。
完整源码:http://gitee.com/xuxueli0323/xxl-sso