3

之前博客的认证方式是 session,最近抽了点时间把它改成了 jwt,顺便学习了下 jwt 相关知识。jwt 介绍可以看阮一峰的文章

jwt 实现流程

jwt 认证.png

上图是最简单的 jwt 流程,token 过期或者失效后就会跳回登录页。不过我的理想状态应该是一周之内不用登录,每次登录之后过期时间都会往后顺延一周。在不修改数据库的前提下,想到以下两个方法:

  1. 将 token 的过期时间设为7天,每次访问接口时后端根据当前时间重新生成 token,然后在每个接口都返回新生成的 token,前端接收到后将 token 存入 cookie,并在下一次请求时带上新的 token。
  2. 登录后生成一个过期时间为 3h 的 token 和一个过期时间为 7 天的 refreshToken。前端请求时在消息头中携带 token 信息,如果 token 过期,前端携带 refreshToken 访问刷新 token 接口,如果 refreshToken 没有过期则后端返回新的 token 和 refreshToken,否则前端跳转到登录页。前端再根据新返回的 token 去重新访问刚刚的接口。

方案 1 缺点很明显,就是每次调用接口都要生成一次 token,增加了不必要的开销。并且后端接口每个都要携带 token 信息,后端改动量较大=。=方案 2 好像没有明显缺点,选用方案 2 实践。

jwt 认证 refresh token.png

上图是 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 上有一篇文章比较详细地解释了这种方法。


MrBigShot
4.8k 声望3.1k 粉丝

菜鸡一个