axios中实现无感刷新token

现状

项目采用前后端分离开发,前后端使用access_token(即token)进行交互认证,但access_token有一个有效期,在access_token过期后,请求接口将无法成功,现在的处理方式是直接退出跳转至登录入口要求重新登录,但这种方式体验非常不友好,如果当前用户正在录入大量数据时token已经失效,提交数据时直接就退出了,从产品及交互上这种方式是不允许的。

分析

后端采用 IdentityService4 构建认证与授权,在登录成功后除返回access_token之外,增加了expires_in、refresh_token。并增加交换token接口。
expires_in:access_token的过期时间。
refresh_token:刷新token。access_token过期后可以使用refresh_token交换新的access_token。一个refresh_token只能使用一次。
交换token接口:使用refresh_token交换access_token,得到新的access_token、新的expires_in、新的refresh_token。

那么前端刷新token即可有两种方式
1、在request请求之前进行拦截,根据expires_in计算出当前token是否过期,若已过期,则将请求挂起,先调用交换token接口,得到新的access_token后再继续请求。
这里本项目放弃此方式。
2、后端接口在检查到access_token过期后,返回状态码40001(前后端约定值),那么在response中进行拦截,当返回状态码为40001时,调用交换token接口,得到新的access_token后再将原请求重发。

实现

对axios进行封装

import axios from 'axios';
import router from '@/router'
import Vue from 'vue'
import {  Loading } from 'element-ui';
import  qs from  'qs';

let host = window.g.ApiUrl

let loadingInstance; //loading 实例
let needLoadingRequestCount = 0; //当前正在请求的数量
//是否有请求正在刷新token
let isRefreshing = false
// 重试请求队列 每一项都是一个待执行待函数
let requests= [];

//Loading 封装
/*
* 打开全页loading
* this.$showLoading()
* */
Vue.prototype.$showLoading = function(text='加载中...'){
    if (needLoadingRequestCount == 0) {
        loadingInstance = Loading.service({text: text});
    }
    needLoadingRequestCount++;
};
/*
* 关闭全页loading
* this.$closeLoading()
* */
Vue.prototype.$closeLoading = function(type=0){
    needLoadingRequestCount--;
    if(type == 1){
        loadingInstance.close();
        return false;
    }
    if (needLoadingRequestCount <= 0) {
        loadingInstance.close();
    }
}
/**
 * 刷新token
 */
function refreshToken (response,instance) {
    const refreshtoken = sessionStorage.getItem('refresh_token');
    // 判断 没有refresh_token的处理
    if (!refreshtoken) {
        sessionStorage.removeItem('access_token')
        sessionStorage.removeItem('sso_token')
        sessionStorage.removeItem('expires_in')
        sessionStorage.removeItem('refresh_token')
        window.location.href = window.g.mainSiteUrl;//返回登陆
    }
    let param = {
        client_id: window.g.client_id,
        client_secret: window.g.client_secret,
        grant_type: 'refresh_token',
        refresh_token: refreshtoken
    };
    // instance是当前已创建的axios实例
    return instance.post('/connect/token',qs.stringify(param)).then(res => {
        //业务系统token
        sessionStorage.setItem('access_token', res.access_token);
        //业务系统token过期时间
        sessionStorage.setItem('expires_in',res.expires_in);
        //业务系统refresh_token
        sessionStorage.setItem('refresh_token', res.refresh_token);

        // 重新请求接口 前过期的接口
        response.config.headers.Authorization ="Bearer "+ sessionStorage.getItem('access_token');
        // 已经刷新了token,将所有队列中的请求进行重试,最后再清空队列
        requests.forEach(cb => cb( res.access_token))
        requests = []
        return instance(response.config)
    }).catch(res => {
        sessionStorage.removeItem('access_token')
        sessionStorage.removeItem('sso_token')
        sessionStorage.removeItem('expires_in')
        sessionStorage.removeItem('refresh_token')
        //返回登陆
        window.location.href = window.g.mainSiteUrl;
    }).finally(() => {
        isRefreshing = false
    })
}

export default function $axios(options) {
    return new Promise((resolve, reject) => {
        const instance = axios.create({
            baseURL: host,
            isEditContentType:true,
            isUpload:false
        })

        // request 拦截器
        instance.interceptors.request.use(
            config => {
                Vue.prototype.$showLoading();
                if(config.url!='/connect/token' && config.isEditContentType){
                    config.headers["Content-Type"]='application/json;charset=UTF-8'
                }else if(config.url==='/connect/token'){
                    config.headers["Content-Type"]='application/x-www-form-urlencoded'
                }
                let token = sessionStorage.getItem('access_token')
                if (token && !config.isUpload) {
                    config.headers.Authorization = "Bearer "+token
                } 
                // 根据请求方法,序列化传来的参数,根据后端需求是否序列化
                if (config.method === 'post') {
                    // if (config.data.__proto__ === FormData.prototype
                    //   || config.url.endsWith('path')
                    //   || config.url.endsWith('mark')
                    //   || config.url.endsWith('patchs')
                    // ) {

                    // } else {
                    //config.data = JSON.stringify(config.data)
                    // }
                }else if (config.method === 'get') { //get请求增加时间戳
                    let url = config.url;
                    url.indexOf('?') === -1 ? config.url = url+'?_='+(new Date().getTime()) : config.url = url+'&_='+(new Date().getTime());
                }
                return config
            },

            error => {
                Vue.prototype.$closeLoading();
                // 请求错误时
                // 1. 判断请求超时
                if (error.code === 'ECONNABORTED' && error.message.indexOf('timeout') !== -1) {
                    // return service.request(originalRequest);// 再重复请求一次
                }
                // 2. 需要重定向到错误页面
                const errorInfo = error.response
                if (errorInfo) {
                    error = errorInfo.data  // 页面那边catch的时候就能拿到详细的错误信息,看最下边的Promise.reject
                    const errorStatus = errorInfo.status; // 404 403 500 ...
                    router.push({
                        path: `/error/${errorStatus}`
                    })
                }
                return Promise.reject(error) // 在调用的那边可以拿到(catch)你想返回的错误信息
            }
        )

        // response 拦截器
        instance.interceptors.response.use(response => {
            Vue.prototype.$closeLoading();
            const code  = response.data.code
            //接口返回token超时
            if (code === "40001") {
                var config = response.config;
                //当前是否有已经在刷新token,防止多次请求刷新token
                if (!isRefreshing) {
                    //没有则请求刷新token
                    isRefreshing = true
                    return refreshToken(response,instance)
                } else {
                    // 正在刷新token,加入队列中,将返回一个未执行resolve的promise
                    return new Promise((resolve) => {
                        // 将resolve放进队列,用一个函数形式来保存,等token刷新后直接执行
                        requests.push((token) => {
                            config.headers.Authorization ="Bearer "+ token;
                            resolve(instance(config))
                        })
                    })
                }
            }
            let data;
            if (response.data == undefined) {
                data = JSON.parse(response.request.responseText)
            } else {
                data = response.data
            }
            return data
        }, error => {
            return Promise.reject(error)
        })

        // 请求处理
        instance(options).then(res => {
            resolve(res)
            return false
        }).catch(error => {
            reject(error)
        })
    })
}

注意:
1、在所有需要清除access_token的地方都同时需要将refresh_token一并清除,否则可能将通过refresh_token获取到新的token。
2、从安全考虑,access_token设置有效期时间较短。refresh_token有效期可设置为用户未操作而需要退出的时间。这样可以实现:当用户一直在线的情况下,token不会突然无故失效而退出。同时如果用户间隔一段时间未操作(大于refresh_token的有效期,因为refresh_token过期后获取新access_token将不会成功),那么再次操作将退出让其重新登录。

在项目实现过程中参考了以下博主的文章,博主写的非常详细。
https://segmentfault.com/a/1190000020210980?_ea=153309043

本文仅供自己开发过程留作笔记。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,265评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,078评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,852评论 0 347
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,408评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,445评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,772评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,921评论 3 406
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,688评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,130评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,467评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,617评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,276评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,882评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,740评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,967评论 1 265
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,315评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,486评论 2 348

推荐阅读更多精彩内容