说一下,项目中单点登录的实现原理
谈谈项目中单点登录的实现原理
一次登录,处处通行——单点登录(SSO)如何让用户体验飞起来
什么是单点登录?
想象一下这样的场景:早晨来到公司,你登录了OA系统;接着需要查看项目进度,又得登录项目管理平台;下午要申请报销,还得再次登录财务系统… 这样的重复登录体验是不是很糟糕?
单点登录(SSO) 就是为了解决这个问题而生的:用户只需登录一次,就可以访问所有相互信任的应用系统。
核心实现原理
- 基于Cookie的共享Session方案
在早期项目中,我们采用基于Cookie的共享Session方案:
实现思路:
所有子系统使用同一个顶级域名
登录成功后,认证中心设置一个全局Session Cookie
其他子系统通过读取这个Cookie来验证用户身份
代码示例:
@Service
public class TraditionalSSOService {
public void login(HttpServletResponse response, String username) {// 创建全局SessionString 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<LoginResult> login(@RequestBody LoginRequest request) {// 1. 验证用户凭证User user = userService.authenticate(request.getUsername(), request.getPassword());// 2. 生成JWT TokenString 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<User> 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<String, Object> 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. 获取TokenString token = extractToken(request);if (token == null) {// 重定向到认证中心登录页redirectToLoginPage(request, response);return false;}// 2. 验证TokenUser 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);
}
}
安全考虑与最佳实践
-
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);
}
}
- 生成安全的随机Token
-
防止重放攻击
@Service
public class ReplayAttackProtection {@Autowired
private RedisTemplate<String, String> 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);
}
}
-
-
数据库优化
– 创建用户会话表
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端统一的单点登录需求的?欢迎在评论区分享你的实践经验!
