前端Token最佳实践

145 阅读5分钟

前端Token最佳实践

本指南介绍前端应用程序中管理认证Token的现代最佳实践,重点涵盖安全性、性能和用户体验,特别针对前后端分离架构优化 httpOnly Cookie 的使用,结合 Nginx 反向代理简化跨域处理。内容包括Token概述、存储方式、安全策略、生命周期管理以及多标签页同步等高级功能。

1. Token概述

1.1 什么是Token?

Token 是一种用于身份验证和授权的加密字符串,代表用户身份或访问权限。JSON Web Token(JWT)是目前最常用的Token格式。

1.2 JWT结构

JWT 由三部分组成,用点号(.)分隔:

Header.Payload.Signature
  • Header:定义算法和Token类型,例如:{ "alg": "HS256", "typ": "JWT" }
  • Payload:包含用户信息和声明,例如:{ "sub": "user123", "exp": 1697059200 }
  • Signature:用于验证Token的真实性数据完整性。使用密钥对编码后的Header和Payload进行签名生成。

2. Token存储:选项与最佳实践

在前后端分离架构中,Token 存储需考虑跨域请求、CORS 配置和安全性。Nginx 作为反向代理可简化跨域处理,统一前后端域名或支持跨域 Cookie 传输。以下比较浏览器主要存储选项,并提供针对前后端分离的 httpOnly Cookie 最佳实践。

2.1 存储选项对比

属性CookiesessionStoragelocalStorage
存储容量4KB(约50个/域名)5-10MB(依浏览器)5-10MB(依浏览器)
生命周期可配置过期时间,默认会话结束标签页关闭清除持久存储,需手动清除
作用域同源+路径,可跨标签页同源+同标签页同源,可跨标签页
服务器交互自动随HTTP请求发送仅客户端存储仅客户端存储
安全性⭐⭐⭐⭐(启用httpOnly时)⭐⭐⭐⭐⭐
Token适用场景Refresh Token(httpOnly)临时Access Token加密后备用方案
主要风险CSRF攻击XSS+标签页关闭丢失XSS攻击

2.2 测试存储容量

以下代码用于验证 sessionStoragelocalStorage 的实际容量限制:

function testStorageCapacity(storage) {
  let data = '', size = 0;
  try {
    const oneMB = 'x'.repeat(1024 * 1024);
    while (true) {
      storage.setItem('test', data + oneMB);
      data += oneMB;
      size += 1;
    }
  } catch (e) {
    storage.removeItem('test');
    return `${size}MB`;
  }
}

console.log('localStorage:', testStorageCapacity(localStorage));
console.log('sessionStorage:', testStorageCapacity(sessionStorage));

2.3 Token存储最佳实践

2.3.1 推荐策略:双Token机制
  • Access Token:短期(15-30分钟),存储于内存,用于API调用。安全性高,无XSS/CSRF风险,但页面刷新会丢失。
  • Refresh Token:长期(7-30天),存储于 httpOnly Cookie,用于刷新Access Token。防止XSS攻击,配合配置可防CSRF。

优势

  • Access Token 短期过期,降低泄露风险。
  • Refresh Token 无法被 JavaScript 访问,安全性高。
  • 自动刷新机制确保无缝用户体验。
2.3.2 存储实现
  • httpOnly Cookie(Refresh Token)

    • 原因:阻止 JavaScript 访问(防XSS),自动随 HTTP 请求发送,支持 secureSameSite 属性防 CSRF。

    • 前后端分离实现
      在前后端分离场景中,前端(如 frontend.example.com)和后端(如 api.example.com)通常位于不同域名,涉及跨域请求。Nginx 反向代理可统一域名或支持跨域 Cookie 传输。

      方案1:Nginx 统一域名(推荐)
      使用 Nginx 反向代理将前端和后端请求路由到同一顶级域名(如 example.com),避免跨域问题:

      server {
          listen 443 ssl;
          server_name example.com;
      
          ssl_certificate /path/to/cert.pem;
          ssl_certificate_key /path/to/key.pem;
      
          # 前端路由
          location / {
              proxy_pass https://frontend.example.com;
              proxy_set_header Host frontend.example.com;
              proxy_set_header X-Real-IP $remote_addr;
          }
      
          # API 路由
          location /api/ {
              proxy_pass https://api.example.com/;
              proxy_set_header Host api.example.com;
              proxy_set_header X-Real-IP $remote_addr;
              # 确保 Cookie 传输
              proxy_set_header Cookie $http_cookie;
              proxy_pass_request_headers on;
          }
      }
      
      • 优势:前端和后端请求使用同一域名(如 example.com),无需跨域,sameSite: 'strict' 可直接使用,增强 CSRF 保护。

      • Cookie 设置(服务端,Node.js/Express):

        const express = require('express');
        const cookieParser = require('cookie-parser');
        const app = express();
        
        app.use(cookieParser());
        
        // 登录接口
        app.post('/api/login', (req, res) => {
          const refreshToken = generateRefreshToken();
          res.cookie('refreshToken', refreshToken, {
            httpOnly: true,        // 防 XSS
            secure: true,          // 仅 HTTPS
            sameSite: 'strict',    // 防 CSRF(同一域名)
            maxAge: 7 * 24 * 60 * 60 * 1000, // 7天
            path: '/'
          });
          res.json({ accessToken: generateAccessToken() });
        });
        
        // 刷新接口
        app.post('/api/refresh', (req, res) => {
          const refreshToken = req.cookies.refreshToken;
          if (!refreshToken || !verifyRefreshToken(refreshToken)) {
            return res.status(401).json({ error: '无效的Refresh Token' });
          }
          const newAccessToken = generateAccessToken();
          const newRefreshToken = generateRefreshToken();
          res.cookie('refreshToken', newRefreshToken, {
            httpOnly: true,
            secure: true,
            sameSite: 'strict',
            maxAge: 7 * 24 * 60 * 60 * 1000,
            path: '/'
          });
          res.json({ accessToken: newAccessToken });
        });
        
      • 客户端请求

        // 登录请求
        async function login(credentials) {
          const response = await fetch('https://example.com/api/login', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(credentials),
            credentials: 'include' // 携带 Cookie
          });
          const { accessToken } = await response.json();
          setAccessToken(accessToken);
        }
        
        // 刷新请求
        async function refreshToken() {
          const response = await fetch('https://example.com/api/refresh', {
            method: 'POST',
            credentials: 'include'
          });
          const { accessToken } = await response.json();
          setAccessToken(accessToken);
          broadcastToken(accessToken);
          return accessToken;
        }
        

      方案2:Nginx 支持跨域
      如果无法统一域名,Nginx 可通过添加 CORS 头支持跨域 Cookie 传输:

      server {
          listen 443 ssl;
          server_name api.example.com;
      
          ssl_certificate /path/to/cert.pem;
          ssl_certificate_key /path/to/key.pem;
      
          location / {
              proxy_pass http://backend:3000; # 后端服务
              proxy_set_header Host $host;
              proxy_set_header X-Real-IP $remote_addr;
      
              # 添加 CORS 头
              add_header 'Access-Control-Allow-Origin' 'https://frontend.example.com';
              add_header 'Access-Control-Allow-Credentials' 'true';
              add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
              add_header 'Access-Control-Allow-Headers' 'X-CSRF-Token, Content-Type';
      
              # 处理 OPTIONS 预检请求
              if ($request_method = 'OPTIONS') {
                  add_header 'Access-Control-Max-Age' 1728000;
                  return 204;
              }
          }
      }
      
      • 服务端设置(Node.js/Express,结合 Nginx):

        const express = require('express');
        const cookieParser = require('cookie-parser');
        const app = express();
        
        app.use(cookieParser());
        
        // 登录接口
        app.post('/login', (req, res) => {
          const refreshToken = generateRefreshToken();
          res.cookie('refreshToken', refreshToken, {
            httpOnly: true,
            secure: true,
            sameSite: 'none', // 跨域场景
            maxAge: 7 * 24 * 60 * 60 * 1000,
            path: '/',
            domain: '.example.com'
          });
          res.json({ accessToken: generateAccessToken(), csrfToken: generateCSRFToken() });
        });
        
        // 刷新接口
        app.post('/refresh', (req, res) => {
          const refreshToken = req.cookies.refreshToken;
          if (!refreshToken || !verifyRefreshToken(refreshToken)) {
            return res.status(401).json({ error: '无效的Refresh Token' });
          }
          const newAccessToken = generateAccessToken();
          const newRefreshToken = generateRefreshToken();
          res.cookie('refreshToken', newRefreshToken, {
            httpOnly: true,
            secure: true,
            sameSite: 'none',
            maxAge: 7 * 24 * 60 * 60 * 1000,
            path: '/',
            domain: '.example.com'
          });
          res.json({ accessToken: newAccessToken });
        });
        
      • CSRF 防护

        // 服务端设置 CSRF Token
        res.cookie('csrfToken', csrfToken, {
          httpOnly: false, // 客户端可读
          secure: true,
          sameSite: 'none'
        });
        
        // 客户端携带 CSRF Token
        fetch('https://api.example.com/protected', {
          method: 'POST',
          headers: {
            'X-CSRF-Token': getCookie('csrfToken'),
            'Content-Type': 'application/json'
          },
          credentials: 'include'
        });
        
  • 内存存储(Access Token)

    • 原因:最高安全性,无磁盘操作,适合短期 Token。

    • 实现

      let accessToken = null;
      function setAccessToken(token) { accessToken = token; }
      function getAccessToken() { return accessToken; }
      
  • sessionStorage(临时Access Token)

    • 原因:标签页隔离,适合临时存储。

    • 实现

      sessionStorage.setItem('accessToken', token);
      const token = sessionStorage.getItem('accessToken');
      
    • 注意:易受 XSS 攻击,标签页关闭丢失。

  • localStorage(加密备用-谨慎使用)

    • 原因:仅作为一种满足“Token不应明文存储”的合规性要求的折中方案,或对存储内容进行轻度混淆。请注意,由于加密密钥必然暴露于前端代码中,它无法有效防御具有XSS能力的攻击者

    • 实现(AES 加密):

      import CryptoJS from 'crypto-js';
      
      const secureStorage = {
        setItem(key, value) {
          const encrypted = CryptoJS.AES.encrypt(JSON.stringify(value), 'secret-key').toString();
          localStorage.setItem(key, encrypted);
        },
        getItem(key) {
          const encrypted = localStorage.getItem(key);
          if (!encrypted) return null;
          const bytes = CryptoJS.AES.decrypt(encrypted, 'secret-key');
          return JSON.parse(bytes.toString(CryptoJS.enc.Utf8));
        }
      };
      
    • 注意首选方案永远是将敏感Token存储在httpOnly Cookie或内存中。此方案仅在充分评估风险后使用。

2.3.3 前后端分离中的注意事项
  • Nginx 统一域名

    • 将前端和后端路由到同一顶级域名(如 example.com),避免跨域,简化 CORS 和 sameSite 配置。
    • 使用 sameSite: 'strict' 增强 CSRF 保护,无需额外 CSRF Token。
  • Nginx 跨域支持

    • 配置 CORS 头,明确允许前端域名,启用 Access-Control-Allow-Credentials
    • 使用 sameSite: 'none'secure: true 支持跨域 Cookie 传输。
    • 结合 CSRF Token 防止跨站请求伪造。
  • 安全性

    • 强制 HTTPS,确保 secure: true
    • 定期轮换 Refresh Token,设置合理过期时间(如 7 天)。
    • 后端维护 Token 黑名单,防止被盗用。
  • 性能

    • 避免频繁刷新,合理设置 Access Token 过期时间(15-30 分钟)。
    • 使用内存存储 Access Token,减少磁盘操作。
  • 替代方案

    • 大数据存储使用 IndexedDB
    • 跨标签页通信使用 BroadcastChannel API
2.3.4 指导原则
  • 优先级httpOnly Cookie(Refresh Token) > 内存(Access Token) > sessionStorage(临时) > localStorage(加密备用)。
  • 避免:未加密存储敏感 Token 于 localStorage
  • 双Token机制:Access Token 短期,Refresh Token 长期,结合自动刷新提升体验。

3. Token管理系统

3.1 自动Token刷新

  • 策略:在 Access Token 过期前 5 分钟自动刷新,确保无缝用户体验。

  • 功能

    • 请求队列控制并发刷新。
    • 静默刷新,无用户干扰。
    • 刷新失败跳转登录页。
  • 实现

    let isRefreshing = false;
    const refreshQueue = [];
    
    async function refreshToken() {
      if (isRefreshing) {
        return new Promise(resolve => refreshQueue.push(resolve));
      }
      isRefreshing = true;
      try {
        const response = await fetch('https://example.com/api/refresh', {
          method: 'POST',
          credentials: 'include'
        });
        const { accessToken } = await response.json();
        setAccessToken(accessToken);
        broadcastToken(accessToken); // 同步多标签页
        refreshQueue.forEach(resolve => resolve(accessToken));
        refreshQueue.length = 0;
        return accessToken;
      } catch (error) {
        redirectToLogin();
        throw error;
      } finally {
        isRefreshing = false;
      }
    }
    
    // 调度刷新
    function scheduleRefresh(expiryTime) {
      const refreshTime = expiryTime - Date.now() - 5 * 60 * 1000;
      setTimeout(refreshToken, refreshTime);
    }
    

3.2 HTTP请求拦截器

拦截器自动处理 Token 添加和未授权响应。

import axios from 'axios';

const http = axios.create({ baseURL: 'https://example.com/api' });

http.interceptors.request.use(config => {
  const token = getAccessToken();
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

http.interceptors.response.use(
  response => response,
  async error => {
    if (error.response?.status === 401) {
      const newToken = await refreshToken();
      if (newToken) {
        error.config.headers.Authorization = `Bearer ${newToken}`;
        return http(error.config); // 重试原始请求
      }
      redirectToLogin();
    }
    throw error;
  }
);

3.3 多标签页同步

使用 BroadcastChannel 实现多标签页 Token 状态同步。

const channel = new BroadcastChannel('token_sync');
channel.onmessage = (event) => {
  setAccessToken(event.data.token); // 更新其他标签页 Token
};

// 刷新后广播
function broadcastToken(token) {
  channel.postMessage({ token });
}

4. Token生命周期

Token 生命周期确保安全认证和流畅用户体验。

flowchart TD
    A[用户登录] --> B[服务端验证]
    B --> C{验证成功?}
    C -->|是| D[生成Access Token + Refresh Token]
    C -->|否| E[返回错误信息]
    
    D --> F[设置httpOnly Cookie存储Refresh Token]
    F --> G[返回Access Token给客户端]
    G --> H[Access Token存储到内存]
    H --> I[调度自动刷新任务]
    
    I --> J[监听Token过期]
    J --> K{Token即将过期?}
    K -->|是| L[自动刷新Token]
    K -->|否| M[继续监听]
    
    L --> N[使用Refresh Token请求新Token]
    N --> O{刷新成功?}
    O -->|是| P[更新Access Token]
    O -->|否| Q[清除所有Token]
    
    P --> R[重新调度刷新任务]
    Q --> S[跳转登录页]
    
    M --> T[用户发起API请求]
    T --> U[请求拦截器添加Token]
    U --> V[发送请求到服务端]
    V --> W{Token有效?}
    W -->|是| X[返回数据]
    W -->|否| Y[返回401错误]
    
    Y --> Z[响应拦截器捕获401]
    Z --> L
    
    X --> AA[请求完成]
    R --> J

多标签页同步机制

sequenceDiagram
    participant Tab1 as 标签页1
    participant BroadcastChannel as BroadcastChannel
    participant Tab2 as 标签页2
    participant Tab3 as 标签页3
    
    Tab1->>Tab1: Token刷新成功
    Tab1->>BroadcastChannel: 广播Token更新消息
    BroadcastChannel->>Tab2: 接收Token更新
    BroadcastChannel->>Tab3: 接收Token更新
    Tab2->>Tab2: 更新本地Token状态
    Tab3->>Tab3: 更新本地Token状态
    
    Note over Tab1,Tab3: 所有标签页Token状态同步

5. 服务端配置

5.1 登录接口

  • 验证用户凭证。
  • 生成 Access Token(15分钟过期)和 Refresh Token(7天过期)。
  • 设置 httpOnly Cookie 存储 Refresh Token。
  • 返回 Access Token 和用户信息。

5.2 刷新接口

  • 验证 Refresh Token 有效性。
  • 检查 Token 黑名单。
  • 生成新的 Access 和 Refresh Token。
  • 更新 httpOnly Cookie。
  • 返回新 Token。

5.3 安全配置

res.cookie('refreshToken', refreshToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict', // 统一域名场景
  maxAge: 7 * 24 * 60 * 60 * 1000,
  path: '/'
});

6. 错误处理与性能优化

6.1 错误处理

  • 网络错误:最多重试 3 次,采用指数退避策略。
  • Token过期:捕获 401 错误,自动刷新。
  • Refresh Token失效:清除 Token,跳转登录。
  • 并发刷新:使用请求队列避免重复刷新。

6.2 性能优化

  • 缓存:Access Token 存储于内存,减少磁盘 I/O。
  • 并发控制:防抖机制处理高频 Token 验证。
  • 刷新时机:过期前 5 分钟刷新,减少不必要网络请求。

7. 最佳实践总结

  • 安全性

    • Access Token 存储于内存,Refresh Token 使用 httpOnly Cookie。
    • 使用 Nginx 统一域名,启用 sameSite: 'strict',或通过 CORS 支持跨域并结合 CSRF Token。
    • localStorage 仅用于加密后备用,并明确其安全局限性。
  • 用户体验

    • 静默刷新,无用户干扰。
    • 多标签页同步通过 BroadcastChannel
    • 网络异常自动重试。
  • 系统健壮性

    • 完善的错误处理和 Token 撤销机制。
    • 请求队列控制并发刷新。
  • 性能优化

    • 内存缓存 Access Token。
    • 优化刷新调度。
    • 防抖验证降低开销。