1. 为什么需要SSO单点登录系统
第一次接触SSO这个概念是在五年前,当时公司内部有十几个系统,每个系统都有自己的登录模块。新来的同事第一天要记住七八套账号密码,经常看到有人把密码写在便利贴上贴在显示器旁边。作为开发者的我们更痛苦,每次新系统上线都要重写一遍登录逻辑,用户表结构不统一导致数据同步成了噩梦。
单点登录(SSO)就是为了解决这种混乱局面而生的。它的核心思想很简单:一次登录,全网通行。想象一下,你早上到公司刷一次工卡,就能畅通无阻地进入所有办公区域,不需要在每个门口都验证身份——这就是SSO带来的体验。
在技术实现上,基于Token的SSO方案特别适合分布式系统。我经手过的项目里,从早期的Session共享到后来的JWT方案,最终发现自研Token中心的灵活度最高。比如去年给某金融机构做的项目,他们要求必须支持动态令牌失效策略,这时候自定义Token体系就能轻松应对。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与基础架构
2.1 开发环境配置
建议直接用IntelliJ IDEA创建Maven项目,我这里用的SpringBoot 2.7.x版本。遇到过不少同学被版本兼容性问题坑过,特别提醒几个关键依赖:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
本地测试需要配置hosts文件,这个老生常谈但总有人踩坑。Windows用户注意要用管理员权限编辑,我见过有人改了半小时发现没生效就是因为权限问题:
code复制127.0.0.1 sso.auth.com
127.0.0.1 app1.client.com
127.0.0.1 app2.client.com
2.2 架构设计图解
我们的系统会包含三个核心组件:
- 认证中心:处理登录/注销的核心服务
- Token存储:使用ConcurrentHashMap模拟(生产环境换Redis)
- 客户端拦截器:校验Token的守卫
![架构流程图]
(注:此处实际项目应插入架构图,文字描述替代)
认证中心就像机场的值机柜台,给你登机牌(Token);各个子系统就像登机口,只认登机牌不关心你怎么买的票。
3. 认证中心核心实现
3.1 登录接口的魔鬼细节
登录逻辑看似简单,但隐藏着不少坑。先看核心代码:
java复制@PostMapping("/login")
public String login(@Valid LoginDTO dto, HttpSession session) {
// 模拟用户校验
if(!"admin".equals(dto.getUsername()) || !"123456".equals(dto.getPassword())) {
model.addAttribute("error", "账号密码错误");
return "login";
}
// 生成Token
String token = UUID.randomUUID().toString().replace("-", "");
session.setAttribute("AUTH_TOKEN", token);
// 存储Token
TokenPool.addToken(token);
// 重定向回原系统
return "redirect:" + dto.getRedirectUrl() + "?token=" + token;
}
这里有几个实战经验:
- Token生成:不要用简单的UUID,生产环境应该加入时间戳+随机数+业务标识
- 重定向安全:必须校验redirectUrl是否在白名单,防止钓鱼攻击
- 会话超时:session.setMaxInactiveInterval(1800)别忘了设置
3.2 Token校验的精细控制
子系统拿到Token后要回调认证中心验证,这里设计成REST接口:
java复制@GetMapping("/verify")
@ResponseBody
public ResponseEntity<Boolean> verifyToken(
@RequestParam String token,
@RequestParam String logoutUrl,
@RequestParam String sessionId) {
if(!TokenPool.contains(token)) {
return ResponseEntity.ok(false);
}
// 注册客户端信息
ClientInfo client = new ClientInfo(logoutUrl, sessionId);
TokenPool.registerClient(token, client);
return ResponseEntity.ok(true);
}
特别注意这个设计要满足:
- 幂等性:相同参数多次调用结果一致
- 线程安全:TokenPool要用ConcurrentHashMap
- 可扩展:后期可以加入调用频率限制
4. 客户端系统集成
4.1 拦截器实现要点
客户端的核心是拦截器,这里分享我优化过三次的版本:
java复制public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 检查本地会话
if(request.getSession().getAttribute("USER") != null) {
return true;
}
// 获取Token
String token = request.getParameter("token");
if(StringUtils.isEmpty(token)) {
redirectToAuthCenter(request, response);
return false;
}
// 验证Token
if(!verifyToken(token, request.getSession().getId())) {
redirectToAuthCenter(request, response);
return false;
}
// 创建本地会话
createLocalSession(request, token);
return true;
}
private void redirectToAuthCenter(...) {
String redirectUrl = URLEncoder.encode(currentUrl, "UTF-8");
String authUrl = "http://sso.auth.com/login?redirect=" + redirectUrl;
response.sendRedirect(authUrl);
}
}
4.2 跨域问题解决方案
现代前端经常是独立部署,会遇到跨域问题。建议这样处理:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://app1.client.com", "http://app2.client.com")
.allowCredentials(true)
.allowedMethods("GET", "POST");
}
}
同时要在拦截器中处理OPTIONS请求:
java复制if("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
5. 统一注销的完整实现
5.1 注销链路的时序控制
最复杂的其实是注销功能,需要:
- 清除认证中心会话
- 通知所有子系统
- 清理Token存储
java复制@GetMapping("/logout")
public String logout(HttpServletRequest request) {
// 获取当前Token
String token = (String) request.getSession().getAttribute("AUTH_TOKEN");
// 销毁全局会话
request.getSession().invalidate();
// 获取所有注册客户端
List<ClientInfo> clients = TokenPool.getClients(token);
// 异步通知客户端
clients.parallelStream().forEach(client -> {
notifyClientLogout(client);
});
// 清理Token
TokenPool.removeToken(token);
return "logout_success";
}
5.2 异步通知的可靠性保障
通知子系统时要注意:
- 设置超时(建议3秒)
- 失败重试机制
- 日志记录
java复制private void notifyClientLogout(ClientInfo client) {
try {
HttpURLConnection conn = (HttpURLConnection)
new URL(client.getLogoutUrl()).openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Cookie", "JSESSIONID=" + client.getSessionId());
conn.setConnectTimeout(3000);
conn.getResponseCode();
} catch (Exception e) {
log.error("注销通知失败: {}", client.getLogoutUrl(), e);
}
}
6. 生产环境进阶优化
6.1 性能优化方案
当系统规模扩大后,需要:
- 用Redis替代内存存储
- 增加二级缓存
- Token分片存储
Redis配置示例:
java复制@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
return template;
}
6.2 安全加固措施
必须实现的安全策略:
- Token加密传输
- 接口签名验证
- 防重放攻击
建议使用HMAC签名:
java复制public class SignUtils {
public static String generateSign(String params, String secret) {
Mac sha256 = Mac.getInstance("HmacSHA256");
sha256.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
byte[] hash = sha256.doFinal(params.getBytes());
return Hex.encodeHexString(hash);
}
}
7. 踩坑记录与解决方案
Cookie跨域问题:曾经遇到Chrome新版SameSite策略导致Token传递失败,最终解决方案:
java复制response.setHeader("Set-Cookie",
"token=" + token + "; Path=/; Domain=.company.com; SameSite=None; Secure");
Session复制问题:在集群环境下,发现部分节点无法同步Session,后来引入Spring Session:
xml复制<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
Token劫持风险:某次安全扫描发现Token可能被XSS窃取,于是增加了:
- HttpOnly标记
- 定期刷新机制
- IP绑定检查
java复制String cookie = String.format("token=%s; Path=/; HttpOnly; Secure", token);
response.setHeader("Set-Cookie", cookie);
8. 扩展思考与未来演进
虽然我们已经实现了基础SSO,但在实际项目中还需要考虑:
- 多因素认证集成
- 风险设备识别
- 行为异常检测
比如可以扩展登录接口:
java复制public LoginResult login(LoginDTO dto) {
// 基础验证...
// 设备指纹检查
if(riskService.isRiskDevice(dto.getDeviceId())) {
return LoginResult.needSmsVerify();
}
// 地理位置检查
if(loginLocationChanged(dto.getUsername(), dto.getIp())) {
return LoginResult.needEmailConfirm();
}
// 正常登录流程...
}
