Spring Authorization Server 下 Token 刷新流程自定义实现

目标

客户端调用刷新令牌接口时,不再沿用首次登录时的旧用户数据,而是根据 Refresh Token 中关联的用户 ID 实时查询数据库,获取最新的角色、权限等信息,并生成新的 access_token、refresh_token 和 id_token ,同时保持 OIDC 会话的一致性(如 auth_timesub 等字段)。


为什么默认刷新逻辑不够用

Spring Authorization Server 内置的 OAuth2RefreshTokenAuthenticationProvider 在刷新时:

  • 直接复用旧的 OAuth2Authorization 中保存的 Principal 对象(即首次登录时的 Authentication);
  • 用户权限、角色变更后,刷新得到的令牌仍然包含旧信息,必须重新登录才能生效

这无法满足"权限实时生效"的安全要求。


自定义刷新提供者核心逻辑

新建 CustomRefreshTokenAuthenticationProvider 并实现 AuthenticationProvider替换默认实现

关键步骤

  1. 校验客户端和 Refresh Token 的有效性。
  2. 从数据库查出旧的 OAuth2Authorization
  3. 从旧授权中提取 userId(通过 Principal 属性)。
  4. 调用 SysUserDetailsService.loadUserByUserId(userId) 加载最新用户信息。
  5. 用新的 SysUserDetails 构建新的 UsernamePasswordAuthenticationToken
  6. 构建 DefaultOAuth2TokenContext特别注意 ID Token 上下文必须传入旧的 OAuth2Authorization ,否则 JwtGenerator 会因为无法读取旧的 ID Token 而抛出 NullPointerException
  7. 生成新的 access_tokenrefresh_tokenid_token
  8. 持久化新的授权记录,并建议废除旧的 Refresh Token。

代码骨架(仅保留刷新关键部分)

bash 复制代码
@Override
public Authentication authenticate(Authentication authentication) {
    OAuth2RefreshTokenAuthenticationToken refreshAuth = (OAuth2RefreshTokenAuthenticationToken) authentication;
​
    // 1. 客户端校验
    OAuth2ClientAuthenticationToken clientPrincipal = ...;
    RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();
​
    // 2. 查找旧授权
    OAuth2Authorization oldAuth = authorizationService.findByToken(
            refreshAuth.getRefreshToken(), OAuth2TokenType.REFRESH_TOKEN);
    if (oldAuth == null) {
        throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT);
    }
​
    // 3. 提取 userId 并加载最新用户信息
    Principal oldPrincipal = oldAuth.getAttribute(Principal.class.getName());
    Authentication newPrincipal = loadLatestPrincipal(oldPrincipal);
​
    // 4. 构建 token 上下文
    Set<String> scopes = oldAuth.getAuthorizedScopes();
    DefaultOAuth2TokenContext.Builder ctxBuilder = DefaultOAuth2TokenContext.builder()
            .registeredClient(registeredClient)
            .principal(newPrincipal)
            .authorizationServerContext(AuthorizationServerContextHolder.getContext())
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .authorizationGrant(refreshAuth)
            .authorizedScopes(scopes);
​
    // 5. 生成新令牌
    OAuth2AccessToken newAccessToken = createAccessToken(ctxBuilder);
    OAuth2RefreshToken newRefreshToken = createRefreshToken(ctxBuilder);
​
    // 6. 构建新授权并添加 ID Token
    OAuth2Authorization.Builder newAuthBuilder = OAuth2Authorization.withRegisteredClient(registeredClient)
            .principalName(newPrincipal.getName())
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .authorizedScopes(scopes)
            .attribute(Principal.class.getName(), newPrincipal)
            .token(newAccessToken)
            .refreshToken(newRefreshToken);
​
    addIdToken(newAuthBuilder, ctxBuilder, scopes, oldAuth);   // 必须传入 oldAuth
​
    OAuth2Authorization newAuth = newAuthBuilder.build();
    authorizationService.save(newAuth);
​
    // 7. 废弃旧 Refresh Token(可选但推荐)
    // authorizationService.remove(oldAuth);
​
    return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, newAccessToken, newRefreshToken);
}

关键点:ID Token 生成必须传入旧授权

bash 复制代码
private void addIdToken(OAuth2Authorization.Builder authBuilder,
                        DefaultOAuth2TokenContext.Builder ctxBuilder,
                        Set<String> scopes,
                        OAuth2Authorization oldAuth) {
    if (scopes.contains(OidcScopes.OPENID)) {
        OAuth2TokenContext idTokenCtx = ctxBuilder
                .tokenType(new OAuth2TokenType(OidcParameterNames.ID_TOKEN))
                .authorization(oldAuth)                //  必须设置,否则 JwtGenerator NPE
                .build();
        OAuth2Token generated = tokenGenerator.generate(idTokenCtx);
​
        OidcIdToken oidcIdToken = toOidcIdToken(generated); // 处理 Jwt 转换
        if (oidcIdToken != null) {
            authBuilder.token(oidcIdToken, (meta) ->
                    meta.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, oidcIdToken.getClaims()));
        }
    }
}

原因JwtGenerator 在生成 ID Token 时会调用 context.getAuthorization().getToken(OidcIdToken.class) 来获取旧的 ID Token(以保持 auth_time 等字段一致)。如果不传入旧授权,则此处为 null,引发 NullPointerException


配置替换默认提供者

AuthorizationServerConfig 中修改 tokenEndpoint 的认证提供者列表:

bash 复制代码
tokenEndpoint.authenticationProviders(providers -> {
    providers.removeIf(p -> p instanceof OAuth2RefreshTokenAuthenticationProvider);
    providers.add(new CustomRefreshTokenAuthenticationProvider(
            authorizationService, tokenGenerator, sysUserDetailsService));
    // ... 其他自定义提供者
});

前提依赖

  • 首次登录时 ID Token 已正确保存OAuth2Authorization 表中(否则刷新时无法获取旧的 ID Token,仍会 NPE)。
  • Jackson 反序列化需允许 Long 等自定义类型(若遇到 allowlist 异常,需添加 SecurityJackson2Modules.allowListClass(Long.class))。

验证方法

  1. 使用自定义 grant_type(如 phone_password)登录,获取 refresh_token
  2. 修改数据库中该用户的角色/权限(不重新登录)。
  3. refresh_token 调用 /oauth2/token,scope 包含 openid
  4. 解码新 access_token 的 JWT,确认其中包含最新的权限信息。
  5. 确认旧 refresh_token 已失效(若实现了失效逻辑)。

总结

通过自定义 RefreshTokenAuthenticationProvider 替换默认实现,并在生成 ID Token 时强制传入旧的 OAuth2Authorization,彻底解决了:

  • 刷新后用户权限不更新
  • 刷新时 JwtGenerator 的空指针异常

使得 OAuth2/OIDC 刷新流程在自定义授权模式下能够稳定、安全地运行,真正实现"权限实时生效"。

相关推荐
alwaysrun1 小时前
C++之灵活易用的YAML解析库yaml-cpp
c++·后端·程序员
pe7er1 小时前
AI为啥会写出if(obj != null && obj.ifEnabled)这样的代码
前端·后端·架构
狗凯之家源码网1 小时前
电商代付系统从零搭建与实战指南
前端·后端·开源
lcj25111 小时前
【list】【手撕 STL】List 容器全解析!迭代器 / 增删改查 / 去重排序,面试必背的核心考点!
c++·面试·list
Jabes.yang1 小时前
Java面试实录:AIGC场景下的Stream、微服务、Redis、Kafka与安全实战
java·spring boot·redis·微服务·面试·kafka·aigc
IT_陈寒1 小时前
Vue组件通信这个坑我跳了两次才知道怎么爬出来
前端·人工智能·后端
copyer_xyf2 小时前
Python 文件基本操作
前端·后端·python
程序员二叉2 小时前
【Java】 面试核心合集:BigDecimal、缓存池、多态、反射全解析
java·缓存·面试
西凉的悲伤2 小时前
Spring Security + JWT 登录认证完整实践指南
java·后端·spring·spring security·jwt