在数字化转型浪潮中,企业往往需要管理数十个业务系统,而传统的重复登录体验已成为效率瓶颈。我曾为一家金融科技公司重构身份体系时发现,员工每天平均需要登录8次不同系统,每年因此损失超过3000小时有效工作时间。单点登录(SSO)技术正是解决这一痛点的银弹——它允许用户通过一次认证访问所有互信系统,同时为开发者提供标准化的安全控制方案。
本文将带您从零构建一个基于OAuth 2.0授权框架和JWT令牌的SSO系统。不同于理论概述,我们聚焦可落地的代码级实现,使用Spring生态最新工具链完成以下目标:
在开始编码前,我们需要明确技术组合的合理性。下表对比了主流SSO方案的核心差异:
| 方案 | 协议类型 | 令牌格式 | 适用场景 | 开发复杂度 |
|---|---|---|---|---|
| JWT | 无状态协议 | JSON | 前后端分离架构 | ★★☆☆☆ |
| OAuth2 | 授权框架 | 任意 | 第三方授权/API保护 | ★★★☆☆ |
| CAS | 有状态协议 | Ticket | 传统企业应用 | ★★★★☆ |
| SAML | XML协议 | XML | 企业级联邦身份 | ★★★★★ |
选择OAuth 2.0+JWT组合主要基于三点考量:
使用Spring Initializr创建项目,关键依赖包括:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.31</version>
</dependency>
提示:建议使用Spring Boot 3.0+版本以获得完整的OAuth 2.1支持
在SecurityConfig中定义认证管理器:
java复制@Bean
public UserDetailsService users() {
UserDetails user = User.withUsername("admin")
.password("{bcrypt}$2a$10$...")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
@Bean
public RegisteredClientRepository clientRepository() {
RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("webapp")
.clientSecret("{bcrypt}$2a$10$...")
.scope("read")
.redirectUri("http://localhost:8080/login/oauth2/code/webapp")
.build();
return new InMemoryRegisteredClientRepository(client);
}
扩展JwtEncoder实现用户信息嵌入:
java复制public class CustomJwtEncoder implements JwtEncoder {
private final JwtEncoder delegate;
public Jwt encode(JwtEncoderParameters parameters) {
JwsHeader headers = JwsHeader.with(SignatureAlgorithm.RS256).build();
Map<String, Object> claims = new HashMap<>();
claims.put("user_id", "123");
claims.put("dept", "finance");
JwtClaimsSet values = parameters.getClaims().getClaims();
JwtClaimsSet.Builder builder = JwtClaimsSet.from(values)
.claims(c -> c.putAll(claims));
return delegate.encode(JwtEncoderParameters.from(headers, builder.build()));
}
}
资源服务器的安全配置关键点:
java复制@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
)
);
return http.build();
}
@Bean
JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(publicKey()).build();
}
创建自定义的JwtAuthenticationConverter:
java复制public class CustomJwtConverter implements Converter<Jwt, AbstractAuthenticationToken> {
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
Collection<? extends GrantedAuthority> authorities = extractAuthorities(jwt);
String username = jwt.getClaimAsString("user_name");
return new JwtAuthenticationToken(jwt, authorities, username);
}
private Collection<? extends GrantedAuthority> extractAuthorities(Jwt jwt) {
// 从JWT claims中解析权限
}
}
实现刷新令牌的存储策略:
java复制@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
return context -> {
if (context.getTokenType() == OAuth2TokenType.ACCESS_TOKEN) {
context.getClaims().claim("token_type", "access");
} else if (context.getTokenType().getValue().equals("refresh_token")) {
context.getClaims()
.claim("token_type", "refresh")
.expiresAt(Instant.now().plus(30, ChronoUnit.DAYS));
}
};
}
由于JWT的无状态特性,实现注销需要特殊处理:
java复制@RestController
public class LogoutController {
@PostMapping("/logout")
public void logout(@RequestHeader("Authorization") String token,
HttpServletResponse response) {
String jti = jwtDecoder.decode(token).getId();
redisTemplate.opsForValue().set(jti, "revoked", 30, TimeUnit.MINUTES);
response.setHeader("Clear-Site-Data", "\"cache\", \"cookies\", \"storage\"");
}
}
在资源服务器端添加黑名单检查:
java复制@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(),
new JwtClaimValidator<>("jti", jti ->
!redisTemplate.hasKey(jti.toString()))
));
return decoder;
}
采用JWK Set端点实现动态密钥管理:
java复制@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
安全配置清单:
在金融级项目中,我们还需要添加:
java复制http.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'")
)
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000)
)
);
典型部署拓扑:
code复制[负载均衡]
├── [授权服务器集群]
├── [Redis哨兵集群]
└── [资源服务器集群]
关键配置参数:
yaml复制spring:
security:
oauth2:
authorization-server:
token:
access-token-time-to-live: 30m
refresh-token-time-to-live: 7d
必备的Prometheus监控项:
oauth2_authorization_requests_totaljwt_validation_errorstoken_revocation_count在Kubernetes环境中,建议配置如下探针:
yaml复制livenessProbe:
httpGet:
path: /actuator/health
port: 8080
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
实现SSO系统后,那家金融科技公司的内部系统登录耗时从平均12秒降至0.5秒,每年节省的等效人力成本超过200万元。这个案例证明,合理的安全架构不仅能提升用户体验,更能创造直接商业价值。