一次登录,处处通行------单点登录(SSO)如何让用户体验飞起来
什么是单点登录?
想象一下这样的场景:早晨来到公司,你登录了OA系统;接着需要查看项目进度,又得登录项目管理平台;下午要申请报销,还得再次登录财务系统... 这样的重复登录体验是不是很糟糕?
单点登录(SSO) 就是为了解决这个问题而生的:用户只需登录一次,就可以访问所有相互信任的应用系统。
核心实现原理
1. 基于Cookie的共享Session方案
在早期项目中,我们采用基于Cookie的共享Session方案:
实现思路:
- 所有子系统使用同一个顶级域名
- 登录成功后,认证中心设置一个全局Session Cookie
- 其他子系统通过读取这个Cookie来验证用户身份
代码示例:
java
@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 登录时序图
text
用户访问业务系统A
→ 重定向到认证中心
→ 用户输入账号密码
→ 认证中心验证身份并生成Token
→ 重定向回业务系统A(携带Token)
→ 业务系统A向认证中心验证Token
→ 登录成功
2.2 认证中心实现
java
@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 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<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工具类
java
@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 业务系统拦截器
java
@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安全策略
java
@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. 防止重放攻击
java
@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);
}
}
2. 数据库优化
sql
-- 创建用户会话表
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))
);
实际部署架构
高可用架构设计
text
客户端 → 负载均衡器 → [认证中心实例1, 认证中心实例2, ...]
↓
[Redis集群]
↓
[数据库主从]
监控与告警
java
@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端统一的单点登录需求的?欢迎在评论区分享你的实践经验!