双token实现无感登录

253 阅读11分钟

方案概述

什么是双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.cookie API访问,可以有效防御XSS攻击,防止脚本窃取Refresh Token。
    • Secure: 在生产环境中应设置为 true,确保Cookie只在HTTPS连接中传输。
    • SameSite: 设置为 StrictLax 可以有效防御CSRF攻击。
  • 安全考虑:

    • 一次性使用(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;

如何实现"无感刷新"

上述代码中的拦截器就是实现“无感刷新”的关键。核心思想是:

  1. 拦截错误: 捕获到401错误时,不立即抛出或让用户感知到。
  2. 暂停请求: 将后续的API请求暂时存入一个队列中。
  3. 静默刷新: 在后台悄悄地发起刷新Token的请求。
  4. 恢复执行: 刷新成功后,用新的Access Token重新执行刚才失败的请求以及队列中所有挂起的请求。
  5. 失败处理: 如果刷新都失败了,才将用户重定向到登录页。

整个过程对用户来说是完全透明的,他们只会感觉到网络请求稍微慢了一点,而不会被打断操作流程去重新登录。


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也过期: 如果刷新接口返回 401403,表明Refresh Token本身也失效了。这是明确的信号,此时必须清空前端所有与认证相关的信息,并将用户重定向到登录页面。

4. 安全性考虑

如何防止Token被盗用

  • HTTPS: 全站强制使用HTTPS,防止Token在传输过程中被中间人攻击窃听。
  • Access Token 短有效期: 这是最核心的防御手段。
  • Refresh Token 安全存储: 使用 HttpOnly Cookie。
  • 旋转刷新令牌 (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: 存入 HttpOnly Cookie后,JS无法读取,基本免疫XSS。
    • Access Token: 存内存或SessionStorage能增加窃取难度,但如果网站存在XSS漏洞,攻击者依然可以劫持 fetch/axios 的实例,在发起请求时拿到Access Token。因此,根本的防御还是在于:对所有用户输入进行严格的转义和过滤,并使用内容安全策略 (CSP)
  • CSRF (跨站请求伪造) :

    • 如果使用Cookie来传递Token,需要配置Cookie的 SameSite 属性为 StrictLax,可以有效防止CSRF攻击。Strict最为严格,完全禁止第三方站点携带Cookie发起请求。

Token撤销机制

当用户主动登出、修改密码或管理员封禁用户时,需要让已签发的Token立即失效。

  • 主动登出: 前端清除本地存储的Access Token和相关的Cookie即可。后端无需处理,因为Access Token很快会自然过期。

  • 强制下线(密码修改、封禁) : 这需要后端配合。

    • 黑名单机制: 将需要撤销的Token ID (JWT中的jti声明) 存入一个有时效性的黑名单(如Redis)。每次验证Token时,先查一下它是否在黑名单中。这是最常见也最简单的方式。
    • 版本号机制: 在用户记录中增加一个Token版本号。每次修改密码等操作时,版本号加一。将此版本号也加入JWT的Payload中。验证时,比对Token中的版本号和用户记录中的版本号是否一致。

5. 用户体验优化

如何实现真正的"无感"体验

关键在于并发请求的处理。如上述代码示例所示,当多个API请求同时发出,且都因为Token过期而失败时,我们不能让每个请求都去触发一次Token刷新。

必须设计一个锁机制:

  1. 用一个 isRefreshing 标志位来锁定刷新状态。
  2. 第一个失败的请求将 isRefreshing 置为 true 并去执行刷新操作。
  3. 在此期间,其他失败的请求则被存入一个 requests 队列中,等待刷新完成。
  4. 当第一个请求成功刷新Token后,遍历 requests 队列,用新的Token重新发起所有被挂起的请求。

这样可以确保Token刷新操作只执行一次,避免了资源浪费和逻辑混乱,保证了流程的顺滑。

登录状态的持久化策略

  • Refresh Token的有效期: Refresh Token本身的长期有效性是持久化登录的基础。
  • 跨标签页通信: 如果Access Token存在内存中,新开一个标签页时,它是没有Token的。可以通过 BroadcastChannel API 或监听 LocalStorage 的变化来实现标签页之间的通信。当一个标签页成功登录或刷新了Token后,可以通知其他标签页更新自己的内存状态。一个更简单的策略是在每个新标签页加载时,主动尝试进行一次静默刷新(如果存在Refresh Token),以获取最新的Access Token。

6. 实际应用场景