封裝 axios 攔截器實現用戶無感刷新 access_token

前言

最近作項目的時候,涉及到一個單點登陸,便是項目的登陸頁面,用的是公司共用的一個登陸頁面,在該頁面統一處理邏輯。最終實現用戶只需登陸一次,就能夠以登陸狀態訪問公司旗下的全部網站。javascript

單點登陸( Single Sign On ,簡稱 SSO),是目前比較流行的企業業務整合的解決方案之一,用於多個應用系統間,用戶只須要登陸一次就能夠訪問全部相互信任的應用系統。

其中本文講的是在登陸後如何管理access_tokenrefresh_token,主要就是封裝 axios攔截器,在此記錄。前端

需求

  • 前置場景
  1. 進入該項目某個頁面http://xxxx.project.com/profile須要登陸,未登陸就跳轉至SSO登陸平臺,此時的登陸網址 url爲http://xxxxx.com/login?app_id=project_name_id&redirect_url=http://xxxx.project.com/profile,其中app_id是後臺那邊約定定義好的,redirect_url是成功受權後指定的回調地址。
  2. 輸入帳號密碼且正確後,就會重定向回剛開始進入的頁面,並在地址欄帶一個參數 ?code=XXXXX,便是http://xxxx.project.com/profile?code=XXXXXX,code的值是使用一次後即無效,且10分鐘內過時
  3. 立馬獲取這個code值再去請求一個api /access_token/authenticate,攜帶參數{ verify_code: code },而且該api已經自帶app_idapp_secret兩個固定值參數,經過它去請求受權的api,請求成功後獲得返回值{ access_token: "xxxxxxx", refresh_token: "xxxxxxxx", expires_in: xxxxxxxx },存下access_tokenrefresh_token到cookie中(localStorage也能夠),此時用戶就算登陸成功了。
  4. access_token爲標準JWT格式,是受權令牌,能夠理解就是驗證用戶身份的,是應用在調用api訪問和修改用戶數據必須傳入的參數(放在請求頭headers裏),2小時後過時。也就是說,作完前三步後,你能夠調用須要用戶登陸才能使用的api;可是假如你什麼都不操做,靜靜過去兩個小時後,再去請求這些api,就會報access_token過時,調用失敗。
  5. 那麼總不能2小時後就讓用戶退出登陸吧,解決方法就是兩小時後拿着過時的access_tokenrefresh_tokenrefresh_token過時時間通常長一些,好比一個月或更長)去請求/refresh api,返回結果爲{ access_token: "xxxxx", expires_in: xxxxx },換取新的access_token,新的access_token過時時間也是2小時,並從新存到cookie,循環往復繼續保持登陸調用用戶api了。refresh_token在限定過時時間內(好比一週或一個月等),下次就能夠繼續換取新的access_token,但過了限定時間,就算真正意義過時了,也就要從新輸入帳號密碼來登陸了。

公司網站登陸過時時間都只有兩小時(token過時時間),但又想讓一個月內常常活躍的用戶再也不次登陸,因而纔有這樣需求,避免了用戶再次輸入帳號密碼登陸。java

爲何要專門用一個 refresh_token 去更新 access_token 呢?首先access_token會關聯必定的用戶權限,若是用戶受權更改了,這個access_token也是須要被刷新以關聯新的權限的,若是沒有 refresh_token,也能夠刷新 access_token,但每次刷新都要用戶輸入登陸用戶名與密碼,多麻煩。有了 refresh_ token,能夠減小這個麻煩,客戶端直接用 refresh_token 去更新 access_token,無需用戶進行額外的操做。ios

說了這麼多,或許有人會吐槽,一個登陸用access_token就好了還要加個refresh_token搞得這麼麻煩,或者有的公司refresh_token是後臺包辦的並不須要前端處理。可是,前置場景在那了,需求都是基於該場景下的。git

  • 需求
  1. access_token過時的時候,要用refresh_token去請求獲取新的access_token,前端須要作到用戶無感知的刷新access_token。好比用戶發起一個請求時,若是判斷access_token已通過期,那麼就先要去調用刷新token接口拿到新的access_token,再從新發起用戶請求。
  2. 若是同時發起多個用戶請求,第一個用戶請求去調用刷新token接口,當接口還沒返回時,其他的用戶請求也依舊發起了刷新token接口請求,就會致使多個請求,這些請求如何處理,就是咱們本文的內容了。

思路

方案一

寫在請求攔截器裏,在請求前,先利用最初請求返回的字段expires_in字段來判斷access_token是否已通過期,若已過時,則將請求掛起,先刷新access_token後再繼續請求。github

  • 優勢: 能節省http請求
  • 缺點: 由於使用了本地時間判斷,若本地時間被篡改,有校驗失敗的風險

方案二

寫在響應攔截器裏,攔截返回後的數據。先發起用戶請求,若是接口返回access_token過時,先刷新access_token,再進行一次重試。json

  • 優勢:無需判斷時間
  • 缺點: 會消耗多一次http請求

在此我選擇的是方案二。axios

實現

這裏使用axios,其中作的是請求後攔截,因此用到的是axios的響應攔截器axios.interceptors.response.use()方法api

方法介紹

  • @utils/auth.js
import Cookies from 'js-cookie'

const TOKEN_KEY = 'access_token'
const REGRESH_TOKEN_KEY = 'refresh_token'

export const getToken = () => Cookies.get(TOKEN_KEY)

export const setToken = (token, params = {}) => {
  Cookies.set(TOKEN_KEY, token, params)
}

export const setRefreshToken = (token) => {
  Cookies.set(REGRESH_TOKEN_KEY, token)
}
  • request.js
import axios from 'axios'
import { getToken, setToken, getRefreshToken } from '@utils/auth'

// 刷新 access_token 的接口
const refreshToken = () => {
  return instance.post('/auth/refresh', { refresh_token: getRefreshToken() }, true)
}

// 建立 axios 實例
const instance = axios.create({
  baseURL:  process.env.GATSBY_API_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
  }
})

instance.interceptors.response.use(response => {
    return response
}, error => {
    if (!error.response) {
        return Promise.reject(error)
    }
    // token 過時或無效,返回 401 狀態碼,在此處理邏輯
    return Promise.reject(error)
})

// 給請求頭添加 access_token
const setHeaderToken = (isNeedToken) => {
  const accessToken = isNeedToken ? getToken() : null
  if (isNeedToken) { // api 請求須要攜帶 access_token 
    if (!accessToken) { 
      console.log('不存在 access_token 則跳轉回登陸頁')
    }
    instance.defaults.headers.common.Authorization = `Bearer ${accessToken}`
  }
}

// 有些 api 並不須要用戶受權使用,則不攜帶 access_token;默認不攜帶,須要傳則設置第三個參數爲 true
export const get = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'get',
    url,
    params,
  })
}

export const post = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'post',
    url,
    data: params,
  })
}

接下來改造 request.js中axios的響應攔截器數組

instance.interceptors.response.use(response => {
    return response
}, error => {
    if (!error.response) {
        return Promise.reject(error)
    }
    if (error.response.status === 401) {
        const { config } = error
        return refreshToken().then(res=> {
            const { access_token } = res.data
            setToken(access_token)
            config.headers.Authorization = `Bearer ${access_token}`
            return instance(config)
        }).catch(err => {
            console.log('抱歉,您的登陸狀態已失效,請從新登陸!')
            return Promise.reject(err)
        })
    }
    return Promise.reject(error)
})

約定返回401狀態碼錶示access_token過時或者無效,若是用戶發起一個請求後返回結果是access_token過時,則請求刷新access_token的接口。請求成功則進入then裏面,重置配置,並刷新access_token並從新發起原來的請求。

但若是refresh_token也過時了,則請求也是返回401。此時調試會發現函數進不到refreshToken()catch裏面,那是由於refreshToken()方法內部是也是用了同個instance實例,重複響應攔截器401的處理邏輯,但該函數自己就是刷新access_token,故須要把該接口排除掉,即:

if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {}

上述代碼就已經實現了無感刷新access_token了,當access_token沒過時,正常返回;過時時,則axios內部進行了一次刷新token的操做,再從新發起原來的請求。

優化

防止屢次刷新 token

若是token是過時的,那請求刷新access_token的接口返回也是有必定時間間隔,若是此時還有其餘請求發過來,就會再執行一次刷新access_token的接口,就會致使屢次刷新access_token。所以,咱們須要作一個判斷,定義一個標記判斷當前是否處於刷新access_token的狀態,若是處在刷新狀態則再也不容許其餘請求調用該接口。

let isRefreshing = false // 標記是否正在刷新 token
instance.interceptors.response.use(response => {
    return response
}, error => {
    if (!error.response) {
        return Promise.reject(error)
    }
    if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {
        const { config } = error
        if (!isRefreshing) {
            isRefreshing = true
            return refreshToken().then(res=> {
                const { access_token } = res.data
                setToken(access_token)
                config.headers.Authorization = `Bearer ${access_token}`
                return instance(config)
            }).catch(err => {
                console.log('抱歉,您的登陸狀態已失效,請從新登陸!')
                return Promise.reject(err)
            }).finally(() => {
                isRefreshing = false
            })
        }
    }
    return Promise.reject(error)
})

同時發起多個請求的處理

上面作法還不夠,由於若是同時發起多個請求,在token過時的狀況,第一個請求進入刷新token方法,則其餘請求進去沒有作任何邏輯處理,單純返回失敗,最終只執行了第一個請求,這顯然不合理。

好比同時發起三個請求,第一個請求進入刷新token的流程,第二個和第三個請求須要存起來,等到token更新後再從新發起請求。

在此,咱們定義一個數組requests,用來保存處於等待的請求,以後返回一個Promise,只要不調用resolve方法,該請求就會處於等待狀態,則能夠知道其實數組存的是函數;等到token更新完畢,則經過數組循環執行函數,即逐個執行resolve重發請求。

let isRefreshing = false // 標記是否正在刷新 token
let requests = [] // 存儲待重發請求的數組

instance.interceptors.response.use(response => {
    return response
}, error => {
    if (!error.response) {
        return Promise.reject(error)
    }
    if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {
        const { config } = error
        if (!isRefreshing) {
            isRefreshing = true
            return refreshToken().then(res=> {
                const { access_token } = res.data
                setToken(access_token)
                config.headers.Authorization = `Bearer ${access_token}`
                // token 刷新後將數組的方法從新執行
                requests.forEach((cb) => cb(access_token))
                requests = [] // 從新請求完清空
                return instance(config)
            }).catch(err => {
                console.log('抱歉,您的登陸狀態已失效,請從新登陸!')
                return Promise.reject(err)
            }).finally(() => {
                isRefreshing = false
            })
        } else {
            // 返回未執行 resolve 的 Promise
            return new Promise(resolve => {
                // 用函數形式將 resolve 存入,等待刷新後再執行
                requests.push(token => {
                    config.headers.Authorization = `Bearer ${token}`
                    resolve(instance(config))
                })  
            })
        }
    }
    return Promise.reject(error)
})

最終 request.js 代碼

import axios from 'axios'
import { getToken, setToken, getRefreshToken } from '@utils/auth'

// 刷新 access_token 的接口
const refreshToken = () => {
  return instance.post('/auth/refresh', { refresh_token: getRefreshToken() }, true)
}

// 建立 axios 實例
const instance = axios.create({
  baseURL:  process.env.GATSBY_API_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
  }
})

let isRefreshing = false // 標記是否正在刷新 token
let requests = [] // 存儲待重發請求的數組

instance.interceptors.response.use(response => {
    return response
}, error => {
    if (!error.response) {
        return Promise.reject(error)
    }
    if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {
        const { config } = error
        if (!isRefreshing) {
            isRefreshing = true
            return refreshToken().then(res=> {
                const { access_token } = res.data
                setToken(access_token)
                config.headers.Authorization = `Bearer ${access_token}`
                // token 刷新後將數組的方法從新執行
                requests.forEach((cb) => cb(access_token))
                requests = [] // 從新請求完清空
                return instance(config)
            }).catch(err => {
                console.log('抱歉,您的登陸狀態已失效,請從新登陸!')
                return Promise.reject(err)
            }).finally(() => {
                isRefreshing = false
            })
        } else {
            // 返回未執行 resolve 的 Promise
            return new Promise(resolve => {
                // 用函數形式將 resolve 存入,等待刷新後再執行
                requests.push(token => {
                    config.headers.Authorization = `Bearer ${token}`
                    resolve(instance(config))
                })  
            })
        }
    }
    return Promise.reject(error)
})

// 給請求頭添加 access_token
const setHeaderToken = (isNeedToken) => {
  const accessToken = isNeedToken ? getToken() : null
  if (isNeedToken) { // api 請求須要攜帶 access_token 
    if (!accessToken) { 
      console.log('不存在 access_token 則跳轉回登陸頁')
    }
    instance.defaults.headers.common.Authorization = `Bearer ${accessToken}`
  }
}

// 有些 api 並不須要用戶受權使用,則無需攜帶 access_token;默認不攜帶,須要傳則設置第三個參數爲 true
export const get = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'get',
    url,
    params,
  })
}

export const post = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'post',
    url,
    data: params,
  })
}


相關文章
相關標籤/搜索