什么是单点登录?
想象一下这样的场景:早晨来到公司,你登录了OA系统;接着需要查看项目进度,又得登录项目管理平台;下午要申请报销,还得再次登录财务系统… 这样的重复登录体验是不是很糟糕?
单点登录(SSO) 就是为了解决这个问题而生的:用户只需登录一次,就可以访问所有相互信任的应用系统。
核心实现原理
1. 基于Cookie的共享Session方案
在早期项目中,我们采用基于Cookie的共享Session方案:
实现思路:
- 所有子系统使用同一个顶级域名
- 登录成功后,认证中心设置一个全局Session Cookie
- 其他子系统通过读取这个Cookie来验证用户身份
代码示例:
@Service
public class TraditionalSSOService {
public void login(HttpServletResponse response, String username) {
// 创建全局Session
String globalSessionId = UUID.randomUUID().toString();
// 存储Session信息
redisTemplate.opsForValue().set(
\"global_session:\" + globalSessionId,
username,
30, TimeUnit.MINUTES
);
// 设置全局Cookie,所有子域名都可以访问
Cookie sessionCookie = new Cookie(\"GLOBAL_SESSION_ID\", globalSessionId);
sessionCookie.setDomain(\".company.com\"); // 设置顶级域名
sessionCookie.setPath(\"/\");
sessionCookie.setMaxAge(30 * 60); // 30分钟
response.addCookie(sessionCookie);
}
}
局限性:
- 域名必须相同或具有父子关系
- 安全性较低,容易受到CSRF攻击
- 不适合跨域场景
2. 基于Token的现代SSO方案(主流)
现在我们普遍采用基于Token的SSO方案,核心流程如下:
2.1 登录时序图
用户访问业务系统A
→ 重定向到认证中心
→ 用户输入账号密码
→ 认证中心验证身份并生成Token
→ 重定向回业务系统A(携带Token)
→ 业务系统A向认证中心验证Token
→ 登录成功
2.2 认证中心实现
@RestController
@RequestMapping(\"/auth\")
public class AuthCenterController {
@Autowired
private UserService userService;
@Autowired
private JwtTokenProvider tokenProvider;
/**
* 登录接口
*/
@PostMapping(\"/login\")
public ResponseEntity login(@RequestBody LoginRequest request) {
// 1. 验证用户凭证
User user = userService.authenticate(request.getUsername(), request.getPassword());
// 2. 生成JWT Token
String token = tokenProvider.generateToken(user);
// 3. 记录登录状态
redisTemplate.opsForValue().set(
\"sso_token:\" + user.getId(),
token,
tokenProvider.getTokenValidity(),
TimeUnit.SECONDS
);
return ResponseEntity.ok(new LoginResult(token, user));
}
/**
* 验证Token接口
*/
@PostMapping(\"/verify\")
public ResponseEntity verifyToken(@RequestParam String token) {
// 1. 验证Token有效性
if (!tokenProvider.validateToken(token)) {
return ResponseEntity.status(401).build();
}
// 2. 从Token中提取用户信息
String userId = tokenProvider.getUserIdFromToken(token);
// 3. 检查Token是否在服务端有记录(支持登出功能)
String serverToken = redisTemplate.opsForValue().get(\"sso_token:\" + userId);
if (!token.equals(serverToken)) {
return ResponseEntity.status(401).build();
}
User user = userService.findById(userId);
return ResponseEntity.ok(user);
}
/**
* 登出接口
*/
@PostMapping(\"/logout\")
public ResponseEntity logout(@RequestHeader(\"Authorization\") String token) {
String userId = tokenProvider.getUserIdFromToken(token.replace(\"Bearer \", \"\"));
redisTemplate.delete(\"sso_token:\" + userId);
return ResponseEntity.ok().build();
}
}
2.3 JWT Token工具类
@Component
public class JwtTokenProvider {
@Value(\"${jwt.secret:defaultSecretKey}\")
private String secretKey;
@Value(\"${jwt.validity:3600}\")
private long tokenValidityInSeconds;
/**
* 生成JWT Token
*/
public String generateToken(User user) {
Map claims = new HashMap();
claims.put(\"userId\", user.getId());
claims.put(\"username\", user.getUsername());
claims.put(\"roles\", user.getRoles());
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + tokenValidityInSeconds * 1000))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
/**
* 验证Token有效性
*/
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return true;
} catch (ExpiredJwtException e) {
log.warn(\"Token已过期: {}\", e.getMessage());
} catch (Exception e) {
log.warn(\"Token验证失败: {}\", e.getMessage());
}
return false;
}
/**
* 从Token中提取用户ID
*/
public String getUserIdFromToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
return claims.get(\"userId\", String.class);
}
}
2.4 业务系统拦截器
@Component
public class SSOInterceptor implements HandlerInterceptor {
@Autowired
private AuthClient authClient;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 排除登录接口本身
if (request.getRequestURI().contains(\"/login\")) {
return true;
}
// 1. 获取Token
String token = extractToken(request);
if (token == null) {
// 重定向到认证中心登录页
redirectToLoginPage(request, response);
return false;
}
// 2. 验证Token
User user = authClient.verifyToken(token);
if (user == null) {
// Token无效,重新登录
redirectToLoginPage(request, response);
return false;
}
// 3. 将用户信息存入请求上下文
UserContext.setCurrentUser(user);
return true;
}
private String extractToken(HttpServletRequest request) {
// 从Header中获取
String authHeader = request.getHeader(\"Authorization\");
if (authHeader != null && authHeader.startsWith(\"Bearer \")) {
return authHeader.substring(7);
}
// 从URL参数中获取
String tokenParam = request.getParameter(\"token\");
if (tokenParam != null) {
return tokenParam;
}
// 从Cookie中获取
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (\"SSO_TOKEN\".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
private void redirectToLoginPage(HttpServletRequest request, HttpServletResponse response)
throws IOException {
String currentUrl = request.getRequestURL().toString();
String queryString = request.getQueryString();
if (queryString != null) {
currentUrl += \"?\" + queryString;
}
String loginUrl = authClient.getAuthCenterUrl() +
\"/auth/login?redirect_url=\" +
URLEncoder.encode(currentUrl, \"UTF-8\");
response.sendRedirect(loginUrl);
}
}
安全考虑与最佳实践
1. Token安全策略
@Component
public class TokenSecurityService {
/**
* 生成安全的随机Token
*/
public String generateSecureToken() {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[32];
random.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
/**
* 设置安全Cookie
*/
public void setSecureCookie(HttpServletResponse response,
String name, String value, int maxAge) {
Cookie cookie = new Cookie(name, value);
cookie.setHttpOnly(true); // 防止XSS攻击
cookie.setSecure(true); // 仅HTTPS传输
cookie.setPath(\"/\");
cookie.setMaxAge(maxAge);
response.addCookie(cookie);
}
}
2. 防止重放攻击
@Service
public class ReplayAttackProtection {
@Autowired
private RedisTemplate redisTemplate;
/**
* 检查并记录Token使用
*/
public boolean checkAndRecordTokenUsage(String token, String requestId) {
String key = \"token_usage:\" + token + \":\" + requestId;
// 如果这个请求ID已经存在,说明是重放攻击
Boolean result = redisTemplate.opsForValue().setIfAbsent(key, \"1\", 5, TimeUnit.MINUTES);
return Boolean.TRUE.equals(result);
}
}
2. 数据库优化
-- 创建用户会话表
CREATE TABLE user_sessions (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id VARCHAR(64) NOT NULL,
token VARCHAR(512) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
client_ip VARCHAR(45),
user_agent TEXT,
INDEX idx_user_id (user_id),
INDEX idx_expires_at (expires_at),
INDEX idx_token (token(64))
);
实际部署架构
高可用架构设计
客户端 → 负载均衡器 → [认证中心实例1, 认证中心实例2, ...]
↓
[Redis集群]
↓
[数据库主从]
监控与告警
@Component
public class SSOMonitor {
@Autowired
private MeterRegistry meterRegistry;
private final Counter loginCounter;
private final Counter tokenVerifyCounter;
public SSOMonitor() {
loginCounter = Counter.builder(\"sso.login.requests\")
.description(\"登录请求次数\")
.register(meterRegistry);
tokenVerifyCounter = Counter.builder(\"sso.token.verify\")
.description(\"Token验证次数\")
.register(meterRegistry);
}
public void recordLogin(boolean success) {
loginCounter.increment();
if (!success) {
// 记录登录失败指标
}
}
}
总结
单点登录的实现需要综合考虑多个方面:
- 用户体验:无缝的登录跳转,减少用户操作
- 安全性:Token安全、防重放攻击、安全传输
- 性能:缓存策略、数据库优化、高并发处理
- 可扩展性:支持多系统、跨域场景
- 可维护性:清晰的架构、完善的监控
通过合理的架构设计和技术选型,单点登录能够显著提升用户体验,同时保证系统的安全性和稳定性。
互动思考:在你的项目中,是如何处理移动端和Web端统一的单点登录需求的?欢迎在评论区分享你的实践经验!
