我们基于 **「信任域 SSO 统一认证中心 + A 系统 + B 系统」模型,用SpringBoot + JWT + Redis实现最通用的基于令牌的 SSO 流程 **,覆盖:
- 未登录访问 → 跳转 SSO 登录
- 登录成功 → 回跳业务系统
- 已登录访问另一信任系统 → 免密自动登录
- 单点登出 → 全系统同步登出
一、核心流程(文字版流程图)
1. 基础角色
- SSO 认证中心(sso-server):端口
8080,负责统一登录、签发令牌、全局会话管理 - A 业务系统(web-a):端口
8081,信任 SSO,受 SSO 保护 - B 业务系统(web-b):端口
8082,信任 SSO,与 A 平级
2. 登录全流程
- 用户访问 A 系统受保护页面 → A 系统检查无本地会话
- A 系统重定向到 SSO 登录页,携带回调地址
- 用户在 SSO 输入账号密码 → SSO 验证通过
- SSO 创建全局会话(Redis 存储 Token),生成授权码
- SSO 重定向回 A 系统回调地址,带上授权码
- A 系统拿授权码向 SSO 换取JWT 令牌,校验令牌合法性
- A 系统创建本地会话(Cookie),用户成功访问 A
3. 免密访问 B 系统流程
- 用户访问 B 系统受保护页面 → B 检查无本地会话
- B 重定向到 SSO → SSO 检查全局会话已存在
- SSO 直接生成授权码,回跳 B 系统回调地址
- B 校验令牌、创建本地会话 → 免密登录 B
4. 单点登出流程
- 用户在 A 系统点击登出 → A 重定向到 SSO 登出接口
- SSO 销毁全局会话(删除 Redis Token)
- SSO 主动通知 A、B 系统销毁各自本地会话
- 所有系统登出完成,跳转回 SSO 登录页
二、项目依赖(通用 POM)
3 个服务共用核心依赖,仅端口 / 配置不同
<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><!-- JWT令牌 --><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>0.11.5</version></dependency><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>0.11.5</version><scope>runtime</scope></dependency><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>0.11.5</version><scope>runtime</scope></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency> </dependencies>
第一部分:SSO 认证中心(sso-server:8080)
1. 配置文件 application.yml
server:port: 8080 spring:redis:host: localhostport: 6379 # JWT密钥 jwt:secret: my-sso-secret-key-202507 # 自定义密钥expire: 3600 # 1小时 # 注册信任的业务系统 sso:clients:web-a: http://localhost:8081web-b: http://localhost:8082
2. JWT 工具类
import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component;import javax.crypto.SecretKey; import java.util.Date; import java.util.Map;@Component public class JwtUtil {@Value("${jwt.secret}")private String secret;@Value("${jwt.expire}")private Long expire;private SecretKey getKey() {return Keys.hmacShaKeyFor(secret.getBytes());}// 生成Tokenpublic String generateToken(Map<String, Object> claims) {return Jwts.builder().setClaims(claims).setExpiration(new Date(System.currentTimeMillis() + expire * 1000)).signWith(getKey(), SignatureAlgorithm.HS256).compact();}// 解析Tokenpublic Claims parseToken(String token) {return Jwts.parserBuilder().setSigningKey(getKey()).build().parseClaimsJws(token).getBody();} }
3. SSO 核心控制器
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.view.RedirectView;import javax.annotation.Resource; import java.util.*; import java.util.concurrent.TimeUnit;@RestController @RequestMapping("/sso") public class SsoController {@Resourceprivate StringRedisTemplate redisTemplate;@Resourceprivate JwtUtil jwtUtil;private static final String REDIS_TOKEN_PREFIX = "sso:token:";private static final String REDIS_CODE_PREFIX = "sso:code:";// 模拟用户库private static final Map<String, String> USER_MAP = new HashMap<>();static { USER_MAP.put("admin", "123456"); }// ====================== 1. 登录接口 ======================@PostMapping("/login")public RedirectView login(String username, String password, String clientId, String redirectUri) {// 1. 校验账号密码if (!USER_MAP.containsKey(username) || !USER_MAP.get(username).equals(password)) {return new RedirectView("/sso/loginPage?error=账号密码错误");}// 2. 生成JWT全局TokenMap<String, Object> claims = new HashMap<>();claims.put("username", username);String token = jwtUtil.generateToken(claims);// 3. Redis存储全局会话redisTemplate.opsForValue().set(REDIS_TOKEN_PREFIX + token, username, 1, TimeUnit.HOURS);// 4. 生成临时授权码String code = UUID.randomUUID().toString();redisTemplate.opsForValue().set(REDIS_CODE_PREFIX + code, token, 5, TimeUnit.MINUTES);// 5. 回跳业务系统return new RedirectView(redirectUri + "?code=" + code);}// ====================== 2. 授权码换Token ======================@GetMapping("/token")public Map<String, Object> getToken(String code) {String token = redisTemplate.opsForValue().get(REDIS_CODE_PREFIX + code);if (token == null) {return Map.of("code", 400, "msg", "授权码无效");}// 用完即删redisTemplate.delete(REDIS_CODE_PREFIX + code);return Map.of("code", 200, "token", token);}// ====================== 3. 校验Token ======================@GetMapping("/check")public Map<String, Object> checkToken(String token) {try {jwtUtil.parseToken(token);String username = redisTemplate.opsForValue().get(REDIS_TOKEN_PREFIX + token);if (username == null) return Map.of("valid", false);return Map.of("valid", true, "username", username);} catch (Exception e) {return Map.of("valid", false);}}// ====================== 4. 单点登出 ======================@GetMapping("/logout")public RedirectView ssoLogout(String token) {// 1. 销毁全局会话redisTemplate.delete(REDIS_TOKEN_PREFIX + token);// 2. 通知所有信任系统登出redisTemplate.opsForHash().entries("sso:clients").forEach((clientId, url) -> {try {// 调用A/B系统登出接口String logoutUrl = url + "/local/logout";new RestTemplate().getForObject(logoutUrl, String.class);} catch (Exception ignored) {}});// 3. 跳回登录页return new RedirectView("/sso/loginPage");}// 登录页面@GetMapping("/loginPage")public String loginPage(String clientId, String redirectUri) {return "<html><body>" +"<form action='/sso/login?clientId="+clientId+"&redirectUri="+redirectUri+"' method='post'>" +"用户名:<input name='username'><br/>" +"密码:<input name='password' type='password'><br/>" +"<button type='submit'>登录</button>" +"</form></body></html>";} }
第二部分:业务系统 A(web-a:8081)
1. 配置文件 application.yml
server:port: 8081 sso:server: http://localhost:8080clientId: web-a
2. 登录拦截器
import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor;import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession;@Component public class SsoInterceptor implements HandlerInterceptor {@Value("${sso.server}")private String ssoServer;@Value("${sso.clientId}")private String clientId;@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {HttpSession session = request.getSession();// 已登录放行if (session.getAttribute("token") != null) {return true;}// 未登录 → 跳SSO,带回调地址String redirectUri = request.getRequestURL().toString().split(request.getRequestURI())[0] + "/a/callback";response.sendRedirect(ssoServer + "/sso/loginPage?clientId=" + clientId + "&redirectUri=" + redirectUri);return false;} }
3. 业务系统控制器
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.client.RestTemplate;import javax.servlet.http.HttpSession; import java.util.Map;@RestController @RequestMapping("/a") public class WebAController {@Value("${sso.server}")private String ssoServer;// 受保护接口@GetMapping("/info")public String info(HttpSession session) {String username = (String) session.getAttribute("username");return "A系统 → 当前登录用户:" + username;}// SSO回调:授权码换Token@GetMapping("/callback")public String callback(String code, HttpSession session) {// 1. 拿code换tokenString tokenUrl = ssoServer + "/sso/token?code=" + code;Map<String, Object> res = new RestTemplate().getForObject(tokenUrl, Map.class);String token = (String) res.get("token");// 2. 校验tokenMap<String, Object> check = new RestTemplate().getForObject(ssoServer + "/sso/check?token=" + token, Map.class);if (!(Boolean) check.get("valid")) {return "登录失败";}// 3. 建立本地会话session.setAttribute("token", token);session.setAttribute("username", check.get("username"));return "A系统登录成功,<a href='/a/info'>访问A资源</a> <a href='/a/logout'>登出</a>";}// 本地登出 → 跳转SSO登出@GetMapping("/logout")public String logout(HttpSession session) {String token = (String) session.getAttribute("token");session.invalidate();return "redirect:" + ssoServer + "/sso/logout?token=" + token;}// 接收SSO登出通知,销毁本地会话@GetMapping("/local/logout")public void localLogout(HttpSession session) {session.invalidate();} }
第三部分:业务系统 B(web-b:8082)
代码与 A 系统完全一致,仅修改:
application.yml端口8082、clientId: web-b- 控制器路径
/b、接口/b/info、/b/callback - 回调地址对应 B 系统
逻辑完全复用,体现信任系统免改造、快速接入 SSO的特点。
三、完整流程演示
1. 首次访问 A 系统
- 浏览器打开:
http://localhost:8081/a/info - 拦截器检测未登录 → 自动跳转到:
http://localhost:8080/sso/loginPage - 输入账号
admin/ 密码123456登录 - SSO 重定向回 A 回调 → A 创建本地会话
- 成功访问 A 系统资源
2. 免密访问 B 系统
- 新开标签页打开:
http://localhost:8082/b/info - B 检测未登录 → 跳 SSO
- SSO 检测全局会话已存在 → 直接回跳 B 回调
- B 自动登录,无需输入密码
3. 单点登出
- 在 A 系统点击登出:
http://localhost:8081/a/logout - 跳转 SSO 登出接口 → SSO 删除全局 Token
- SSO 调用 A、B
/local/logout→ 销毁两个系统本地会话 - 最终跳回 SSO 登录页,A、B 均需重新登录
四、关键设计说明
- 信任机制
A/B 系统只信任 SSO 签发的 JWT 令牌,通过固定密钥校验,无需账号密码互通。
- 会话分离
- 全局会话:SSO 端 Redis 存储 Token
- 局部会话:A/B 端 Session/Cookie
- 安全点
- 授权码一次性使用,5 分钟过期
- JWT 带过期时间,防止令牌永久有效
- 登出全链路同步,避免单系统登出其他仍在线
- 扩展
可替换 JWT 为 OAuth2.0、OIDC,或增加 CAS 协议,核心跳转 / 校验逻辑不变。
