前言
单点登录(SSO,Single Sign-On)是一种用户认证和授权的解决方案,允许用户使用一组凭据(如用户名和密码)登录多个相互关联的应用程序和网站,而不需要在每个应用程序和网站中重新输入凭据。
SSO 通过在用户访问第一个应用程序或网站时进行身份验证,然后将用户的身份信息存储在一个中央位置中,以便于其他应用程序和网站进行访问。这种方式可以简化用户的登录过程,减少用户需要记住的凭据数量,提高用户体验和工作效率
四种实现方案
基于Cookie-Session的传统SSO方案
原理:
这是最基础的SSO实现方式,其核心是将用户认证状态存储在服务端Session中,并通过Cookie在客户端保存Session标识符。
当用户登录SSO服务器后,服务器创建Session存储用户信息,并将SessionID通过Cookie设置在顶级域名下,使所有子域应用都能访问该Cookie并验证同一个Session。
优点:
• 实现相对简单,遵循传统Web开发模式
• 服务端完全控制会话状态和生命周期
• 客户端无需存储和管理复杂状态
• 支持即时会话失效和撤销
缺点:
• 受同源策略限制,仅适用于同一顶级域名下的应用
• 依赖Cookie机制,在某些环境可能受限(如移动应用)
• 存在CSRF风险
基于JWT的无状态SSO方案
原理:
JWT(JSON Web Token)是一种紧凑的、自包含的令牌格式,可以在不同应用间安全地传递信息。
使用JWT实现SSO时,认证服务器在用户登录后生成JWT令牌,其中包含用户相关信息和签名。
由于JWT可以独立验证而无需查询中央服务器,它非常适合构建无状态的SSO系统。
优点:
• 完全无状态,服务器不需要存储会话信息
• 跨域支持,适用于分布式系统和微服务
• 可扩展性好,JWT可包含丰富的用户信息
• 不依赖Cookie,避免CSRF问题
• 适用于各种客户端(Web、移动应用、API)
缺点:
• 无法主动失效已颁发的令牌(除非使用黑名单机制)
• JWT可能较大,增加网络传输负担
• 令牌管理需要客户端介入
• 刷新令牌机制较复杂
• 存在令牌被盗用的风险
基于OAuth 2.0/OpenID Connect的SSO方案
原理:
OAuth 2.0是一个授权框架,而OpenID Connect(OIDC)是建立在OAuth 2.0之上的身份认证层。
这是目前最标准化和完善的SSO解决方案,特别适合企业级应用和需要第三方集成的场景。它提供了丰富的授权流程选项和安全特性。
优点:
• 成熟的安全协议,广泛采用的行业标准
• 支持多种认证流程(授权码、隐式、密码等)
• 令牌撤销机制完善
• 可扩展性极好,适合企业级应用
• 明确分离认证与授权职责
缺点:
• 实现复杂度高,小型应用可能不合适
• 配置和理解学习曲线陡峭
基于Spring Session的共享会话SSO方案
原理:
Spring Session提供了一个将会话数据存储在共享外部存储(如Redis)中的框架,使得不同的应用之间能够共享会话信息。
这种方式特别适合基于Spring的同构系统,可以在保持简单实现的同时解决分布式会话共享问题。
优点:
• 实现简单,易于理解
• 与Spring生态无缝集成
• 会话可包含丰富的信息
缺点:
• 依赖中央存储(如Redis)
• 会话数据需要序列化/反序列化
• 依赖Cookie,不适合非Web应用
基于JWT具体实现方案
首先添加依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>
|
实现JWT工具类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| @Component public class JwtTokenUtil {
@Value("${jwt.secret}") private String secret;
@Value("${jwt.expiration}") private Long expiration;
private SecretKey getSecretKey() { return Keys.hmacShaKeyFor(secret.getBytes()); }
public String generateToken(String username, Map<String, Object> claims) { return Jwts.builder() .setClaims(claims) .setSubject(username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000)) .signWith(getSecretKey(), SignatureAlgorithm.HS256) .compact(); }
public Boolean validateToken(String token) { try { Jwts.parserBuilder().setSigningKey(getSecretKey()).build().parseClaimsJws(token); return true; } catch (Exception e) { return false; } }
public String getUsernameFromToken(String token) { return Jwts.parserBuilder().setSigningKey(getSecretKey()).build() .parseClaimsJws(token).getBody().getSubject(); }
public Claims getClaimsFromToken(String token) { return Jwts.parserBuilder().setSigningKey(getSecretKey()).build() .parseClaimsJws(token).getBody(); } }
|
实现认证控制器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| @RestController @RequestMapping("/auth") public class AuthController {
@Autowired private JwtTokenUtil jwtTokenUtil;
@PostMapping("/login") public ResponseEntity<?> login(@RequestParam String username, @RequestParam String password) { if (!"admin".equals(username) || !"123456".equals(password)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("用户名或密码错误"); }
Map<String, Object> claims = new HashMap<>(); claims.put("role", "ADMIN"); String token = jwtTokenUtil.generateToken(username, claims);
Map<String, String> response = new HashMap<>(); response.put("token", token); return ResponseEntity.ok(response); } }
|
JWT过滤器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenUtil jwtTokenUtil;
public JwtAuthenticationFilter(JwtTokenUtil jwtTokenUtil) { this.jwtTokenUtil = jwtTokenUtil; }
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String token = getTokenFromRequest(request);
if (token != null && jwtTokenUtil.validateToken(token)) { String username = jwtTokenUtil.getUsernameFromToken(token);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( username, null, new ArrayList<>()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { logger.error("认证失败: " + e.getMessage()); }
filterChain.doFilter(request, response); }
private String getTokenFromRequest(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } return null; } }
|
安全配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Configuration @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired private JwtTokenUtil jwtTokenUtil;
@Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/auth/**").permitAll() .anyRequest().authenticated() .and() .addFilterBefore(new JwtAuthenticationFilter(jwtTokenUtil), UsernamePasswordAuthenticationFilter.class); } }
|
方案选择与最佳实践
方案类型 |
推荐场景 |
不适合场景 |
Cookie-Session |
同域名下的小型应用,简单的认证需求 |
跨域应用,移动应用集成,高安全性需求 |
JWT |
分布式微服务,前后端分离应用 |
需要即时撤销令牌的场景,极高安全性要求 |
OAuth 2.0/OIDC |
企业级应用,需要第三方集成,多租户系统 |
小型应用,资源受限环境,急速开发需求 |
Spring Session |
Spring技术栈的应用,中型企业应用 |
异构技术栈,非Web应用集成 |