node.js實(shí)現(xiàn)雙Token+Cookie存儲+無感刷新機(jī)制的示例
為什么要實(shí)施雙token機(jī)制?
優(yōu)點(diǎn) | 描述 |
---|---|
安全性 | Access Token 短期有效,降低泄露風(fēng)險(xiǎn);Refresh Token 權(quán)限受限,僅用于獲取新 Token |
用戶體驗(yàn) | 用戶無需頻繁重新登錄,Token 自動刷新過程對用戶透明 |
靈活性 | 獨(dú)立控制不同 Token 的生命周期,適應(yīng)各種場景需求 |
可管理性 | 支持多設(shè)備登錄管理,便于撤銷特定設(shè)備的登錄狀態(tài) |
性能優(yōu)化 | 減少數(shù)據(jù)庫查詢次數(shù),提升系統(tǒng)響應(yīng)速度 |
實(shí)現(xiàn)方案:
模塊 | 實(shí)現(xiàn)方式 |
---|---|
登錄接口 | 返回 accessToken 和 refreshToken ,分別存入 Cookie |
Access Token | 短時效 JWT,用于請求鑒權(quán) |
Refresh Token | 長時效 JWT,用于刷新 Access Token |
Token 校驗(yàn)方式 | 后端從 Cookie 中讀取 token (即 Access Token) |
前端 Axios | 使用響應(yīng)攔截器統(tǒng)一處理 Token 失效和自動刷新 |
- 使用 JWT 生成兩個 Token:
- Access Token(短時效):用于接口認(rèn)證,例如有效期為 15 分鐘
- Refresh Token(長時效):用于刷新 Access Token,例如有效期為 7 天
- 在用戶登錄時返回這兩個 Token,并將 Refresh Token 存儲在數(shù)據(jù)庫中
- 當(dāng) Access Token 過期后,客戶端使用 Refresh Token 請求新的 Access Token
- 如果 Refresh Token 也過期或無效,則強(qiáng)制重新登錄
具體代碼實(shí)現(xiàn)
1. 安裝依賴:
cookie-parser用來解析 Cookie 中的 Token
npm install jsonwebtoken bcryptjs cookie-parser
2. 數(shù)據(jù)庫添加兩個字段
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, // 秒數(shù) refreshExpiresInSec: 7 * 24 * 60 * 60 // 秒數(shù) }
- 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一起存入數(shù)據(jù)庫
const expiresAt = new Date() expiresAt.setSeconds(expiresAt.getSeconds() + config.refreshExpiresInSec)
- 將accessToken訪問token、refreshToken刷新token存入cookie
// 設(shè)置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' })
登錄邏輯完整代碼:
// 用戶登錄的處理函數(shù) exports.login = (req, res) => { // 接收表單數(shù)據(jù) const userInfo = req.body console.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 }) } // 執(zhí)行sql語句成功,但是獲取的條數(shù)不等于1 if (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: '密碼錯誤' }) } // 在服務(wù)器端生成Token字符串 const user = { ...results[0] } // 生成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 } ) // 將refresh token存儲到數(shù)據(jù)庫中 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失敗' }) } // 設(shè)置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' }) res.send({ status: 0, message: '登錄成功', data: { username: results[0].username } }) }) }) }
4. 實(shí)現(xiàn)token刷新接口
創(chuàng)建新路由/refreshToken
// token刷新接口 exports.refreshToken = (req, res) => { // 直接從cookie中獲取刷新token => 前端不需要再單獨(dú)把token傳入請求頭 const refreshToken = req.cookies.refresh_token // 判斷refresh token是否存在 if (!refreshToken) { return res.send({ status: 1, message: '缺少refreshToken,請先登錄' }) } try { // 驗(yàn)證refreshToken const 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 token const accessToken = jwt.sign( { id: user.id, username: user.username, email: user.email }, config.jwtSecretKey, { expiresIn: config.accessExpiresIn } ) // 更新accessToken到Cookie res.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. 響應(yīng)攔截器中處理token
import axios from 'axios' import { message } from 'antd' import { refreshTokenService } from '@/api/user' const instance = axios.create({ baseURL: 'http://localhost:3333', // 你的API服務(wù)器地址 timeout: 10000, // 請求超時時間 headers: { 'Content-Type': 'application/json' }, // 必須加上這個選項(xiàng)才能跨域攜帶 withCredentials: true }) // 添加請求攔截器 instance.interceptors.request.use( (config) => { // 后端將token存在了cookie中,這里不需要攜帶token return config }, (err) => Promise.reject(err) ) // 標(biāo)記是否正在刷新 Token(防止并發(fā)刷新) let isRefreshing = false // 保存所有因 Token 失效而等待新 Token 的請求回調(diào)函數(shù) let refreshSubscribers = [] // 成功獲取到新的 Token 后,執(zhí)行所有等待的請求 function onRefreshed(newToken) { refreshSubscribers.forEach((cb) => cb(newToken)) refreshSubscribers = [] } // 將等待刷新 Token 的請求封裝成一個回調(diào)函數(shù),加入隊(duì)列中 function addRefreshSubscriber(callback) { refreshSubscribers.push(callback) } // 響應(yīng)攔截器 instance.interceptors.response.use( (res) => { console.log(res) // 摘取核心響應(yīng)數(shù)據(jù) if (res.data.status === 0) { return res } // 處理業(yè)務(wù)失敗 message.error({type: 'error', content: res.data.message || '服務(wù)異常'}) return Promise.reject(res.data) }, async (err) => { // 錯誤的特殊情況 => 401權(quán)限不足或token過期 => 攔截到登錄 const originalRequest = err.config // 判斷是否是 401 并且不是已經(jīng)重試過的請求 if (err.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true // 控制 Token 刷新流程(防止多次刷新) if (!isRefreshing) { // 標(biāo)記刷新狀態(tài) isRefreshing = true try { const res = await refreshTokenService() const newToken = res.data.data.token // 重試請求 onRefreshed(newToken) } catch { // 刷新失敗 message.error({ type: 'error', content: '登錄已過期,請重新登錄' }) // 跳轉(zhuǎn)登錄 if (window.location.pathname !== '/login') { history.push('/login') } } finally { isRefreshing = false } } // 把當(dāng)前請求放入隊(duì)列,等待 Token 刷新后再重發(fā) return new Promise((resolve) => { addRefreshSubscriber((newToken) => { originalRequest.headers['Authorization'] = `Bearer ${newToken}` resolve(instance(originalRequest)) }) }) } else { // 錯誤的默認(rèn)情況 =》 只給提示 message.error({ type: 'error', content: err.response.data.message || '服務(wù)異常' }) } return Promise.reject(err) } ) export default instance
到此這篇關(guān)于node.js實(shí)現(xiàn)雙Token+Cookie存儲+無感刷新機(jī)制的示例的文章就介紹到這了,更多相關(guān)node.js 雙Token+Cookie存儲+無感刷新內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
nodejs基于express實(shí)現(xiàn)文件上傳的方法
這篇文章主要介紹了nodejs基于express實(shí)現(xiàn)文件上傳的方法,結(jié)合實(shí)例形式分析了nodejs基于express框架實(shí)現(xiàn)文件上傳功能的具體步驟與相關(guān)操作技巧,需要的朋友可以參考下2018-03-03Nodejs關(guān)于gzip/deflate壓縮詳解
本文主要向大家介紹了nodejs中關(guān)于gzip/deflate壓縮的2種方法,分別是管道壓縮和非管道壓縮,十分詳細(xì),并附帶示例,這里推薦給大家參考下。2015-03-03Nodejs解析網(wǎng)站網(wǎng)址內(nèi)容并獲取標(biāo)題圖標(biāo)
cheerio類似于jQuery的API,讓我們可以方便地操作HTML文檔,下面我們就來看看在Node.js中如何借助cheerio庫高效地解析和提取HTML內(nèi)容吧2024-11-11基于NodeJS的前后端分離的思考與實(shí)踐(二)模版探索
在傳統(tǒng)的開發(fā)模式中,瀏覽器端與服務(wù)器端是由不同的前后端兩個團(tuán)隊(duì)開發(fā),但是模版卻又在這兩者中間的模糊地帶。因此模版上面總不可避免的越來越多復(fù)雜邏輯,最終難以維護(hù)。2014-09-09