沒有什么比在用戶操作得正嗨時,突然提示“登錄已過期,請重新登錄”的提示更讓人沮喪的了。這種突兀的中斷不僅破壞了用戶體驗,甚至可能導致未保存的數據丟失。
然而,我們都知道,出于安全考慮,用于身份驗證的 Token
(通常是 Access Token
)必須有較短的有效期。那么,我們如何在保證安全的前提下,創造一種“永不掉線”的絲滑體驗呢?
問題的根源:Access Token 的“天生矛盾”
首先,我們要理解為什么需要刷新 Token。
我們通常使用 Access Token
來驗證用戶的每一次 API 請求。為了安全,Access Token
的生命周期被設計得很短(例如 30 分鐘或 1 小時)。如果有效期太長,一旦泄露,攻擊者就能在很長一段時間內冒充用戶進行操作,風險極高。
這就產生了一個矛盾:
- 安全性要求:
Access Token
有效期要短。 - 用戶體驗要求:用戶不想頻繁地被強制重新登錄。
為了解決這個矛盾,Refresh Token
應運而生。
核心理念:雙 Token 認證系統
無感刷新機制的核心在于引入了兩種類型的 Token:
Access Token
(訪問令牌)
- 用途:用于訪問受保護的 API 資源,附加在每個請求的
Header
中。 - 特點:生命周期短(如 1 小時),無狀態,服務器無需存儲。
- 存儲:通常存儲在客戶端內存中(如 Vuex/Redux),因為需要頻繁讀取。
Refresh Token
(刷新令牌)
- 用途:當
Access Token
過期時,專門用于獲取一個新的 Access Token
。 - 特點:生命周期長(如 7 天或 30 天),與特定用戶綁定,服務器需要安全存儲其有效性記錄。
- 存儲:必須安全存儲。最佳實踐是存儲在
HttpOnly
Cookie 中,這樣可以防止客戶端 JavaScript 腳本(如 XSS 攻擊)讀取它。
既然如此,為何不直接使用 Refresh Token
呢?
Access Token
通常是無狀態的,服務器無需記錄它,也導致 JWT 無法主動吊銷,而 Refresh Token
是有狀態的,服務器需要一個列表(數據庫中的“白名單”或“吊銷列表”)來記錄哪些 Refresh Token
是有效的,當用戶更改密碼、或從某個設備上“主動登出”時,服務器端可以主動將對應的 Refresh Token
設為無效。
無感刷新的詳細工作流
下面是這個“魔法”發生的具體步驟:
- 首次登錄:用戶使用用戶名和密碼登錄。服務器驗證成功后,返回一個
Access Token
和一個 Refresh Token
。 - 正常請求:客戶端將
Access Token
存儲起來,并在后續的每次 API 請求中,通過 Authorization
請求頭將其發送給服務器。 - Token 過期:當
Access Token
過期后,客戶端再次用它請求 API。服務器會拒絕該請求,并返回一個特定的狀態碼,通常是 401 Unauthorized
。 - 攔截 401 錯誤:客戶端的請求層(如 Axios 攔截器)會捕獲這個
401
錯誤。此時,它不會立即通知用戶“你已掉線”,而是暫停這個失敗的請求。 - 發起刷新請求:攔截器使用
Refresh Token
去調用一個專門的刷新接口(例如 /api/auth/refresh
)。 - 處理刷新結果:
- 刷新成功:服務器驗證
Refresh Token
有效,生成一個新的 Access Token
(有時也會返回一個新的 Refresh Token
,這被稱為“刷新令牌旋轉”策略,可以提高安全性),并將其返回給客戶端。 - 刷新失敗:如果
Refresh Token
也過期了或無效,服務器會返回錯誤(如 403 Forbidden
)。這意味著用戶的登錄會話徹底結束。
- 重試與終結:
- 若刷新成功:客戶端用新的
Access Token
自動重發剛才失敗的那個 API 請求。用戶完全感覺不到任何中斷,數據操作無縫銜接。 - 若刷新失敗:客戶端清除所有認證信息,強制用戶登出,并重定向到登錄頁面。
實戰演練:使用 Axios 攔截器實現無感刷新
Axios
的攔截器是實現這一流程的完美工具。下面是一個完整且考慮了并發問題的實現方案。
1. 創建 Axios 實例
首先,我們創建一個單獨的 Axios 實例,方便統一管理。
// a-pi/request.js
import axios from 'axios';
const service = axios.create({
baseURL: '/api',
timeout: 10000,
});
// 請求攔截器
service.interceptors.request.use(
config => {
// 在發送請求之前,從 state management (e.g., Vuex/Pinia/Redux) 獲取 token
const accessToken = getAccessTokenFromStore();
if (accessToken) {
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
return config;
},
error => {
return Promise.reject(error);
}
);
2. 核心:響應攔截器
這是實現無感刷新的關鍵。
// a-pi/request.js (續)
// 用于刷新 token 的 API
import { refreshTokenApi } from './auth';
let isRefreshing = false; // 控制刷新狀態的標志
let requests = []; // 存儲因 token 過期而掛起的請求
service.interceptors.response.use(
response => response, // 對成功響應直接返回
async error => {
const { config, response: { status } } = error;
// 1. 如果不是 401 錯誤,直接返回錯誤
if (status !== 401) {
return Promise.reject(error);
}
// 2. 避免重復刷新:如果正在刷新 token,將后續請求暫存
if (isRefreshing) {
return new Promise(resolve => {
requests.push(() => resolve(service(config)));
});
}
isRefreshing = true;
try {
// 3. 調用刷新 token 的 API
const { newAccessToken } = await refreshTokenApi(); // 假設 refresh token 通過 HttpOnly cookie 自動發送
// 4. 更新本地存儲的 access token
setAccessTokenInStore(newAccessToken);
// 5. 重試剛才失敗的請求
config.headers['Authorization'] = `Bearer ${newAccessToken}`;
// 6. 重新執行所有被掛起的請求
requests.forEach(cb => cb());
requests = []; // 清空隊列
return service(config); // 返回重試請求的結果
} catch (refreshError) {
// 7. 如果刷新 token 也失敗了,則執行登出操作
console.error('Unable to refresh token.', refreshError);
logoutUser(); // 清除 token,重定向到登錄頁
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);
export default service;
代碼解析:
- 并發處理:
isRefreshing
標志和 requests
數組是關鍵。當第一個 401
錯誤觸發刷新時,isRefreshing
變為 true
。后續在刷新完成前到達的 401
請求,都會被推進 requests
隊列中掛起,而不是重復發起刷新請求。當刷新成功后,再遍歷隊列,依次執行這些被掛起的請求。 - 原子操作:通過這種“加鎖”機制,確保了刷新 Token 的操作是原子的,避免了資源浪費和潛在的競態條件。
- 優雅降級:當
Refresh Token
也失效時,系統會執行 logoutUser()
,進行清理工作并引導用戶重新登錄,這是一個優雅的失敗處理方案。
無感刷新 Token 機制是現代 Web 應用提升用戶體驗的“標配”。它將身份驗證的復雜性隱藏在后臺,為用戶提供了一個流暢、不間斷的操作環境。
實現這一機制,不僅僅是寫幾行代碼,更是對認證流程、安全性和用戶體驗三者之間平衡的深刻理解。
該文章在 2025/7/9 12:40:23 編輯過