统一消息中心认证改造记:JWT与不透明令牌的“双轨制”实现
统一消息中心认证改造记:JWT与不透明令牌的“双轨制”实现
前言
在统一消息中心(UMP)项目中,我们原有的一套基于不透明令牌(Opaque Token)的OAuth2认证体系运行良好。但随着业务发展,我们需要接入上级消息中心,对方要求使用JWT令牌;同时,我们内部的app_key模式也需要支持JWT,以便与其他系统集成。这就引出了一个新挑战:如何让同一套资源服务器同时支持JWT和不透明令牌,并且让两种令牌在不同认证模式下都正常工作?
本文将详细记录我们遇到的种种坑,以及最终的解决方案。希望能为正在做类似改造的同行提供一些参考。
一、背景与问题
原有架构
- 认证服务:基于Spring Authorization Server,使用不透明令牌(REFERENCE)作为access_token格式。
- 资源服务:通过
OpaqueTokenIntrospector内省token,获取用户信息。 - 认证模式:支持password(用户名密码)、sms、client_credentials等。
新需求
- 接入上级消息中心,对方要求使用JWT令牌(SELF_CONTAINED)。
- app_key认证模式需要支持JWT。
- 原有password模式必须保持兼容,既可以生成不透明令牌,也可以生成JWT(通过配置切换)。
问题涌现
当我们开始改造后,一系列问题接踵而至:
- 资源服务器无法同时配置
jwt()和opaqueToken(),Spring Security会抛出“只能使用一种”的错误。 - JWT模式下,
@AuthenticationPrincipal Jwt注入为null,导致获取不到appKey。 - 登录后菜单加载为空,权限丢失。
- app_key模式下获取JWT令牌后,调用业务接口报“用户不存在”。
二、总体解决思路
要实现两种令牌格式共存,我们需要解决三个核心问题:
- 认证管理器的动态选择:根据token格式(是否有两个点)分发到不同的认证管理器。
- JWT中用户信息的正确还原:JWT中不应包含完整的用户敏感信息,而是只存储用户名,再由资源服务器通过
UserDetailsService加载完整信息。 - 客户端模式(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_info或username 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 | 不透明令牌 | ✅ 登录正常,菜单正常 |
| password | JWT | ✅ 登录正常,菜单正常 |
| app_key | JWT | ✅ 获取token,发送消息正常 |
| app_key | 不透明令牌(若配置) | ✅ 兼容原有逻辑 |
五、统一消息中心简介
在解决认证问题后,我们顺便介绍一下我们的开源项目:统一消息中心(Unified Message Platform,UMP)。
项目概述
UMP是一个基于Spring Boot + MyBatis-Plus构建的轻量级消息中间件,用于解决多业务系统之间的消息分发、状态追踪、可靠送达等问题。它支持个人消息、部门消息、自定义范围消息,并提供推送/拉取两种模式。
核心特性
- 双模式分发:推送(主动回调业务方) + 拉取(业务方主动轮询)
- 全生命周期管理:消息从创建到接收、阅读、归档全流程可追踪
- 可靠重试:失败任务自动重试,支持指数退避
- 灵活认证:支持JWT和不透明令牌,兼容多种客户端类型
- 开箱即用:提供RESTful API和详细的接口文档
技术栈
| 组件 | 版本 |
|---|---|
| Spring Boot | 4.0.3 |
| Spring Authorization Server | 1.4.0 |
| MyBatis-Plus | 3.5.10 |
| Nacos | 3.1.1 |
| Redis | 7.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扩展的灵活性和复杂性。通过自定义AuthenticationManagerResolver和JwtAuthenticationConverter,我们成功实现了JWT与不透明令牌在同一个资源服务器中的共存。希望本文的分享能帮助到有类似需求的开发者,也欢迎大家试用统一消息中心,提出宝贵意见。
最后,再次提醒:
- YAML配置一定要用空格,不要用Tab(别问为什么,问就是血泪教训)。
- 资源服务器同时支持多种令牌格式时,务必通过
authenticationManagerResolver动态选择,不要试图同时配置jwt()和opaqueToken()。 - JWT中不要存储敏感信息,只存用户名,用户详情由
UserDetailsService加载。
如果觉得本文对你有帮助,欢迎点赞、分享!有任何问题,可以在GitHub提Issue或加入QQ群交流。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)