access token 的效期是 15 分鐘,假使使用者 10:00 登入,10:15 token 過期,10:16 使用者的 App 仍夾著這顆舊 token 送出三個請求:
| 10:16 的請求 | 結果 | 是否正確 |
|---|---|---|
換新 token(POST /auth/refresh) |
放行,換發成功 | 正確 |
| 連 WebSocket 收通知(STOMP CONNECT) | 拒絕連線 | 正確 |
登出(POST /login/logout) |
修正前:401,登出失敗 | 錯誤,已修正 |
同一過期 token,三種結局,差別在於這三個入口後面還有沒有人把關。
Refresh Token 是拿來「重新取得身分憑證」的,所以
/auth/refresh本身必須能讓「帶著過期 Access Token 的請求」通過 Security。
直覺寫法會在 JwtAuthenticationFilter 發現 Access token 過期時,直接回 401:
if (!valid(token)) {
response.setStatus(401);
return; // chain 中止,後面都不跑
}
問題在於這裡其實有兩個不同的判斷,發生在 filter chain 的不同位置:JwtAuthenticationFilter 判斷「有沒有身分」,AuthorizationFilter 依白名單判斷「這個 API 需不需要身分」。前者若在後者之前就結束請求,AuthorizationFilter 根本沒機會查到 /auth/refresh 在白名單裡,換發端點就跟著被擋,從下圖看流程,等於在第一關就被擋下來,根本走不到換發流程。而 Access token 過期,正是該用 Refresh token 換新的時候。換發端點進不去,使用者只能重新登入。

最後的結論就是驗不過也不擋,失敗時 ifPresent 什麼都不做,doFilter 寫在 if 外面,不論成功還是失敗都會往下走:
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
tokenAuthenticator.tryAuthenticate(header.substring(7))
.ifPresent(auth -> SecurityContextHolder.getContext().setAuthentication(auth));
}
filterChain.doFilter(request, response);
同一個 TokenAuthenticator,WebSocket 入口的處理相反,驗不過直接丟例外:
Authentication auth = tokenAuthenticator.tryAuthenticate(header.substring(7))
.orElseThrow(() -> new MessageDeliveryException("JWT 驗證失敗"));
accessor.setUser(auth);
/ws/** 在 HTTP 層屬於白名單,握手不檢查身分;WebSocket 這一層也沒有授權層,只掛了這一個攔截器,WebSocket 連線上,只有建立連線的那一刻會檢查身分。
登出 API /login/logout 原本不在白名單內。10:15 access token 過期後,使用者在 10:16 按登出,反而因驗證未通過而登出失敗,以下是修正前的流程:

事實上,過期的Access token根本不會走進AuthService。
登出的邏輯在 AuthService(參考下方程式碼),AuthService會先嘗試把 access token 加入黑名單,再把 body 裡的 refresh token 從 Redis 刪掉。
public void logout(String refreshTokenId, String accessToken) {
// Blacklist access token in Redis
if (accessToken != null) {
try {
Claims claims = jwtUtils.parseToken(accessToken);
String jti = claims.getId();
if (jti != null) {
long remaining = jwtUtils.getRemainingSeconds(claims);
tokenRedisService.blacklistAccessToken(jti, remaining);
}
} catch (Exception e) {
// Access token may already be expired — ignore
}
}
// Delete refresh token from Redis
if (refreshTokenId != null) {
tokenRedisService.deleteRefreshToken(refreshTokenId);
}
}
登出真正需要的是 refresh token,登出不需要再靠有效的 access token 證明身分,因此把 /login/logout 加入白名單:
public static final String[] WHITE_LIST = {
"/login/create_token",
"/login/logout",
"/auth/refresh",
"/order/get_all_shops",
"/ws/**",
"/swagger-ui.html",
"/swagger-ui/**",
"/v3/api-docs",
"/v3/api-docs/**"
};
修改後的流程:
白名單讓登出走得進 Service;黑名單只處理還有效的 access token。