第一次接触SSO这个概念是在五年前,当时公司内部有十几个系统,每个系统都有自己的登录模块。新来的同事第一天要记住七八套账号密码,经常看到有人把密码写在便利贴上贴在显示器旁边。作为开发者的我们更痛苦,每次新系统上线都要重写一遍登录逻辑,用户表结构不统一导致数据同步成了噩梦。
单点登录(SSO)就是为了解决这种混乱局面而生的。它的核心思想很简单:一次登录,全网通行。想象一下,你早上到公司刷一次工卡,就能畅通无阻地进入所有办公区域,不需要在每个门口都验证身份——这就是SSO带来的体验。
在技术实现上,基于Token的SSO方案特别适合分布式系统。我经手过的项目里,从早期的Session共享到后来的JWT方案,最终发现自研Token中心的灵活度最高。比如去年给某金融机构做的项目,他们要求必须支持动态令牌失效策略,这时候自定义Token体系就能轻松应对。
建议直接用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
我们的系统会包含三个核心组件:
![架构流程图]
(注:此处实际项目应插入架构图,文字描述替代)
认证中心就像机场的值机柜台,给你登机牌(Token);各个子系统就像登机口,只认登机牌不关心你怎么买的票。
登录逻辑看似简单,但隐藏着不少坑。先看核心代码:
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后要回调认证中心验证,这里设计成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);
}
特别注意这个设计要满足:
客户端的核心是拦截器,这里分享我优化过三次的版本:
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);
}
}
现代前端经常是独立部署,会遇到跨域问题。建议这样处理:
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;
}
最复杂的其实是注销功能,需要:
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";
}
通知子系统时要注意:
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);
}
}
当系统规模扩大后,需要:
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;
}
必须实现的安全策略:
建议使用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);
}
}
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窃取,于是增加了:
java复制String cookie = String.format("token=%s; Path=/; HttpOnly; Secure", token);
response.setHeader("Set-Cookie", cookie);
虽然我们已经实现了基础SSO,但在实际项目中还需要考虑:
比如可以扩展登录接口:
java复制public LoginResult login(LoginDTO dto) {
// 基础验证...
// 设备指纹检查
if(riskService.isRiskDevice(dto.getDeviceId())) {
return LoginResult.needSmsVerify();
}
// 地理位置检查
if(loginLocationChanged(dto.getUsername(), dto.getIp())) {
return LoginResult.needEmailConfirm();
}
// 正常登录流程...
}