之前博客的认证方式是 session,最近抽了点时间把它改成了 jwt,顺便学习了下 jwt 相关知识。jwt 介绍可以看阮一峰的文章。
jwt 实现流程
上图是最简单的 jwt 流程,token 过期或者失效后就会跳回登录页。不过我的理想状态应该是一周之内不用登录,每次登录之后过期时间都会往后顺延一周。在不修改数据库的前提下,想到以下两个方法:
- 将 token 的过期时间设为7天,每次访问接口时后端根据当前时间重新生成 token,然后在每个接口都返回新生成的 token,前端接收到后将 token 存入 cookie,并在下一次请求时带上新的 token。
- 登录后生成一个过期时间为 3h 的 token 和一个过期时间为 7 天的 refreshToken。前端请求时在消息头中携带 token 信息,如果 token 过期,前端携带 refreshToken 访问刷新 token 接口,如果 refreshToken 没有过期则后端返回新的 token 和 refreshToken,否则前端跳转到登录页。前端再根据新返回的 token 去重新访问刚刚的接口。
方案 1 缺点很明显,就是每次调用接口都要生成一次 token,增加了不必要的开销。并且后端接口每个都要携带 token 信息,后端改动量较大=。=方案 2 好像没有明显缺点,选用方案 2 实践。
上图是 refresh token 的认证流程。可以看到比最简单的流程多了一步刷新 token 和 refresh token 的步骤,这步也是刷新 token 的关键。
实现代码
后端 node
在项目中约定,401表示 token 超时,402表示 refreshToken 无效
登录的时候初始化 token 和 refreshToken
// jwt.js
const jwt = require('jsonwebtoken')
const { SECRET_KEY, JWT_EXPIRES, REFRESH_JWT_EXPIRES } = require('../config/config') // 从配置文件中引入 SECRET_KEY,token 过期时间,refreshToken 过期时间
const { cacheUser } = require('../cache/user') // 用闭包缓存用户名
/**
* 生成 token 和 initToken
* @param {string} username
*/
function initToken (username) {
return {
token: jwt.sign({
username: username
}, SECRET_KEY, {
expiresIn: JWT_EXPIRES
}),
refresh_token: jwt.sign({
username
}, SECRET_KEY, {
expiresIn: REFRESH_JWT_EXPIRES
})
}
}
/**
* 验证 token/refreshToken
* @param {string} token 格式为 `Beare ${token}`
*/
function validateToken (token, type) {
try {
token = token.replace(/^Beare /, '')
const { username } = jwt.verify(token, SECRET_KEY)
if (type !== 'refreshToken') {
// 如果是 token 且 token 生效,缓存 token
cacheUser.setUserName(username)
}
} catch (e) {
throw new Error(e.message)
}
}
module.exports = {
initToken,
getTokenUser,
validateToken
}
之后在中间件中调用 validateToken
方法
// 检查是否登录中间件方法
checkLogin (req, res, next) {
const { headers: { authorization } } = req
if (!authorization) {
res.status(402).json({ code: 'ERROR', data: '未检测到登录信息' })
return false
}
try {
validateToken(authorization)
} catch (e) {
if (e.message === 'jwt expired') {
// token 超时
res.status(401).json({ code: 'ERROR', data: '登录超时' })
return false
} else {
res.status(402).json({ code: 'ERROR', data: '未检测到登录信息' })
return false
}
}
在一些需要登录验证的接口(比如获取用户留言接口),会先调用中间件验证是否登录。中间件中会先验证一次 token。这里我用闭包写了一个用户缓存,中间件验证成功后将用户存入闭包,之后在接口中获取用户信息时就可以调用闭包的 getUserName
方法而不用再去解析 token,这样可以避免中间件验证 token 成功但接口中验证 token 过期带来的困扰。
/**
* 写一个闭包来缓存用户名
*/
const cacheUser = (() => {
let username = ''
return {
setUserName: (user) => {
if (username === user) {
return false
}
username = user
},
getUserName: () => {
return username
},
clearUserName: () => {
username = ''
}
}
})()
module.exports = {
cacheUser
}
在登录成功后获取 token 和 refreshToken 返回给前端。
客户端 js
js 需要重新封装 axios 函数
import axios from 'axios'
import qs from 'qs'
......
// 根据 refreshToken 刷新 token 和 refreshToken
const fetchRefreshToken = () => {
const token = Cookies.get('refreshToken')
return axios({
url: '/api/signin/refreshToken',
headers: { Authorization: `Beare ${token}` },
method: 'get'
})
}
export const post = (url, formData, headers = {}) => {
const token = Cookies.get('token')
headers = Object.assign({}, headers, { Authorization: `Beare ${token}` })
return axios({
url,
headers,
method: 'post',
data: qs.stringify(formData)
}).then(res => {
return res
}).catch(e => {
if (e.response.status === 401) {
// token 超时,访问刷新 token 接口
return fetchRefreshToken().then(res => {
const { data } = res.data
const { token, refresh_token: refreshToken } = data
Cookies.set('token', token)
Cookies.set('refreshToken', refreshToken)
return post(url, formData, headers) // 重新调用这个接口
})
}
})
}
...
修改 axios 拦截函数
// axios 拦截器 未登录则跳转到登录页
axios.interceptors.response.use(
res => {
if (res.data.code === 'OK') {
return res
} else {
// 提示报错信息
message.error(res.data.data, 10)
return Promise.reject(res)
}
},
error => {
if (error.response) {
switch (error.response.status) {
case 402:
// 登录超时,跳转到登录页,自行实现
// store.dispatch(actionCreators.logoutSuccess())
break
// 跳到登录页
default:
break
}
}
return Promise.reject(error)
}
)
优化点
目前为止已经实现了 token 自动刷新的功能。但是这种方法还是有缺陷的。比如同时调用多个接口时,如果 token 过期了,因为请求是异步的,所以会出现多次调用 refreshtoken 接口的情况。这种最优解应该是第一个接口去刷新 token,剩下的先等待,等 token 刷新后剩下的接口才开始调用。
sf 上有一篇文章比较详细地解释了这种方法。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。