最近在作管理系統項目的時候,涉及到登錄驗證的問題。因爲該系統的實時性要求並不高,排除了session + cookie的傳統方案,糾結再三,最終選擇了使用token進行驗證。如此,服務端無需存儲會話信息,將信息加密保存在token中,只要密鑰沒有泄露,安全性仍是能夠獲得保障的。javascript
技術棧vue
歡迎有志之士交流探討。轉載請附原文連接。 java
token認證的基本流程是:客戶端登錄成功,服務端返回token,客戶端存儲token。之後的每次請求,都將攜帶token,通常會放在請求頭中,隨請求發送:Authorization: token
。ios
token的特色是沒法廢除,即一個token只要頒發了,在有效期內始終是有效的,即便頒發了新的token也不影響原有的token使用。出於安全性考慮,token的有效時間應該設置的短一些,一般設置爲30min ~ 1h
。git
這樣一來每隔這麼久就要重置一次token,能夠經過refresh token的方式來更新token。es6
refresh token與token同樣,都是一段加密字符串,不一樣的是,refresh token是用來獲取新的token的。github
在使用成熟的網站、社區時,經常發現很長一段時間咱們都不須要從新登錄,這貌似有悖於「token的有效時間應該設置的短一些」。實際上使用refresh token後,登陸權限驗證的流程變成了下面這樣:web
登錄成功後服務端返回token和refresh token,客戶端存儲這兩個信息。平時發送請求時攜帶token,當token過時時服務端會拒絕響應,此時發送一個攜帶refresh token的新請求,服務端驗證後返回新的token,客戶端替換舊的token後從新發起請求便可。若refresh token過時,則從新登錄。vuex
能夠看出refresh token做用於長期,僅用於獲取新的token,同時也控制着用戶登陸的最長時間,通常會設置的長一些,例如7天,14天,過時以後必須從新登陸;token做用於短時間,用於獲取數據。
基本的流程瞭解清楚後,能夠引出如下幾個問題:
token裏啥均可以放,只要你願意。通常會包含簡短的用戶信息和過時時間,因爲token內放的數據是能夠解密的,因此千萬不要放敏感信息如密碼等。過時時間用來驗證token是否過時,簡短的用戶信息用於可能的數據庫操做(若是有的話)。
試想若是因爲token過時致使用戶好不容易填完的表單數據丟失,用戶必定會暴跳如雷吧?刷新token必定要考慮用戶體驗問題。
一般咱們會設置全局的攔截(以axios爲例)。設置全局響應攔截,經過約定好的信息(如狀態碼)判斷屬於哪一種狀況,最後根據狀況採起不一樣的操做(是刷新token?仍是從新登錄?),以後再從新發送以前的請求。
提高用戶體驗的關鍵在於,不能中斷當前請求,而是使用新的請求替換原來的請求。這一點在axios中能夠輕鬆實現,下文會示例。
併發請求時,若剛好token過時,則最終會發起多個刷新token的請求,多餘的請求除了增長服務器的壓力,沒有任何作用。
瀏覽器中,發出請求時候會開啓一條線程。請求完成以後,將對應的回調函數添加到任務隊列中,等待 JS 引擎處理。而咱們須要整合這個過程,將併發請求攔截彙總,最終只發出一次刷新請求。這便涉及線程同步的問題,咱們能夠經過加鎖,和緩衝來解決。
加鎖:簡單來講就是在模塊內部設置一個全局變量,用來標誌當前的狀態,防止衝突。
緩衝:就是設置一個空間,將當時發生但來不及處理的內容存儲起來,在合適的時機再處理。
幹說很差理解,下面上代碼。
約定:403從新登錄,401須要刷新
使用vue + axios
實現,首先封裝一下全局的axios API
/** * axios.js */
import { message } from 'ant-design-vue'
import axios from 'axios'
import store from '../store'
import router from '../router'
import handle401 from './handle401'
axios.defaults.baseURL = '/api'
// 請求攜帶token
axios.interceptors.request.use(config => {
// 判斷是爲了防止自定義header被覆蓋
if (!config.headers.authorization && store.state.token) {
config.headers.authorization = store.state.token
}
return config
})
// 若因401而拒絕,則刷新token,若403則跳轉登陸
// 返回的內容將會替換當前請求(Promise鏈式調用)
axios.interceptors.response.use(null, error => {
const { status, config } = error.response
if (status === 401) {
return handle401(config)
} else if (status === 403) {
message.warn('身份憑證過時,請從新登陸')
router.replace({ name: 'login' }).catch(e => e)
}
return Promise.reject(error)
})
export default axios // 導出axios對象,全部請求都使用這個對象
複製代碼
而後是對於401狀態的處理,細心的小夥伴能夠發現,這裏存在handle404.js
和axios.js
的循環引用問題,感興趣的能夠戳 阮一峯 —— ES6模塊加載,因爲不會影響代碼邏輯的正常執行,這裏不作展開。
/** * handle404.js */
import store from '../store'
import axios from './axios'
import { REFRESH_TOKEN } from '../store/mutation-types'
let lock = false // 鎖
const originRequest = [] // 緩衝
/** * 處理401——刷新token並處理以前的請求,目的在於實現用戶無感知刷新 * @param config 以前的請求的配置 * @returns {Promise<unknown>} */
export default function (config) {
if (!lock) {
lock = true
store.dispatch(REFRESH_TOKEN).then(newToken => {
// 使用新的token替換舊的token,並構造新的請求
const requests = originRequest.map(callback => callback(newToken))
// 從新發送請求
return axios.all(requests)
}).finally(() => {
// 重置
lock = false
originRequest.splice(0)
})
}
// 關鍵代碼,返回Promise替換當前的請求
return new Promise(resolve => {
// 收集舊的請求,以便刷新後構造新的請求,同時因爲Promise鏈式調用的效果,
// axios(config)的結果就是最終的請求結果
originRequest.push(newToken => {
config.headers.authorization = newToken
resolve(axios(config))
})
})
}
複製代碼
這是接口:
/** * index.js */
import axios from './axios'
export const login = data => axios.post('/auth/login', data)
export const refreshToken = originToken => {
return axios.get('/auth/refresh', {
headers: {
authorization: originToken
}
})
}
複製代碼
而後是vuex
的相關代碼:
import { message } from 'ant-design-vue'
import { LOGIN, REFRESH_TOKEN } from './mutation-types'
import { login, refreshToken } from '../api/index.js'
export default {
[LOGIN] ({ commit, state }, info) {
···
},
[REFRESH_TOKEN] ({ commit, state }) {
// 使用Promise包裝便於控制流程
return new Promise((resolve, reject) => {
refreshToken(state.refreshToken).then(({ data: newToken }) => {
commit(REFRESH_TOKEN, newToken)
resolve(newToken)
}).catch(reject)
})
}
}
複製代碼
使用express + jsonwebtoken
實現
爲了便於演示,token過時時間設置爲10s,refresh token過時時間設置爲20s。
/** * token.ts */
import dayjs from 'dayjs'
import { sign } from 'jsonwebtoken'
import secretKey from '../config/tokenKey'
// 控制普通token,客戶端過時後無需再次登陸
export const getToken = function () {
return sign({
exp: dayjs().add(10, 's').valueOf()
}, secretKey)
}
// 控制客戶端最長登錄時間,超時從新登陸
export const getRefreshToken = function (payload: any) {
return sign({
user: payload, // 這裏放入一點用戶信息,刷新的時候用來查數據庫,簡單的驗證一下。
exp: dayjs().add(20, 's').valueOf()
}, secretKey)
}
複製代碼
登陸路由部分代碼:
/** * login.ts */
import { Router } from 'express'
import { getRefreshToken, getToken } from '../../utils/token'
const router = Router().
router.post('/auth/login', function (req, res) {
...
res.json({
code: 0,
msg: '登錄成功',
data: {
user: { identity, ...user },
token: getToken(),
refreshToken: getRefreshToken({ identity, account })
}
})
...
})
export default router
複製代碼
resfresh token
路由
/** * refresh.ts */
import { Router } from 'express'
import dayjs from 'dayjs'
import { verify } from 'jsonwebtoken'
import { find } from '../../db/dao'
import { USER } from '../../db/model'
import secretKey from '../../config/tokenKey'
import { getToken } from '../../utils/token'
const router = Router()
router.get('/auth/refresh', function (req, res) {
const refreshToken = req.headers.authorization
if (!refreshToken) {
return res.status(403).end()
}
verify(refreshToken, secretKey, function (err, payload: any) {
// token 解析失敗,從新登陸
if (err) {
return res.status(403).end()
}
const { exp, user } = payload
// refreshToken過時,從新登陸
if (dayjs().isAfter(exp)) {
return res.status(403).end()
}
// 不然刷新token
find(USER, user).then(users => {
if (users.length === 0) {
res.status(403).end()
} else {
res.status(200).send(getToken())
}
}).catch(e => {
res.status(500).end(e.message)
})
})
})
export default router
複製代碼
登錄驗證中間件:
/** * loginChecker.ts */
import dayjs from 'dayjs'
import { Request, Response, NextFunction } from 'express'
import { verify } from 'jsonwebtoken'
import secretKey from '../config/tokenKey'
export default function (req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization
if (!token) {
return res.status(403).end()
}
verify(token, secretKey, function (err, payload: any) {
if (err) {
return res.status(403).end(err.message)
}
const { exp } = payload
console.log(dayjs(exp).format('YYYY-MM-DD HH:mm:ss'))
if (dayjs().isAfter(exp)) {
res.status(401).end('Unauthorized') // 過時,401提示客戶端刷新token
} else {
next() // 不然經過驗證
}
})
}
複製代碼
前兩個action發出了兩個請求,而且都失敗了,進入401處理邏輯,能夠看到緊接着執行了refresh token的操做,而後等refresh token結束後,繼續以前的請求,進而完成以前的請求,觸發mutation,完成整個操做。整個過程用戶沒有感知。
能夠看到,refresh token 失敗後,觸發403的邏輯,而後跳轉到登陸界面。達到了想要的效果。
這個登錄驗證的流程中,最值得細細品味的,當屬併發請求處理的部分,即handle404.js
文件中的內容。它涉及併發問題,而Vue中也有相似的問題, 如視圖更新:
vue中數據變化觸發的視圖更新是異步的,這使得短期內數據的屢次變化能夠整合到一塊兒,避免渲染無心義的中間態。其內部也是使用一個標誌量和一個緩衝區來實現的。
文章若有紕漏,歡迎批評指正。轉載請附原文連接。