node.js如何實現雙 Token + Cookie 存儲 + 無感刷新機制
為什么要實施雙token機制?
優點 | 描述 |
---|---|
安全性 | Access Token 短期有效,降低泄露風險;Refresh Token 權限受限,僅用于獲取新 Token |
用戶體驗 | 用戶無需頻繁重新登錄,Token 自動刷新過程對用戶透明 |
靈活性 | 獨立控制不同 Token 的生命周期,適應各種場景需求 |
可管理性 | 支持多設備登錄管理,便于撤銷特定設備的登錄狀態 |
性能優化 | 減少數據庫查詢次數,提升系統響應速度 |
實現方案:
模塊 | 實現方式 |
---|---|
登錄接口 | 返回 accessToken 和 refreshToken ,分別存入 Cookie |
Access Token | 短時效 JWT,用于請求鑒權 |
Refresh Token | 長時效 JWT,用于刷新 Access Token |
Token 校驗方式 | 后端從 Cookie 中讀取 token (即 Access Token) |
前端 Axios | 使用響應攔截器統一處理 Token 失效和自動刷新 |
- 使用 JWT 生成兩個 Token:
- Access Token(短時效):用于接口認證,例如有效期為 15 分鐘
- Refresh Token(長時效):用于刷新 Access Token,例如有效期為 7 天
- 在用戶登錄時返回這兩個 Token,并將 Refresh Token 存儲在數據庫中
- 當 Access Token 過期后,客戶端使用 Refresh Token 請求新的 Access Token
- 如果 Refresh Token 也過期或無效,則強制重新登錄
具體代碼實現
1. 安裝依賴:
cookie-parser用來解析 Cookie 中的 Token
npm install jsonwebtoken bcryptjs cookie-parser
2. 數據庫添加兩個字段
refresh_token | VARCHAR(255) | 加密后的 RefreshToken |
---|---|---|
expires_at | DATETIME | RefreshToken 過期時間 |
3. 在后端cors跨域中間中添加屬性
// 將cors注冊為全局中間件
app.use(cors({origin: 'http://localhost:5173', // 前端地址credentials: true // 👈 允許攜帶憑證(cookies)
}))
3. 登錄邏輯改造(添加雙token)
- 添加配置文件config.js
module.exports = {jwtSecretKey: 'yke;eky1]239_jwt87-2up34',refreshTokenSecretKey: 'yke;eky1]239_refresh87-2up34',accessExpiresIn: '15m', // 訪問令牌有效期refreshExpiresIn: '7d', // 刷新令牌有效期accessExpiresInSec: 15 * 60, // 秒數refreshExpiresInSec: 7 * 24 * 60 * 60 // 秒數
}
- jwt生成accessToken訪問token、refreshToken刷新token
// 生成access token
const accessToken = jwt.sign({ id: user.id, username: user.username, email: user.email },config.jwtSecretKey,{ expiresIn: config.accessExpiresIn }
)
// 生成refresh token
const refreshToken = jwt.sign({ id: user.id, username: user.username, email: user.email },config.refreshTokenSecretKey,{ expiresIn: config.refreshExpiresIn }
)
- 生成token過期時間,和refreshToken一起存入數據庫
const expiresAt = new Date()expiresAt.setSeconds(expiresAt.getSeconds() + config.refreshExpiresInSec)
- 將accessToken訪問token、refreshToken刷新token存入cookie
// 設置cookie
res.cookie('token', accessToken, {maxAge: config.accessExpiresInSec * 1000,httpOnly: true,secure: true,path: '/'
})
res.cookie('refresh_token', refreshToken, {maxAge: config.refreshExpiresInSec * 1000,httpOnly: true,secure: true,path: '/api/user/refresh-token', // 限制路徑提高安全性sameSite: 'none'
})
登錄邏輯完整代碼:
// 用戶登錄的處理函數
exports.login = (req, res) => {// 接收表單數據const userInfo = req.bodyconsole.log(userInfo)// 查詢用戶信息const sqlStr_name = 'select * from user where username=?'db.query(sqlStr_name, [userInfo.username], (err, results) => {if (err) {return res.send({ status: 1, message: err })}// 執行sql語句成功,但是獲取的條數不等于1if (results.length === 0) {return res.send({ status: 1, message: '該用戶不存在' })}// 判斷密碼是否正確const cmpresult = bcrypt.compareSync(userInfo.password, results[0].password)if (!cmpresult) {return res.send({ status: 1, message: '密碼錯誤' })}// 在服務器端生成Token字符串const user = { ...results[0] }// 生成access tokenconst accessToken = jwt.sign({ id: user.id, username: user.username, email: user.email },config.jwtSecretKey,{ expiresIn: config.accessExpiresIn })// 生成refresh tokenconst refreshToken = jwt.sign({ id: user.id, username: user.username, email: user.email },config.refreshTokenSecretKey,{ expiresIn: config.refreshExpiresIn })// 將refresh token存儲到數據庫中const expiresAt = new Date()expiresAt.setSeconds(expiresAt.getSeconds() + config.refreshExpiresInSec)const sqlStr_refreshToken = 'update user set refresh_token=?, expires_at=? where id=?'db.query(sqlStr_refreshToken, [refreshToken, expiresAt, user.id], (err) => {if (err) {console.error('保存refreshToken失敗:', err)return res.send({ status: 1, message: '保存refreshToken失敗' })}// 設置cookieres.cookie('token', accessToken, {maxAge: config.accessExpiresInSec * 1000,httpOnly: true,secure: true,path: '/'})res.cookie('refresh_token', refreshToken, {maxAge: config.refreshExpiresInSec * 1000,httpOnly: true,secure: true,path: '/api/user/refresh-token', // 限制路徑提高安全性sameSite: 'none'})res.send({status: 0,message: '登錄成功',data: {username: results[0].username}})}) })
}
4. 實現token刷新接口
創建新路由/refreshToken
// token刷新接口
exports.refreshToken = (req, res) => {// 直接從cookie中獲取刷新token => 前端不需要再單獨把token傳入請求頭const refreshToken = req.cookies.refresh_token// 判斷refresh token是否存在if (!refreshToken) {return res.send({ status: 1, message: '缺少refreshToken,請先登錄' })}try {// 驗證refreshTokenconst decoded = jwt.verify(refreshToken, config.refreshTokenSecretKey)// 查詢用戶是否存在且refreshToken匹配const sql = 'select * from user where id=? and refresh_token=?'db.query(sql, [decoded.id, refreshToken], (err, results) => {if (err) {return res.send({ status: 1, message: '無效的refreshToken' + err.message })}const user = results[0]// 生成新的access tokenconst accessToken = jwt.sign({ id: user.id, username: user.username, email: user.email },config.jwtSecretKey,{ expiresIn: config.accessExpiresIn })// 更新accessToken到Cookieres.cookie('token', accessToken, {maxAge: config.accessExpiresInSec * 1000,httpOnly: true,secure: true,path: '/'})res.send({status: 0,message: 'accessToken刷新成功',data: {token: accessToken}})})} catch (error) {return res.status(403).send({ status: 1, message: 'token已過期,請重新登錄' })}
}
5. 響應攔截器中處理token
import axios from 'axios'
import { message } from 'antd'
import { refreshTokenService } from '@/api/user'const instance = axios.create({baseURL: 'http://localhost:3333', // 你的API服務器地址timeout: 10000, // 請求超時時間headers: {'Content-Type': 'application/json'},// 必須加上這個選項才能跨域攜帶withCredentials: true
})// 添加請求攔截器
instance.interceptors.request.use((config) => {// 后端將token存在了cookie中,這里不需要攜帶tokenreturn config},(err) => Promise.reject(err)
)// 標記是否正在刷新 Token(防止并發刷新)
let isRefreshing = false
// 保存所有因 Token 失效而等待新 Token 的請求回調函數
let refreshSubscribers = []
// 成功獲取到新的 Token 后,執行所有等待的請求
function onRefreshed(newToken) {refreshSubscribers.forEach((cb) => cb(newToken))refreshSubscribers = []
}
// 將等待刷新 Token 的請求封裝成一個回調函數,加入隊列中
function addRefreshSubscriber(callback) {refreshSubscribers.push(callback)
}
// 響應攔截器
instance.interceptors.response.use((res) => {console.log(res) // 摘取核心響應數據if (res.data.status === 0) {return res}// 處理業務失敗message.error({type: 'error', content: res.data.message || '服務異常'})return Promise.reject(res.data)},async (err) => {// 錯誤的特殊情況 => 401權限不足或token過期 => 攔截到登錄const originalRequest = err.config// 判斷是否是 401 并且不是已經重試過的請求if (err.response?.status === 401 && !originalRequest._retry) {originalRequest._retry = true// 控制 Token 刷新流程(防止多次刷新)if (!isRefreshing) {// 標記刷新狀態isRefreshing = truetry {const res = await refreshTokenService()const newToken = res.data.data.token// 重試請求onRefreshed(newToken)} catch {// 刷新失敗message.error({ type: 'error', content: '登錄已過期,請重新登錄' })// 跳轉登錄if (window.location.pathname !== '/login') {history.push('/login')}} finally {isRefreshing = false}}// 把當前請求放入隊列,等待 Token 刷新后再重發return new Promise((resolve) => {addRefreshSubscriber((newToken) => {originalRequest.headers['Authorization'] = `Bearer ${newToken}`resolve(instance(originalRequest))})})} else {// 錯誤的默認情況 =》 只給提示message.error({ type: 'error', content: err.response.data.message || '服務異常' })}return Promise.reject(err)}
)export default instance