Skip to content

前端JWT认证与无感刷新Token实现详解

1. 引言

在现代Web应用中,JWT (JSON Web Token) 认证已成为无状态身份验证的主流选择。相比传统的基于会话的认证方式,JWT提供了更高的扩展性和灵活性。本文将深入探讨如何在前端实现JWT认证,并通过拦截器实现token的无感刷新,同时结合WebSocket连接的认证机制,构建一个完整的前端身份验证体系。

2. JWT认证原理

JWT是一种开放标准,它使用紧凑且自包含的方式,在各方之间安全地传输信息。

graph LR
    A[客户端] -->|1. 提交用户名密码| B[服务器]
    B -->|2. 验证凭据| B
    B -->|3. 生成JWT| B
    B -->|4. 返回JWT| A
    A -->|5. 存储JWT| A
    A -->|6. 后续请求携带JWT| B
    B -->|7. 验证JWT签名| B
    B -->|8. 返回受保护资源| A

JWT由三部分组成:

  1. Header: 包含令牌类型和使用的算法
  2. Payload: 包含声明(用户ID、权限等)
  3. Signature: 用于验证token未被篡改

2.1 JWT结构示例

xxxxx.yyyyy.zzzzz
  • xxxxx: Base64Url编码的Header
  • yyyyy: Base64Url编码的Payload
  • zzzzz: 使用Header中指定算法和密钥计算的签名

3. 前端登录实现与JWT存储

前端登录页面需要收集用户凭据,发送到服务器进行验证,并处理返回的JWT。

3.1 登录API封装

typescript
// 定义登录接口返回的数据类型
export interface LoginResponse {
  accessToken?: string; // 可选字段
  token?: string;       // 可选字段,某些后端API可能使用token替代accessToken
  refreshToken?: string; // 可选字段
  user?: {
    id: string;
    username: string;
    avatar: string;
    status: string;
  };
}

/**
 * 登录API
 * @param username 用户名
 * @param password 密码
 * @returns 返回包含 token 和用户信息的响应
 */
export async function loginAPI(username: string, password: string) {
  return request<ResponseData<LoginResponse>>('/api/users/login', {
    method: 'POST',
    body: JSON.stringify({ username, password }), // 将 username 和 password 放入 body 中
    headers: {
      'Content-Type': 'application/json', // 设置请求头,指明请求体的格式
    },
  });
}

3.2 登录逻辑与JWT存储

typescript
const handleLogin = async () => {
  if (!username || !password) {
    setErrorMsg('请输入用户名和密码');
    return;
  }

  try {
    const response = await loginAPI(username, password);
    console.log('登录响应:', response);
    
    // 处理直接返回token的情况和通过data属性返回token的情况
    const responseData = response.data || response;
    
    if (responseData) {
      // 存储 token 和 refreshToken
      if (responseData.accessToken) {
        localStorage.setItem('token', responseData.accessToken);
      } else if (responseData.token) {
        localStorage.setItem('token', responseData.token);
      }
      
      // 存储 refreshToken
      if (responseData.refreshToken) {
        localStorage.setItem('refreshToken', responseData.refreshToken);
      }
      
      // 存储用户 ID
      if (responseData.user && responseData.user.id) {
        localStorage.setItem('id', responseData.user.id);
      }
      
      window.location.href = '/chat';
    } else {
      setErrorMsg('登录失败,未返回 token');
    }
  } catch (error: any) {
    console.error('登录请求失败:', error);
    // 错误处理逻辑...
  }
};

3.3 JWT存储安全性思考

将JWT存储在localStorage中提供了持久化存储,但存在XSS攻击风险。考虑以下替代方案:

  1. HttpOnly Cookie: 更安全,但需要后端配合设置适当的Cookie策略
  2. 内存存储 + 刷新机制: 在内存中保存token,页面刷新时通过refresh_token重新获取
  3. 加密存储: 在localStorage中存储前先加密,但仍不能完全防范XSS
graph TD
    A[JWT存储方式] --> B[localStorage/sessionStorage]
    A --> C[HttpOnly Cookie]
    A --> D[内存存储]
    A --> E[加密存储]
    
    B --> F[优点: 方便实现, 持久存储]
    B --> G[缺点: 易受XSS攻击]
    
    C --> H[优点: 抵抗XSS攻击]
    C --> I[缺点: 需适当的CORS和Cookie策略]
    
    D --> J[优点: 抵抗XSS, 页面关闭即失效]
    D --> K[缺点: 刷新页面时需重新登录]
    
    E --> L[优点: 增加攻击难度]
    E --> M[缺点: 仍不能完全防范XSS]

4. Axios请求拦截器

请求拦截器确保每个API请求都带有最新的JWT,实现认证信息的自动化添加。

typescript
// 请求拦截器
// 在发送请求前对请求配置进行处理
request.interceptors.request.use((url, options) => {
  // 从本地存储中获取token
  const token = localStorage.getItem('token');
  
  // 设置Authorization头部,如果token存在则使用Bearer模式
  const authHeader = { Authorization: token ? `Bearer ${token}` : '' };
  
  // 返回修改后的请求配置
  return {
    url,
    options: {
      ...options,
      headers: {
        ...options.headers,
        ...authHeader, // 将Authorization头部添加到请求头中
      },
    },
  };
});

5. Token无感刷新实现

无感刷新是前端JWT认证中的关键环节,当access_token过期时,系统使用refresh_token自动更新token,无需用户重新登录。

5.1 刷新Token方法

typescript
// 添加刷新 token 的接口
export async function refreshToken(refreshTokenStr: string) {
  try {
    const response = await fetch(`${process.env.API_URL || 'http://localhost:3006'}/api/users/refresh-token`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ refreshToken: refreshTokenStr }),
    });
    
    if (!response.ok) {
      throw new Error('刷新token失败');
    }
    
    const data = await response.json();
    console.log('刷新token响应:', data);
    return data;
  } catch (error) {
    console.error('刷新token出错:', error);
    throw error;
  }
}

5.2 响应拦截器中实现无感刷新

核心是在响应拦截器中捕获401错误,然后触发token刷新流程:

typescript
// 是否正在刷新 token,用于响应拦截器
let isRefreshing = false;
// 等待 token 刷新的请求队列
let refreshSubscribers: ((token: string) => void)[] = [];

// 将请求添加到队列
// cb 是回调函数,用于在 token 刷新后执行队列中的请求
const subscribeTokenRefresh = (cb: (token: string) => void) => {
  refreshSubscribers.push(cb);
};

// 刷新 token 后执行队列中的请求
const onTokenRefreshed = (token: string) => {
  refreshSubscribers.forEach(cb => cb(token));
  refreshSubscribers = [];
};

/**
 * 响应拦截器
 * 在接收到响应后对响应数据进行处理
 */
request.interceptors.response.use(async (response) => {
  // 克隆响应以避免多次读取 body
  const res = response.clone();
  
  // 检查响应状态码
  if (res.status === 401) {
    console.log('检测到401错误,尝试刷新token');
    
    // 获取原始请求的URL和选项
    const url = response.url;
    const options = {
      method: response.request?.method || 'GET',
      headers: {
        ...Object.fromEntries(response.clone().headers.entries())
      },
      body: response.request?.body
    };
    
    // 如果是刷新 token 的请求失败,则清除 token 并跳转到登录页
    if (url.includes('/refresh-token')) {
      console.log('刷新token请求失败');
      localStorage.removeItem('token');
      localStorage.removeItem('refreshToken');
      window.location.href = '/login';
      return Promise.reject({ message: '刷新 token 失败,请重新登录' });
    }
    
    // 获取 refreshToken
    const refreshTokenStr = localStorage.getItem('refreshToken');
    if (!refreshTokenStr) {
      console.log('没有找到refreshToken,跳转到登录页');
      localStorage.removeItem('token');
      window.location.href = '/login';
      return Promise.reject({ message: '未找到刷新令牌,请重新登录' });
    }
    
    // 如果当前没有在刷新 token,则开始刷新
    if (!isRefreshing) {
      isRefreshing = true;
      console.log('开始刷新token');
      
      try {
        // 调用刷新 token 的接口
        const refreshRes = await refreshToken(refreshTokenStr);
        console.log('刷新token响应:', refreshRes);
        
        if (refreshRes && refreshRes.accessToken) {
          console.log('刷新token成功:', refreshRes);
          
          // 更新本地存储的 token
          const newToken = refreshRes.accessToken;
          localStorage.setItem('token', newToken);
          console.log('已更新token:', newToken);
          
          // 通知所有等待的请求
          onTokenRefreshed(newToken);
          
          // 重置刷新状态
          isRefreshing = false;
          
          // 使用新 token 重新发送原始请求
          console.log('使用新token重新发送请求');
          const newOptions = {
            ...options,
            headers: {
              ...options.headers,
              Authorization: `Bearer ${newToken}`,
            },
          };
          
          // 返回重新发送的请求
          return request(url, newOptions);
        } else {
          // 刷新失败,清除 token 并跳转到登录页
          console.error('刷新token失败,响应不包含新token');
          localStorage.removeItem('token');
          localStorage.removeItem('refreshToken');
          window.location.href = '/login';
          isRefreshing = false;
          return Promise.reject({ message: '刷新 token 失败,请重新登录' });
        }
      } catch (error) {
        // 刷新出错,清除 token 并跳转到登录页
        console.error('刷新token过程中出错:', error);
        localStorage.removeItem('token');
        localStorage.removeItem('refreshToken');
        window.location.href = '/login';
        isRefreshing = false;
        return Promise.reject(error);
      }
    } else {
      // 如果已经在刷新 token,则将请求加入队列
      console.log('已有刷新token请求在进行中,将当前请求加入队列');
      return new Promise((resolve) => {
        subscribeTokenRefresh((token) => {
          const newOptions = {
            ...options,
            headers: {
              ...options.headers,
              Authorization: `Bearer ${token}`,
            },
          };
          resolve(request(url, newOptions));
        });
      });
    }
  }
  
  // 处理正常响应...
  return response;
});

5.3 Token无感刷新流程

sequenceDiagram
    participant A as 客户端
    participant B as 请求拦截器
    participant C as API服务器
    participant D as 响应拦截器
    participant E as Token刷新服务
    
    A->>B: 发起API请求
    B->>B: 添加Authorization头
    B->>C: 发送请求
    C->>D: 返回401(token过期)
    
    rect rgb(191, 223, 255)
    note over D: Token刷新流程开始
    
    alt 首次遇到401
        D->>E: 发送刷新token请求
        E->>D: 返回新token
        D->>B: 更新本地token
        D->>D: 通知等待队列
        D->>C: 使用新token重发原请求
        C->>A: 返回API响应
    else 已有刷新请求进行中
        D->>D: 将请求加入等待队列
        D->>D: 等待token刷新完成
        D->>C: 收到新token后重发请求
        C->>A: 返回API响应
    end
    
    end

6. WebSocket连接中的JWT认证

WebSocket建立长连接时同样需要进行身份认证,确保只有授权用户才能建立连接和收发消息。

typescript
// 创建WebSocket连接
this.socket = io('http://localhost:3006', {
  transports: ['websocket'], // 使用websocket传输
  autoConnect: true, // 自动连接
  path: '/socket.io', // socket.io的路径
  auth: {
    token: this.token // 认证token
  },
  query: {
    userId: this.userId // 用户ID
  }
});

6.1 WebSocket认证与心跳机制

WebSocket连接后,通过心跳机制维持连接活跃,同时心跳消息中也携带了用户身份信息:

typescript
// 创建心跳消息
const message = HeartbeatMessage.create({
  userId: this.userId, // 用户ID
  timestamp: Date.now(), // 当前时间戳
  type: type === 'PING' ? 0 : 2, // 心跳类型
  retryCount: retryCount // 重试计数
});

// 编码消息
const buffer = HeartbeatMessage.encode(message).finish();

// 发送心跳
this.socket.emit('heartbeat', buffer);

7. JWT安全策略与最佳实践

7.1 多层次安全策略

mindmap
  root((JWT安全策略))
    Token管理
      短期有效的access_token
      长期有效的refresh_token
      实现token轮换
    存储安全
      敏感信息不存入Payload
      考虑使用HttpOnly Cookie
      加密存储refresh_token
    传输安全
      使用HTTPS
      仅在必要时传输token
    token验证
      验证签名
      检查过期时间
      验证颁发者
    权限控制
      基于角色的访问控制
      细粒度权限设计
      最小权限原则

7.2 最佳实践

  1. Access Token短期有效: 通常设置为15-30分钟
  2. Refresh Token轮换: 每次刷新后生成新的refresh token
  3. 合理的错误处理: 提供友好的用户体验
  4. 防止并发刷新: 使用锁或标志位防止多个请求同时触发刷新
  5. 监控与日志: 记录token使用和刷新情况,便于排查问题

8. JWT分析与解码

前端可以解析JWT,查看其中包含的信息,用于调试和开发:

typescript
// 首先,我们检查是否存在 token
if (token) {
  try {
    // 将 token 按照 '.' 分割,获取第二部分,这部分通常是 JWT 的负载部分
    const base64Url = token.split('.')[1];
    
    // 将 base64Url 中的字符进行替换,以符合 base64 的标准格式
    const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
    
    // 使用 atob 函数将 base64 字符串解码为原始字符串
    // 然后使用 decodeURIComponent 处理字符串中的编码
    const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
      return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
    }).join(''));
    
    // 将解码后的 JSON 字符串解析为对象
    const payload = JSON.parse(jsonPayload);
    
    // 输出 token 的过期时间
    console.log('Token 过期时间:', new Date(payload.exp * 1000));
    
    // 输出当前时间
    console.log('当前时间:', new Date());
    
    // 计算并输出 token 剩余的有效时间(以秒为单位)
    console.log('剩余时间(秒):', payload.exp - Math.floor(Date.now() / 1000));
  } catch (error) {
    // 如果解析过程中发生错误,输出错误信息
    console.error('解析 token 失败:', error);
  }
}

9. 完整的JWT认证流程图

flowchart TD
    A[用户登录] --> B{验证成功?}
    B -->|是| C[服务器生成JWT]
    B -->|否| D[返回错误]
    C --> E[返回access_token和refresh_token]
    E --> F[本地存储tokens]
    F --> G[用户访问受保护资源]
    G --> H[请求拦截器添加Authorization头]
    H --> I[发送API请求]
    I --> J{响应状态}
    J -->|200成功| K[返回数据]
    J -->|401未授权| L{是否正在刷新token?}
    L -->|是| M[将请求加入队列]
    L -->|否| N[使用refresh_token获取新token]
    N --> O{刷新成功?}
    O -->|是| P[更新本地token]
    O -->|否| Q[清除tokens并跳转登录页]
    P --> R[重发原始请求]
    R --> K
    M --> S[等待token刷新完成]
    S --> R
    
    %% WebSocket认证流程
    F --> T[初始化WebSocket连接]
    T --> U[连接时携带token]
    U --> V{WebSocket认证}
    V -->|成功| W[启动心跳机制]
    V -->|失败| X[WebSocket连接断开]
    X --> Q
    W --> Y[定期发送心跳]
    Y --> Z[保持连接活跃]

10. 总结与思考

通过本文,我们详细介绍了前端JWT认证的实现方式,包括登录认证、token存储、请求拦截、无感刷新和WebSocket认证。以下是一些关键的思考点:

10.1 权衡与取舍

graph TD
    subgraph 高安全性/复杂实现
        D["多因素认证 (高安全/最复杂)"]
        E["OAuth 2.0 + PKCE (高安全/复杂)"]
        F["JWT + 双token + 无感刷新 (较高安全/中等复杂)"]
    end
    
    subgraph 中等安全性/中等复杂度
        G["JWT + HttpOnly Cookie (中高安全/中等复杂)"]
        H["基于Cookie的会话认证 (中等安全/中等简单)"]
    end
    
    subgraph 低安全性/简单实现
        I["JWT + localStorage (低安全/简单实现)"]
    end

10.2 未来发展方向

  1. 统一身份认证标准: 如OAuth 2.1和FIDO2
  2. 零信任架构: 不再隐式信任网络内部的用户
  3. 去中心化身份: 基于区块链的自主身份识别
  4. 无密码认证: 生物识别和硬件token代替传统密码

10.3 开发建议

  • 充分测试token刷新逻辑,特别是并发场景
  • 开发环境和生产环境使用不同的token有效期策略
  • 考虑添加token使用情况的分析功能
  • 设计用户友好的会话过期处理流程

11. 参考资料

  1. JWT官方文档
  2. OAuth 2.0规范
  3. OWASP认证最佳实践
  4. IETF RFC 7519 - JSON Web Token