前端自动刷新Token与超时安全退出攻略

一、token的作用

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

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

每次请求资源服务器时都会在请求头中添加 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 存入两个字段:

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

当有操作事件时,将当前时间戳存入 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'))
}
相关推荐
崔庆才丨静觅3 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60613 小时前
完成前端时间处理的另一块版图
前端·github·web components
Hello.Reader3 小时前
Flink ZooKeeper HA 实战原理、必配项、Kerberos、安全与稳定性调优
安全·zookeeper·flink
掘了3 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅3 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅4 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
智驱力人工智能4 小时前
小区高空抛物AI实时预警方案 筑牢社区头顶安全的实践 高空抛物检测 高空抛物监控安装教程 高空抛物误报率优化方案 高空抛物监控案例分享
人工智能·深度学习·opencv·算法·安全·yolo·边缘计算
崔庆才丨静觅4 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment4 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
数据与后端架构提升之路5 小时前
论系统安全架构设计及其应用(基于AI大模型项目)
人工智能·安全·系统安全