谈谈项目中单点登录的实现原理

一次登录,处处通行------单点登录(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) {
            // 记录登录失败指标
        }
    }
}

总结

单点登录的实现需要综合考虑多个方面:

  1. 用户体验:无缝的登录跳转,减少用户操作
  2. 安全性:Token安全、防重放攻击、安全传输
  3. 性能:缓存策略、数据库优化、高并发处理
  4. 可扩展性:支持多系统、跨域场景
  5. 可维护性:清晰的架构、完善的监控

通过合理的架构设计和技术选型,单点登录能够显著提升用户体验,同时保证系统的安全性和稳定性。

互动思考:在你的项目中,是如何处理移动端和Web端统一的单点登录需求的?欢迎在评论区分享你的实践经验!

相关推荐
码路飞16 分钟前
GPT-5.3 Instant 终于学会好好说话了,顺手对比了下同天发布的 Gemini 3.1 Flash-Lite
java·javascript
序安InToo18 分钟前
第6课|注释与代码风格
后端·操作系统·嵌入式
xyy12319 分钟前
C#: Newtonsoft.Json 到 System.Text.Json 迁移避坑指南
后端
洋洋技术笔记21 分钟前
Spring Boot Web MVC配置详解
spring boot·后端
JxWang0522 分钟前
VS Code 配置 Markdown 环境
后端
navms25 分钟前
搞懂线程池,先把 Worker 机制啃明白
后端
JxWang0525 分钟前
离线数仓的优化及重构
后端
Nyarlathotep011326 分钟前
gin01:初探gin的启动
后端·go
JxWang0526 分钟前
安卓手机配置通用多屏协同及自动化脚本
后端
JxWang0528 分钟前
Windows Terminal 配置 oh-my-posh
后端