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

一、token的作用

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

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

89e4a202403071133361293.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;
}

Copy

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' })

Copy

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
    }
})

Copy

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

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

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'))
}
<think>我们讨论的是双Token刷新机制(AccessToken和RefreshToken)的实现位置。根据引用内容,这是一个前后端协作的过程,但具体职责需要明确。###双Token刷新机制的核心实现位置####✅前端的关键职责1.**Token存储携带**-登录成功后存储双Token到本地(localStorage/sessionStorage)[^1][^2]-每次请求在`Authorization`头携带AccessToken2.**拦截过期响应**-在Axios响应拦截器中捕获401错误(AccessToken过期)[^2]-**触发刷新流程**:检测到401后自动发送RefreshToken到后端获取新AccessToken[^2][^3]3.**刷新后重试请求**-刷新成功后用新AccessToken重发原请求-示例代码:```javascript//Axios响应拦截器示例axios.interceptors.response.use(response=>response,asyncerror=>{if(error.response.status===401){constnewToken=awaitrefreshToken();//调用刷新接口error.config.headers.Authorization=`Bearer${newToken}`;returnaxios.request(error.config);//重试原请求}returnPromise.reject(error);});```####✅后端的关键职责1.**验证RefreshToken**-提供专用接口(如`/refresh_token`)验证RefreshToken的合法性[^3][^4]-**重要安全措施**:检查RefreshToken是否被撤销/过期2.**签发新AccessToken**-验证通过后生成新AccessToken返回前端[^4]-**不更新RefreshToken**:通常RefreshToken有较长有效期(如7天)3.**失效控制**-支持手动使RefreshToken失效(如用户主动退出)[^3]-后端代码示例(Node.js):```javascriptapp.post('/refresh_token',(req,res)=>{constrefreshToken=req.body.refreshToken;if(!isValid(refreshToken))returnres.status(401).send();constpayload=verify(refreshToken,SECRET);constnewAccessToken=generateAccessToken(payload.userId);res.json({access_token:newAccessToken});});```####⚠️协作流程图解```mermaidsequenceDiagramparticipantFas前端participantBas后端F->>B:请求受保护接口(携带过期AccessToken)B->>F:返回401错误F->>B:发送RefreshToken到刷新接口altToken有效B->>F:返回新AccessTokenF->>B:用新Token重试原请求B->>F:返回请求结果elseToken无效B->>F:返回401/403F->>F:跳转登录页end```###关键设计原则1.**前端不处理Token验证**-AccessToken过期判断完全由后端返回401状态码触发[^4]-前端**绝不**尝试解析JWT内容(避免安全风险)2.**后端控制RefreshToken生命周期**-维护RefreshToken黑名单/白名单(可选)-设置RefreshToken较长的固定有效期(如7天),超时后要求重新登录3.**无感刷新实现要点**-前端需处理**并发请求**:当多个请求同时返回401时,应确保只发起一次刷新请求[^2]-RefreshToken**仅用于获取新AccessToken**,不可用于常规API请求>**安全警告**:>-RefreshToken必须通过HTTPS传输>-避免将RefreshToken存储在可被XSS读取的位置(推荐HttpOnlyCookie)---###§§相关问题§§1.如何防止RefreshToken被窃取后的重放攻击?2.双Token方案中,后端是否需要存储RefreshToken?为什么?3.前端如何处理刷新Token过程中的并发请求?4.RefreshToken的有效期设置多长比较合理?[^1]:在登录成功后,后端会返回两个Token...保存到本地存储中。[^2]:前端实现token的无感刷新...调用刷新token接口。[^3]:前后端分离模式下,实现双token鉴权登录...重新获取新的SessionToken。[^4]:前端axios拦截器配合后端实现token刷新流程...创建新的token信息。</think>### 双Token刷新机制的核心实现位置 双Token刷新机制(Access Token + Refresh Token)需要**前后端协同实现**,但职责分工明确: #### ✅ 前端核心职责 1. **Token存储管理** - 登录后保存双Token安全存储(localStorage/sessionStorage)[^1] - 区分存储Access Token(短时效)和Refresh Token(长时效) 2. **请求拦截刷新触发** - 在Axios拦截器中检测API返回的401状态(Token过期) ```javascript // 响应拦截器示例 axios.interceptors.response.use( response => response, async error => { if (error.response.status === 401) { const newToken = await refreshToken(); // 触发刷新流程 retryOriginalRequest(error.config); // 用新Token重试请求 } return Promise.reject(error); } ); ``` 3. **无感刷新流程** - 使用Refresh Token请求后端获取新Access Token[^2] - 更新本地存储的新Access Token - 自动重试因过期失败的请求(用户无感知)[^3] #### ✅ 后端核心职责 1. **双Token签发** - 登录成功时生成: - Access Token(短时效,如15分钟) - Refresh Token(长时效,如7天)[^1] ```javascript // 伪代码示例 function login() { const accessToken = generateJWT(user, '15m'); const refreshToken = generateJWT(user, '7d'); return { accessToken, refreshToken }; } ``` 2. **Refresh Token验证** - 提供专用刷新接口(如`/auth/refresh`) - 验证Refresh Token有效性: ```javascript app.post('/refresh', (req, res) => { const refreshToken = req.body.refreshToken; if (!validateToken(refreshToken)) return res.sendStatus(403); const newAccessToken = generateJWT(/*...*/); res.json({ accessToken: newAccessToken }); // 返回新Access Token }); ``` 3. **安全控制** - 使Refresh Token失效的场景: - 用户主动退出 - 检测异常请求 - Refresh Token过期[^4] #### ⚠️ 关键协作点 | 场景 | 前端动作 | 后端动作 | |---------------------|----------------------------------|----------------------------------| | 登录成功 | 存储双Token | 签发双Token | | 常规请求 | 请求头携带Access Token | 验证Access Token | | Access Token过期 | 用Refresh Token请求新Access Token | 验证Refresh Token并签发新Access Token | | Refresh Token过期 | 跳转登录页 | 返回401状态 | > **安全最佳实践**: > 1. Refresh Token应通过HttpOnly Cookie传输(防XSS) > 2. 设置合理的Refresh Token有效期(通常7-30天) > 3. 实现Refresh Token轮换机制(每次刷新生成新Refresh Token) ### 典型流程示例 ```mermaid sequenceDiagram participant 前端 participant 后端 前端->>后端: 携带过期Access Token请求API 后端-->>前端: 返回401错误 前端->>后端: 发送Refresh Token到刷新接口 alt Token有效 后端-->>前端: 返回新Access Token 前端->>后端: 用新Token重试原请求 后端-->>前端: 返回请求数据 else Token无效 后端-->>前端: 返回401/403 前端->>前端: 跳转登录页 end ``` --- ###
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值