前端token的刷新

在前后端分离项目中,前端处理 Token 过期与自动刷新是保障用户体验的核心环节。以下将从 “如何确定 Token 过期时间”“自动刷新 Token 的实现逻辑”“关键细节与安全注意事项” 三个维度展开,结合实际代码示例说明。

一、如何确定 Token 过期时间?

Token 过期时间的获取方式,取决于后端返回的 Token 类型(如 JWT Token / 普通 Token),常见有两种方案:

1. 方案 1:从 JWT Token 本身解码过期时间(推荐)

如果后端使用 JWT Token(结构为 Header.Payload.Signature),其 Payload 中会自带过期时间字段(通常是 exp,表示 Unix 时间戳,单位:秒)。
前端可直接解码 Payload 拿到 exp,无需额外请求后端。

实现步骤:
  • 安装 JWT 解码库(避免手动处理 Base64 解码和格式问题):

    推荐轻量库

    1
    jwt-decode

    (无依赖,仅用于解码,不负责验证签名)。

    1
    npm install jwt-decode --save
  • 解码 Token 并计算过期时间:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    import jwtDecode from 'jwt-decode';

    // 1. 从存储中获取 Token(示例用 localStorage,实际需根据安全需求选择存储方式)
    const accessToken = localStorage.getItem('accessToken');

    // 2. 解码 Token,获取 Payload 中的 exp(过期时间戳)
    const decodedToken = jwtDecode(accessToken);
    /* decodedToken 结构示例:
    {
    "sub": "user123", // 用户ID
    "name": "张三", // 用户名
    "exp": 1720000000, // 过期时间(Unix 时间戳,秒)
    "iat": 1719996400 // 签发时间
    }
    */

    // 3. 转换为前端可理解的时间(如 Date 对象)
    const expireTime = new Date(decodedToken.exp * 1000); // 时间戳转毫秒
    const currentTime = new Date(); // 当前时间

    // 4. 判断是否即将过期(建议提前 30 秒触发刷新,避免网络延迟导致过期)
    const isAboutToExpire = expireTime - currentTime < 30 * 1000; // 剩余时间 < 30 秒

⚠️ 注意jwt-decode 仅用于解码字段,不验证 Token 签名有效性(签名验证必须由后端完成)。前端解码的 exp 仅作为 “触发刷新的参考”,最终 Token 是否有效以后端接口返回为准。

2. 方案 2:后端额外返回过期时间(适用于非 JWT Token)

如果后端使用普通 Token(如随机字符串 Token),Token 本身不包含过期信息,此时后端需在返回 Token 时,额外返回过期相关字段(如 expiresInexpireTime)。

示例:后端返回格式
1
2
3
4
5
6
7
8
9
10
// 登录接口 /api/login 返回
{
"code": 200,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", // 访问令牌(短期,如2小时)
"refreshToken": "defg123456...", // 刷新令牌(长期,如7天)
"expiresIn": 7200, // accessToken 有效期(秒),2小时 = 7200秒
"expireTime": "2024-07-01 18:00:00" // 可选,直接返回过期时间字符串
}
}
前端处理逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 登录成功后,存储 Token 及过期时间
const loginRes = await axios.post('/api/login', { username, password });
const { accessToken, refreshToken, expiresIn } = loginRes.data.data;

// 1. 存储 Token
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);

// 2. 计算并存储过期时间(当前时间 + expiresIn 秒)
const expireTime = new Date().getTime() + expiresIn * 1000;
localStorage.setItem('accessTokenExpireTime', expireTime.toString());

// 3. 判断是否即将过期
const currentTime = new Date().getTime();
const storedExpireTime = Number(localStorage.getItem('accessTokenExpireTime'));
const isAboutToExpire = storedExpireTime - currentTime < 30 * 1000; // 剩余 < 30 秒

二、自动刷新 Token 的实现逻辑

自动刷新的核心是:在 accessToken 过期前,用长期有效的 refreshToken 向后端请求新的 accessToken,避免用户感知到登录状态中断。

核心原理:Token 分层设计

后端通常会返回两种 Token:

  • accessToken:短期有效(如 2 小时),用于接口请求的身份验证(放在请求头 Authorization: Bearer {accessToken})。
  • refreshToken:长期有效(如 7 天),仅用于请求新的 accessToken(权限范围窄,即使泄露风险较低)。

实现步骤(以 Axios 拦截器为例)

前端通常通过 请求拦截器(添加 Token 到请求头)和 响应拦截器(处理 Token 过期错误)实现自动刷新,同时需解决 “并发请求重复刷新” 的问题。

1. 初始化 Axios 实例(封装基础配置)
1
2
3
4
5
6
7
8
9
10
11
12
13
// src/utils/request.js
import axios from 'axios';

// 创建 Axios 实例
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, // 环境变量配置后端地址
timeout: 5000
});

// 存储刷新状态(避免并发请求重复触发刷新)
let isRefreshing = false;
// 存储等待刷新的请求队列(刷新成功后统一重试)
let requestQueue = [];
2. 请求拦截器:添加 accessToken 到请求头

每次接口请求前,自动从存储中读取 accessToken 并添加到 Authorization 头。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 请求拦截器
request.interceptors.request.use(
(config) => {
// 1. 从存储中获取 accessToken
const accessToken = localStorage.getItem('accessToken');
// 2. 若有 Token,添加到请求头
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
3. 响应拦截器:处理 Token 过期与自动刷新

当后端返回 Token 过期错误(如 code: 401code: 403)时,触发刷新逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 响应拦截器
request.interceptors.response.use(
(response) => response.data, // 直接返回响应体(简化后续处理)
async (error) => {
const originalRequest = error.config; // 原始请求配置

// 1. 排除“刷新 Token 接口本身的错误”(避免死循环)
if (originalRequest.url === '/api/refresh-token') {
// 刷新 Token 失败(如 refreshToken 过期),需重新登录
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('accessTokenExpireTime');
// 跳转到登录页(携带当前页面地址,登录后返回)
window.location.href = `/login?redirect=${window.location.pathname}`;
return Promise.reject(error);
}

// 2. 判断是否为 Token 过期错误(后端需统一错误码,如 code: 401)
const isTokenExpired = error.response?.data?.code === 401;
if (isTokenExpired && !originalRequest._retry) { // _retry 标记避免重复重试
originalRequest._retry = true; // 标记该请求已进入重试流程

// 3. 处理并发请求:若正在刷新,将请求加入队列;否则触发刷新
if (isRefreshing) {
// 等待刷新完成后,重试原始请求
return new Promise((resolve) => {
requestQueue.push(() => resolve(request(originalRequest)));
});
}

// 4. 触发刷新 Token 逻辑
isRefreshing = true; // 标记为“正在刷新”
try {
// 4.1 调用后端刷新接口(用 refreshToken 换 newAccessToken)
const refreshToken = localStorage.getItem('refreshToken');
const refreshRes = await axios.post(
`${import.meta.env.VITE_API_BASE_URL}/api/refresh-token`,
{ refreshToken } // 传 refreshToken 给后端验证
);
const { accessToken: newAccessToken, expiresIn } = refreshRes.data.data;

// 4.2 更新存储的 Token 及过期时间
localStorage.setItem('accessToken', newAccessToken);
const newExpireTime = new Date().getTime() + expiresIn * 1000;
localStorage.setItem('accessTokenExpireTime', newExpireTime.toString());

// 4.3 重试队列中的所有请求
requestQueue.forEach(cb => cb());
requestQueue = []; // 清空队列

// 4.4 重试当前原始请求(用新的 accessToken)
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return request(originalRequest);
} catch (refreshError) {
// 刷新失败(如 refreshToken 过期),跳转登录页
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('accessTokenExpireTime');
window.location.href = `/login?redirect=${window.location.pathname}`;
return Promise.reject(refreshError);
} finally {
isRefreshing = false; // 刷新结束,重置标记
}
}

// 非 Token 过期错误,直接抛出
return Promise.reject(error);
}
);

export default request;
4. 主动检测过期(可选,优化体验)

除了 “被动等待接口返回 401”,还可在 页面初始化 / 路由切换时 主动检测 Token 是否即将过期,提前触发刷新(避免用户操作时才发现过期)。

示例(Vue 路由守卫):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import request from '@/utils/request';
import jwtDecode from 'jwt-decode';

const router = createRouter({
history: createWebHistory(),
routes: [/* 路由配置 */]
});

// 全局前置守卫:进入页面前列检 Token
router.beforeEach(async (to, from, next) => {
const accessToken = localStorage.getItem('accessToken');
const refreshToken = localStorage.getItem('refreshToken');

// 1. 无 Token 且不是登录页,跳转登录
if (!accessToken && to.path !== '/login') {
next({ path: '/login', query: { redirect: to.path } });
return;
}

// 2. 有 Token,检测是否即将过期
if (accessToken) {
const decoded = jwtDecode(accessToken);
const expireTime = decoded.exp * 1000;
const currentTime = new Date().getTime();
const isAboutToExpire = expireTime - currentTime < 60 * 1000; // 剩余 < 1 分钟

// 3. 即将过期且有 refreshToken,提前刷新
if (isAboutToExpire && refreshToken && !isRefreshing) {
try {
const refreshRes = await request.post('/api/refresh-token', { refreshToken });
const { accessToken: newAccessToken, expiresIn } = refreshRes.data.data;
localStorage.setItem('accessToken', newAccessToken);
localStorage.setItem('accessTokenExpireTime', new Date().getTime() + expiresIn * 1000);
} catch (e) {
// 刷新失败,跳转登录
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
next({ path: '/login', query: { redirect: to.path } });
return;
}
}
}

next();
});

export default router;

三、关键细节与安全注意事项

  1. Token 存储方式选择
    • 不推荐 localStorage:易受 XSS 攻击(恶意脚本可读取)。
    • 推荐方案:
      • httpOnly + Secure Cookie:后端设置 Set-Cookie: refreshToken=xxx; HttpOnly; Secure; SameSite=Strict,前端无法通过 JS 读取,彻底防 XSS;但需处理跨域 Cookie 携带问题(后端配置 Access-Control-Allow-Credentials: true)。
      • 若用 sessionStorage:关闭页面后 Token 失效,安全性高于 localStorage,适合 “一次会话内有效” 的场景。
  2. refreshToken 的安全策略
    • 后端需给 refreshToken 绑定用户设备(如记录设备 ID),避免被盗用后跨设备使用。
    • 每次刷新 accessToken 时,可选择性更新 refreshToken(“Token 轮换”),降低 refreshToken 泄露后的风险。
    • refreshToken 有效期不宜过长(如 7 天),过期后强制用户重新登录。
  3. 避免重复刷新
    通过 isRefreshing 标记和 requestQueue 队列,解决 “多个并发请求同时触发刷新” 的问题(否则会生成多个无效的 accessToken)。
  4. 后端验证不可少
    前端的过期时间判断仅为 “优化体验”,最终 Token 是否有效必须由后端验证(如 JWT 签名验证、Token 黑名单检查),不能信任前端传递的任何 Token 相关信息。

四、总结

前端自动刷新 Token 的核心流程:

  1. 获取过期时间:JWT 解码 exp 字段,或使用后端返回的 expiresIn
  2. 拦截请求 / 响应:用 Axios 拦截器添加 Token、处理 401 错误。
  3. 刷新逻辑:用 refreshToken 请求新 accessToken,重试排队请求。
  4. 异常处理:刷新失败时清除 Token 并跳转登录页。

通过这套方案,可实现 “用户无感知续期登录状态”,同时兼顾安全性和体验。