# 单点登录技术预研
author:徐振东
createTime:2022-04-11
updateTime:2022-04-11
2022-04-11: 培训结束
# 何为单点登录
在百度百科中,单点登录的定义是这样的:单点登录(Single Sign On),简称为 SSO,是比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用 (opens new window)系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
# 主流单点登录的解决方案
在讨论单点登录前,我们先来看一下单体应用中是如何实现登录功能的:
在早期客户端与服务端交互的过程中,由于Http的无状态,server 并不清楚前后接收到的两次请求是来自于同一用户还是不同用户,所以用户也只能进行简单的浏览访问,无法与服务端进行深层次的交互,服务端也无法向各个用户提供个性化的服务。随着社会的发展,这种原始的交互模式已经无法满足人们日益增长的需求了。所以后面就出现了cookie和session技术,这两者都可以看作是一个简单的存储数据的容器,可存储各种key、value数据,区别是一个存储在用户端,一个存储于服务端。
- cookie:存储在用户终端的数据,每次请求浏览器会自动将之前保存的符合规则的cookie携带返还给后端
- session:存储在服务端的数据
cookie 和 session出现后,服务端在用户一次登录完成后,自动生成随机的与该用户绑定的唯一token信息(如 Servlet容器的 JSESSIONID),并写入到cookie中;然后在后续客户端与服务端每次交互中,服务端可以通过客户端携带的token信息反向查找用户信息。
然后随着业务的发展,单体应用越做越大,相应的开发、维护成本也大幅度提升,经常是牵一发而动全身,所以后续将单体应用拆分微服务或独立的子系统也在情理之中;但是拆分后,之前的登录技术也需要有新的技术方案去解决,那么拆分后又碰到了什么问题,导致原先的登录方案无法使用?
基本上分为以下三种情况,不同的情况也对应了不同的解决方案
- cookie可共享,session无法共享(现主流单点登录,大部分是解决该种情况的)
- session 同步 -- 性能存在问题
- session 共享数据源 -- 分布式session (主流)
- cookie无法共享,session共享
- cookie无法共享,session也无法共享
- CAS单点登录
# 单点登录与Shiro集成的代码实现
# 1、引入依赖类

下面依次介绍下5个依赖类的功能作用
# 1.1 SsoProperties
单点登录的配置
按照Spring-boot约定大于配置的思想,该类对大部分的属性都提供了默认值,仅需对少量属性进行配置即可使用
enabled :默认情况下,不对单点登录进行启用,如需启用,需手动将该属性置为 true
cookie.domain:该属性配置为多个单点登录系统的一级域名,如现有'sso1.xzd.com'、'sso2.xzd.com'、'sso3.xzd.com'三个二级域名,那么
cookie.domain的值需配置为 'xzd.com'aseKey:SSO单点登录时进行AES加密的密钥,有默认值,更改时可另外配置,但需要保证多个单点登录系统的密钥保持一致
其它:可根据实际情况更改默认值, 也可直接使用默认值
package com.lowan.reallinoa.authority.config.shiro;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* @author xuzhendong
* @date 2022/4/8
*/
@Getter
@Setter
@ConfigurationProperties(prefix = "sso")
public class SsoProperties {
/**
* 是否启用SSO单点登录, 默认情况不启用
*/
private boolean enabled = false;
/**
* 未登录code码
*/
private Integer unauthorizedCode = 401;
/**
* 重新授权code码
*/
private Integer reauthorizedCode = 402;
/**
* 授权的接口地址
*/
private String authUrl = "/auth";
/**
* AES加密的key
*/
private String aseKey = "d7b85f6e214e3f6e";
/**
* SSO的cookie模板
*/
private CookieTemplate cookie;
@Data
public static class CookieTemplate {
private String domain;
private String name = "sso";
private boolean secure = false;
private String path = "/";
private boolean httpOnly = true;
private Integer maxAge = -1;
}
}
# 1.2 SsoToken
单点登录的token令牌
当用户在一个系统中登录后,服务端会向客户端写入token令牌,其它系统通过该令牌实现身份互认,进而实现单点登录
package com.lowan.reallinoa.authority.config.shiro;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.shiro.SecurityUtils;
import java.util.Objects;
/**
* @author xuzhendong
* @date 2022/4/8
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SsoToken {
/**
* 多个系统间互信的token令牌, 用于获取用户信息
*/
private String token;
/**
* token创建的时间
*/
private Long createTime;
/**
* 创建token时, 与服务端通信的客户端主机信息
*/
private String host;
/**
* 是否为有效token
*
* @return true: token有效,否则返回false
*/
public boolean isValid() {
// 字段非空校验
if (token == null || createTime == null) {
return false;
}
return Objects.equals(host, SecurityUtils.getSubject().getSession().getHost());
}
}
# 1.3 ShiroAesEncryptUtils
安全加解密的工具类
该类仅对外提供了两个公共方法,加密和解密,用于对SSO相关参数进行加密认证,防止敏感信息直接外泄
package com.lowan.reallinoa.authority.config.shiro;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/**
* @author xuzhendong
* @date 2022/4/8
*/
public class ShiroAesEncryptUtils {
private static final String ALGORITHMSTR = "AES/ECB/PKCS5Padding";
/**
* 解密文本内容
*
* @param content 待解密的内容
* @param aesKey 加密的密钥
* @return 解密后的文本内容
* @throws Exception 加密异常
*/
public static String decrypt(String content, String aesKey) throws Exception {
if (StringUtils.isBlank(content)) {
return content;
}
byte[] bytes = buildCipher(aesKey, Cipher.DECRYPT_MODE).doFinal(Base64.decodeBase64(content));
return new String(bytes, StandardCharsets.UTF_8);
}
/**
* 加密文本内容
*
* @param content 待加密的内容
* @param aesKey 加密的密钥
* @return 加密后的文本内容
* @throws Exception 加密异常
*/
public static String encrypt(String content, String aesKey) throws Exception {
if (StringUtils.isBlank(content)) {
return content;
}
byte[] bytes = buildCipher(aesKey,Cipher.ENCRYPT_MODE)
.doFinal(content.getBytes(StandardCharsets.UTF_8));
return Base64.encodeBase64String(bytes);
}
/**
* 构建加密函数
*
* @param aesKey 加密的key
*/
private static Cipher buildCipher(String aesKey, Integer mode) throws Exception {
KeyGenerator kgen = KeyGenerator.getInstance("AES");
kgen.init(128);
Cipher cipher = Cipher.getInstance(ALGORITHMSTR);
cipher.init(mode, new SecretKeySpec(aesKey.getBytes(), "AES"));
return cipher;
}
}
# 1.4 ShiroSsoFilter
实现单点登录的核心类
该类为 Shiro 过滤器的实现类,会对所有请求进行拦截,主要实现两个核心功能
1、单点登录,用户一处登录,其它系统互认互信,免登陆,直接进入系统
2、单点登出,用户一处登出,其它系统也退出登录
对于拦截的请求可能存在一下三种情况
- 当前系统未登录,其它系统也未登录
- 通过code码,引导客户端跳转至登录界面,重新登录(手动登录)
- 当前系统未登录,其它系统已登录
- 通过code码,引导客户端调用授权接口,获取用户信息(自动登录)
- 当前系统登录,其它系统未登录
- 该情况为用户在其它系统已退出登录,若需访问信息,需重新登录(手动登录)
package com.lowan.reallinoa.authority.config.shiro;
import org.apache.commons.lang.Validate;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.authc.UserFilter;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
/**
* @author xuzhendong
* @date 2022/4/8
*/
public class ShiroSsoFilter extends UserFilter {
private final SsoProperties ssoProperties;
public ShiroSsoFilter(SsoProperties ssoProperties) {
this.ssoProperties = ssoProperties;
if (ssoProperties.isEnabled()) {
SsoProperties.CookieTemplate cookie = ssoProperties.getCookie();
Validate.isTrue(StringUtils.isNotBlank(cookie.getDomain()), "sso.cookie.domain不允许为空");
Validate.isTrue(StringUtils.isNotBlank(cookie.getName()), "sso.cookie.name不允许为空");
}
}
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if (!ssoProperties.isEnabled()) {
return true;
}
try {
SsoToken ssoToken = SsoUtils.getSsoUserFromCookie(request, ssoProperties);
return getSubject().getPrincipal() != null && ssoToken != null && ssoToken.isValid();
} catch (Exception e) {
return false;
}
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
// 跳转授权接口
if (SsoUtils.getSsoCookie(request, ssoProperties) != null && getSubject().getPrincipal() == null) {
SsoUtils.writeReauthorizedResponse(response, ssoProperties);
} else {
if (getSubject().getPrincipal() != null) {
getSubject().logout();
}
// 跳转登录
SsoUtils.writeUnauthorizedResponse(response, ssoProperties);
}
return false;
}
/**
* 获取访问主体
*/
private Subject getSubject() {
return SecurityUtils.getSubject();
}
}
# 1.5 SsoUtils
单点登录的工具类
package com.lowan.reallinoa.authority.config.shiro;
import com.alibaba.fastjson.JSON;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/**
* @author xuzhendong
* @date 2022/4/8
*/
public class SsoUtils {
/**
* 写入重新授权的响应体内容
*
* @param response 响应体
* @param ssoProperties 单点登录相关的配置信息
* @throws Exception 消息写入异常
*/
public static void writeReauthorizedResponse(ServletResponse response, SsoProperties ssoProperties) throws Exception {
SsoUtils.writeResponse(response, ssoProperties.getReauthorizedCode(), "请重新授权", null);
}
/**
* 写入未登录的响应体内容
*
* @param response 响应体
* @param ssoProperties 单点登录相关的配置信息
* @throws Exception 消息写入异常
*/
public static void writeUnauthorizedResponse(ServletResponse response, SsoProperties ssoProperties) throws Exception {
SsoUtils.writeResponse(response, ssoProperties.getUnauthorizedCode(), "请重新登录", null);
}
/**
* 向响应体写入内容
*
* @param response 响应体
* @param code 响应码
* @param msg 消息描述
* @param data 消息内容
* @throws Exception 消息写入异常
*/
public static void writeResponse(ServletResponse response, Integer code, String msg, Object data) throws Exception {
HttpServletResponse resp = (HttpServletResponse) response;
resp.setContentType(MediaType.APPLICATION_JSON_VALUE);
resp.setCharacterEncoding(StandardCharsets.UTF_8.name());
PrintWriter out = resp.getWriter();
Map<String, Object> paramMap = new HashMap<>(3);
paramMap.put("code", code);
paramMap.put("msg", msg);
if (data != null) {
paramMap.put("data", data);
}
out.println(JSON.toJSONString(paramMap));
out.flush();
out.close();
}
/**
* 获取sso单点登录的cookie
*
* @param request 请求体
* @param ssoProperties sso相关的配置参数
* @return sso单点登录的cookie
*/
@Nullable
public static Cookie getSsoCookie(ServletRequest request, SsoProperties ssoProperties) {
if (ssoProperties.isEnabled()) {
HttpServletRequest servletRequest = (HttpServletRequest) request;
Cookie[] cookies = servletRequest.getCookies();
if (cookies != null) {
String name = ssoProperties.getCookie().getName();
for (Cookie cookie : cookies) {
if (name.equals(cookie.getName())) {
return cookie;
}
}
}
}
return null;
}
/**
* 从 SSO的cookie中获取用户信息
*
* @param request 请求体
* @param ssoProperties SSO参数
* @return 用户信息
*/
@Nullable
public static SsoToken getSsoUserFromCookie(ServletRequest request, SsoProperties ssoProperties) throws Exception {
SsoToken ssoUser = null;
Cookie cookie = getSsoCookie(request, ssoProperties);
if (cookie != null && cookie.getValue() != null) {
ssoUser = JSON.toJavaObject(JSON
.parseObject(decodeCookieValue(cookie.getValue(), ssoProperties))
, SsoToken.class);
}
return ssoUser;
}
/**
* 解码cookie的值
*
* @param value 编码后的值
* @return 解码后的值
*/
private static String decodeCookieValue(String value, SsoProperties ssoProperties) throws Exception {
if (value == null) {
return null;
}
return ShiroAesEncryptUtils.decrypt(URLDecoder
.decode(value, StandardCharsets.UTF_8), ssoProperties.getAseKey());
}
/**
* 编码cookie的值
*
* @param value 原始值
* @return 编码后的值
*/
private static String encodeCookieValue(String value, SsoProperties ssoProperties) throws Exception {
if (value == null) {
return null;
}
return URLEncoder.encode(ShiroAesEncryptUtils
.encrypt(value, ssoProperties.getAseKey()), StandardCharsets.UTF_8);
}
/**
* 将SSO相关的cookie写入浏览器
*
* @param response 响应体
* @param ssoUser sso通用的用户体
* @param ssoProperties sso相关的配置参数
*/
public static void addSsoCookie(HttpServletResponse response
, SsoToken ssoUser, SsoProperties ssoProperties) throws Exception {
if (!ssoProperties.isEnabled()) {
return;
}
response.addCookie(buildCookie(ssoProperties, ssoUser));
}
/**
* 删除SSO相关的cookie
*
* @param response 响应体
* @param ssoProperties sso相关的配置参数
*/
public static void deleteSsoCookie(HttpServletResponse response, SsoProperties ssoProperties) throws Exception {
if (!ssoProperties.isEnabled()) {
return;
}
Cookie cookie = buildCookie(ssoProperties, null);
cookie.setMaxAge(0);
response.addCookie(cookie);
}
private static Cookie buildCookie(SsoProperties ssoProperties, Object data) throws Exception {
SsoProperties.CookieTemplate cookieTemplate = ssoProperties.getCookie();
Cookie cookie = new Cookie(cookieTemplate.getName(), encodeCookieValue(data == null
? null : JSON.toJSONString(data), ssoProperties));
cookie.setPath(cookieTemplate.getPath());
cookie.setDomain(cookieTemplate.getDomain());
cookie.setHttpOnly(cookieTemplate.isHttpOnly());
cookie.setSecure(cookieTemplate.isSecure());
cookie.setMaxAge(cookieTemplate.getMaxAge());
return cookie;
}
}
# 2、集成shiro
- 若启用单点登录,将系统间互信互认授权的接口地址加入免认证
- 将该过滤器在 shiro 中进行定义,并命名为 sso
- 配置拦截所有路径

# 3、登录相关的代码改造
# 3.1 登录接口
与之前的登录接口相比,原有登录逻辑不变,此处封装doLogin, 然后在登录成功后写入单点登录的token令牌,其它系统通过当前token实现自动认证免登录
这里的token令牌为用户的用户名---多个系统间用户名一致,认定为同一用户

# 3.2 退出登录
同理,用户退出登录后,需要在当前接口删除单点登录的令牌,实现一处登出,其它系统自动登出

# 3.3 授权接口
该接口为单点登录新增接口,实现通过其它系统写入的token令牌,自动登录

# 4、前端登录逻辑的变更
- 变更前的逻辑:
前端未登录或前端请求接口后端返回未登录的code码,前端重定向至登录接口,引导用户重新登录
变更后的逻辑:
由于后端已经实现的单点登录,用户在第一次访问当前系统时,可能已经在其它系统登陆过了,可以免登录,所以这里的登录逻辑需要发生变更。
这里分为两种情况:
前端判断用户未登录(一般为首次进入该系统的前端界面)
前端先调用后端的 **
授权**接口:若后端判定符合免登录的条件,直接返回用户信息,前端保存用户信息然后改为已登录状态,再进行后续的路基操作;若判断判定不符合免登录条件,直接返回未登录的code码,前端重定向至登录界面
前端判断用户已登录
前端按之前的逻辑正常调用接口,通过后端也会通过实际情况返回成功、未登录(登录失效)、重新授权的code码,前端再根据code码执行后续的逻辑判断
# 该方案优缺点
优点
- 仅需少量代码和配置,改动较小
- 各系统间无需统一用户数据,只要用户名一致即可
缺点
安全性还有已知的上升空间
虽然该方案实现了单点登录,但因后端数据源(Redis、MySQL数据库)还是割裂隔离的,所以各系统仅能通过username,做为各系统免登录的token令牌,不具有随机性,容易被拦截仿造。目前的解决方案是,对token进行了加密,并对用户的host进行强验证(即使用过程中,若发现用户的主机信息发生变化,则拒绝服务,强制重新登录),提升了用户仿造token的难度,实现的相对的安全性