单点登录(SSO)完整流程
我们基于 **「信任域 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. 登录全流程

  1. 用户访问 A 系统受保护页面 → A 系统检查无本地会话
  2. A 系统重定向到 SSO 登录页,携带回调地址
  3. 用户在 SSO 输入账号密码 → SSO 验证通过
  4. SSO 创建全局会话(Redis 存储 Token),生成授权码
  5. SSO 重定向回 A 系统回调地址,带上授权码
  6. A 系统拿授权码向 SSO 换取JWT 令牌,校验令牌合法性
  7. A 系统创建本地会话(Cookie),用户成功访问 A

3. 免密访问 B 系统流程

  1. 用户访问 B 系统受保护页面 → B 检查无本地会话
  2. B 重定向到 SSO → SSO 检查全局会话已存在
  3. SSO 直接生成授权码,回跳 B 系统回调地址
  4. B 校验令牌、创建本地会话 → 免密登录 B
 

4. 单点登出流程

  1. 用户在 A 系统点击登出 → A 重定向到 SSO 登出接口
  2. SSO 销毁全局会话(删除 Redis Token)
  3. SSO 主动通知 A、B 系统销毁各自本地会话
  4. 所有系统登出完成,跳转回 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 系统完全一致,仅修改:
 
  1. application.yml 端口8082clientId: web-b
  2. 控制器路径/b、接口/b/info/b/callback
  3. 回调地址对应 B 系统
 
逻辑完全复用,体现信任系统免改造、快速接入 SSO的特点。

三、完整流程演示

1. 首次访问 A 系统

  1. 浏览器打开:http://localhost:8081/a/info
  2. 拦截器检测未登录 → 自动跳转到:http://localhost:8080/sso/loginPage
  3. 输入账号admin/ 密码123456登录
  4. SSO 重定向回 A 回调 → A 创建本地会话
  5. 成功访问 A 系统资源

2. 免密访问 B 系统

  1. 新开标签页打开:http://localhost:8082/b/info
  2. B 检测未登录 → 跳 SSO
  3. SSO 检测全局会话已存在 → 直接回跳 B 回调
  4. B 自动登录,无需输入密码

3. 单点登出

  1. 在 A 系统点击登出:http://localhost:8081/a/logout
  2. 跳转 SSO 登出接口 → SSO 删除全局 Token
  3. SSO 调用 A、B/local/logout → 销毁两个系统本地会话
  4. 最终跳回 SSO 登录页,A、B 均需重新登录

四、关键设计说明

  1. 信任机制
     
    A/B 系统只信任 SSO 签发的 JWT 令牌,通过固定密钥校验,无需账号密码互通。
  2. 会话分离
    • 全局会话:SSO 端 Redis 存储 Token
    • 局部会话:A/B 端 Session/Cookie
     
  3. 安全点
    • 授权码一次性使用,5 分钟过期
    • JWT 带过期时间,防止令牌永久有效
    • 登出全链路同步,避免单系统登出其他仍在线
     
  4. 扩展
     
    可替换 JWT 为 OAuth2.0、OIDC,或增加 CAS 协议,核心跳转 / 校验逻辑不变。