在現(xiàn)代 Web 應(yīng)用中,用戶認(rèn)證是保障系統(tǒng)安全的核心環(huán)節(jié)。隨著前后端分離架構(gòu)的普及,傳統(tǒng)的 Session 認(rèn)證方式逐漸被 Token 認(rèn)證取代。其中,「雙 Token(Access Token + Refresh Token)機(jī)制」憑借其在安全性與用戶體驗(yàn)之間的出色平衡,成為主流的認(rèn)證方案。本文將結(jié)合完整的 Express 后端與 Vue 前端代碼,詳細(xì)解析雙 Token 機(jī)制的實(shí)現(xiàn)原理與實(shí)踐細(xì)節(jié)。雙 Token 機(jī)制的核心原理
雙 Token 機(jī)制通過兩種不同特性的令牌協(xié)同工作,解決了 "安全性" 與 "用戶體驗(yàn)" 之間的矛盾。其核心設(shè)計(jì)思想是:
- 「Access Token(訪問令牌)」?:短期有效,用于直接訪問受保護(hù)資源,有效期通常設(shè)置為幾分鐘(示例中為 12 秒,僅用于演示)
- 「Refresh Token(刷新令牌)」?:長期有效,僅用于獲取新的 Access Token,有效期可設(shè)置為幾天甚至幾周(示例中為 7 天)
這種設(shè)計(jì)的優(yōu)勢在于:當(dāng) Access Token 被盜取時(shí),攻擊者僅有很短的時(shí)間窗口可以利用;而 Refresh Token 雖然長期有效,但通常存儲(chǔ)在更安全的環(huán)境中,且一旦發(fā)現(xiàn)異??闪⒓吹蹁N。
后端實(shí)現(xiàn):Express 框架下的雙 Token 系統(tǒng)
后端作為 Token 的簽發(fā)者和驗(yàn)證者,承擔(dān)著整個(gè)認(rèn)證系統(tǒng)的核心邏輯。以下從初始化配置、核心工具函數(shù)、認(rèn)證中間件到具體接口,逐步解析實(shí)現(xiàn)過程。
基礎(chǔ)配置與依賴
const?express?=?require('express');
const?cors?=?require('cors');
const?cookieParser?=?require('cookie-parser');
const?app?=?express();
app.use(cors({
? ??origin: [
? ? ? ??'http://localhost:5173',?'http://localhost:5174',?
? ? ? ??'http://localhost:5175',?'http://localhost:5176'
? ? ],
? ??credentials:?true?// 允許跨域請(qǐng)求攜帶Cookie
}));
app.use(express.json());
app.use(cookieParser());
這段代碼完成了三件關(guān)鍵工作:
- 引入 Express 框架及必要中間件(cors 處理跨域,cookie-parser 解析 Cookie)
- 配置跨域規(guī)則,允許指定前端域名訪問并支持跨域攜帶 Cookie
- 啟用 JSON 請(qǐng)求體解析和 Cookie 解析功能,為后續(xù)處理打好基礎(chǔ)
令牌存儲(chǔ)與核心工具函數(shù)
const?accessTokens =?new?Map();
const?refreshTokens =?new?Map();
function?now() {
? ??return?Math.floor(Date.now() /?1000);
}
function?getRandom(prefix) {
? ??return?`${prefix}-${Math.random().toString(26).slice(2)}${Date.now()}`;
}
為模擬令牌存儲(chǔ),示例使用 Map 對(duì)象臨時(shí)存儲(chǔ)令牌信息。在生產(chǎn)環(huán)境中,應(yīng)替換為 Redis 等分布式存儲(chǔ)系統(tǒng),以支持多實(shí)例部署和令牌過期自動(dòng)清理。
令牌簽發(fā)函數(shù)
function?getAccessToken(userId, ttlSec =?12)?{
? ??const?at?=?getRandom('AccessToken');
? ? accessTokens.set(at, { userId,?expiresIn:?now() + ttlSec });
? ??return?at;
}
function?getRefreshToken(userId, ttlSec =?3600?*?24?*?7)?{
? ??const?rt?=?getRandom('RefreshToken');
? ? refreshTokens.set(rt, {?
? ? ? ? userId,?
? ? ? ??expiresIn:?now() + ttlSec,?
? ? ? ??revoked:?false?
? ? });
? ??return?rt;
}
這兩個(gè)函數(shù)分別負(fù)責(zé)生成 Access Token 和 Refresh Token:
- 記錄令牌關(guān)聯(lián)的用戶 ID 和過期時(shí)間
- 為 Refresh Token 額外添加 "revoked" 狀態(tài),支持主動(dòng)吊銷
令牌驗(yàn)證與吊銷函數(shù)
function verifyAccessToken(at) {
? ??const?result = accessTokens.get(at);
? ??if?(!result || result.expiresIn <= now())?return?null;
? ??return?result.userId;
}
function verifyRefreshToken(rt) {
? ??const?result = refreshTokens.get(rt);
? ??if?(!result || result.revoked || result.expiresIn <= now())?return?null;
? ??return?result.userId;
}
function revokeRefreshToken(rt) {
? ??const?result = refreshTokens.get(rt);
? ??if?(result) result.revoked =?true;
}
驗(yàn)證函數(shù)通過檢查令牌是否存在、是否過期、是否被吊銷等狀態(tài),決定是否返回有效的用戶 ID。這種設(shè)計(jì)確保了只有符合條件的令牌才能通過驗(yàn)證。
認(rèn)證中間件:請(qǐng)求的第一道防線
app.use((req, res, next) => {
? ??
? ??if?(['/auth/login',?'/auth/refresh',?'/auth/logout',?'/login'].includes(req.path)) {
? ? ? ??return?next();
? ? }
? ??
? ??const?token?= req.headers.token ||?'';
? ??const?userId?=?verifyAccessToken(token);
? ??if?(userId) {
? ? ? ? req.userId = userId;?
? ? ? ??return?next();
? ? }
? ??
? ? res.send({?status:?401,?msg:?"未登錄或令牌過期"?});
});
這個(gè)中間件實(shí)現(xiàn)了 "守門人" 功能:
- 對(duì)其他所有請(qǐng)求驗(yàn)證 Access Token 的有效性
- 驗(yàn)證通過則將用戶 ID 掛載到請(qǐng)求對(duì)象,供后續(xù)接口使用
- 驗(yàn)證失敗則返回 401 錯(cuò)誤,提示客戶端進(jìn)行處理
核心接口實(shí)現(xiàn)
登錄接口:令牌的初始發(fā)放
app.post('/auth/login', (req, res) => {
? ??const?{ username } = req.body || {};
? ??const?userId?= username ||?'demoUser';
? ??const?at?=?getAccessToken(userId,?12);
? ??const?rt?=?getRefreshToken(userId);
? ??
? ? res.cookie('rt', rt, {
? ? ? ??httpOnly:?true, // 禁止前端JS訪問,防XSS攻擊
? ? ? ??sameSite:?'lax', // 限制跨站請(qǐng)求攜帶,防CSRF攻擊
? ? ? ??secure:?false, // 本地開發(fā)為false,生產(chǎn)需設(shè)為true
? ? ? ??path:?'/',
? ? ? ??maxAge:?7?*?24?*?3600?*?1000
? ? });
? ? res.send({?status:?200,?data: at });
});
登錄接口是用戶獲取初始令牌的入口:
- 生成 Access Token 和 Refresh Token
- 將 Access Token 直接返回給前端(通常存儲(chǔ)在 localStorage)
- 將 Refresh Token 存入 httpOnly Cookie,提升安全性
特別注意 Cookie 的配置:httpOnly: true防止前端 JavaScript 訪問,有效抵御 XSS 攻擊;sameSite: 'lax'限制跨站請(qǐng)求攜帶 Cookie,降低 CSRF 攻擊風(fēng)險(xiǎn)。
令牌刷新接口:無感續(xù)期的關(guān)鍵
app.post('/auth/refresh', (req, res) => {
? ??const?rt?= req.cookies.rt;?
? ??if?(!rt)?return?res.send({?status:?401,?msg:?'無刷新令牌'?});
? ??const?userId?=?verifyRefreshToken(rt);
? ??if?(!userId)?return?res.send({?status:?401,?msg:?'刷新令牌失效'?});
? ??
? ??revokeRefreshToken(rt);
? ??const?newRt?=?getRefreshToken(userId);
? ??const?newAt?=?getAccessToken(userId);
? ??
? ? res.cookie('rt', newRt, { ... });
? ? res.send({?status:?200,?data: newAt });
});
刷新接口實(shí)現(xiàn)了 Token 的無感續(xù)期:
- 從 Cookie 中獲取 Refresh Token 并驗(yàn)證其有效性
- 采用 "令牌旋轉(zhuǎn)" 機(jī)制:吊銷舊的 Refresh Token,生成新的一對(duì)令牌
- 將新的 Refresh Token 存入 Cookie,新的 Access Token 返回給前端
令牌旋轉(zhuǎn)機(jī)制大幅提升了安全性,即使 Refresh Token 被盜取,攻擊者也只能使用一次。
登出接口:安全終止會(huì)話
app.post('/auth/logout', (req, res) => {
? ??const?rt?= req.cookies.rt;
? ??if?(rt)?revokeRefreshToken(rt);?
? ? res.clearCookie('rt', {?path:?'/'?});?
? ? res.send({?status:?200,?msg:?'已登出'?});
});
登出接口通過吊銷 Refresh Token 并清除 Cookie,確保用戶會(huì)話被安全終止,防止后續(xù)被惡意使用。
前端實(shí)現(xiàn):Vue 中的令牌管理
前端作為令牌的持有者和使用方,需要妥善處理令牌的存儲(chǔ)、傳遞和刷新邏輯。以下從路由守衛(wèi)、請(qǐng)求攔截器到頁面組件,解析前端實(shí)現(xiàn)細(xì)節(jié)。
路由守衛(wèi):控制頁面訪問權(quán)限
router.beforeEach((to,?from, next) =>?{
? ??
? ??const?token = to.query.token;
? ??if?(token) {
? ? ? ??localStorage.setItem("token", token);
? ? ? ??next({?path: to.path,?query: {} });
? ? ? ??return;
? ? }
? ??
? ??if?(to.meta.requiresAuth) {
? ? ? ??const?currentToken =?localStorage.getItem('token');
? ? ? ??if?(!isValidToken(currentToken)) {
? ? ? ? ? ??
? ? ? ? ? ??window.open(`http://localhost:5174/login?resource=${window.location.origin}${to.path}`);
? ? ? ? ? ??return;
? ? ? ? }
? ? }
? ??next();
})
路由守衛(wèi)實(shí)現(xiàn)了頁面級(jí)別的訪問控制:
- 處理 URL 中攜帶的 token 參數(shù),存儲(chǔ)到 localStorage
- 對(duì)標(biāo)記為需要認(rèn)證的路由(如/about),檢查 token 有效性
- 沒有有效 token 時(shí),引導(dǎo)用戶到登錄頁面
Axios 攔截器:自動(dòng)處理令牌
request.interceptors.request.use((config) =>?{
? ??const?token =?localStorage.getItem("token");
? ? config.headers?= config.headers?|| {}
? ??if?(isValidToken(token)){
? ? ? ? config.headers.token?= token;
? ? }else{
? ? ? ??localStorage.removeItem('token');
? ? }
? ??return?config;
})
request.interceptors.response.use(async?(res) => {
? ??if?(res.data?&& res.data.status?===?401) {
? ? ? ??const?original = res.config?|| {}
? ? ? ??if?(original._retried) {
? ? ? ? ? ??
? ? ? ? ? ??window.open(`http://localhost:5174/login?resource=${window.location.origin}`)
? ? ? ? ? ??return?res;
? ? ? ? }
? ? ? ? original._retried?=?true
? ? ? ??if?(!isRefreshing) {
? ? ? ? ? ? isRefreshing =?true
? ? ? ? ? ? refreshPromise = request.post('/auth/refresh', {})
? ? ? ? ? ? ? ? .then(r?=>?{
? ? ? ? ? ? ? ? ? ??if?(r.data?&& r.data.status?===?200) {
? ? ? ? ? ? ? ? ? ? ? ??const?newToken = r.data.data
? ? ? ? ? ? ? ? ? ? ? ??localStorage.setItem('token', newToken)
? ? ? ? ? ? ? ? ? ? ? ??return?newToken
? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? ? ??throw?new?Error('refresh failed')
? ? ? ? ? ? ? ? })
? ? ? ? ? ? ? ? .catch(() =>?{
? ? ? ? ? ? ? ? ? ??localStorage.removeItem('token')
? ? ? ? ? ? ? ? ? ??throw?new?Error('refresh failed')
? ? ? ? ? ? ? ? })
? ? ? ? ? ? ? ? .finally(() =>?{
? ? ? ? ? ? ? ? ? ? isRefreshing =?false
? ? ? ? ? ? ? ? })
? ? ? ? }
? ? ? ??try?{
? ? ? ? ? ??const?newToken =?await?refreshPromise
? ? ? ? ? ? original.headers.token?= newToken
? ? ? ? ? ??return?request(original)
? ? ? ? }?catch?(e) {
? ? ? ? ? ??window.open(`http://localhost:5174/login?resource=${window.location.origin}`)
? ? ? ? ? ??return?res
? ? ? ? }
? ? }
? ??return?res;
})
攔截器是實(shí)現(xiàn) "無感刷新" 的核心:
- 請(qǐng)求攔截器自動(dòng)為每個(gè)請(qǐng)求添加 Access Token
- 響應(yīng)攔截器在收到 401 錯(cuò)誤時(shí),自動(dòng)嘗試刷新令牌
- 使用isRefreshing和refreshPromise避免并發(fā)刷新請(qǐng)求
- 刷新成功則用新令牌重試原請(qǐng)求,失敗則引導(dǎo)用戶重新登錄
登錄頁面組件:處理身份驗(yàn)證
<script?setup>
import?request?from?"../server/request";
import?{ useRoute }?from?"vue-router";
import?{ watch, ref }?from?"vue";
const?route =?useRoute();
const?resource =?ref("");
const?token =?localStorage.getItem("token");
function?windowPostMessage(token, resource) {
??if?(window.opener) {
? ??window.opener.postMessage({ token }, resource.value)
? }
}
watch(
??() =>?route.query.resource,
??(val) =>?{
? ? resource.value?= val ??decodeURIComponent(val) :?"";
? ??if?(token) {
? ? ??windowPostMessage(token, resource.value)
? ? }
? },
? {?immediate:?true?}
);
function?login() {
? request.get("/auth/login").then((res) =>?{
? ??const?apitoken = res.data.data;
? ??localStorage.setItem("token", apitoken);
? ??windowPostMessage(apitoken, resource.value)
? ??window.location.href?=?`${resource.value}?token=${apitoken}`;
? ??window.close()
? });
}
</script>
登錄頁面處理用戶身份驗(yàn)證流程:
- 通過 URL 參數(shù)接收跳轉(zhuǎn)來源(resource)
- 登錄成功后,通過 postMessage 通知父窗口
- 將新令牌通過 URL 參數(shù)傳遞給來源頁面
雙 Token 機(jī)制操作流程演示
為了更直觀地理解雙 Token 機(jī)制的實(shí)際運(yùn)行過程,以下是一個(gè) GIF 演示,展示了從用戶登錄到令牌刷新、訪問資源以及登出的完整操作流程:
演示內(nèi)容說明:
- 用戶未登錄時(shí)在Home頁面跳轉(zhuǎn)登錄頁面后輸入信息并登錄,前端獲取 Access Token 并存儲(chǔ),Refresh Token 通過 Cookie 存儲(chǔ)
- 登錄后訪問受保護(hù)資源/api1,請(qǐng)求頭攜帶 Access Token,成功獲取資源
- 等待 Access Token 過期后再次訪問/api1,前端攔截 401 錯(cuò)誤,自動(dòng)調(diào)用刷新接口獲取新令牌
- 使用新的 Access Token 重新請(qǐng)求/api1,成功獲取資源,用戶無感知
- 點(diǎn)擊登出按鈕,前端清除本地存儲(chǔ)的 Access Token,后端吊銷 Refresh Token 并清除 Cookie
通過這個(gè)演示可以清晰看到,整個(gè)過程中用戶無需多次輸入賬號(hào)密碼,在 Access Token 過期時(shí)實(shí)現(xiàn)了無感續(xù)期,既保證了安全性又提升了用戶體驗(yàn)。
雙 Token 機(jī)制的安全性考量
雙 Token 機(jī)制的安全性建立在多個(gè)層面的防護(hù)措施上:
- Access Token 存儲(chǔ)在 localStorage,便于前端管理但存在 XSS 風(fēng)險(xiǎn)
- Refresh Token 存儲(chǔ)在 httpOnly Cookie,防止前端 JS 訪問,抵御 XSS 攻擊
- 生產(chǎn)環(huán)境應(yīng)啟用 HTTPS,防止令牌在傳輸過程中被竊聽
- 合理設(shè)置 Cookie 的 secure 屬性,確保僅通過 HTTPS 傳輸
- Access Token 短期有效,減少被盜用后的風(fēng)險(xiǎn)窗口
- Refresh Token 長期有效但支持主動(dòng)吊銷,平衡安全性與用戶體驗(yàn)
- 令牌旋轉(zhuǎn)機(jī)制確保 Refresh Token 只能使用一次
- sameSite Cookie 屬性降低 CSRF 攻擊風(fēng)險(xiǎn)
- 嚴(yán)格的令牌驗(yàn)證邏輯防止無效令牌被使用
總結(jié)與擴(kuò)展
雙 Token 機(jī)制通過 Access Token 和 Refresh Token 的協(xié)同工作,在安全性和用戶體驗(yàn)之間取得了出色的平衡。本文提供的完整代碼實(shí)現(xiàn)了從令牌簽發(fā)、驗(yàn)證、刷新到登出的全流程,包含了前端和后端的關(guān)鍵處理邏輯。
在實(shí)際應(yīng)用中,還可以進(jìn)一步擴(kuò)展:
- 使用 Redis 等分布式存儲(chǔ)替換內(nèi)存存儲(chǔ),支持集群部署
- 實(shí)現(xiàn)令牌黑名單機(jī)制,處理已吊銷但未過期的令牌
- 添加令牌撤銷通知,在用戶修改密碼等場景立即失效所有令牌
- 結(jié)合 JWT(JSON Web Token)實(shí)現(xiàn)無狀態(tài)令牌驗(yàn)證,減輕服務(wù)器負(fù)擔(dān)
通過理解和實(shí)踐雙 Token 機(jī)制,開發(fā)者可以為 Web 應(yīng)用構(gòu)建更加安全、可靠的認(rèn)證系統(tǒng),為用戶提供流暢的使用體驗(yàn)同時(shí)保障系統(tǒng)安全。
?
閱讀原文:https://mp.weixin.qq.com/s/vFR_hNx_yBCbdHdiW3kHFA
該文章在 2025/9/26 9:52:54 編輯過