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

目标

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


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

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

相关推荐
Nebula_g2 分钟前
JavaSE拓展:工具类Executors
java·开发语言·后端·spring·基础·javase
Zelman21 分钟前
第06章-超节点
人工智能·后端
Raas10023 分钟前
AI网关能做语义缓存吗?MAI Gateway(魔芋企业级AI网关)实战能力深度解读
java·后端·spring
静水深码26 分钟前
AI 能翻好谐音梗吗
人工智能·后端
柏修29 分钟前
Java工程师转型Agent Engineer - 24周详细学习计划
后端
牛气冲天的星球29 分钟前
服务器资源监控之grafana+Prometheus配置
后端
Zelman31 分钟前
第07章-数据中心网络
后端·网络协议
属于自己的天空32 分钟前
Claude Code 进阶自动化:用 Memory + Hook 把重复操作变成全自动
后端
AI视觉监控工程师33 分钟前
视频分析平台高并发告警架构实战:从"识别到告警"的工程化落地
后端
小蒜学长34 分钟前
基于SpringBoot+Vue的租房管理系统的设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端·租房管理