方案概述
什么是双Token方案?
双Token认证方案,顾名思义,是通过使用两种不同类型的Token来管理用户的登录状态。这两种Token分别是:
- Access Token ( 访问令牌 ) : 它是客户端访问受保护资源的凭证。它的特点是生命周期短,通常只有几分钟到几小时。客户端在每次请求API时,都需要在请求头中携带Access Token。
- Refresh Token(刷新令牌) : 它的唯一作用就是用来获取新的Access Token。Refresh Token的生命周期很长,可以是几天甚至几个月。它只在Access Token过期时才会被使用。
为什么需要双Token而不是单Token?
传统的单Token方案,如果为了安全将Token有效期设置得很短,用户就需要频繁登录,体验极差。如果为了用户体验将有效期设置得很长,一旦Token在传输或存储过程中被窃取,攻击者就能在很长一段时间内冒充用户身份进行操作,带来极大的安全风险。
双Token方案正是为了解决这个矛盾而设计的。它通过分离“访问”和“刷新”的权限,实现了安全与体验的平衡:
- 安全性提升: Access Token由于有效期很短,即使被截获,其可用时间也非常有限,大大降低了风险。真正关键的Refresh Token,只在特定的刷新接口使用,并且后端可以对其进行更严格的风控管理(例如绑定IP、设备指纹等),从而提高了整体的安全性。
- 用户体验优化: 用户在Access Token过期后,前端可以自动使用Refresh Token去换取新的Access Token,整个过程用户无感,避免了需要频繁重新登录的糟糕体验。
对比传统单Token方案
| 特性 | 单Token方案 | 双Token方案 |
|---|---|---|
| 安全性 | 较低。Token有效期长,被盗用风险高。 | 较高。Access Token有效期短,Refresh Token使用频率低且有风控。 |
| 用户体验 | 较差。为保证安全,需频繁登录。 | 极佳。实现无感刷新,用户基本无需手动登录。 |
| 复杂度 | 简单,客户端只需存储和发送一个Token。 | 较高,前端需要处理Token刷新、并发请求、存储策略等逻辑。 |
| 服务端开销 | 较低,只需验证一个Token。 | 略高,需要额外处理刷新Token的逻辑和存储。 |
2. 技术实现细节
Access Token的设计
- 有效期 (Expiration) : 通常设置为较短的时间,例如15分钟到1小时。这保证了即使Token被窃取,攻击者能够利用它的时间也非常有限。
- 存储位置 (Storage) : 内存 (In-memory) 是最安全的选择,可以存在一个变量中,因为它完全杜绝了XSS攻击的风险。其次是 SessionStorage,它可以在页面刷新时保留Token,但关闭标签页后会丢失。不推荐使用LocalStorage,因为它的安全性较低,容易受到XSS攻击。
- 使用场景 (Usage) : 在每一次需要身份认证的API请求中,通过HTTP请求头的
Authorization字段(通常是Bearer <token>的形式)发送给服务器。
Refresh Token的设计
-
有效期 (Expiration) : 设置为较长的时间,例如7天或30天,以支持用户的“记住我”功能。
-
存储策略 (Storage) : 强烈推荐存储在 HttpOnly Cookie 中。这是最安全的客户端存储方式。
- HttpOnly: 设置为
true后,该Cookie无法被JavaScript的document.cookieAPI访问,可以有效防御XSS攻击,防止脚本窃取Refresh Token。 - Secure: 在生产环境中应设置为
true,确保Cookie只在HTTPS连接中传输。 - SameSite: 设置为
Strict或Lax可以有效防御CSRF攻击。
- HttpOnly: 设置为
-
安全考虑:
- 一次性使用(One-time Use) : Refresh Token在被使用一次后立即失效,后端会返回新的Access Token和新的Refresh Token。这被称为旋转刷新令牌(Rotating Refresh Tokens) ,可以有效发现Refresh Token被盗用的情况。如果一个已被使用的Refresh Token被再次使用,说明可能被盗,后端可以立即将该用户的所有会话都下线。
- 后端风控: 后端可以对Refresh Token的使用进行监控,例如绑定IP地址或设备信息,如果发现异常,可以使Refresh Token失效。
Token自动刷新的具体实现逻辑和代码示例
自动刷新是双Token方案的核心。实现它的关键在于拦截API的响应。当前端发起一个请求,收到后端返回的“Access Token已过期”的错误码时(通常是 401 Unauthorized),就触发刷新流程。
这里以 axios 为例,展示如何通过拦截器实现自动刷新:
import axios from 'axios';
// 创建axios实例const apiClient = axios.create({
baseURL: '/api',
});
// 请求拦截器:为每个请求添加Access Token
apiClient.interceptors.request.use(config => {
const accessToken = sessionStorage.getItem('accessToken'); // 从内存或SessionStorage获取if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
}, error => {
return Promise.reject(error);
});
// 响应拦截器:处理401错误并刷新Tokenlet isRefreshing = false; // 标记是否正在刷新Tokenlet requests = []; // 存储因Token过期而挂起的请求
apiClient.interceptors.response.use(
response => response,
async (error) => {
const { config, response } = error;
// 如果不是401错误,直接返回错误if (response?.status !== 401) {
return Promise.reject(error);
}
// 如果正在刷新Token,则将当前请求挂起if (isRefreshing) {
return new Promise((resolve) => {
requests.push(() => resolve(apiClient(config)));
});
}
isRefreshing = true;
try {
// 调用刷新Token的接口,Refresh Token通过httpOnly Cookie自动发送const { data } = await axios.post('/api/auth/refresh');
// 更新新的Access Tokenconst newAccessToken = data.accessToken;
sessionStorage.setItem('accessToken', newAccessToken); // 更新存储
apiClient.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`;
// 重新执行所有挂起的请求
requests.forEach(cb => cb());
requests = []; // 清空队列// 重新执行本次失败的请求return apiClient(config);
} catch (refreshError) {
// 如果刷新Token失败,则说明用户身份彻底过期,需要重新登录console.error('Unable to refresh token.', refreshError);
// 跳转到登录页window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false; // 重置刷新状态
}
}
);
export default apiClient;
如何实现"无感刷新"
上述代码中的拦截器就是实现“无感刷新”的关键。核心思想是:
- 拦截错误: 捕获到401错误时,不立即抛出或让用户感知到。
- 暂停请求: 将后续的API请求暂时存入一个队列中。
- 静默刷新: 在后台悄悄地发起刷新Token的请求。
- 恢复执行: 刷新成功后,用新的Access Token重新执行刚才失败的请求以及队列中所有挂起的请求。
- 失败处理: 如果刷新都失败了,才将用户重定向到登录页。
整个过程对用户来说是完全透明的,他们只会感觉到网络请求稍微慢了一点,而不会被打断操作流程去重新登录。
3. 生命周期管理
Token的完整生命周期流程图
Generated mermaid
graph TD
A[用户输入账号密码登录] --> B{后端验证};
B -->|验证成功| C[生成Access Token和Refresh Token];
C --> D[Access Token返回给前端(内存/SessionStorage)];
C --> E[Refresh Token通过HttpOnly Cookie返回给前端];
D --> F[前端携带Access Token请求API];
F --> G{后端验证Access Token};
G -->|有效| H[返回数据];
H --> F;
G -->|过期(返回401)| I[前端使用Refresh Token请求刷新接口];
I --> J{后端验证Refresh Token};
J -->|有效| K[生成新的Access Token和Refresh Token];
K --> D;
K --> E;
J -->|无效/过期| L[强制用户下线,清空前端Token];
L --> A;
过期检测机制
前端不需要、也不应该主动去解码JWT来检测其过期时间。最可靠的过期检测机制是依赖后端的响应。
- 当后端API接收到请求时,会验证Access Token的签名和有效期。
- 如果Token过期,后端API会拒绝请求并返回一个明确的状态码,通常是
401 Unauthorized,有时也可能附带特定的错误代码,如{"error": "token_expired"}。 - 前端的HTTP拦截器正是基于这个响应来触发刷新逻辑的。
自动续期策略
“续期”实际上是通过“刷新”来实现的。只要用户的Refresh Token有效,就可以不断地换取新的Access Token,从而实现会话的自动延续。后端还可以设计一个策略:如果Refresh Token在有效期的一半之后被使用,则可以返回一个新的、有效期被重置的Refresh Token,实现“长效登录”。
异常情况处理
- 网络错误: 如果刷新Token的请求本身就失败了(例如用户网络断开),请求会进入
catch块。此时应该给用户一个友好的提示,比如“网络连接错误,请稍后再试”,而不是直接判定为登录过期。可以实现一个重试机制。 - 服务器异常: 如果后端刷新接口返回
5xx错误,处理方式同网络错误,应认为是服务端临时故障,可以稍后重试。 - Refresh Token也过期: 如果刷新接口返回
401或403,表明Refresh Token本身也失效了。这是明确的信号,此时必须清空前端所有与认证相关的信息,并将用户重定向到登录页面。
4. 安全性考虑
如何防止Token被盗用
- HTTPS: 全站强制使用HTTPS,防止Token在传输过程中被中间人攻击窃听。
- Access Token 短有效期: 这是最核心的防御手段。
- Refresh Token 安全存储: 使用
HttpOnlyCookie。 - 旋转刷新令牌 (Rotating Refresh Tokens) : Refresh Token使用一次后就作废,可以侦测到盗用行为。
- 关键操作二次验证: 对于修改密码、支付等敏感操作,即使Access Token有效,也应该要求用户再次输入密码或验证码。
- 后端风控: 监测IP、设备指纹等信息的变化,发现异常时可以主动让相关Token失效。
存储安全策略对比
| 存储方式 | 优点 | 缺点 | 安全性 |
|---|---|---|---|
| HttpOnly Cookie | 最高。无法被JS读取,防御XSS。自动携带,防御CSRF (需配合SameSite)。 | 依赖浏览器Cookie机制,可能被禁用。不适合非浏览器环境。 | 高 (推荐存Refresh Token) |
| 内存 (In-memory) | 安全性好,页面关闭即消失,不会被XSS直接攻击到存储本身。 | 页面刷新丢失,多标签页不共享,需要额外逻辑处理。 | 中高 (推荐存Access Token) |
| SessionStorage | 仅当前标签页有效,关闭即丢。 | 仍然可以被同源下的XSS攻击读取。 | 中 |
| LocalStorage | 持久化,跨标签页共享,易于使用。 | 安全性最低。永久存储,易受XSS攻击,可被同源下的所有窗口读取。 | 低 (不推荐) |
最佳实践: 将 Refresh Token 存储在 HttpOnly Cookie 中,将 Access Token 存储在内存中(例如JS变量或Pinia/Redux等状态管理库)。
CSRF和XSS攻击防护
-
XSS (跨站脚本攻击) :
- Refresh Token: 存入
HttpOnlyCookie后,JS无法读取,基本免疫XSS。 - Access Token: 存内存或SessionStorage能增加窃取难度,但如果网站存在XSS漏洞,攻击者依然可以劫持
fetch/axios的实例,在发起请求时拿到Access Token。因此,根本的防御还是在于:对所有用户输入进行严格的转义和过滤,并使用内容安全策略 (CSP) 。
- Refresh Token: 存入
-
CSRF (跨站请求伪造) :
- 如果使用Cookie来传递Token,需要配置Cookie的
SameSite属性为Strict或Lax,可以有效防止CSRF攻击。Strict最为严格,完全禁止第三方站点携带Cookie发起请求。
- 如果使用Cookie来传递Token,需要配置Cookie的
Token撤销机制
当用户主动登出、修改密码或管理员封禁用户时,需要让已签发的Token立即失效。
-
主动登出: 前端清除本地存储的Access Token和相关的Cookie即可。后端无需处理,因为Access Token很快会自然过期。
-
强制下线(密码修改、封禁) : 这需要后端配合。
- 黑名单机制: 将需要撤销的Token ID (JWT中的
jti声明) 存入一个有时效性的黑名单(如Redis)。每次验证Token时,先查一下它是否在黑名单中。这是最常见也最简单的方式。 - 版本号机制: 在用户记录中增加一个Token版本号。每次修改密码等操作时,版本号加一。将此版本号也加入JWT的Payload中。验证时,比对Token中的版本号和用户记录中的版本号是否一致。
- 黑名单机制: 将需要撤销的Token ID (JWT中的
5. 用户体验优化
如何实现真正的"无感"体验
关键在于并发请求的处理。如上述代码示例所示,当多个API请求同时发出,且都因为Token过期而失败时,我们不能让每个请求都去触发一次Token刷新。
必须设计一个锁机制:
- 用一个
isRefreshing标志位来锁定刷新状态。 - 第一个失败的请求将
isRefreshing置为true并去执行刷新操作。 - 在此期间,其他失败的请求则被存入一个
requests队列中,等待刷新完成。 - 当第一个请求成功刷新Token后,遍历
requests队列,用新的Token重新发起所有被挂起的请求。
这样可以确保Token刷新操作只执行一次,避免了资源浪费和逻辑混乱,保证了流程的顺滑。
登录状态的持久化策略
- Refresh Token的有效期: Refresh Token本身的长期有效性是持久化登录的基础。
- 跨标签页通信: 如果Access Token存在内存中,新开一个标签页时,它是没有Token的。可以通过
BroadcastChannelAPI 或监听LocalStorage的变化来实现标签页之间的通信。当一个标签页成功登录或刷新了Token后,可以通知其他标签页更新自己的内存状态。一个更简单的策略是在每个新标签页加载时,主动尝试进行一次静默刷新(如果存在Refresh Token),以获取最新的Access Token。