统一消息中心认证改造记:JWT与不透明令牌的“双轨制”实现

前言

在统一消息中心(UMP)项目中,我们原有的一套基于不透明令牌(Opaque Token)的OAuth2认证体系运行良好。但随着业务发展,我们需要接入上级消息中心,对方要求使用JWT令牌;同时,我们内部的app_key模式也需要支持JWT,以便与其他系统集成。这就引出了一个新挑战:如何让同一套资源服务器同时支持JWT和不透明令牌,并且让两种令牌在不同认证模式下都正常工作?

本文将详细记录我们遇到的种种坑,以及最终的解决方案。希望能为正在做类似改造的同行提供一些参考。

一、背景与问题

原有架构

  • 认证服务:基于Spring Authorization Server,使用不透明令牌(REFERENCE)作为access_token格式。
  • 资源服务:通过OpaqueTokenIntrospector内省token,获取用户信息。
  • 认证模式:支持password(用户名密码)、sms、client_credentials等。

新需求

  1. 接入上级消息中心,对方要求使用JWT令牌(SELF_CONTAINED)。
  2. app_key认证模式需要支持JWT。
  3. 原有password模式必须保持兼容,既可以生成不透明令牌,也可以生成JWT(通过配置切换)。

问题涌现

当我们开始改造后,一系列问题接踵而至:

  • 资源服务器无法同时配置jwt()opaqueToken(),Spring Security会抛出“只能使用一种”的错误。
  • JWT模式下,@AuthenticationPrincipal Jwt注入为null,导致获取不到appKey。
  • 登录后菜单加载为空,权限丢失。
  • app_key模式下获取JWT令牌后,调用业务接口报“用户不存在”。

二、总体解决思路

要实现两种令牌格式共存,我们需要解决三个核心问题:

  1. 认证管理器的动态选择:根据token格式(是否有两个点)分发到不同的认证管理器。
  2. JWT中用户信息的正确还原:JWT中不应包含完整的用户敏感信息,而是只存储用户名,再由资源服务器通过UserDetailsService加载完整信息。
  3. 客户端模式(app_key)的特殊处理:该类请求没有用户概念,应避免查询用户表。

下面详细介绍每一步的解决方案。

三、关键改造步骤

3.1 资源服务器同时支持JWT和不透明令牌

Spring Security的oauth2ResourceServer()不允许同时配置jwt()opaqueToken(),但我们可以通过authenticationManagerResolver来动态选择认证管理器。

关键代码:FengResourceServerConfiguration

@Bean
public AuthenticationManagerResolver<HttpServletRequest> tokenAuthenticationManagerResolver(
        JwtDecoder jwtDecoder,
        OpaqueTokenIntrospector introspector,
        Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter) {
    // JWT认证管理器
    JwtAuthenticationProvider jwtProvider = new JwtAuthenticationProvider(jwtDecoder);
    jwtProvider.setJwtAuthenticationConverter(jwtAuthenticationConverter);
    AuthenticationManager jwtManager = new ProviderManager(jwtProvider);
    
    // 不透明令牌认证管理器
    OpaqueTokenAuthenticationProvider opaqueProvider = new OpaqueTokenAuthenticationProvider(introspector);
    AuthenticationManager opaqueManager = new ProviderManager(opaqueProvider);
    
    return request -> {
        String token = fengBearerTokenExtractor.resolve(request);
        if (token != null && token.contains(".") && token.split("\\.").length == 3) {
            return jwtManager;
        }
        return opaqueManager;
    };
}

然后在SecurityFilterChain中使用该resolver:

http.oauth2ResourceServer(oauth2 -> oauth2
        .authenticationManagerResolver(tokenAuthenticationManagerResolver)
        .authenticationEntryPoint(resourceAuthExceptionEntryPoint)
        .bearerTokenResolver(fengBearerTokenExtractor)
);

3.2 解决JWT解码器的负载均衡问题

起初我们使用@LoadBalanced RestTemplate来调用/oauth2/jwks,但该模板会将127.0.0.1当作服务名解析,导致失败。解决方案是提供一个普通的RestTemplate供JWT解码器使用:

@Bean
public RestTemplate restTemplate() {
    return new RestTemplate();
}

@Bean
public JwtDecoder jwtDecoder(RestTemplate restTemplate) {
    return NimbusJwtDecoder.withJwkSetUri(jwkSetUri)
            .restOperations(restTemplate)
            .build();
}

3.3 自定义JWT认证转换器,加载完整用户信息

JWT中不应包含密码、权限等敏感信息,因此我们只在token中存储用户名,资源服务器收到后通过UserDetailsService重新加载完整用户。

核心类:FengJwtAuthenticationConverter

@Slf4j
public class FengJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
    private final UserDetailsService userDetailsService;

    public FengJwtAuthenticationConverter(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    @Override
    public AbstractAuthenticationToken convert(Jwt jwt) {
        Map<String, Object> claims = jwt.getClaims();
        String username = extractUsername(claims, jwt);
        
        // 区分用户模式与客户端模式
        boolean isUserMode = (claims.get(SecurityConstants.DETAILS_USER) != null) ||
                             (jwt.getClaimAsString(SecurityConstants.USERNAME) != null);
        
        FengUser fengUser;
        if (isUserMode) {
            // 用户模式:从数据库加载完整信息
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            fengUser = (FengUser) userDetails;
        } else {
            // 客户端模式:构造虚拟用户,仅包含应用标识
            Collection<GrantedAuthority> authorities = extractAuthorities(jwt);
            fengUser = new FengUser(null, null, username, "", null,
                    true, true, true, true, authorities);
        }
        fengUser.getAttributes().putAll(claims);
        return new FengJwtAuthenticationToken(jwt, fengUser.getAuthorities(), fengUser);
    }

    private String extractUsername(Map<String, Object> claims, Jwt jwt) {
        // 优先从 user_info 获取
        Map<String, Object> userMap = (Map<String, Object>) claims.get(SecurityConstants.DETAILS_USER);
        if (userMap != null) {
            return (String) userMap.getOrDefault("username", jwt.getSubject());
        }
        // 从独立 username claim 获取
        String username = jwt.getClaimAsString(SecurityConstants.USERNAME);
        if (username != null) return username;
        // 最后使用 subject
        return jwt.getSubject();
    }

    private Collection<GrantedAuthority> extractAuthorities(Jwt jwt) {
        List<String> scopes = jwt.getClaimAsStringList("scope");
        return scopes == null ? Collections.emptyList() :
                scopes.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
    }
}

注意FengJwtAuthenticationToken 是我们自定义的认证令牌,继承自JwtAuthenticationToken,覆盖getPrincipal()返回FengUser,这样SecurityUtils.getUser()就能正常获取用户对象。

3.4 确保JWT中包含必要的用户名

在授权服务器的CustomeOAuth2TokenCustomizer中,我们需要将用户名写入JWT的user_infousername claim。

if (!clientCredentials && !appKeyMode) {
    FengUser fengUser = (FengUser) context.getPrincipal().getPrincipal();
    claims.claim(SecurityConstants.DETAILS_USER, fengUser);
    claims.claim(SecurityConstants.USERNAME, fengUser.getUsername());
}

这样JWT中就包含了用户名信息,资源服务器才能通过UserDetailsService加载到正确的用户。

四、测试验证

完成以上改造后,我们进行了全面的测试:

模式令牌格式结果
password不透明令牌✅ 登录正常,菜单正常
passwordJWT✅ 登录正常,菜单正常
app_keyJWT✅ 获取token,发送消息正常
app_key不透明令牌(若配置)✅ 兼容原有逻辑

五、统一消息中心简介

在解决认证问题后,我们顺便介绍一下我们的开源项目:统一消息中心(Unified Message Platform,UMP)

项目概述

UMP是一个基于Spring Boot + MyBatis-Plus构建的轻量级消息中间件,用于解决多业务系统之间的消息分发、状态追踪、可靠送达等问题。它支持个人消息、部门消息、自定义范围消息,并提供推送/拉取两种模式。

核心特性

  • 双模式分发:推送(主动回调业务方) + 拉取(业务方主动轮询)
  • 全生命周期管理:消息从创建到接收、阅读、归档全流程可追踪
  • 可靠重试:失败任务自动重试,支持指数退避
  • 灵活认证:支持JWT和不透明令牌,兼容多种客户端类型
  • 开箱即用:提供RESTful API和详细的接口文档

技术栈

组件版本
Spring Boot4.0.3
Spring Authorization Server1.4.0
MyBatis-Plus3.5.10
Nacos3.1.1
Redis7.0
RabbitMQ / Kafka可选

快速开始

# 1. 克隆代码
git clone https://github.com/radarfyh/unified-message-center.git

# 2. 执行数据库脚本
mysql -u root -p < docs/sql/feng_register.sql
mysql -u root -p < docs/sql/feng_user3_biz.sql
mysql -u root -p < docs/sql/feng_message_center_biz.sql

# 3. 配置Nacos(或本地application-dev.yml)
# 修改数据库、Redis、MQ等连接信息

# 4. 启动服务
cd feng-message-center-biz
mvn clean install
java -jar target/feng-message-center-biz-1.0.1.jar

项目链接

  • GitHub:https://github.com/radarfyh/unified-message-center
  • Gitee:https://gitee.com/radarfyh/unified-message-center
  • QQ交流群:1094082817

欢迎Star、Fork,一起参与建设!

六、总结

这次改造让我们深刻体会到Spring Security OAuth2扩展的灵活性和复杂性。通过自定义AuthenticationManagerResolverJwtAuthenticationConverter,我们成功实现了JWT与不透明令牌在同一个资源服务器中的共存。希望本文的分享能帮助到有类似需求的开发者,也欢迎大家试用统一消息中心,提出宝贵意见。

最后,再次提醒

  • YAML配置一定要用空格,不要用Tab(别问为什么,问就是血泪教训)。
  • 资源服务器同时支持多种令牌格式时,务必通过authenticationManagerResolver动态选择,不要试图同时配置jwt()opaqueToken()
  • JWT中不要存储敏感信息,只存用户名,用户详情由UserDetailsService加载。

如果觉得本文对你有帮助,欢迎点赞、分享!有任何问题,可以在GitHub提Issue或加入QQ群交流。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐