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

一次登录,处处通行------单点登录(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端统一的单点登录需求的?欢迎在评论区分享你的实践经验!

相关推荐
cike_y2 小时前
Mybatis之解析配置优化
java·开发语言·tomcat·mybatis·安全开发
WanderInk2 小时前
刷新后点赞全变 0?别急着怪 Redis,这八成是 Long 被 JavaScript 偷偷“改号”了(一次线上复盘)
后端
是一个Bug3 小时前
Java基础50道经典面试题(四)
java·windows·python
Slow菜鸟3 小时前
Java基础架构设计(三)| 通用响应与异常处理(分布式应用通用方案)
java·开发语言
吴佳浩3 小时前
Python入门指南(七) - YOLO检测API进阶实战
人工智能·后端·python
我是Superman丶3 小时前
《Spring WebFlux 实战:基于 SSE 实现多类型事件流(支持聊天消息、元数据与控制指令混合传输)》
java
廋到被风吹走4 小时前
【Spring】常用注解分类整理
java·后端·spring
是一个Bug4 小时前
Java基础20道经典面试题(二)
java·开发语言
Z_Easen4 小时前
Spring 之元编程
java·开发语言