Spring Security 7 OAuth2 Token 格式选择浅析

摘要 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 核心建议

  1. 如果 Token 超过 Header 限制 ,使用 精简 JWT 是最佳选择
  2. 分布式场景 下,Opaque Token + Redis 提供更好的撤销能力
  3. 根据业务对延迟、撤销、存储的实际需求做出选择

7.3 Token 长度对比

格式 典型长度 适用场景
标准 JWT ~1000B 不关心 Token 长度
精简 JWT ~200B 推荐,解决 Header 限制
Opaque Token ~50B 需要即时撤销的场景

参考资料

相关推荐
幽络源小助理18 小时前
Springboot机场乘客服务系统源码 – SpringBoot+Vue项目免费下载 | 幽络源
vue.js·spring boot·后端
shughui18 小时前
最新版IntelliJ IDEA下载+安装+汉化(详细图文)
java·ide·intellij-idea
小罗和阿泽18 小时前
java 【多线程基础 三】
java·开发语言
想你依然心痛18 小时前
从x86到ARM的HPC之旅:鲲鹏开发工具链(编译器+数学库+MPI)上手与实战
java·开发语言·arm开发·鲲鹏·昇腾
我的golang之路果然有问题18 小时前
积累的 java 找工作资源
java·笔记
源代码•宸18 小时前
Golang基础语法(go语言error、go语言defer、go语言异常捕获、依赖管理、Go Modules命令)
开发语言·数据库·后端·算法·golang·defer·recover
Coder码匠18 小时前
从项目实践中学习 Spring 事务范围优化
数据库·spring
编程大师哥18 小时前
Java 常见异常(按「运行时 / 编译时」分类)
java·开发语言
SnrtIevg19 小时前
Vavr 用户指南
java·后端