实现Token的无感刷新,React与Node.js

开头

这两天刚开始写自己构思的练习项目,登录逻辑写了以后开始写验证的逻辑,然后我就在想项目用哪种方式,最后是选择了双Token无感刷新的方案。

为什么选择无感刷新Token的方案呢?我觉得主要原因是:单Token如果过期时间短,用户体验差,过期时间长或者频繁获取的话,包含用户信息的单Token会有一定的安全隐患。(我写完了发现其实用SessionId挺好的,因为我的项目不存在服务器分布式的情况)

Token基本过程:

  1. 用户登录,服务端返回长、短Token,短Token的负载中携带服务端需要的用户信息,长Token仅携带一个用户名,一般短Token的过期时间是30min,长Token在7天左右;

  2. 随后前端把两个Token都保存下来,实现本地化存储,我是选择保存在localstorage里面的;

  3. 前端每次请求将短Token携带在请求头的Authorization字段中,服务端验证短Token有效并执行逻辑;

  4. Token30min后过期,此时前端发送请求到服务端,服务端验证时会发现短Token失效,此时返回状态码401,前端判断状态码随后将长Token作为参数去请求服务端获取新的长、短Token,再将失效的请求重发完成请求。

代码

后端:

服务端使用Node.jsexpress框架搭建。

创建路由与服务相关的逻辑就不写在这里了,只陈述和Token相关的逻辑。

token.js:

首先这里使用jsonWebtoken库,需要运行npm i jsonwebtoken安装并导入;随后定义两个密钥,定义使用jwt.sign创建两个Token并设置过期时间的方法。

短Token:accessToken 长Token:refreshToken

const jwt = require('jsonwebtoken')

const secretKey = 'a'
const secretKeyx = 'b'

const createAccessToken = user => {
    return jwt.sign(
        { username: user.username, uid: user.uid, nickname: user.nickname },
        secretKey,
        { expiresIn: 60 * 30 }
    )
}

const createRefreshToken = user => {
    return jwt.sign({ username: user.username }, secretKeyx, { expiresIn: '7d' })
}

我们还需要一个验证Token的中间件函数,如下:

这里使用jwt.verify来验证Token的有效性,无效就返回,有效就获取解码负载给到后续中间件。

//token校验
const verifyToken = async function (req, res, next) {
    const token = req.headers.authorization.split(' ')[1]
    jwt.verify(token, secretKey, function (err, decoded) {
        if (err) {
            res.status(401)
            return res.send({ code: 4004, msg: 'token无效' })
        }
        req.data = decoded
        next()
    })
}
module.exports = { secretKey, secretKeyx, verifyToken, createAccessToken, createRefreshToken }

其中req.headers.authorization.split(' ')[1]用来获取前端请求头中的Token,为什么需要这样获取呢?因为请求头中配置的authorization字段是由'Breaer'Token组成的,这里是为了符合相应规范所以这样设置。

tokenApi.js:

接下来需要定义一个验证长Token并刷新短Token给到前端的api:

const express = require('express')
const jwt = require('jsonwebtoken')

const { createRefreshToken, createAccessToken, secretKeyx } = require('../utils/token')
const User = require('../models/User') //数据库

const router = express.Router()

router.use(express.json()) //解析post请求体

router.post('/api/tokens/getAccessToken', async (req, res) => {
    const { refreshToken } = req.body
    jwt.verify(refreshToken, secretKeyx, async function (err, decoded) {
        if (err) {
            // console.log('verify error', err)
            console.log('验证失败')
            return res.status(200).send({ code: 4005, msg: 'token过期,请重新登录' })
        }
        const user = await User.findOne({ username: decoded.username })
        if (!user) {
            return res.status(200).send({ code: 4005, msg: 'token验证失败,请重新登录' })
        }
        const accessToken = createAccessToken({
            username: user.username,
            uid: user.uid,
            nickname: user.nickname,
        })
        const refreshToken = createRefreshToken({
            username: user.username,
        })
        console.log('刷新token')
        res.status(200).send({
            msg: 'token已刷新',
            code: 2000,
            data: { accessToken: accessToken, refreshToken: refreshToken },
        })
    })
})

module.exports = router

这里同样是使用jwt.verify方法验证长Token的有效性,有效就调用方法创建两个Token并返回给前端,若是长Token也无效,就告诉前端Token过期,请重新登陆。

哦不要忘了在用户登陆成功后要创建两个Token给到用户:

if (user.password !== password) {
            res.send({ msg: '密码错误', code: 4003 })
        } else {
            const accessToken = createAccessToken(user)
            const refreshToken = createRefreshToken(user)
            res.send({
                msg: '登陆成功',
                code: 2000,
                data: {
                    accessToken: accessToken,
                    refreshToken: refreshToken,
                },
            })
        }

前端:

前端首先是登录嘛,登陆以后拿到后端返回的Token,首先就是要存到本地:

const login = async e => {
        e.preventDefault()
        const res = await userLogin(params)
        localStorage.setItem('refreshToken', res.refreshToken)
        localStorage.setItem('accessToken', res.accessToken)
}

(暂时是直接存localstorage,后面还是准备用redux来管理)

然后是axios请求拦截器:

request.interceptors.request.use(
    config => {
        const token = localStorage.getItem('accessToken')
        config.headers.Authorization = `Bearer ${token}`
        return config
    },
    error => {
        return Promise.reject(error)
    }
)

拿到本地的accessToken并设置到请求头的Authorization字段中去,后面的Bearer之前也讲了,是因为相关规范。

最后就是和刷新accessToken相关的部分了,这一块是我研究下来最麻烦的一部分,可能讲的会不太清楚:

error => {
        //响应拦截器错误处理
        const res = error.response.data
        return new Promise((resolve, reject) => {
            // token过期
            if (res.code === 4004) {
                const { config } = error
                getAccessToken({ resolve, config })
            } else {
                reject(error)
            }
        })
    }

首先:在响应拦截器中判断状态码,我这里是自定义的4004为token失效,随后获取到error对象中的config对象,这里面是本次失败请求的相关配置,等会刷新完成后就是使用它进行重新请求,随后调用一个方法getAccessToken并传入对象参数{resolve,config}

const requestList = []
let flag = true

export const getAccessToken = async ({ resolve, config }) => {
    requestList.push({ resolve, config })
    if (flag) {
        flag = false
        await refreshToken()
        retryRequest()
        flag = true
    }
}

这个方法将对象保存到一个请求数组requestList里,然后进入一个if逻辑,里面两个方法分别是调用接口刷新accessToken和重新发送失败的请求。

这里的flag主要是用来判断当前是否正在刷新Token:一开始第一个请求失败了,进入了刷新Token的过程,如果不把flag改为false,在刷新Token的过程中若又有失败的请求进入这个方法,那么会再次刷新Token,造成Token的多次刷新,实际场景就是几个请求并发,都被告知Token失效,都会进入这个方法;

const refreshToken = async () => {
    const refreshToken = localStorage.getItem('refreshToken')
    try {
        const res = await request.post('/tokens/getAccessToken', { refreshToken: refreshToken })
        localStorage.setItem('accessToken', res.accessToken)
        localStorage.setItem('refreshToken', res.refreshToken)
        console.log('token刷新成功')
    } catch (err) {
        if (err.code === 4005) {
            console.log('err:', err)
            router.navigate('./login')
            requestList.length = 0
        }
    }
}

const retryRequest = async () => {
    requestList.forEach(({ resolve, config }) => {
        resolve(request(config))
    })
    requestList.length = 0
}

随后就是那两个方法,用来刷新Token和重新请求,如果refreshToken也过期了就会导致刷新失败,直接跳转到登录页,注意不管刷新成功还是失败最后都需要将请求列表清零。

最后

Token的无感刷新基本就实现完了,但我还发现了一个问题,暂时不知道怎么解决:

在我测试的时候,如果有 10个 并发的请求,后端处理后全都返回401,然后肯定会有第一个回到前端的请求触发token刷新,随后若是token刷新完成,flag已经置为true,还有后续请求没有来得及回到前端被添加到队列中,他又会重新进入刷新token的逻辑,会导致token的多次刷新。

作者:林可like
链接:https://juejin.cn/post/7372135071979487247
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

文章转自:https://juejin.cn/post/7372135071979487247

  • 18
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值