Http系列:前端401错误 & 解决方法:响应拦截器&&同时多个axios请求怎么实现无痛刷新token

1,432 阅读7分钟

1. 问题出现的原因

在前后端分离项目中,最常见的是前端点击登录后,后端返回token字符串,这个token可以看作是一个“令牌”,就比如你去酒店办理入住后,拿到的房卡,那代表你有着进去房间的权限。

  1. 登录用户的token过期
  • token是具有时效性的,如你登录腾讯视频,接下来几天你再登录就不用输入账号密码,而时间很长如半个月后,你还要重新输入账号密码登录,这个过程就是token过期
  • 具体多久过期,一般是和后端商量着来,后台管理类项目如企业内部的项目为了安全性且并不会过多的考虑用户体验,一般设为很短或者干脆不做这个。
  • 而像腾讯视频,淘宝,京东之类的商业类项目需要考虑用户体验,且对于安全性要求并不高,一般设置为7~14天比较合适。
  1. 用户未登录情况,返回401错误,应该回到登录页(这个不一定是401错误)

2. 处理401问题的解决方案原理

完整的逻辑为:

前端请求接口api ----->返回401错误 ----->前端判断是否有refresh_token ----->如果有就用refresh_token请求新的token ----->后台成功返回一个新的token给我们 ----->更新store + 本地存储持久化 ----->然后重新发送请求 ----->带上新的token请求数据

当然,如果没有refresh_token----->清除登录信息,返回登录页

示例图:

image.png

3.方案:

1.目前常见的处理方式是:当用户登陆成功之后,返回的token中有两个值。

2.原理:一个是token,他的有效期是2小时(举例),一个是姑且称为refresh_token,他的有效期长,比如是14天,假设用户登录后2小时后,token过期了,那么我们看一下refresh_token在不在,在的话,就用refresh_token再次发送,后端会返回一个新的token。

3.核心点:

  • 解决401问题重点在于让用户“无感”,也就是说用户不知道token过期也不需要用户再次登录,需要的是我们程序员去处理。
  • 解决这个问题的地方在响应拦截器。

示例图:

image.png

4.使用响应拦截器解决问题

作用: 所有从后端回来的响应都会集中进入响应拦截器中,如果发生401错误就可以解决。

图示说明

image.png 封装的响应拦截器,主要完成两件事:处理401问题,以及注入token

import router from '../router/auth.js'
 
// 响应拦截器
request.interceptors.response.use(function (response) {
  console.log('响应拦截器', response)
  return response
}, async function (error) {
  // 如果发生了错误,判断是否是401
  console.dir(error)
  if (error.response.status === 401) {
    // 出现401就在这里面 开始处理 ---
    console.log('响应拦截器-错误-401')
    const refreshToken = store.state.tokenInfo.refresh_token
    // if (有refresh_token) {       ---- 有refresh_token
    if (refreshToken) {
      // 1. 请求新token
      try {
        const res = await axios({
          url: 'http://localhost:8000/v1_0/authorizations',
          method: 'PUT',
          headers: {
            Authorization: `Bearer ${refreshToken}`
          }
        })
        console.log('请求新token', res.data.data.token)
        // 2. 保存到vuex
        store.commit('mSetToken', {   // mSetToken是前面定义的mutations名字
          refresh_token: refreshToken,
          token: res.data.data.token
        })
        // 3. 重发请求
        //    request是上面创建的axios的实例,它会自动从vuex取出token带上
        return request(error.config)
      } catch (error) {
        // 1. 清除token
        store.commit('mSetToken', {})  
        // 2. 去到登录页(如果有token值,就不能到login)
        const backtoUrl = encodeURIComponent(router.currentRoute.fullPath)
        router.push('/login?backto=' + backtoUrl)  
        return Promise.reject(error) 
      }
    } else {
       // 如果没有refresh_token的时候   ----没有refresh_token
      // 1.去到登录页
      // 2.清除token
      store.commit('mSetToken', {})
      const backtoUrl = encodeURIComponent(router.currentRoute.fullPath)  // 回到原来跳过来的的页面,不加?后面的一串就会到首页
      router.push('/login?backto=' + backtoUrl)
      return Promise.reject(error)  // 返回错误信息
    }
  } else {
    return Promise.reject(error)
  }
})

5.axios请求拦截器添加token和拦截白名单

let whitelist = ['/login'] // 拦截器白名单,登录时不添加 token
axios.interceptors.request.use(
 config => {
  // 拦截白名单以及添加token
  if (whitelist.includes(config.url)) {
   return config
  } else {
   let token = localStorage.token
   config.headers.token = token
   return config
  }
 },
 err => {
  return err
 }
)

6.同时多个axios请求怎么实现无痛刷新token

需求

最近遇到个需求:前端登录后,后端返回token和refresh_token,当token过期时用旧refresh_token去获取新的token,前端需要做到无痛刷新token,即请求刷新token时要做到用户无感知。

需求解析

当用户发起一个请求时,判断token是否已过期,若已过期则先调refreshToken接口,拿到新的token后再继续执行之前的请求。

这个问题的难点在于:当同时发起多个请求,而刷新token的接口还没返回,此时其他请求该如何处理?

实现

这里会使用axios来实现,以上方法是请求后拦截,所以会使用axios.interceptors.response.use()方法。

首先说明一下,项目中的token是存在localStorage中的。

如何防止多次刷新token 如果refreshToken接口还没返回,此时再有一个过期的请求进来,上面的代码就会再一次执行refreshToken,这就会导致多次执行刷新token的接口,因此需要防止这个问题。我们可以在request.js中用一个flag来标记当前是否正在刷新token的状态,如果正在刷新则不再调用刷新token的接口。

这样子就可以避免在刷新token时再进入方法了。但是这种做法是相当于把其他失败的接口给舍弃了,假如同时发起两个请求,且几乎同时返回,第一个请求肯定是进入了refreshToken后再重试,而第二个请求则被丢弃了,仍是返回失败,所以接下来还得解决其他接口的重试问题。

同时发起两个或以上的请求时,其他接口如何重试 两个接口几乎同时发起和返回,第一个接口会进入刷新token后重试的流程,而第二个接口需要先存起来,然后等刷新token后再重试。同样,如果同时发起三个请求,此时需要缓存后两个接口,等刷新token后再重试。由于接口都是异步的,处理起来会有点麻烦。

当第二个过期的请求进来,token正在刷新,我们先将这个请求存到一个数组队列中,想办法让这个请求处于等待中,一直等到刷新token后再逐个重试清空请求队列。 那么如何做到让这个请求处于等待中呢?为了解决这个问题,我们得借助Promise。将请求存进队列中后,同时返回一个Promise,让这个Promise一直处于Pending状态(即不调用resolve),此时这个请求就会一直等啊等,只要我们不执行resolve,这个请求就会一直在等待。当刷新请求的接口返回来后,我们再调用resolve,逐个重试。

代码

import axios from 'axios'
import { Loading, Message, MessageBox } from 'element-ui'
import api from './api'
import { getToken, setToken, removeToken, getRefreshToken } from '../utils/cookies'

let UserModule = {
	RefreshToken: (data) => {
		setToken('Bearer ' + data.access_token, data.refresh_token)
	}
}

// 是否正在刷新的标记
let isRefreshing = false

// 重试队列,每一项将是一个待执行的函数形式
let retryRequests = []

const request = axios.create({
	baseURL: api.baseUrl,
	timeout: 50000,
	withCredentials: true // cookie跨域必备
})
// http request 拦截器 Request
request.interceptors.request.use(
	(config) => {
		if (getToken()) {
			config.headers['Authorization'] = getToken()
		}
		return config
	},
	(error) => {
		Promise.reject(error)
	}
)

// http response 拦截器 Response
request.interceptors.response.use(
	(response) => {
		// code == 0: 成功
		const res = response.data
		if (res.code !== 0) {
			if (res.message) {
				Message({
					message: res.message,
					type: 'error',
					duration: 5 * 1000
				})
			}
			return Promise.reject(res)
		} else {
			return response.data
		}
	},
	(error) => {
		if (!error.response) return Promise.reject(error)
		// 根据refreshtoken重新获取token
		// 5000系统繁忙
		// 5001参数错误
		// 1003该用户权限不足以访问该资源接口
		// 1004访问此资源需要完全的身份验证
		// 1001access_token无效
		// 1002refresh_token无效
		if (error.response.data.code === 1004 || error.response.data.code === 1001) {
			const config = error.config
			if (!isRefreshing) {
				isRefreshing = true
				return getRefreshTokenFunc()
					.then((res) => {
						// 重新设置token
						UserModule.RefreshToken(res.data.data)
						config.headers['Authorization'] = getToken()
						// 已经刷新了token,将所有队列中的请求进行重试
						// @ts-ignore
						retryRequests.forEach((cb) => cb(getToken()))
						// 重试完清空这个队列
						retryRequests = []
						// 这边不需要baseURL是因为会重新请求url,url中已经包含baseURL的部分了
						config.baseURL = ''
						return request(config)
					})
					.catch(() => {
						resetLogin()
					})
					.finally(() => {
						isRefreshing = false
					})
			} else {
				// 正在刷新token,返回一个未执行resolve的promise
				return new Promise((resolve) => {
					// 将resolve放进队列,用一个函数形式来保存,等token刷新后直接执行
					// @ts-ignore
					retryRequests.push((token: any) => {
						config.baseURL = ''
						config.headers['Authorization'] = token
						resolve(request(config))
					})
				})
			}
		} else if (error.response.data.code === 1002) {
			resetLogin()
		} else {
			Message({
				message: error.response.data.message,
				type: 'error',
				duration: 5 * 1000
			})
			return Promise.reject(error)
		}
	}
)
// 刷新token的请求方法
function getRefreshTokenFunc() {
	let params = {
		refresh_token: getRefreshToken() || ''
	}
	return axios.post(api.baseUrl + 'auth-center/auth/refresh_token', params)
}
function resetLogin(title = '身份验证失败,请重新登录!') {
	if (window.location.href.indexOf('/login') === -1) {
		MessageBox.confirm(title, '退出', {
			confirmButtonText: '重新登录',
			cancelButtonText: '取消',
			type: 'warning'
		}).then(() => {
			removeToken()
			location.reload() // To prevent bugs from vue-router
		})
	}
}
/**
 * []请求
 * @param params  参数
 * @param operation     接口
 */
function customRequest(url: string, method: any, data: any) {
	// service.defaults.headers['Content-Type']=contentType
	let datatype = method.toLocaleLowerCase() === 'get' ? 'params' : 'data'
	return request({
		url: url,
		method: method,
		[datatype]: data
	})
}

export { request, customRequest }