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

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

相关推荐
Chenyiax10 分钟前
从 Chat 到 Responses:OpenAI API 抽象为什么变了?
后端
MariaH11 分钟前
Koa和Express的区别
后端
MariaH17 分钟前
Koa框架的使用
后端
luckdewei1 小时前
那个用 passlib 做认证的新同事,上线第一天就把用户密码写进了日志
后端
ping某3 小时前
为什么 Nginx 明明监听了 80,转发后端时却用了 4xxxx 端口?
后端·nginx
JustHappy3 小时前
我汇总了身边朋友的经历才发现,其实第一份实习是最难找的......
前端·后端·面试
uhakadotcom3 小时前
在python 的 工程化架构中 ,什么是 薄包装器层?
后端·面试·github
唐青枫7 小时前
Java JDBC 实战指南:从 Connection 到事务和连接池
java
用户1474853079747 小时前
CodeX使用Skill生成游戏美术和音乐资源,一分钟入门
后端
Melody1237 小时前
用 abort 中断 AI 流式请求,我之前做错了
后端