JWT 还是 Opaque Token?Dante Cloud 让你不再纠结

2026-05-18 24 预计阅读时间:1 分钟
来源:oschina.net AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:9 分钟

前后端分离架构下,Token 认证几乎是现代 Web 开发的事实标准。但选哪种 Token——JWT(自包含)还是 Opaque(不透明)——从来不是一道轻松的选择题。Dante Cloud 4.0.6.3 的核心改进,就是让这两种 Token 的动态切换变得更丝滑,不再需要重启服务或硬编码认证方式。

两种 Token,两种哲学

先看本质区别:

维度 JWT Token Opaque Token
结构 结构化,可解码读取 claims 无规则随机字符串,本身不含信息
信息获取 本地解码即可,无需远程调用 每次必须向授权服务器 introspect
性能 无网络开销,解码快 每次校验都有网络往返
安全性 一旦签发无法撤销(除非短过期+黑名单) 随时可撤销,服务器端完全掌控
体积 claims 多时 token 较长 固定长度,通常很短

一句话总结:JWT 用空间换时间,Opaque 用时间换安全

生产环境里,这两种需求经常同时存在——内部高频调用偏好 JWT 的零网络开销,对外暴露的 API 更需要 Opaque 的即时撤销能力。问题来了:Spring Security OAuth2 的资源服务器配置,通常在启动时就绑定了 jwt()opaqueToken(),想换?改配置、重启、上线。这在多租户或需要灰度切换的场景下,代价太高。

动态切换的难点在哪

Spring Authorization Server 的资源服务器认证过滤器链,默认是这样配置的:

http.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));

或者:

http.oauth2ResourceServer(oauth2 -> oauth2.opaqueToken(Customizer.withDefaults()));

两者共用同一个 BearerTokenAuthenticationFilter,但后续的认证处理器完全不同——JWT 走 JwtAuthenticationProvider,Opaque 走 OpaqueTokenAuthenticationProvider。Spring Security 的 DSL 不允许你同时注册两个,因为内部会用 AuthenticationManager 解析 token,而 AuthenticationManager 只认一个 provider 优先级。

所以"动态切换"不是简单加个 if-else,而是要在运行时根据请求上下文(比如请求头、租户标识、路由规则)决定走哪条认证链路,并且两条链路都得活着。

Dante Cloud 4.0.6.3 怎么做的

Dante Cloud 的方案核心思路:自定义 AuthenticationManager,根据 token 特征自动路由到对应的 Provider

具体来说:

  1. Token 特征识别:JWT 有明确的结构特征——三段式 Base64,中间段解码后含 issexp 等 claims;Opaque Token 则是随机字符串,无法解码出结构化数据。
  2. 双 Provider 共存:同时注册 JwtAuthenticationProviderOpaqueTokenAuthenticationProvider,但不在 DSL 层面绑定,而是手动组装到自定义 AuthenticationManager 中。
  3. 路由决策:在 AuthenticationManagerauthenticate() 方法里,先尝试解析 token 结构,判断是 JWT 还是 Opaque,再委托给对应 Provider。

下面是一个简化实现,可以直接改造用于你的项目:

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider;
import org.springframework.security.oauth2.server.resource.authentication.OpaqueTokenAuthenticationProvider;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;

/**
 * 动态路由 AuthenticationManager —— 根据 token 结构自动选择认证方式
 */
public class DynamicTokenAuthenticationManager implements AuthenticationManager {

    private final AuthenticationProvider jwtProvider;
    private final AuthenticationProvider opaqueProvider;

    public DynamicTokenAuthenticationManager(JwtDecoder jwtDecoder,
                                              OpaqueTokenIntrospector introspector) {
        this.jwtProvider = new JwtAuthenticationProvider(jwtDecoder);
        this.opaqueProvider = new OpaqueTokenAuthenticationProvider(introspector);
    }

    @Override
    public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {
        if (!(authentication instanceof BearerTokenAuthenticationToken)) {
            return null;
        }
        String token = ((BearerTokenAuthenticationToken) authentication).getToken();

        // 判断是否为 JWT:尝试按三段式拆分并解码中间段
        if (looksLikeJwt(token)) {
            try {
                return jwtProvider.authenticate(authentication);
            } catch (AuthenticationException jwtEx) {
                // JWT 解码失败,可能是格式伪装的 Opaque token,降级尝试
                return opaqueProvider.authenticate(authentication);
            }
        }
        // 不像 JWT,直接走 Opaque 认证
        return opaqueProvider.authenticate(authentication);
    }

    /**
     * 简易 JWT 结构判断:三段 Base64,中间段解码后含 JSON 特征
     */
    private boolean looksLikeJwt(String token) {
        String[] parts = token.split("\\.");
        if (parts.length != 3) return false;
        try {
            // 第二段是 payload,Base64 解码后应含 JSON 标记
            String payload = new String(
                java.util.Base64.getUrlDecoder().decode(parts[1]));
            return payload.contains("\"") && payload.contains(":");
        } catch (Exception e) {
            return false;
        }
    }
}

注册到 Security 配置中:

@Configuration
@EnableWebSecurity
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http,
                                            JwtDecoder jwtDecoder,
                                            OpaqueTokenIntrospector introspector) throws Exception {
        AuthenticationManager dynamicAuthManager =
            new DynamicTokenAuthenticationManager(jwtDecoder, introspector);

        http
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2
                .authenticationManagerResolver(
                    // 对所有请求使用同一个动态 AuthenticationManager
                    request -> dynamicAuthManager));

        return http.build();
    }
}

关键点在于 authenticationManagerResolver——Spring Security 从 5.4 开始支持按请求动态选择 AuthenticationManager,这正是 Dante Cloud 利用的扩展点。上面的例子对所有请求用同一个 manager,你也可以按请求路径或租户头返回不同的 manager 实例,实现更细粒度的路由。

实际运行需要注意什么

1. Introspect 端点性能

一旦走 Opaque 路径,每次请求都要远程 introspect。高频场景下这是瓶颈。建议:

  • 内部服务间调用强制走 JWT
  • 只对外网暴露的网关层启用 Opaque
  • introspect 结果做短时间缓存(30-60 秒),用 SpringCacheOpaqueTokenIntrospector 包装
@Component
public class CachedOpaqueTokenIntrospector implements OpaqueTokenIntrospector {

    private final OpaqueTokenIntrospector delegate;
    private final Cache cache; // 使用 Caffeine 或 Redis

    public CachedOpaqueTokenIntrospector(OpaqueTokenIntrospector delegate, CacheManager cm) {
        this.delegate = delegate;
        this.cache = cm.getCache("tokenIntrospect");
    }

    @Override
    public OAuth2AuthenticatedPrincipal introspect(String token) {
        OAuth2AuthenticatedPrincipal cached = cache.get(token, () -> delegate.introspect(token));
        return cached;
    }
}

2. JWT 撤销的补丁

选了 JWT 不等于放弃撤销能力。常见补丁方案:

  • 短过期时间(5-15 分钟)+ refresh token
  • 维护一个 token 黑名单(Redis SET),在 JwtDecoder 校验链中加一步黑名单检查
@Bean
public JwtDecoder jwtDecoder(RedisTemplate<String, String> redis) {
    NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
    decoder.setJwtValidator(new DelegatingOAuth2TokenValidator(
        List.of(
            new JwtTimestampValidator(),
            new JwtIssuerValidator(issuerUri),
            new JwtClaimValidator("jti", jti -> {
                // 黑名单检查
                return !redis.hasKey("jwt:blacklist:" + jti);
            })
        )
    ));
    return decoder;
}

3. 降级策略

DynamicTokenAuthenticationManager 里 JWT 失败后降级到 Opaque,这看起来稳妥,但要警惕:如果一个合法 JWT 因为密钥轮换暂时无法解码,降级到 introspect 可能返回"token 不存在"(因为授权服务器根本没签发过这个 opaque token)。所以降级路径更适合"token 格式不确定"的场景,而非"JWT 校验失败"的容错。生产中建议降级时直接返回 401,而不是静默尝试另一条链路。

什么时候该切换

场景 推荐 Token 原因
内部微服务间调用 JWT 雑网络开销,延迟敏感
前端 SPA → 后端 API JWT(短过期) 浏览器每次请求都带 token,introspect 延迟不可接受
第三方合作方接入 Opaque 需要即时撤销、权限变更实时生效
管理后台 / 高权限操作 Opaque 安全优先,宁可多一次网络调用
移动端 App JWT + refresh 网络不稳定,减少依赖授权服务器可用性

Dante Cloud 4.0.6.3 的价值不是替你做选择,而是让选择不再是单选题——你可以按租户、按路由、按运行时策略灵活切换,而不用改配置重启服务。

上手清单

  1. 确认你的 Spring Security 版本 ≥ 5.4(支持 authenticationManagerResolver
  2. 同时配置 JwtDecoderOpaqueTokenIntrospector 两个 Bean
  3. 实现 DynamicTokenAuthenticationManager 或直接参考 Dante Cloud 源码中的实现
  4. oauth2ResourceServer DSL 中用 authenticationManagerResolver 替代 jwt() / opaqueToken()
  5. 对 introspect 结果加缓存,对 JWT 加黑名单——两种 Token 的短板都要补
  6. 灰度发布时,先让内部服务走 JWT 验证稳定后,再逐步开放 Opaque 路径

相关推荐