基于 Spring Security 实现轻量级单点登录方案

基于 Spring Security 实现轻量级单点登录方案

在企业应用集成场景中,单点登录(SSO)是一个常见需求。特别是在微服务架构或多系统协作环境下,用户希望只需登录一次就能访问所有授权系统。本文将介绍如何利用 Spring Security 框架实现一个简单但有效的单点登录方案,无需引入额外的 CAS、OAuth 等重量级框架。

1. 单点登录的核心问题

单点登录的本质是身份凭证的跨系统传递与验证。实现 SSO 时需要解决三个核心问题:

  1. 安全的身份信息传递 - 如何安全地将用户身份信息从系统 A 传递到系统 B
  2. 无感知认证 - 用户只在源系统进行认证,目标系统应自动完成登录
  3. 会话管理 - 确保登录状态在目标系统中正确维护

2. 实现思路

我们采用基于令牌的轻量级方案,主要流程如下:

  1. 源系统生成加密令牌(包含用户名和时间戳)
  2. 将令牌作为参数传递给目标系统
  3. 目标系统解析令牌并验证有效性
  4. 目标系统执行”模拟登录”,无需用户提供密码
  5. 创建目标系统的会话并完成重定向

关键点是:如何在 Spring Security 中实现”模拟登录”

3. 核心组件

3.1 令牌工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Base64;

/**
* 双向加密工具类
* 用于第三方系统单点登录的加密/解密
*/
public class EncryptionUtil {

private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/ECB/PKCS5Padding";
// 这里使用固定的密钥,在生产环境中应该从配置文件读取
private static final String SECRET_KEY = "https://mybatis.io"; // 16字节密钥

/**
* 加密字符串
* @param plainText 明文
* @return 加密后的Base64字符串
*/
public static String encrypt(String plainText) {
try {
SecretKeySpec keySpec = new SecretKeySpec(SECRET_KEY.getBytes(), ALGORITHM);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
byte[] encrypted = cipher.doFinal(plainText.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new RuntimeException("加密失败", e);
}
}

/**
* 解密字符串
* @param encryptedText 加密的Base64字符串
* @return 解密后的明文
*/
public static String decrypt(String encryptedText) {
try {
SecretKeySpec keySpec = new SecretKeySpec(SECRET_KEY.getBytes(), ALGORITHM);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] decoded = Base64.getDecoder().decode(encryptedText);
byte[] decrypted = cipher.doFinal(decoded);
return new String(decrypted, "UTF-8");
} catch (Exception e) {
throw new RuntimeException("解密失败", e);
}
}

/**
* 生成单点登录令牌
* @param username 用户名
* @param timestamp 时间戳
* @return 加密令牌
*/
public static String generateSSOToken(String username, String timestamp) {
String plainToken = username + "|" + timestamp;
return encrypt(plainToken);
}

/**
* 解析单点登录令牌
* @param token 加密令牌
* @return SSOToken对象
*/
public static SSOToken parseSSOToken(String token) {
try {
String decrypted = decrypt(token);
String[] parts = decrypted.split("\\|");
if (parts.length != 2) {
throw new IllegalArgumentException("令牌格式错误");
}
return new SSOToken(parts[0], parts[1]);
} catch (Exception e) {
throw new RuntimeException("令牌解析失败", e);
}
}

/**
* 验证令牌是否过期
* @param timestamp 时间戳字符串 (格式: yyyy-MM-dd HH:mm:ss)
* @param timeoutMinutes 超时分钟数
* @return true if valid, false if expired
*/
public static boolean isTokenValid(String timestamp, int timeoutMinutes) {
try {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime tokenTime = LocalDateTime.parse(timestamp, formatter);
LocalDateTime now = LocalDateTime.now();
return tokenTime.plusMinutes(timeoutMinutes).isAfter(now);
} catch (Exception e) {
return false;
}
}

/**
* SSO令牌对象
*/
public static class SSOToken {
private String username;
private String timestamp;

public SSOToken(String username, String timestamp) {
this.username = username;
this.timestamp = timestamp;
}

// getters
public String getUsername() { return username; }
public String getTimestamp() { return timestamp; }
}
}

3.2 SSO登录控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
@Controller
@RequestMapping("/sso")
public class SSOLoginController {
@Autowired
private CustomUserDetailsService userDetailsService;

@Autowired
private CustomAuthenticationSuccessHandler successHandler;

@RequestMapping(value = "/login", method = RequestMethod.GET)
public void login(@RequestParam String token, HttpServletRequest request,
HttpServletResponse response) {
try {
// 解析令牌
SSOToken ssoToken = EncryptionUtil.parseSSOToken(token);

// 验证令牌有效性
if (!EncryptionUtil.isTokenValid(ssoToken.getTimestamp(), 30)) {
response.sendRedirect("/login?error=token_expired");
return;
}

// 核心:模拟Spring Security登录
simulateLogin(ssoToken.getUsername(), request, response);

} catch (Exception e) {
// 错误处理
}
}

private void simulateLogin(String username, HttpServletRequest request,
HttpServletResponse response) throws Exception {
// 加载用户信息
UserDetails userDetails = userDetailsService.loadUserByUsername(username);

// 创建认证令牌
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());

// 这一步很关键 - 先创建session
HttpSession session = request.getSession(true);

// 设置认证详情 - 包含sessionId、remoteAddr等
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

// 设置安全上下文
SecurityContextHolder.getContext().setAuthentication(authentication);

// 保存到session
session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext());

// 使用标准登录成功处理器完成后续流程
successHandler.onAuthenticationSuccess(request, response, authentication);
}


/**
* FIXME 生成SSO令牌(仅供测试使用,记得注释)
*/
//@RequestMapping(value = "/generateToken", method = RequestMethod.POST)
@ResponseBody
public Map<String, Object> generateToken(
@RequestParam String username,
HttpServletRequest request,
HttpServletResponse response) {

Map<String, Object> result = new HashMap<>();

try {
// 生成时间戳
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));

// 生成令牌
String token = EncryptionUtil.generateSSOToken(username, timestamp);

result.put("success", true);
result.put("token", token);
result.put("username", username);
result.put("timestamp", timestamp);
result.put("loginUrl", "/sso/login?token=" + token);
} catch (Exception e) {
result.put("success", false);
result.put("message", "令牌生成失败: " + e.getMessage());
}

return result;
}

/**
* FIXME 解析SSO令牌(仅供测试使用,记得注释)
*/
//@RequestMapping(value = "/parseToken", method = RequestMethod.POST)
@ResponseBody
public Map<String, Object> parseToken(
@RequestParam String token,
HttpServletRequest request,
HttpServletResponse response) {

Map<String, Object> result = new HashMap<>();

try {
EncryptionUtil.SSOToken ssoToken = EncryptionUtil.parseSSOToken(token);

result.put("success", true);
result.put("username", ssoToken.getUsername());
result.put("timestamp", ssoToken.getTimestamp());
result.put("isValid", EncryptionUtil.isTokenValid(ssoToken.getTimestamp(), 30));
} catch (Exception e) {
result.put("success", false);
result.put("message", "令牌解析失败: " + e.getMessage());
}

return result;
}
}

4. 实现中的关键点

4.1 模拟认证的顺序很重要

在模拟 Spring Security 认证过程时,顺序非常关键。经历了多次尝试,最终确定了正确的步骤顺序:

  1. 先创建 HTTP Session:request.getSession(true)
  2. 再创建认证对象:new UsernamePasswordAuthenticationToken(...)
  3. 然后设置认证详情:authentication.setDetails(...)
  4. 设置安全上下文:SecurityContextHolder.getContext().setAuthentication(...)
  5. 将安全上下文保存到 session:session.setAttribute("SPRING_SECURITY_CONTEXT", ...)

如果步骤顺序错误,可能导致会话信息不完整或认证状态丢失。

这里的顺序也有可能是各种巧合凑成的,遇到问题要针对解决。

4.2 认证详情(details)的重要性

我们发现,单纯创建 Authentication 对象是不够的,还需要正确设置其 details 属性:

1
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

这一步将会话ID与认证对象关联起来,确保后续请求能够维持登录状态。如果缺少这一步,可能导致重定向循环或登录状态丢失。

4.3 处理重定向

Spring Security 默认使用 SavedRequestAwareAuthenticationSuccessHandler 处理登录成功后的重定向,它有两种行为:

  • 如果有保存的请求,重定向到原请求URL
  • 否则重定向到默认URL(通常是 /

在 SSO 场景中,如果默认URL配置不当,可能导致重定向循环。解决方法是自定义成功处理器,或直接指定目标URL:

1
2
3
4
5
// 方法1:使用自定义处理器
successHandler.onAuthenticationSuccess(request, response, authentication);

// 方法2:直接重定向到指定页面
response.sendRedirect("/dashboard");

5. 安全性考虑

这个简化的 SSO 实现有一些安全风险需要注意:

  1. 令牌传输安全 - 应使用 HTTPS 传输令牌
  2. 令牌有效期 - 必须设置短暂的有效期(例如5-10分钟)
  3. IP限制 - 考虑将来源IP编入令牌
  4. 加密强度 - 生产环境应使用更强的加密算法和密钥管理
  5. 令牌一次性 - 理想情况下令牌应为一次性使用

6. 集成到现有系统

要将此 SSO 方案集成到现有系统,需要:

  1. 源系统集成

    • 实现令牌生成(EncryptionUtil + 时间格式)
    • 在需要跳转时构建带令牌的目标系统URL
  2. 目标系统集成

    • 添加 SSO 控制器
    • 在 Spring Security 配置中排除 SSO 相关路径
    1
    <http pattern="/sso/**" security="none"/>

7. 总结

基于 Spring Security 实现轻量级 SSO 的核心在于:

  1. 安全的令牌传递机制
  2. 正确模拟 Spring Security 的认证过程
  3. 恰当的会话管理

这种方案适用于中小型应用或内部系统集成,无需引入复杂的 CAS 或 OAuth 框架。对于更大规模或严格安全要求的场景,建议考虑成熟的 SSO 解决方案如 Keycloak、Spring Authorization Server 等。

记住:简单不意味着不安全。只要理解了认证的核心机制并妥善处理安全细节,轻量级 SSO 方案同样能够安全可靠地运行。


基于 Spring Security 实现轻量级单点登录方案
https://blog.mybatis.io/post/29aaf1ca.html
作者
Liuzh
发布于
2025年8月27日
许可协议