摘要 JWT Token 堪称一把双刃剑,让开发者既爱又恨。其自包含的特质让我们无需再为分布式存储而烦恼,带来了更快的响应速度,这确实是值得称赞的优点。但不得不承认,Token 长度的膨胀确实会增加网络传输的额外开销,而算法验签所带来的 CPU 消耗也是客观存在的现实。因此,在实际项目中选择 JWT 还是传统 Session 方案,关键还是要结合用户规模和系统架构来权衡利弊。
概述
在 OAuth2 授权服务器中,Token 是连接客户端和资源服务器的核心凭证。Spring Security OAuth2 Authorization Server (7.x) 提供了两种 Token 格式:SELF_CONTAINED(JWT) 和 REFERENCE(Opaque Token)。本文将深入分析这两种格式的特点、配置方式以及如何根据业务场景做出最佳选择。
1. Token 格式类型
1.1 SELF_CONTAINED(自包含令牌 / JWT)
JWT(JSON Web Token)是一种自包含的令牌格式,包含头部(Header)、载荷(Payload)和签名(Signature)三部分。
默认生成的 JWT claims 示例:
json
{
"iss": "http://localhost:8080",
"sub": "user123",
"aud": "client-app",
"exp": 1704892800,
"nbf": 1704889200,
"iat": 1704889200,
"jti": "abc123-def456-ghi789",
"scope": "read write admin",
"azp": "client-app",
"auth_time": 1704889200,
"at_hash": "xxx",
"c_hash": "xxx"
}
问题: 字段过多导致 Token 长度超过 1KB,可能超过 HTTP Header 限制(通常 8KB,但在某些网关/代理层可能被限制)。
1.2 REFERENCE(不透明令牌 / Opaque Token)
Opaque Token 是一个随机生成的标识符,不包含任何用户信息。服务端需要通过该标识符查询存储来获取 Token 详情。
格式示例: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9(短随机字符串)
特点:
- Token 长度极短(通常 32-64 字节)
- 需要服务端存储(如 Redis、数据库)
- 验证时需要额外的网络请求
2. Token 格式配置
2.1 在 RegisteredClient 中配置
在 LocalRacClientService.java 中为每个客户端配置 Token 格式:
java
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
import java.time.Duration;
TokenSettings tokenSettings = TokenSettings.builder()
// 配置 Token 格式
.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) // JWT
// .accessTokenFormat(OAuth2TokenFormat.REFERENCE) // Opaque Token
// Token 有效期配置
.accessTokenTimeToLive(Duration.ofSeconds(7200)) // 2小时
.refreshTokenTimeToLive(Duration.ofSeconds(604800)) // 7天
// 是否复用 Refresh Token(默认 true)
.reuseRefreshTokens(true)
.build();
RegisteredClient oidcClient = RegisteredClient.withId(clientId)
.clientId(clientId)
.clientSecret(secret)
.authorizationGrantTypes(types -> types.addAll(grantTypes))
.redirectUris(uris -> uris.addAll(redirectUris))
.scopes(scopes -> scopes.addAll(scopeList))
.tokenSettings(tokenSettings)
.build();
2.2 OAuth2TokenFormat 可选值
| 常量 | 值 | 说明 |
|---|---|---|
OAuth2TokenFormat.SELF_CONTAINED |
"self-contained" | JWT 格式,包含所有 claims |
OAuth2TokenFormat.REFERENCE |
"reference" | Opaque Token,短随机 ID |
3. 自定义 JWT Claims(精简 Token)
如果选择 JWT 格式但 Token 过长,可以通过 OAuth2TokenCustomizer 自定义 claims。
3.1 自定义 Opaque Token Claims
java
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenClaimsContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
@Bean
public OAuth2TokenCustomizer<OAuth2TokenClaimsContext> accessTokenCustomizer() {
return context -> {
var claims = context.getClaims().getClaims();
OAuth2TokenType tokenType = context.getTokenType();
// 只处理 access_token
if (OAuth2TokenType.ACCESS_TOKEN.equals(tokenType)) {
// 移除不常用的 claims
claims.remove("jti"); // JWT ID
claims.remove("aud"); // Audience
claims.remove("azp"); // Authorized Party
claims.remove("auth_time"); // 认证时间
claims.remove("at_hash"); // Access Token Hash
claims.remove("c_hash"); // Code Hash
// 用短名称替代
Object scope = claims.remove("scope");
if (scope != null) {
claims.put("sc", scope); // sc = scope
}
}
};
}
3.2 自定义 JWT Headers 和 Claims
对于 JWT 格式,使用 OAuth2TokenCustomizer<JwtEncodingContext>:
java
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.security.oauth2.jose.jws.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
return context -> {
JwsHeader.Builder headers = context.getJwsHeader();
JwtClaimsSet.Builder claims = context.getClaims();
// 精简 JWT headers
headers.alg(context.getJwsHeader().getAlgorithm()); // 保持必要算法
if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
// 精简 claims
claims.claim("sc", claims.getClaim("scope")); // 用 sc 替代 scope
claims.remove("scope");
claims.remove("jti");
claims.remove("aud");
claims.remove("azp");
claims.remove("at_hash");
claims.remove("c_hash");
}
};
}
精简后的 JWT:
json
{
"iss": "http://localhost:8080",
"sub": "user123",
"sc": ["read", "write"],
"exp": 1704892800,
"iat": 1704889200
}
// 仅 ~200 字节
4. 分布式场景下的考虑
4.1 方案对比
| 特性 | JWT | Opaque Token + Redis |
|---|---|---|
| Token 长度 | 长 (~1KB) | 短 (~50B) |
| 验证延迟 | 最低(本地验证) | 高(网络查库) |
| CPU 消耗 | 高(RSA验签) | 低 |
| 撤销能力 | 差(需等待过期) | 好(即时) |
| 存储需求 | 无 | 需要 Redis |
| 扩展性 | 需公钥同步 | 无需同步 |
4.2 性能数据参考
| 指标 | JWT | Opaque + Redis |
|---|---|---|
| 单次验证延迟 | 1-5ms | 5-20ms |
| 10000次吞吐量 | 5000 ops/s | 1500 ops/s |
| CPU 使用率 | 高 | 低 |
| 网络开销 | 无 | 每次验证1次查库 |
4.3 推荐方案
场景1:高性能、对延迟敏感
→ 选择 精简 JWT
场景2:需要即时撤销、安全性优先
→ 选择 Opaque Token + Redis
场景3:均衡方案
→ 选择 JWT + Redis 黑名单
4.4 Opaque Token + Redis 实现
Spring Security OAuth2 Authorization Server 没有内置 Redis 实现,需要自定义 OAuth2AuthorizationService:
java
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
import org.springframework.stereotype.Service;
import org.springframework.util.SerializationUtils;
import java.time.Duration;
@Service
public class RedisOAuth2AuthorizationService implements OAuth2AuthorizationService {
private static final String AUTHORIZATION_KEY_PREFIX = "oauth2:auth:";
private static final String ACCESS_TOKEN_KEY_PREFIX = "oauth2:at:";
private static final String REFRESH_TOKEN_KEY_PREFIX = "oauth2:rt:";
private final RedisTemplate<String, byte[]> redisTemplate;
public RedisOAuth2AuthorizationService(RedisTemplate<String, byte[]> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public void save(OAuth2Authorization authorization) {
String key = AUTHORIZATION_KEY_PREFIX + authorization.getId();
byte[] bytes = SerializationUtils.serialize(authorization);
Duration duration = getTokenExpiration(authorization);
redisTemplate.opsForValue().set(key, bytes, duration);
storeTokenMapping(authorization);
}
@Override
public OAuth2Authorization findById(String id) {
String key = AUTHORIZATION_KEY_PREFIX + id;
byte[] bytes = redisTemplate.opsForValue().get(key);
return bytes != null ? SerializationUtils.deserialize(bytes) : null;
}
@Override
public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) {
String tokenKey;
if (tokenType == null || OAuth2TokenType.ACCESS_TOKEN.equals(tokenType)) {
tokenKey = ACCESS_TOKEN_KEY_PREFIX + token;
} else if (OAuth2TokenType.REFRESH_TOKEN.equals(tokenType)) {
tokenKey = REFRESH_TOKEN_KEY_PREFIX + token;
} else {
tokenKey = tokenType.getValue() + ":" + token;
}
byte[] bytes = redisTemplate.opsForValue().get(tokenKey);
if (bytes == null) return null;
String authId = SerializationUtils.deserialize(bytes);
return findById(authId);
}
@Override
public void remove(OAuth2Authorization authorization) {
String key = AUTHORIZATION_KEY_PREFIX + authorization.getId();
redisTemplate.delete(key);
removeTokenMapping(authorization);
}
private void storeTokenMapping(OAuth2Authorization authorization) {
authorization.getToken(OAuth2AccessToken.class).ifPresent(at -> {
String atKey = ACCESS_TOKEN_KEY_PREFIX + at.getTokenValue();
Duration atDuration = Duration.between(
at.getToken().getIssuedAt(),
at.getToken().getExpiresAt());
redisTemplate.opsForValue().set(atKey,
SerializationUtils.serialize(authorization.getId()),
atDuration);
});
authorization.getToken(OAuth2RefreshToken.class).ifPresent(rt -> {
String rtKey = REFRESH_TOKEN_KEY_PREFIX + rt.getToken().getTokenValue();
Duration rtDuration = rt.getToken().getExpiresAt() != null
? Duration.between(rt.getToken().getIssuedAt(), rt.getToken().getExpiresAt())
: Duration.ofDays(30);
redisTemplate.opsForValue().set(rtKey,
SerializationUtils.serialize(authorization.getId()),
rtDuration);
});
}
private void removeTokenMapping(OAuth2Authorization authorization) {
authorization.getToken(OAuth2AccessToken.class).ifPresent(at ->
redisTemplate.delete(ACCESS_TOKEN_KEY_PREFIX + at.getTokenValue()));
authorization.getToken(OAuth2RefreshToken.class).ifPresent(rt ->
redisTemplate.delete(REFRESH_TOKEN_KEY_PREFIX + rt.getToken().getTokenValue()));
}
private Duration getTokenExpiration(OAuth2Authorization authorization) {
return authorization.getToken(OAuth2AccessToken.class)
.map(at -> Duration.between(at.getToken().getIssuedAt(), at.getToken().getExpiresAt()))
.orElse(Duration.ofHours(2));
}
}
注册 Bean:
java
@Bean
public OAuth2AuthorizationService authorizationService(RedisTemplate<String, byte[]> redisTemplate) {
return new RedisOAuth2AuthorizationService(redisTemplate);
}
Redis 配置:
java
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, byte[]> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, byte[]> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new JdkSerializationRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new JdkSerializationRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
5. Endpoint 路径自定义
在 Oauth2AuthorizationServerHttpSecurityConfig 中:
java
import static org.springframework.security.oauth2.server.authorization.settings.ConfigurationSettingNames.AuthorizationServer.*;
@Bean
public AuthorizationServerSettings authorizationServerSettings(Oauth2Properties oauth2Properties) {
// 通过配置自定义EndPoint的端点列表
Map<String, String> endpointMap = oauth2Properties.getEndpoints();
return AuthorizationServerSettings.builder()
.tokenEndpoint(endpointMap.getOrDefault(TOKEN_ENDPOINT, "/oauth2/token"))
.authorizationEndpoint(endpointMap.getOrDefault(AUTHORIZATION_ENDPOINT, "/oauth2/authorize"))
.deviceAuthorizationEndpoint(endpointMap.getOrDefault(DEVICE_AUTHORIZATION_ENDPOINT, "/oauth2/device_authorization"))
.deviceVerificationEndpoint(endpointMap.getOrDefault(DEVICE_VERIFICATION_ENDPOINT, "/oauth2/device_verification"))
.tokenIntrospectionEndpoint(endpointMap.getOrDefault(TOKEN_INTROSPECTION_ENDPOINT, "/oauth2/introspect"))
.tokenRevocationEndpoint(endpointMap.getOrDefault(TOKEN_REVOCATION_ENDPOINT, "/oauth2/revoke"))
.jwkSetEndpoint(endpointMap.getOrDefault(JWK_SET_ENDPOINT, "/oauth2/jwks"))
.oidcUserInfoEndpoint(endpointMap.getOrDefault(OIDC_USER_INFO_ENDPOINT, "/userinfo"))
.oidcLogoutEndpoint(endpointMap.getOrDefault(OIDC_LOGOUT_ENDPOINT, "/connect/logout"))
.clientRegistrationEndpoint(endpointMap.getOrDefault(CLIENT_REGISTRATION_ENDPOINT, "/oauth2/register"))
.build();
}
配置文件:
yaml
xx:
security:
oauth2:
endpoints:
settings.authorization-server.token-endpoint: "/auth/token"
settings.authorization-server.authorization-endpoint: "/auth/authorize"
settings.authorization-server.jwk-set-endpoint: "/auth/jwks"
6. 完整配置示例
java
@Configuration
@EnableConfigurationProperties(Oauth2Properties.class)
public class OAuth2AuthorizationServerConfig implements ICustomHttpSecurityConfig {
@Override
public void config(HttpSecurity http) {
http.oauth2AuthorizationServer(oauthServer -> {
oauthServer.oidc(Customizer.withDefaults());
});
}
// Token 格式自定义(JWT claims 精简)
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
return context -> {
context.getClaims(claims ->{
// 自定义需要删除的元素
claims.remove("scope");
claims.remove("jti");
claims.remove("aud");
claims.remove("azp");
});
};
}
// Redis 存储(可选,用于 Opaque Token , 采用自包含的JWT格式Token就可以避免定义分布式存储)
@Bean
public OAuth2AuthorizationService authorizationService(RedisTemplate<String, byte[]> redisTemplate) {
return new RedisOAuth2AuthorizationService(redisTemplate);
}
// 自定义 Endpoint 路径
@Bean
public AuthorizationServerSettings authorizationServerSettings(Oauth2Properties props) {
Map<String, String> endpoints = props.getEndpoints();
return AuthorizationServerSettings.builder()
.tokenEndpoint(endpoints.getOrDefault("token-endpoint", "/oauth2/token"))
.authorizationEndpoint(endpoints.getOrDefault("authorization-endpoint", "/oauth2/authorize"))
.jwkSetEndpoint(endpoints.getOrDefault("jwk-set-endpoint", "/oauth2/jwks"))
.build();
}
}
7. 总结
7.1 方案对比总结
| 场景 | 推荐方案 | 配置要点 |
|---|---|---|
| 通用场景、高性能 | 精简 JWT | accessTokenFormat=SELF_CONTAINED + jwtCustomizer |
| 需要即时撤销 | Opaque + Redis | accessTokenFormat=REFERENCE + RedisOAuth2AuthorizationService |
| 高安全要求 | JWT + 黑名单 | JWT 短期有效 + Redis 存储撤销列表 |
| 单体应用 | 任意 | 根据需求选择 |
7.2 核心建议
- 如果 Token 超过 Header 限制 ,使用 精简 JWT 是最佳选择
- 分布式场景 下,Opaque Token + Redis 提供更好的撤销能力
- 根据业务对延迟、撤销、存储的实际需求做出选择
7.3 Token 长度对比
| 格式 | 典型长度 | 适用场景 |
|---|---|---|
| 标准 JWT | ~1000B | 不关心 Token 长度 |
| 精简 JWT | ~200B | 推荐,解决 Header 限制 |
| Opaque Token | ~50B | 需要即时撤销的场景 |