注册
web

前端无感知刷新token & 超时自动退出

前端无感知刷新token&超时自动退出


一、token的作用


因为http请求是无状态的,是一次性的,请求之间没有任何关系,服务端无法知道请求者的身份,所以需要鉴权,来验证当前用户是否有访问系统的权限。


以oauth2.0授权码模式为例:


oauth2授权码模式.png


每次请求资源服务器时都会在请求头中添加 Authorization: Bearer access_token 资源服务器会先判断token是否有效,如果无效或过期则响应 401 Unauthorize。此时用户处于操作状态,应该自动刷新token保证用户的行为正常进行。


刷新token:使用refresh_token获取新的access_token,使用新的access_token重新发起失败的请求。


二、无感知刷新token方案


2.1 刷新方案


当请求出现状态码为 401 时表明token失效或过期,拦截响应,刷新token,使用新的token重新发起该请求。


如果刷新token的过程中,还有其他的请求,则应该将其他请求也保存下来,等token刷新完成,按顺序重新发起所有请求。


2.2 原生AJAX请求


2.2.1 http工厂函数


function httpFactory({ method, url, body, headers, readAs, timeout }) {
   const xhr = new XMLHttpRequest()
   xhr.open(method, url)
   xhr.timeout = isNumber(timeout) ? timeout : 1000 * 60

   if(headers){
       forEach(headers, (value, name) => value && xhr.setRequestHeader(name, value))
  }
   
   const HTTPPromise = new Promise((resolve, reject) => {
       xhr.onload = function () {
           let response;

           if (readAs === 'json') {
               try {
                   response = JSONbig.parse(this.responseText || null);
              } catch {
                   response = this.responseText || null;
              }
          } else if (readAs === 'xml') {
               response = this.responseXML
          } else {
               response = this.responseText
          }

           resolve({ status: xhr.status, response, getResponseHeader: (name) => xhr.getResponseHeader(name) })
      }

       xhr.onerror = function () {
           reject(xhr)
      }
       xhr.ontimeout = function () {
           reject({ ...xhr, isTimeout: true })
      }

       beforeSend(xhr)

       body ? xhr.send(body) : xhr.send()

       xhr.onreadystatechange = function () {
           if (xhr.status === 502) {
               reject(xhr)
          }
      }
  })

   // 允许HTTP请求中断
   HTTPPromise.abort = () => xhr.abort()

   return HTTPPromise;
}

2.2.2 无感知刷新token


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

// 存放因token过期而失败的请求
let requests = []

function httpRequest(config) {
   let abort
   let process = new Promise(async (resolve, reject) => {
       const request = httpFactory({...config, headers: { Authorization: 'Bearer ' + cookie.load('access_token'), ...configs.headers }})
       abort = request.abort
       
       try {                            
           const { status, response, getResponseHeader } = await request

           if(status === 401) {
               try {
                   if (!isRefreshing) {
                       isRefreshing = true
                       
                       // 刷新token
                       await refreshToken()

                       // 按顺序重新发起所有失败的请求
                       const allRequests = [() => resolve(httpRequest(config)), ...requests]
                       allRequests.forEach((cb) => cb())
                  } else {
                       // 正在刷新token,将请求暂存
                       requests = [
                           ...requests,
                          () => resolve(httpRequest(config)),
                      ]
                  }
              } catch(err) {
                   reject(err)
              } finally {
                   isRefreshing = false
                   requests = []
              }
          }                        
      } catch(ex) {
           reject(ex)
      }
  })
   
   process.abort = abort
   return process
}

// 发起请求
httpRequest({ method: 'get', url: 'http://127.0.0.1:8000/api/v1/getlist' })

2.3 Axios 无感知刷新token


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

let requests: ReadonlyArray<(config: any) => void> = []

// 错误响应拦截
axiosInstance.interceptors.response.use((res) => res, async (err) => {
   if (err.response && err.response.status === 401) {
       try {
           if (!isRefreshing) {
               isRefreshing = true
               // 刷新token
               const { access_token } = await refreshToken()

               if (access_token) {
                   axiosInstance.defaults.headers.common.Authorization = `Bearer ${access_token}`;

                   requests.forEach((cb) => cb(access_token))
                   requests = []

                   return axiosInstance.request({
                       ...err.config,
                       headers: {
                           ...(err.config.headers || {}),
                           Authorization: `Bearer ${access_token}`,
                      },
                  })
              }

               throw err
          }

           return new Promise((resolve) => {
               // 将resolve放进队列,用一个函数形式来保存,等token刷新后直接执行
               requests = [
                   ...requests,
                  (token) => resolve(axiosInstance.request({
                       ...err.config,
                       headers: {
                           ...(err.config.headers || {}),
                           Authorization: `Bearer ${token}`,
                      },
                  })),
              ]
          })
      } catch (e) {
           isRefreshing = false
           throw err
      } finally {
           if (!requests.length) {
               isRefreshing = false
          }
      }
  } else {
       throw err
  }
})

三、长时间无操作超时自动退出


当用户登录之后,长时间不操作应该做自动退出功能,提高用户数据的安全性。


3.1 操作事件


操作事件:用户操作事件主要包含鼠标点击、移动、滚动事件和键盘事件等。


特殊事件:某些耗时的功能,比如上传、下载等。


3.2 方案


用户在登录页面之后,可以复制成多个标签,在某一个标签有操作,其他标签也不应该自动退出。所以需要标签页之间共享操作信息。这里我们使用 localStorage 来实现跨标签页共享数据。


在 localStorage 存入两个字段:


名称类型说明说明
lastActiveTimestring最后一次触发操作事件的时间戳
activeEventsstring[ ]特殊事件名称数组

当有操作事件时,将当前时间戳存入 lastActiveTime。


当有特殊事件时,将特殊事件名称存入 activeEvents ,等特殊事件结束后,将该事件移除。


设置定时器,每1分钟获取一次 localStorage 这两个字段,优先判断 activeEvents 是否为空,若不为空则更新 lastActiveTime 为当前时间,若为空,则使用当前时间减去 lastActiveTime 得到的值与规定值(假设为1h)做比较,大于 1h 则退出登录。


3.3 代码实现


const LastTimeKey = 'lastActiveTime'
const activeEventsKey = 'activeEvents'
const debounceWaitTime = 2 * 1000
const IntervalTimeOut = 1 * 60 * 1000

export const updateActivityStatus = debounce(() => {
   localStorage.set(LastTimeKey, new Date().getTime())
}, debounceWaitTime)

/**
* 页面超时未有操作事件退出登录
*/

export function timeout(keepTime = 60) {
   document.addEventListener('mousedown', updateActivityStatus)
   document.addEventListener('mouseover', updateActivityStatus)
   document.addEventListener('wheel', updateActivityStatus)
   document.addEventListener('keydown', updateActivityStatus)

   // 定时器
   let timer;

   const doTimeout = () => {
       timer && clearTimeout(timer)
       localStorage.remove(LastTimeKey)
       document.removeEventListener('mousedown', updateActivityStatus)
       document.removeEventListener('mouseover', updateActivityStatus)
       document.removeEventListener('wheel', updateActivityStatus)
       document.removeEventListener('keydown', updateActivityStatus)

       // 注销token,清空session,回到登录页
       logout()
  }

   /**
    * 重置定时器
    */

   function resetTimer() {
       localStorage.set(LastTimeKey, new Date().getTime())

       if (timer) {
           clearInterval(timer)
      }

       timer = setInterval(() => {
           const isSignin = document.cookie.includes('access_token')
           if (!isSignin) {
               doTimeout()
               return
          }

           const activeEvents = localStorage.get(activeEventsKey)
           if(!isEmpty(activeEvents)) {
               localStorage.set(LastTimeKey, new Date().getTime())
               return
          }
           
           const lastTime = Number(localStorage.get(LastTimeKey))

           if (!lastTime || Number.isNaN(lastTime)) {
               localStorage.set(LastTimeKey, new Date().getTime())
               return
          }

           const now = new Date().getTime()
           const time = now - lastTime

           if (time >= keepTime) {
               doTimeout()
          }
      }, IntervalTimeOut)
  }

   resetTimer()
}

// 上传操作
function upload() {
   const current = JSON.parse(localStorage.get(activeEventsKey))
   localStorage.set(activeEventsKey, [...current, 'upload'])
   ...
   // do upload request
   ...
   const current = JSON.parse(localStorage.get(activeEventsKey))
   localStorage.set(activeEventsKey, Array.isArray(current) ? current.filter((item) => itme !== 'upload'))
}

作者:ww_怒放
来源:juejin.cn/post/7320044522910269478

0 个评论

要回复文章请先登录注册