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 刷新流程在自定义授权模式下能够稳定、安全地运行,真正实现"权限实时生效"。

相关推荐
MobotStone2 分钟前
五个AI使用习惯让你的效率翻倍
后端
pixcarp5 分钟前
GMP调度模型和GC垃圾回收机制解析
后端·学习·golang·内存管理·gc·gmp
万少7 小时前
用腻了 WorkBuddy 的默认界面?这个开源小工具给它换上了毛玻璃
前端·javascript·后端
皮皮林5519 小时前
代码越“整洁”,性能越“拉胯”?
后端
金金金__10 小时前
一篇文章带你入门OpenSpec
后端
阿维的博客日记11 小时前
MultipartFile 是不是表示仅仅是一个分片?
java·后端·spring·multipartfile
海上彼尚11 小时前
Nodejs也能写Agent - 16.LangGraph篇 - 条件分支与循环
前端·后端·langchain·node.js
Dovis(誓平步青云)12 小时前
远程办公软件文件传输实测:6 款工具的速度、稳定性和办公体验对比
linux·运维·服务器·后端·生成对抗网络
凌虚13 小时前
AI 时代,程序员会消失吗?
人工智能·后端·程序员