江夏
主 题
前言
主要步骤:
- 在nuxt中基于$fetch封装请求工具http类;
- 总的思路是请求方法为返回一个
Promise
;正常200响应的时在onResponse
回到中执行resolve(data)
返回数据;401响应时直接执行refreshToken
方法请求刷新token,成功则重新执行因为401报错的请求;然后执行resolve(data)
把当前Promise状态变为fulfilled; - 主要难点是当token过期时,已经发出了多个失败的请求,重新请求时如一一响应;正在刷新中时会把多个请求的
config
和url
放进一个全局请求队列中,把当前的请求方法的Promise
中的resolve
方法也一并放入队里中并且返回(请求的Promise
状态还是处于pending
); - 当
refreshToken
接口成功响应之后,循环全局队列一一重新发请求fn(apiFetch(url, config))
并且都执行fn变更Promise
状态后清空全局队列;失败时则执行重新登录逻辑。
一、前端实现
前端项目请求工具方法封装;
具体项目请参考 request.js
typescript
import { baseUrl } from '~~/config'
import { messageDanger } from '~~/utils/toast'
import { TokenKey, RefreshTokenKey } from '@/utils/cookie'
interface PendingTask {
config: any
url: string
fn: Function
}
// 无感刷新token
let refreshing = false // 是否正在刷新token
let queue: PendingTask[] = []
// async/await函数错误统一处理 const [err, data] = await checkFile({ hash, })
export const awaitWrap = <T, U = any>(promise: Promise<T>): Promise<[U | null, T | null]> => {
return promise.then<[null, T]>((data: T) => [null, data]).catch<[U, null]>(err => [err, null])
}
// 创建一个实例
const apiFetch = $fetch.create({ baseURL: baseUrl, })
const $http = async (url: string, options: any): Promise<ApiResponse> => {
const { method = 'GET', params = {}, body = {}, headers, } = options
const config: any = {
headers: {
...headers,
},
credentials: 'include', // session需要携带cookie
method,
/* fetch中 params和body不能同时存在 */
params: ['GET', 'DELETE'].includes(method.toUpperCase()) ? params : undefined,
body: ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase()) ? body : undefined,
onRequest (ctx: any) {
ctx.options.headers.Authorization = getToken()
},
}
return await new Promise((resolve, reject) => {
apiFetch<ApiResponse>(url, {
...config,
onResponse (ctx) {
const status: number = ctx.response.status
if (status === 200 || status === 201) {
resolve(ctx.response._data)
}
},
async onResponseError (ctx: any) {
console.log('onResponseError', ctx)
// console.log('status', ctx.response)
const status: number = ctx.response.status
const { url, } = ctx.response
if (refreshing) {
queue.push({
config,
url,
// 作用是把当前状态为pending的promise放进全局数组中
// 刷新完token之后再把对应的promise状态改为fulfilled,
// 这样之前报401响应的请求没有变更状态,刷新token再变为fulfilled响应后执行等待的相关操作
fn: resolve,
})
// return为关键,不执行下面代码,不然下面resolve变更promise状态,
// 造成每个promise都会执行foreach请求多个
return
}
try {
if (status === 401 && !url.includes('/user/refresh')) {
refreshing = true
const res = await refreshToken()
refreshing = false
if (res) {
queue.forEach(({ config, url, fn, }) => {
fn(apiFetch(url, config))
})
console.log('queue', queue)
queue = []
resolve(apiFetch(url, config))
} else {
// 清除token
const token = useToken()
token.value = ''
localStorage.setItem(TokenKey, '')
console.error(ctx.response._data.message)
}
} else {
// 其他状态码直接变为reject
messageDanger(ctx.response._data.message || '')
reject(ctx.response._data)
}
} catch (error) {
reject(error)
}
},
})
})
}
// 获取 token
const getToken = () => {
const tk = useToken()
let token = ''
if (tk.value) {
token = 'Bearer ' + tk.value
}
// console.log({ token });
return token
}
const get = async (url: string, params = {}): Promise<any> => {
return await $http(url, { method: 'GET', params, }).then(res => res.data)
}
const del = async (url: string, params = {}): Promise<any> => {
return await $http(url, { method: 'DELETE', params, }).then(res => res.data)
}
const post = async (url: string, params = {}): Promise<any> => {
return await $http(url, { method: 'POST', body: params, }).then(res => res.data)
}
const put = async (url: string, params = {}): Promise<any> => {
return await $http(url, { method: 'PUT', body: params, }).then(res => res.data)
}
// 刷新token
async function refreshToken () {
const token = useToken()
const userInfo = useUserInfo()
const accessToken = localStorage.getItem(RefreshTokenKey) || ''
const res = await get('/user/refresh', { token: accessToken, })
localStorage.setItem(TokenKey, res.accessToken)
localStorage.setItem(RefreshTokenKey, res.refreshToken)
token.value = res.accessToken
const { nickname, homepage, intro, avatar, id: uid, role, } = res.user
userInfo.value = {
nickname,
homepage,
intro,
avatar,
uid,
role,
}
return res
}
export default { http: $http, get, post, put, del, awaitWrap, }
二、后端实现
- 接口一:验证用户登录之后生成一个过期时间为0.5h的accessToken和一个过期时间为7d的refreshToken;
- 接口二:当accessToken过期之后,用refreshToken请求该接口刷新两个token的过期时间;具体请求参考后端user模块代码 UserService
typescript
// 生成 token
async certificate(user: User) {
// 设置在token中的信息
const payload = {
id: user.id,
nickname: user.nickname,
mobile: user.mobile,
role: user.role,
};
// console.log(payload);
// 兼容老登录token
const token = this.jwtService.sign(payload);
const accessToken = this.jwtService.sign(payload, { expiresIn: '2s' });
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
return {
token,
accessToken,
refreshToken,
};
}
async login(loginDTO: LoginDTO): Promise<any> {
// 用户信息
const user = await this.checkLoginForm(loginDTO);
// 密码和加盐不返回
delete user.password;
delete user.salt;
const data = await this.certificate(user);
return {
info: {
...data,
user,
},
};
}
/**
* 根据refreshToken刷新accessToken
* @param accessToken
*/
async refresh(token: string) {
try {
let user = this.jwtService.verify(token);
const data = await this.certificate(user);
user = await this.findById(user.id);
return {
...data,
user,
message: '刷新token成功',
};
} catch (e) {
throw new UnauthorizedException('token 失效,请重新登录');
}
}
链接
三、前端axios实现
JavaScript
import { showFailToast } from 'vant'
import axios from 'axios'
import { getToken } from './auth'
// import * as crypto from './crypto'
import 'vant/es/toast/style'
import { TOKEN_KEY, TOKEN_KEY2 } from '@/utils/auth'
// 默认 axios 实例请求配置
const configDefault = {
headers: {},
timeout: 0,
baseURL: import.meta.env.VITE_BASE_API,
data: {},
withCredentials: true
}
const SUCCESSCODE = 200
// 无感刷新token
let refreshing = false // 是否正在刷新token
let queue = []
// // 请求内容加密
// const requestEncrypt = (data) =>{
// console.log('请求报文: ',data)
// data = {
// key: crypto.sm2Encrypt(crypto.SM4KEY),
// content: crypto.sm4Encrypt(data),
// }
// return data
// }
// // 响应内容解密
// const responseDecrypt = (response) =>{
// // console.log('response==================>',response)
// const { key, content } = response.data
// let resData = crypto.sm4Decrypt(content,key)
// console.log('响应报文: ',resData)
// return resData
// }
class Http {
// 当前实例
static axiosInstance
// 请求配置
static axiosConfigDefault
// 请求拦截
httpInterceptorsRequest() {
this.axiosInstance.interceptors.request.use(
(config) => {
// 发送请求前,可在此携带 token
const token = getToken()
if (token) {
// config.headers['Authorization'] = 'Bearer ' + token
config.headers['Jwt-Token'] = token
}
return config
},
(error) => {
showFailToast(error.message)
return Promise.reject(error)
}
)
}
// 响应拦截
httpInterceptorsResponse() {
this.axiosInstance.interceptors.response.use(
(response) => {
// 开启加密
// let resData = responseDecrypt(response)
let resData = response.data
// 与后端协定的返回字段
const { code, message } = resData
// 判断请求是否成功
const isSuccess = response && Reflect.has(resData, 'code') && code === SUCCESSCODE
// console.log(code, message, resData ,isSuccess,'-------------------')
if (isSuccess) {
return resData.data
} else {
// 处理请求错误
showFailToast(message)
return Promise.reject(resData.data)
}
},
async (error) => {
try {
// console.log(error)
let { status, data, config } = error.response || {}
console.log('status========================>', status)
if (refreshing) {
return new Promise((resolve) => {
queue.push({
config,
resolve
})
})
}
let message = ''
// HTTP 状态码
console.log('==================>response.data', data)
switch (status) {
case 400:
message = '请求错误'
break
case 401:
message = '未授权,请登录'
if (status === 401 && !config.url.includes('/user/refresh')) {
refreshing = true
const res = await refreshToken()
refreshing = false
if (res) {
queue.forEach(({ config, resolve }) => {
resolve(this.axiosInstance(config))
})
queue = []
return this.axiosInstance(config)
} else {
message = data.message || '登录过期,请重新登录'
}
} else {
return error.response
}
break
case 403:
message = data.message || '拒绝访问'
break
case 404:
message = data.message || `请求地址出错: ${error.response?.config?.url}`
break
case 408:
message = data.message || '请求超时'
break
case 500:
message = data.message || '服务器内部错误'
break
case 501:
message = data.message || '服务未实现'
break
case 502:
message = data.message || '网关错误'
break
case 503:
message = data.message || '服务不可用'
break
case 504:
message = data.message || '网关超时'
break
case 505:
message = data.message || 'HTTP版本不受支持'
break
default:
message = data.message || '网络连接故障'
}
showFailToast(message)
return Promise.reject(error)
} catch (error) {
return Promise.reject('网络连接故障')
}
}
)
}
constructor(config) {
this.axiosConfigDefault = config
this.axiosInstance = axios.create(config)
this.httpInterceptorsRequest()
this.httpInterceptorsResponse()
}
// 通用请求函数
request(paramConfig) {
const config = { ...this.axiosConfigDefault, ...paramConfig }
return new Promise((resolve, reject) => {
this.axiosInstance
.request(config)
.then((response) => {
resolve(response)
})
.catch((error) => {
reject(error)
})
})
}
post(url, data) {
// data = requestEncrypt(data)
return this.request({ url, method: 'post', data })
}
patch(url, data) {
return this.request({ url, method: 'patch', data })
}
get(url, params) {
return this.request({ url, method: 'get', params })
}
del(url, params) {
return this.request({ url, method: 'delete', params })
}
}
const refreshToken = async () => {
const refreshToken = localStorage.getItem(TOKEN_KEY2) || ''
const res = await http.get('/mobile/refreshToken', { token: refreshToken })
// console.log(res,'refreshToken>>>>>>>')
localStorage.setItem(TOKEN_KEY, res.accessToken)
localStorage.setItem(TOKEN_KEY2, res.refreshToken)
return res
}
export const http = new Http(configDefault)
全部评论(0)