刷新 token 使用源码解析
sequenceDiagram
participant UI as pigx-ui
participant Gateway as pigx-gateway
participant Auth as pigx-auth
participant Redis as Redis
UI->>UI: 定时检查 Token 有效期
UI->>UI: 有效期 ≤ 30 分钟,触发刷新
UI->>Gateway: POST /auth/oauth2 /token<br/>grant_type=refresh_token
Gateway->>Auth: 路由转发请求
Auth->>Auth: 客户端认证(Basic Auth)
Auth->>Auth: RefreshTokenConverter 解析请求
Auth->>Redis: 根据 refresh_token 查找授权信息
Redis-->>Auth: 返回 OAuth2Authorization
Auth->>Auth: 验证 refresh_token 有效性
Auth->>Auth: 生成新 access_token + refresh_token
Auth->>Redis: 存储新的 Token 授权信息
Auth-->>Gateway: 返回新 Token
Gateway-->>UI: 返回新 Token
UI->>UI: 更新本地存储
前端 Token 有效期检查
前端通过定时轮询 checkToken() 方法检查当前 access_token 的有效期,当剩余有效期不足 30 分钟时自动触发刷新。
// Token 刷新锁,防止并发刷新
const tokenRefreshLock = refAutoReset(false, 100);
async function checkToken(): Promise<boolean> {
// 1. 调用后端检查 Token 有效期
const response = await request.get('/auth/token/check_token', { token });
// 2. 计算剩余有效期
const expiredPeriod = Date.parse(response.data.expiresAt) - Date.now();
const HALF_HOUR = 30 * 60 * 1000;
// 3. 小于 30 分钟且未在刷新中,触发续期
if (expiredPeriod <= HALF_HOUR && !tokenRefreshLock.value) {
tokenRefreshLock.value = true;
await useUserInfo().refreshToken();
tokenRefreshLock.value = false;
}
}
💡防并发机制
使用 VueUse 的 refAutoReset 实现刷新锁,100ms 后自动重置为 false,避免多个请求同时触发刷新。
前端组装刷新请求
当 checkToken() 判断需要刷新时,通过 Pinia Store 调用 refreshTokenApi 发起刷新请求。
// 刷新 Token API
function refreshTokenApi(refresh_token: string) {
return request.post('/auth/oauth2/token', {
params: { refresh_token, grant_type: 'refresh_token', scope: 'server' },
headers: {
Authorization: Session.get('basicAuth'), // 客户端 Basic 认证
'Content-Type': 'application/x-www-form-urlencoded',
},
});
}
// Pinia Store 刷新 Token
async refreshToken() {
const refreshToken = Session.get('refresh_token');
const res = await refreshTokenApi(refreshToken);
// 更新本地存储
Session.set('token', res.access_token);
Session.set('refresh_token', res.refresh_token);
}
刷新请求报文示例:
POST /auth/oauth2/token?grant_type=refresh_token&refresh_token=xxx&scope=server HTTP/1.1
Host: pig-gateway:9999
Authorization: Basic dGVzdDp0ZXN0
Content-Type: application/x-www-form-urlencoded
网关路由转发
http://127.0.0.1:9999/auth/oauth2/token
转发到 pigx-auth 的请求路径自动截取前缀变成
http://127.0.0.1:3000/oauth2/token
客户端认证
与登录流程一致,刷新请求中会携带 Basic base64(clientId:clientSecret),OAuth2ClientAuthenticationFilter 通过 RegisteredClientRepository(数据库存储)来校验客户端凭证。
⚠客户端一致性
刷新请求的 Basic Auth 必须与登录时使用的客户端一致,前端通过 Session.get('basicAuth') 获取登录时缓存的客户端认证信息。
请求转换:PigxOAuth2RefreshTokenAuthenticationConverter
pigx-auth 自定义了 PigxOAuth2RefreshTokenAuthenticationConverter 覆盖原生实现,解决了原生 Converter 无法获取 query params 的问题。
// 覆盖原生实现,支持 query params 获取
public class PigxOAuth2RefreshTokenAuthenticationConverter implements AuthenticationConverter {
public Authentication convert(HttpServletRequest request) {
// 1. 从 query + body 中统一获取参数
MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);
// 2. 校验 grant_type 是否为 refresh_token
String grantType = parameters.getFirst("grant_type");
if (!"refresh_token".equals(grantType)) return null;
// 3. 校验 refresh_token 参数(必填)
String refreshToken = parameters.getFirst("refresh_token");
...
// 4. 解析 scope(可选,可重新指定权限范围)
Set<String> requestedScopes = parseScopes(parameters);
// 5. 组装认证对象
return new OAuth2RefreshTokenAuthenticationToken(
refreshToken, clientPrincipal, requestedScopes, additionalParameters);
}
}
⚠覆盖原因
原生 OAuth2RefreshTokenAuthenticationConverter 仅从 request body 获取参数,而前端通过 query params 传递 refresh_token,因此需要自定义实现以兼容两种传参方式。
该 Converter 在 AuthorizationServerConfiguration 中注册:
public AuthenticationConverter accessTokenRequestConverter() {
return new DelegatingAuthenticationConverter(Arrays.asList(
new OAuth2ResourceOwnerPasswordAuthenticationConverter(), // 密码模式
new OAuth2ResourceOwnerSmsAuthenticationConverter(), // 短信模式
new PigxOAuth2RefreshTokenAuthenticationConverter(), // 刷新 Token
new OAuth2ClientCredentialsAuthenticationConverter(), // 客户端模式
new OAuth2AuthorizationCodeAuthenticationConverter(), // 授权码模式
...
));
}
核心认证:OAuth2RefreshTokenAuthenticationProvider
OAuth2RefreshTokenAuthenticationProvider 是 Spring Authorization Server 内置的刷新 Token 认证提供者,负责核心的刷新逻辑。
// Spring Authorization Server 内置实现
public class OAuth2RefreshTokenAuthenticationProvider implements AuthenticationProvider {
public Authentication authenticate(Authentication authentication) {
OAuth2RefreshTokenAuthenticationToken refreshTokenAuth = (OAuth2RefreshTokenAuthenticationToken) authentication;
// 1. 根据 refresh_token 值查找授权信息
OAuth2Authorization authorization = authorizationService.findByToken(
refreshTokenAuth.getRefreshToken(), OAuth2TokenType.REFRESH_TOKEN);
...
// 2. 验证客户端是否支持 refresh_token 授权模式
RegisteredClient registeredClient = ...;
if (!registeredClient.getAuthorizationGrantTypes()
.contains(AuthorizationGrantType.REFRESH_TOKEN)) {
throw new OAuth2AuthenticationException(...);
}
// 3. 生成新的 access_token
OAuth2AccessToken accessToken = tokenGenerator.generate(tokenContext);
// 4. 生成新的 refresh_token(如果启用了轮转)
OAuth2RefreshToken refreshToken = tokenGenerator.generate(refreshTokenContext);
// 5. 返回认证结果
return new OAuth2AccessTokenAuthenticationToken(
registeredClient, clientPrincipal, accessToken, refreshToken, additionalParameters);
}
}
💡Refresh Token 查找
PIGX 使用 Redis 存储 Token,authorizationService.findByToken() 会从 Redis 中按 key token::refresh_token::{value} 查找对应的 OAuth2Authorization 对象。
Token 生成器配置
AuthorizationServerConfiguration 中配置了 Token 生成器,由 DelegatingOAuth2TokenGenerator 委派给不同类型的生成器。
@Bean
public OAuth2TokenGenerator oAuth2TokenGenerator() {
CustomeOAuth2AccessTokenGenerator accessTokenGenerator = new CustomeOAuth2AccessTokenGenerator();
// 注入 Token 增加关联用户信息
accessTokenGenerator.setAccessTokenCustomizer(new CustomeOAuth2TokenCustomizer());
return new DelegatingOAuth2TokenGenerator(accessTokenGenerator, new OAuth2RefreshTokenGenerator());
}
flowchart LR
A[DelegatingOAuth2TokenGenerator] --> B[CustomeOAuth2AccessTokenGenerator]
A --> C[OAuth2RefreshTokenGenerator]
B --> D[生成 access_token]
C --> E[生成 refresh_token]
Token 有效期通过 PigxRemoteRegisteredClientRepository 中的客户端配置确定:
| 配置项 | 默认值 | 说明 |
|---|
| accessTokenTimeToLive | 12 小时 | 访问令牌有效期 |
| refreshTokenTimeToLive | 30 天 | 刷新令牌有效期 |
// 客户端 Token 配置
TokenSettings.builder()
.accessTokenFormat(OAuth2TokenFormat.REFERENCE)
.accessTokenTimeToLive(Duration.ofSeconds(accessTokenValidity)) // 默认 12 小时
.refreshTokenTimeToLive(Duration.ofSeconds(refreshTokenValidity)) // 默认 30 天
.build()
💡有效期可配置
每个客户端可通过 sys_oauth_client_details 表的 access_token_validity 和 refresh_token_validity 字段单独配置有效期。
Redis Token 存储更新
刷新成功后,PigxRedisOAuth2AuthorizationService 负责将新的授权信息存储到 Redis,同时清理旧的 Token。
// Redis Token 存储
public class PigxRedisOAuth2AuthorizationService implements OAuth2AuthorizationService {
public void save(OAuth2Authorization authorization) {
// 1. 存储 refresh_token
if (isRefreshToken(authorization)) {
OAuth2RefreshToken refreshToken = authorization.getRefreshToken().getToken();
long ttl = ChronoUnit.SECONDS.between(refreshToken.getIssuedAt(), refreshToken.getExpiresAt());
redisTemplate.opsForValue().set(
"token::refresh_token::" + refreshToken.getTokenValue(),
authorization, ttl, TimeUnit.SECONDS);
}
// 2. 存储 access_token
if (isAccessToken(authorization)) {
OAuth2AccessToken accessToken = authorization.getAccessToken().getToken();
long ttl = ChronoUnit.SECONDS.between(accessToken.getIssuedAt(), accessToken.getExpiresAt());
redisTemplate.opsForValue().set(
"token::access_token::" + accessToken.getTokenValue(),
authorization, ttl, TimeUnit.SECONDS);
// 3. 维护 username 与 access_token 的关系索引
String tokenUsername = "token::username::" + username + "::" + clientId
+ "::" + tenantId + "::" + accessToken.getTokenValue();
redisTemplate.opsForValue().set(tokenUsername, accessToken.getTokenValue(), ttl, TimeUnit.SECONDS);
}
}
}
Redis 中的 Key 结构:
| Key 格式 | TTL | 说明 |
|---|
token::refresh_token::{value} | refresh_token 有效期 | 刷新令牌索引 |
token::access_token::{value} | access_token 有效期 | 访问令牌索引 |
token::username::{name}::{clientId}::{tenantId}::{value} | access_token 有效期 | 用户令牌关系索引 |
认证成功响应
PigxAuthenticationSuccessEventHandler 处理认证成功后的响应输出,将新的 Token 写入 HTTP 响应。
// 认证成功处理器
public class PigxAuthenticationSuccessEventHandler implements AuthenticationSuccessHandler {
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) {
OAuth2AccessTokenAuthenticationToken tokenAuth = (OAuth2AccessTokenAuthenticationToken) authentication;
OAuth2AccessToken accessToken = tokenAuth.getAccessToken();
OAuth2RefreshToken refreshToken = tokenAuth.getRefreshToken();
// 组装响应
OAuth2AccessTokenResponse.Builder builder = OAuth2AccessTokenResponse
.withToken(accessToken.getTokenValue())
.tokenType(accessToken.getTokenType())
.scopes(accessToken.getScopes())
.expiresIn(ChronoUnit.SECONDS.between(accessToken.getIssuedAt(), accessToken.getExpiresAt()));
if (refreshToken != null) {
builder.refreshToken(refreshToken.getTokenValue()); // 返回新的 refresh_token
}
// 无状态,清除 SecurityContext
SecurityContextHolder.clearContext();
accessTokenHttpResponseConverter.write(builder.build(), null, httpResponse);
}
}
响应报文示例:
{
"access_token": "新的访问令牌",
"refresh_token": "新的刷新令牌",
"token_type": "Bearer",
"expires_in": 43200,
"scope": "server"
}
前端存储更新
前端收到新 Token 后,通过 Session 工具类更新本地存储,采用 SessionStorage + Cookies 双重存储策略。
// 更新本地 Token 存储
Session.set('token', res.access_token);
Session.set('refresh_token', res.refresh_token);
// Session 内部实现:双重存储
function set(key: string, val: any) {
if (key === 'token' || key === 'refresh_token') {
Cookies.set(key, val); // 持久化到 Cookie
}
window.sessionStorage.setItem(key, JSON.stringify(val)); // 存入 SessionStorage
}
💡双重存储策略
Token 同时存储在 SessionStorage 和 Cookies 中,SessionStorage 用于当前会话的快速读取,Cookies 用于跨标签页的持久化。
Token 过期兜底处理
当 refresh_token 也过期或刷新失败时,后端返回 424 状态码,前端响应拦截器会引导用户重新登录。
// 响应拦截器 - Token 过期兜底
service.interceptors.response.use(handleResponse, (error) => {
const status = error.response.status;
if (status === 424) {
// Token 完全过期,引导重新登录
MessageBox.confirm('令牌状态已过期,请点击重新登录').then(() => {
Session.clear();
window.location.href = '/';
});
}
});
⚠424 与自动刷新的关系
正常情况下 checkToken() 会在 access_token 到期前 30 分钟自动刷新,424 是最终兜底机制,仅在 refresh_token 也失效或刷新异常时触发。