Token认证的实现流程

从登录、Access Token、Refresh Token 到刷新重试,解释 Token 认证链路及拦截器中的循环刷新陷阱与解决策略。

#type / howto #status / growing #tech / dev / backend #resource / nodejs #resource / jwt

Token 认证的循环拦截问题与 X-Skip-Auth 解决方案

[!info] related notes

目标

这篇不只是在讲一个前端拦截器小技巧,而是在讲一整条 Token 认证链路:

  • 登录时如何签发
  • 请求时如何携带
  • 过期后如何刷新
  • 刷新时为什么容易出现循环拦截

也就是说,这篇的重点是:

Access Token / Refresh Token 体系在真实工程里怎么跑,以及它最容易出事故的地方在哪里。

基础概念

什么是 Token 认证?

Token 认证是一种无状态的认证机制,常用于现代 Web 应用中:

┌─────────┐                        ┌─────────┐
│ Client  │  1. Login Request      │ Server  │
│         │ ─────────────────────> │         │
│         │                        │         │
│         │  2. Access Token +     │         │
│         │     Refresh Token      │         │
│         │ <───────────────────── │         │
│         │                        │         │
│         │  3. API Request +      │         │
│         │     Access Token       │         │
│         │ ─────────────────────> │         │
│         │                        │         │
│         │  4. Response           │         │
│         │ <───────────────────── │         │
└─────────┘                        └─────────┘

核心组件:

  1. Access Token(访问令牌)

    • 短期有效(通常 1 小时)
    • 用于日常 API 请求
    • 存储在内存或 localStorage
    • 格式:JWT (JSON Web Token)
  2. Refresh Token(刷新令牌)

    • 长期有效(通常 7-30 天)
    • 仅用于刷新 Access Token
    • 更严格的安全要求
    • 格式:JWT 或随机字符串

JWT 的结构

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50VXVpZCI6IjEyMyIsInR5cGUiOiJhY2Nlc3MiLCJpYXQiOjE3NjI4MzczMjEsImV4cCI6MTc2Mjg0MDkyMX0.cupOnGEwN-kaqlcyWc8fIYnQM1rVS2quBphNC4PSlKk

├─ Header (算法和类型)
│  { "alg": "HS256", "typ": "JWT" }

├─ Payload (声明/数据)
│  {
│    "accountUuid": "123",
│    "type": "access",
│    "iat": 1762837321,  // 签发时间
│    "exp": 1762840921   // 过期时间
│  }

└─ Signature (签名)
   HMAC-SHA256(header + payload, secret)

Token 刷新流程

┌─────────┐                        ┌─────────┐
│ Client  │                        │ Server  │
└────┬────┘                        └────┬────┘
     │                                  │
     │  1. API Request (Access Token)  │
     ├─────────────────────────────────>│
     │                                  │
     │  2. 401 Unauthorized (过期)      │
     │<─────────────────────────────────┤
     │                                  │
     │  3. Refresh Request              │
     │     (Refresh Token)              │
     ├─────────────────────────────────>│
     │                                  │
     │  4. New Access Token +           │
     │     New Refresh Token            │
     │<─────────────────────────────────┤
     │                                  │
     │  5. Retry Original Request       │
     │     (New Access Token)           │
     ├─────────────────────────────────>│
     │                                  │
     │  6. Success Response             │
     │<─────────────────────────────────┤
│                                  │

服务端在这条链路里真正负责什么

  • 校验登录凭证
  • 签发 Access Token 和 Refresh Token
  • 校验 Access Token 的签名与过期时间
  • 刷新时验证 Refresh Token 是否仍然有效
  • 在登出、踢下线、权限变化时决定怎样撤销旧会话

所以如果面试官追问“Token 方案的控制点在哪里”,更稳的回答通常不是“JWT 怎么解码”,而是:

短期 Access Token 负责高频请求,长期 Refresh Token 负责续期和可撤销性。

使用指南

问题:Token 刷新的循环拦截陷阱

场景描述

在 SPA(单页应用)中,通常使用 HTTP 拦截器(Axios Interceptor)来:

  1. 请求拦截器:自动在每个请求中添加 Authorization: Bearer <access_token>
  2. 响应拦截器:检测 401 错误,自动刷新 token 并重试

问题出现:

// ❌ 危险:会导致无限循环!
axios.interceptors.request.use((config) => {
  const token = getAccessToken();
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      // 🔴 问题:这个刷新请求也会被请求拦截器拦截!
      const newToken = await axios.post('/auth/refresh', {
        refreshToken: getRefreshToken()
      });
      // ...重试逻辑
    }
  }
);

循环流程:

1. 用户请求 /api/goals (Access Token 过期)

2. 响应 401,触发 Token 刷新

3. 发送刷新请求 POST /auth/refresh

4. 请求拦截器发现有 Access Token(过期的)

5. 自动添加 Authorization: Bearer <expired_token>

6. 服务端验证 Token 失败,返回 401

7. 响应拦截器再次触发刷新...

8. 🔄 无限循环!

解决方案:X-Skip-Auth 标记

核心思想: 在刷新请求中添加一个特殊的请求头,告诉拦截器”跳过认证处理”。

前端实现

// apps/web/src/shared/api/core/interceptors.ts

export class InterceptorManager {
  private setupRequestInterceptors(): void {
    this.instance.interceptors.request.use((config) => {
      // ✅ 检查是否有跳过认证的标记
      if (config.headers?.['X-Skip-Auth'] === 'true') {
        // 有标记,跳过认证处理
        delete config.headers['X-Skip-Auth']; // 清理标记
        return config;
      }

      // 正常流程:添加 Access Token
      if (this.config.enableAuth && AuthManager.isAuthenticated()) {
        const token = AuthManager.getAccessToken();
        if (token) {
          config.headers.Authorization = `Bearer ${token}`;
        }
      }

      return config;
    });
  }

  private setupResponseInterceptors(): void {
    this.instance.interceptors.response.use(
      (response) => response,
      async (error) => {
        if (error.response?.status === 401) {
          // Token 过期,需要刷新
          const newToken = await this.refreshAccessToken();
          // 重试原始请求
          return this.instance(error.config);
        }
      }
    );
  }

  /**
   * 刷新访问令牌
   */
  private async refreshAccessToken(): Promise<string> {
    const refreshToken = AuthManager.getRefreshToken();

    if (!refreshToken) {
      throw new Error('No refresh token available');
    }

    // ✅ 关键:添加 X-Skip-Auth 标记
    const response = await this.instance.post(
      '/auth/refresh',
      { refreshToken },
      {
        headers: {
          'X-Skip-Auth': 'true', // 🎯 告诉请求拦截器跳过认证
        },
      }
    );

    const { accessToken, refreshToken: newRefreshToken, expiresIn } = response.data;

    // 更新本地存储的 Token
    AuthManager.setTokens(accessToken, newRefreshToken, undefined, expiresIn);

    return accessToken;
  }
}

后端 CORS 配置

由于 X-Skip-Auth 是自定义请求头,需要在服务端的 CORS 配置中明确允许:

// apps/api/src/app.ts

app.use(
  cors({
    origin: ['http://localhost:5173', 'http://127.0.0.1:5173'],
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
    allowedHeaders: [
      'Content-Type',
      'Authorization',
      'X-Skip-Auth',      // ✅ 必须明确允许这个自定义头
      'Cache-Control',
    ],
    exposedHeaders: ['Content-Length', 'Content-Type'],
    maxAge: 86400, // 24 hours
  })
);

CORS Preflight Request:

浏览器:我要发送一个带有 X-Skip-Auth 头的请求,可以吗?
  ↓ OPTIONS /auth/refresh
服务端:可以,这些头我都允许:Content-Type, Authorization, X-Skip-Auth
  ↓ Access-Control-Allow-Headers: Content-Type, Authorization, X-Skip-Auth
浏览器:好的,那我发送真正的请求
  ↓ POST /auth/refresh (带 X-Skip-Auth: true)

这里真正要记住的不是 X-Skip-Auth 这个名字,而是一个更一般化的原则:

刷新请求必须被系统识别成“特殊请求”,否则它就会再次命中认证注入和 401 自动刷新逻辑,把自己递归套进去。


实战经验

Access Token 和 Refresh Token 不要混用职责

更稳的分工通常是:

  • Access Token:短期有效,高频使用
  • Refresh Token:长期有效,低频使用,承担续期与撤销控制

如果两者都被当成“长期万能凭证”,问题通常会出在:

  • 主动失效困难
  • 设备会话难管理
  • 被盗后的风险窗口太长

刷新失败一定要有收口

如果 refresh 失败,不应该无限重试。

更合理的策略通常是:

  1. 清掉本地登录态
  2. 记录日志或埋点
  3. 跳回登录页或要求重新认证

并发 401 是真实工程问题

真实项目里常见的不是“一个请求刚好过期”,而是一批请求同时收到 401。

因此除了避免循环,还要考虑:

  • 同一时刻只发一个 refresh 请求
  • 其他请求先排队等待结果
  • 刷新成功后再统一重试

否则很容易从“循环拦截”升级成“并发刷新风暴”。

完整的认证流程实现

1. Token 管理类

// AuthManager.ts

class AuthManager {
  private static ACCESS_TOKEN_KEY = 'access_token';
  private static REFRESH_TOKEN_KEY = 'refresh_token';
  private static TOKEN_EXPIRY_KEY = 'token_expiry';

  /**
   * 设置 Tokens
   */
  static setTokens(
    accessToken: string,
    refreshToken: string,
    accountUuid?: string,
    expiresIn?: number
  ): void {
    localStorage.setItem(this.ACCESS_TOKEN_KEY, accessToken);
    localStorage.setItem(this.REFRESH_TOKEN_KEY, refreshToken);

    if (expiresIn) {
      const expiryTime = Date.now() + expiresIn * 1000;
      localStorage.setItem(this.TOKEN_EXPIRY_KEY, expiryTime.toString());
    }
  }

  /**
   * 获取 Access Token
   */
  static getAccessToken(): string | null {
    return localStorage.getItem(this.ACCESS_TOKEN_KEY);
  }

  /**
   * 获取 Refresh Token
   */
  static getRefreshToken(): string | null {
    return localStorage.getItem(this.REFRESH_TOKEN_KEY);
  }

  /**
   * 检查是否已认证
   */
  static isAuthenticated(): boolean {
    const token = this.getAccessToken();
    return !!token && !this.isTokenExpired();
  }

  /**
   * 检查 Token 是否过期
   */
  static isTokenExpired(): boolean {
    const expiry = localStorage.getItem(this.TOKEN_EXPIRY_KEY);
    if (!expiry) return false;
    return Date.now() >= parseInt(expiry);
  }

  /**
   * 清除所有 Tokens
   */
  static clearTokens(): void {
    localStorage.removeItem(this.ACCESS_TOKEN_KEY);
    localStorage.removeItem(this.REFRESH_TOKEN_KEY);
    localStorage.removeItem(this.TOKEN_EXPIRY_KEY);
  }
}

2. 拦截器管理器(完整版)

// InterceptorManager.ts

export class InterceptorManager {
  private instance: AxiosInstance;
  private isRefreshing = false;
  private failedQueue: Array<{
    resolve: (token: string) => void;
    reject: (error: any) => void;
  }> = [];

  constructor(axiosInstance: AxiosInstance) {
    this.instance = axiosInstance;
    this.setupInterceptors();
  }

  /**
   * 设置请求拦截器
   */
  private setupRequestInterceptors(): void {
    this.instance.interceptors.request.use(
      (config) => {
        // 🎯 检查跳过认证标记
        if (config.headers?.['X-Skip-Auth'] === 'true') {
          delete config.headers['X-Skip-Auth'];
          return config;
        }

        // 添加 Access Token
        const token = AuthManager.getAccessToken();
        if (token && AuthManager.isAuthenticated()) {
          config.headers.Authorization = `Bearer ${token}`;
        }

        return config;
      },
      (error) => Promise.reject(error)
    );
  }

  /**
   * 设置响应拦截器
   */
  private setupResponseInterceptors(): void {
    this.instance.interceptors.response.use(
      (response) => response,
      async (error) => {
        const originalRequest = error.config;

        // 不是 401 或已经重试过,直接拒绝
        if (error.response?.status !== 401 || originalRequest._retry) {
          return Promise.reject(error);
        }

        // 🔄 Token 刷新流程
        if (this.isRefreshing) {
          // 正在刷新,加入队列
          return new Promise((resolve, reject) => {
            this.failedQueue.push({ resolve, reject });
          }).then((token) => {
            originalRequest.headers.Authorization = `Bearer ${token}`;
            return this.instance(originalRequest);
          });
        }

        originalRequest._retry = true;
        this.isRefreshing = true;

        try {
          // 刷新 Token
          const newToken = await this.refreshAccessToken();

          // 处理队列中的请求
          this.processQueue(null, newToken);

          // 重试原始请求
          originalRequest.headers.Authorization = `Bearer ${newToken}`;
          return this.instance(originalRequest);
        } catch (refreshError) {
          // 刷新失败,清理队列并跳转登录
          this.processQueue(refreshError, null);
          await this.handleUnauthorized();
          return Promise.reject(error);
        } finally {
          this.isRefreshing = false;
        }
      }
    );
  }

  /**
   * 刷新 Access Token
   */
  private async refreshAccessToken(): Promise<string> {
    const refreshToken = AuthManager.getRefreshToken();

    if (!refreshToken) {
      throw new Error('No refresh token available');
    }

    try {
      // 🎯 使用 X-Skip-Auth 避免循环拦截
      const response = await this.instance.post(
        '/auth/refresh',
        { refreshToken },
        {
          headers: {
            'X-Skip-Auth': 'true',
          },
        }
      );

      const { accessToken, refreshToken: newRefreshToken, expiresIn } = response.data;

      // 更新 Token
      AuthManager.setTokens(accessToken, newRefreshToken, undefined, expiresIn);

      return accessToken;
    } catch (error) {
      console.error('Token refresh failed:', error);
      throw error;
    }
  }

  /**
   * 处理队列中的请求
   */
  private processQueue(error: any, token: string | null): void {
    this.failedQueue.forEach((promise) => {
      if (error) {
        promise.reject(error);
      } else if (token) {
        promise.resolve(token);
      }
    });

    this.failedQueue = [];
  }

  /**
   * 处理未授权错误
   */
  private async handleUnauthorized(): Promise<void> {
    AuthManager.clearTokens();
    
    // 跳转到登录页
    if (typeof window !== 'undefined') {
      const currentPath = window.location.pathname;
      window.location.href = `/auth?redirect=${encodeURIComponent(currentPath)}`;
    }
  }
}

3. 后端 Token 生成

// apps/api/src/modules/authentication/application/services/SessionManagementApplicationService.ts

import jwt from 'jsonwebtoken';

class SessionManagementApplicationService {
  /**
   * 生成访问令牌和刷新令牌
   */
  private generateTokens(): {
    accessToken: string;
    refreshToken: string;
    expiresAt: number;
  } {
    const secret = process.env.JWT_SECRET || 'default-secret';
    const accessTokenExpiresIn = 3600; // 1 hour
    const refreshTokenExpiresIn = 7 * 24 * 3600; // 7 days
    const expiresAt = Date.now() + accessTokenExpiresIn * 1000;

    // 🎯 生成 Access Token
    const accessToken = jwt.sign(
      {
        type: 'access',
        iat: Math.floor(Date.now() / 1000),
      },
      secret,
      { expiresIn: accessTokenExpiresIn }
    );

    // 🎯 生成 Refresh Token(不同的 payload)
    const refreshToken = jwt.sign(
      {
        type: 'refresh',
        iat: Math.floor(Date.now() / 1000),
        purpose: 'token-refresh', // 额外的声明
      },
      secret,
      { expiresIn: refreshTokenExpiresIn }
    );

    return { accessToken, refreshToken, expiresAt };
  }

  /**
   * 刷新会话
   */
  async refreshSession(request: RefreshSessionRequest): Promise<RefreshSessionResponse> {
    // 1. 查询会话
    const session = await this.sessionRepository.findByRefreshToken(request.refreshToken);
    if (!session) {
      throw new Error('Session not found or expired');
    }

    // 2. 验证会话有效性
    const isValid = this.authenticationDomainService.validateSession(session);
    if (!isValid) {
      throw new Error('Session is invalid or expired');
    }

    // 3. 生成新的令牌
    const { accessToken, refreshToken, expiresAt } = this.generateTokens();

    // 4. 更新会话
    session.refreshAccessToken(accessToken, 60);
    session.refreshRefreshToken();

    // 5. 持久化
    await this.sessionRepository.save(session);

    // 6. 返回新的 Tokens
    return {
      success: true,
      session: {
        sessionUuid: session.uuid,
        accessToken,
        refreshToken,
        expiresAt,
      },
      message: 'Session refreshed successfully',
    };
  }
}

常见问题与解决

问题 1:CORS Preflight 失败

错误信息:

Access to XMLHttpRequest at 'http://localhost:3888/api/v1/auth/refresh' 
from origin 'http://127.0.0.1:5173' has been blocked by CORS policy: 
Request header field x-skip-auth is not allowed by Access-Control-Allow-Headers 
in preflight response.

原因: 自定义请求头 X-Skip-Auth 没有在 CORS allowedHeaders 中声明。

解决:

// apps/api/src/app.ts
app.use(
  cors({
    origin: ['http://localhost:5173', 'http://127.0.0.1:5173'],
    allowedHeaders: [
      'Content-Type',
      'Authorization',
      'X-Skip-Auth',      // ✅ 添加这一行
      'Cache-Control',
    ],
  })
);

问题 2:Token 刷新后仍然返回 401

原因: 刷新请求本身也被拦截器添加了过期的 Access Token。

解决: 确保 X-Skip-Auth 标记被正确处理:

// ✅ 正确
if (config.headers?.['X-Skip-Auth'] === 'true') {
  delete config.headers['X-Skip-Auth'];  // 清理标记
  return config;  // 直接返回,不添加 Authorization
}

// ❌ 错误
if (config.headers?.['X-Skip-Auth'] === 'true') {
  // 忘记 return,继续执行后面的代码
}
// 后面的代码仍然会添加 Authorization 头

问题 3:多个请求同时触发刷新

场景: 用户同时发起多个 API 请求,Access Token 刚好过期。

问题: 每个请求都会触发 Token 刷新,导致多次刷新请求。

解决: 使用队列机制:

private isRefreshing = false;
private failedQueue: Array<{ resolve, reject }> = [];

// 第一个 401 请求
if (this.isRefreshing) {
  // 已经在刷新,加入队列等待
  return new Promise((resolve, reject) => {
    this.failedQueue.push({ resolve, reject });
  });
}

// 开始刷新
this.isRefreshing = true;
const newToken = await this.refreshAccessToken();

// 刷新成功,处理队列中的所有请求
this.failedQueue.forEach(({ resolve }) => {
  resolve(newToken);
});
this.failedQueue = [];
this.isRefreshing = false;

经验总结

核心要点

  1. X-Skip-Auth 的本质

    • 它是一个”标记”,不是”权限”
    • 告诉拦截器”这个请求很特殊,别管它”
    • 只在前端→后端的刷新请求中使用
  2. 为什么需要它?

    • 防止无限循环:刷新请求不应该被认证拦截器拦截
    • 避免鸡生蛋问题:刷新 Token 的请求不需要 Access Token
  3. 安全考虑

    • X-Skip-Auth 只是跳过”自动添加 Authorization 头”
    • 刷新请求仍然需要 Refresh Token 认证
    • 服务端不会因为 X-Skip-Auth 而放松验证

架构建议

┌─────────────────────────────────────────────────────────┐
│                   前端架构                                │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  🔐 AuthManager (Token 存储和管理)                       │
│      ├─ setTokens()                                     │
│      ├─ getAccessToken()                                │
│      ├─ getRefreshToken()                               │
│      ├─ isAuthenticated()                               │
│      └─ clearTokens()                                   │
│                                                          │
│  🔄 InterceptorManager (请求/响应拦截)                   │
│      ├─ 请求拦截器                                       │
│      │   ├─ 检查 X-Skip-Auth 标记                       │
│      │   └─ 自动添加 Authorization 头                   │
│      │                                                   │
│      └─ 响应拦截器                                       │
│          ├─ 检测 401 错误                               │
│          ├─ 触发 Token 刷新 (带 X-Skip-Auth)            │
│          ├─ 队列管理并发请求                             │
│          └─ 重试原始请求                                 │
│                                                          │
│  🎯 API Client (业务接口调用)                            │
│      ├─ login()                                         │
│      ├─ getGoals()                                      │
│      └─ updateProfile()                                 │
│                                                          │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                   后端架构                                │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  🌐 CORS 配置                                            │
│      └─ allowedHeaders: ['X-Skip-Auth', ...]           │
│                                                          │
│  🔐 Auth Middleware (JWT 验证)                           │
│      ├─ 提取 Authorization header                       │
│      ├─ 验证 JWT signature                              │
│      ├─ 检查过期时间                                     │
│      └─ 注入用户信息到 req.user                          │
│                                                          │
│  🎯 Auth Routes                                          │
│      ├─ POST /auth/login (生成 Tokens)                  │
│      ├─ POST /auth/refresh (刷新 Tokens)                │
│      └─ POST /auth/logout (撤销 Session)                │
│                                                          │
│  🛡️ Protected Routes (需要认证)                         │
│      ├─ GET /api/goals                                  │
│      ├─ POST /api/tasks                                 │
│      └─ ...                                             │
│                                                          │
└─────────────────────────────────────────────────────────┘

最佳实践

✅ DO(推荐)

  1. 使用标记而非特殊路由

    // ✅ 好:使用 X-Skip-Auth 标记
    axios.post('/auth/refresh', data, {
      headers: { 'X-Skip-Auth': 'true' }
    });
    
    // ❌ 差:为刷新创建特殊路由
    axios.post('/auth/refresh-without-auth', data);
  2. 清理标记以避免泄露

    // ✅ 好:检查后删除标记
    if (config.headers?.['X-Skip-Auth'] === 'true') {
      delete config.headers['X-Skip-Auth'];
      return config;
    }
    
    // ❌ 差:保留标记发送到服务器
    if (config.headers?.['X-Skip-Auth'] === 'true') {
      return config; // 标记会被发送到服务器
    }
  3. 使用队列管理并发刷新

    // ✅ 好:只刷新一次
    if (this.isRefreshing) {
      return new Promise((resolve, reject) => {
        this.failedQueue.push({ resolve, reject });
      });
    }
    
    // ❌ 差:每个请求都刷新
    const newToken = await refreshAccessToken();
  4. 刷新失败后清理状态

    // ✅ 好:清理并跳转登录
    try {
      await refreshAccessToken();
    } catch (error) {
      AuthManager.clearTokens();
      router.push('/auth');
    } finally {
      this.isRefreshing = false;
    }
    
    // ❌ 差:不清理,用户还以为自己登录了
    try {
      await refreshAccessToken();
    } catch (error) {
      // 什么都不做
    }

❌ DON’T(避免)

  1. 不要在服务端使用 X-Skip-Auth 做权限判断

    // ❌ 错误:服务端不应该依赖这个标记
    if (req.headers['x-skip-auth']) {
      return next(); // 跳过认证?绝对不行!
    }
  2. 不要在所有请求中添加 X-Skip-Auth

    // ❌ 错误:破坏了认证体系
    axios.defaults.headers.common['X-Skip-Auth'] = 'true';
  3. 不要忘记 CORS 配置

    // ❌ 错误:忘记在 allowedHeaders 中添加
    cors({
      allowedHeaders: ['Content-Type', 'Authorization'],
      // X-Skip-Auth 不在列表中,浏览器会阻止请求
    });

调试技巧

1. 浏览器开发者工具

Network 面板:

查看 Preflight Request (OPTIONS):
  Request Headers:
    Access-Control-Request-Headers: x-skip-auth, content-type
  
  Response Headers:
    Access-Control-Allow-Headers: Content-Type, Authorization, X-Skip-Auth
    ✅ 如果包含 X-Skip-Auth,说明 CORS 配置正确

Console 面板:

// 查看 Token 内容(不验证签名)
function decodeJWT(token) {
  const base64Url = token.split('.')[1];
  const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
  return JSON.parse(window.atob(base64));
}

const accessToken = localStorage.getItem('access_token');
const refreshToken = localStorage.getItem('refresh_token');

console.log('Access Token:', decodeJWT(accessToken));
console.log('Refresh Token:', decodeJWT(refreshToken));

2. 日志记录

// 前端拦截器日志
this.instance.interceptors.request.use((config) => {
  console.log('🔵 [Request]', {
    url: config.url,
    method: config.method,
    hasAuth: !!config.headers?.Authorization,
    skipAuth: config.headers?.['X-Skip-Auth'],
  });
  return config;
});

this.instance.interceptors.response.use(
  (response) => {
    console.log('✅ [Response]', {
      url: response.config.url,
      status: response.status,
    });
    return response;
  },
  (error) => {
    console.error('❌ [Error]', {
      url: error.config?.url,
      status: error.response?.status,
      message: error.message,
    });
    return Promise.reject(error);
  }
);

信息参考

相关文档

  1. 本项目文档

    • /docs/troubleshooting/EXPRESS_ROUTE_AUTH_MIDDLEWARE_ISSUE.md - 路由认证问题
    • /docs/modules/auth-flows/USER_LOGIN_FLOW.md - 用户登录流程
    • /docs/packages/utils/SSE_TOKEN_AUTH_IMPLEMENTATION.md - SSE Token 认证
  2. 代码实现

    • /apps/web/src/shared/api/core/interceptors.ts - 前端拦截器
    • /apps/api/src/app.ts - CORS 配置
    • /apps/api/src/modules/authentication/ - 认证模块

外部资源

  1. JWT 规范

  2. CORS 规范

  3. Axios 文档

工具推荐

  1. JWT 调试

  2. API 测试

  3. 浏览器扩展


附录:完整示例代码

前端完整实现

// src/shared/api/core/interceptors.ts

import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import { AuthManager } from './auth-manager';

interface ExtendedAxiosRequestConfig extends AxiosRequestConfig {
  _retry?: boolean;
  metadata?: {
    requestId: string;
    startTime: number;
  };
}

export class InterceptorManager {
  private instance: AxiosInstance;
  private isRefreshing = false;
  private failedQueue: Array<{
    resolve: (token: string) => void;
    reject: (error: any) => void;
  }> = [];
  private requestId = 0;

  constructor(axiosInstance: AxiosInstance) {
    this.instance = axiosInstance;
    this.setupInterceptors();
  }

  private setupInterceptors(): void {
    this.setupRequestInterceptors();
    this.setupResponseInterceptors();
  }

  private setupRequestInterceptors(): void {
    this.instance.interceptors.request.use(
      (config: ExtendedAxiosRequestConfig) => {
        // 生成请求ID
        const requestId = `req-${++this.requestId}-${Date.now()}`;
        config.metadata = { requestId, startTime: Date.now() };

        // 🎯 检查跳过认证标记
        if (config.headers?.['X-Skip-Auth'] === 'true') {
          console.log('🔓 [Auth] Skipping auth for refresh request');
          delete config.headers['X-Skip-Auth'];
          return config;
        }

        // 添加认证头
        if (AuthManager.isAuthenticated()) {
          const token = AuthManager.getAccessToken();
          if (token) {
            config.headers = config.headers || {};
            config.headers.Authorization = `Bearer ${token}`;
          }
        }

        console.log('🔵 [Request]', {
          requestId,
          method: config.method?.toUpperCase(),
          url: config.url,
          hasAuth: !!config.headers?.Authorization,
        });

        return config;
      },
      (error) => {
        console.error('❌ [Request Error]', error);
        return Promise.reject(error);
      }
    );
  }

  private setupResponseInterceptors(): void {
    this.instance.interceptors.response.use(
      (response) => {
        const requestId = response.config.metadata?.requestId;
        console.log('✅ [Response]', {
          requestId,
          status: response.status,
          url: response.config.url,
        });
        return response;
      },
      async (error) => {
        const originalRequest = error.config as ExtendedAxiosRequestConfig;

        console.error('❌ [Response Error]', {
          requestId: originalRequest.metadata?.requestId,
          status: error.response?.status,
          url: originalRequest.url,
        });

        // 不是 401 错误,或已经重试过
        if (error.response?.status !== 401 || originalRequest._retry) {
          return Promise.reject(error);
        }

        // 🔄 Token 刷新流程
        if (this.isRefreshing) {
          console.log('⏳ [Auth] Refresh in progress, queueing request');
          return new Promise((resolve, reject) => {
            this.failedQueue.push({ resolve, reject });
          }).then((token) => {
            originalRequest.headers.Authorization = `Bearer ${token}`;
            return this.instance(originalRequest);
          });
        }

        originalRequest._retry = true;
        this.isRefreshing = true;

        try {
          console.log('🔄 [Auth] Starting token refresh');
          const newToken = await this.refreshAccessToken();
          console.log('✅ [Auth] Token refreshed successfully');

          // 处理队列
          this.processQueue(null, newToken);

          // 重试原始请求
          originalRequest.headers.Authorization = `Bearer ${newToken}`;
          return this.instance(originalRequest);
        } catch (refreshError) {
          console.error('❌ [Auth] Token refresh failed', refreshError);
          this.processQueue(refreshError, null);
          await this.handleUnauthorized();
          return Promise.reject(error);
        } finally {
          this.isRefreshing = false;
        }
      }
    );
  }

  /**
   * 刷新 Access Token
   */
  private async refreshAccessToken(): Promise<string> {
    const refreshToken = AuthManager.getRefreshToken();

    if (!refreshToken) {
      throw new Error('No refresh token available');
    }

    try {
      // 🎯 使用 X-Skip-Auth 标记避免循环拦截
      const response = await this.instance.post(
        '/auth/refresh',
        { refreshToken },
        {
          headers: {
            'X-Skip-Auth': 'true',
          },
        }
      );

      const { accessToken, refreshToken: newRefreshToken, expiresIn } = response.data;

      // 更新本地存储
      AuthManager.setTokens(accessToken, newRefreshToken, undefined, expiresIn);

      return accessToken;
    } catch (error) {
      console.error('Token refresh failed:', error);
      throw error;
    }
  }

  /**
   * 处理队列中的请求
   */
  private processQueue(error: any, token: string | null): void {
    this.failedQueue.forEach((promise) => {
      if (error) {
        promise.reject(error);
      } else if (token) {
        promise.resolve(token);
      }
    });

    this.failedQueue = [];
  }

  /**
   * 处理未授权错误
   */
  private async handleUnauthorized(): Promise<void> {
    console.warn('🔒 [Auth] Unauthorized, clearing tokens and redirecting to login');
    AuthManager.clearTokens();

    // 跳转到登录页
    if (typeof window !== 'undefined') {
      const currentPath = window.location.pathname;
      window.location.href = `/auth?redirect=${encodeURIComponent(currentPath)}`;
    }
  }
}

后端完整实现

// apps/api/src/app.ts (CORS 配置)

import cors from 'cors';
import express from 'express';

const app = express();

// CORS 配置
app.use(
  cors({
    origin: [
      'http://localhost:5173',
      'http://127.0.0.1:5173',
      process.env.CLIENT_URL || '',
    ].filter(Boolean),
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
    allowedHeaders: [
      'Content-Type',
      'Authorization',
      'X-Skip-Auth',      // ✅ 允许自定义请求头
      'Cache-Control',
    ],
    exposedHeaders: ['Content-Length', 'Content-Type'],
    maxAge: 86400, // 24 hours
  })
);
// apps/api/src/modules/authentication/application/services/SessionManagementApplicationService.ts

import jwt from 'jsonwebtoken';

export class SessionManagementApplicationService {
  /**
   * 刷新会话
   */
  async refreshSession(request: { refreshToken: string }): Promise<{
    success: boolean;
    session: {
      sessionUuid: string;
      accessToken: string;
      refreshToken: string;
      expiresAt: number;
    };
    message: string;
  }> {
    console.log('🔄 [SessionManagement] Starting session refresh');

    // 1. 查询会话
    const session = await this.sessionRepository.findByRefreshToken(request.refreshToken);
    if (!session) {
      throw new Error('Session not found or expired');
    }

    // 2. 验证会话
    const isValid = this.authenticationDomainService.validateSession(session);
    if (!isValid) {
      throw new Error('Session is invalid or expired');
    }

    // 3. 生成新的令牌
    const { accessToken, refreshToken, expiresAt } = this.generateTokens();

    // 4. 更新会话
    session.refreshAccessToken(accessToken, 60);
    session.refreshRefreshToken();

    // 5. 持久化
    await this.sessionRepository.save(session);

    console.log('✅ [SessionManagement] Session refreshed successfully');

    return {
      success: true,
      session: {
        sessionUuid: session.uuid,
        accessToken,
        refreshToken,
        expiresAt,
      },
      message: 'Session refreshed successfully',
    };
  }

  /**
   * 生成访问令牌和刷新令牌
   */
  private generateTokens(): {
    accessToken: string;
    refreshToken: string;
    expiresAt: number;
  } {
    const secret = process.env.JWT_SECRET || 'default-secret';
    const accessTokenExpiresIn = 3600; // 1 hour
    const refreshTokenExpiresIn = 7 * 24 * 3600; // 7 days
    const expiresAt = Date.now() + accessTokenExpiresIn * 1000;

    // 生成 Access Token
    const accessToken = jwt.sign(
      {
        type: 'access',
        iat: Math.floor(Date.now() / 1000),
      },
      secret,
      { expiresIn: accessTokenExpiresIn }
    );

    // 生成 Refresh Token(不同的 payload)
    const refreshToken = jwt.sign(
      {
        type: 'refresh',
        iat: Math.floor(Date.now() / 1000),
        purpose: 'token-refresh',
      },
      secret,
      { expiresIn: refreshTokenExpiresIn }
    );

    return { accessToken, refreshToken, expiresAt };
  }
}

最稳的面试说法

如果让我讲 Token 认证实现,我会先拆成登录签发、请求携带、过期刷新和失败收口四段。普通请求带 Access Token,过期后走 Refresh Token 换新 token;刷新请求本身必须避开自动认证注入和 401 自动刷新逻辑,否则会形成循环拦截。除此之外,还要考虑 Refresh Token 的可撤销性,以及并发 401 时的排队刷新问题。


总结: X-Skip-Auth 是一个简单但强大的解决方案,用于避免 Token 刷新流程中的循环拦截问题。通过在刷新请求中添加这个特殊标记,我们告诉前端拦截器”这个请求不需要自动添加认证头”,从而打破了无限循环。记住,这只是前端的实现细节,服务端仍然需要通过 Refresh Token 进行严格的验证。

面试要点

来自 login-flow-interview-question 的面试视角整理。

一句话回答

登录链路分四层:前端输入校验 → 后端凭证校验与会话签发 → 前端状态收敛 → 后续请求鉴权。核心不是”token 放哪”,而是结合安全等级谈存储方案的 trade-off。

面试回答主线

第一层:前端输入校验

  • 表单基础校验(非空、格式、长度)
  • 防重复提交(按钮禁用 + 请求锁)
  • 提交后进入 loading 态,阻断用户重复操作

第二层:后端凭证校验

  • 收到账号密码后,查库比对(密码必须加盐哈希存储)
  • 校验通过,签发 access token 和 refresh token
  • 返回当前身份、会话信息和权限概要
  • 校验失败,返回明确错误码(用户不存在、密码错误、账号锁定等)

第三层:前端状态收敛

  • 响应回来后,不是把 token 散落到各组件,而是统一交给认证模块管理
  • 后续普通请求带 access token,失效时走 refresh 链路
  • 路由守卫检查登录态,未登录跳转登录页

第四层:后续请求鉴权

  • 统一 HTTP client 注入 token
  • 401 触发 refresh,刷新成功重试原请求
  • 刷新失败清空状态,跳转登录

安全取舍(加分项)

Web 场景

  • localStorage 存 token:开发方便,但 XSS 风险高
  • HttpOnly Cookie:更安全,但需要处理 CSRF 防护
  • 企业安全场景:优先 HttpOnly Cookie + 短期 access token + BFF 层

Electron 场景

  • 本地安全存储(如 keychain / credential manager)
  • preload 隔离,不暴露存储细节给 renderer
  • IPC 白名单控制访问

ToB 场景补充

  • 会话管理、设备管理、强制下线
  • 审计日志记录登录行为
  • 多因素认证(MFA)
  • 权限模型和细粒度访问控制

常见误区

不要把”token 放 localStorage”说成标准答案

OWASP 明确提醒不要把会话标识符存到 localStorage,因为它始终可被 JavaScript 访问。面试里更稳妥的说法是结合 XSS/CSRF 风险、业务形态和部署方式谈取舍。

不要只讲前端不讲后端

登录是前后端协作的功能,至少要能说清后端做了什么(密码校验、token 签发、会话管理)。

最短记忆方式

登录 = 前端校验 → 后端签发 → 状态收敛 → 统一鉴权 → 安全取舍

扫码登录怎么讲

扫码登录不是另一套完全独立的体系,而是在普通登录链路上多了一层“跨设备确认”。面试里重点要主动带出:状态机、状态同步方式、安全边界、异常处理、前端职责范围

一句话回答

扫码登录的本质是:服务端创建一个短期、一次性的登录会话,Web 端展示二维码并监听状态,App 端扫码后由已登录用户确认,服务端完成 App 用户和 Web 会话的绑定,最后给 Web 端签发登录态。

先拆成三端

  • Web 端:申请二维码会话、展示二维码、维护倒计时、监听状态变化、登录成功后收敛状态并跳转
  • App 端:扫码解析 qrToken、展示确认页、让已登录用户确认是否授权
  • 服务端:创建会话、维护状态机、校验 App 身份、保证状态流转原子性、给 Web 端下发最终登录态

二维码里放什么

  • 二维码里不要直接放用户信息
  • 不要直接放最终登录 token
  • 一般只放一个短期有效、不可预测、一次性的 qrTokenloginSessionId

可以直接这样说:

二维码本质上只是一次登录请求的会话标识,不代表用户身份本身,也不能直接拿来当最终登录凭证。

服务端状态机

扫码登录很适合按状态机来讲。常见状态可以是:

  • PENDING:待扫码
  • SCANNED:已扫码,待确认
  • CONFIRMED:用户已确认
  • EXPIRED:二维码过期
  • CANCELLED:用户取消
  • USED:已完成交换,不能再次使用

最关键的是说明状态流转要受控:

PENDING -> SCANNED -> CONFIRMED -> USED

不能让两个用户同时确认同一个二维码,也不能让过期二维码继续被确认。

Web 端怎么感知状态变化

方案一:轮询

  • Web 每隔 1 到 2 秒请求一次状态接口
  • 优点是简单、兼容性好、实现成本低
  • 缺点是有延迟,也会增加服务端请求压力

方案二:SSE / WebSocket

  • 服务端主动推送状态变化
  • 优点是实时性更好,减少无效请求
  • 缺点是实现更复杂,要处理断线、重连、心跳和代理兼容

面试里稳妥的说法通常是:

如果是中小型系统或面试场景,我会优先选轮询,因为实现简单、可控;如果对实时性和规模要求更高,再考虑 SSE 或 WebSocket。

Web 端最终怎么拿到登录态

这个点最好单独强调,因为它最能体现安全意识。

  • Web 端不能从二维码里直接拿登录信息
  • App 端也不应该把自己的 token 直接传给 Web
  • 更合理的做法是:服务端确认扫码和授权成功后,再由 Web 端用自己的 loginSessionId 去交换登录态

如果是 Web 企业场景,可以主动补一句:

我更倾向让服务端通过 Set-Cookie 下发 HttpOnlySecureSameSite 的 Cookie,而不是把长期凭证暴露给前端脚本。

前端职责边界

前端在扫码登录里最核心的责任有五件事:

  1. 初始化二维码会话并渲染二维码
  2. 维护倒计时和过期刷新
  3. 轮询或监听扫码状态
  4. 根据 PENDING / SCANNED / CONFIRMED / EXPIRED / CANCELLED 渲染不同 UI
  5. 登录成功后停止轮询、收敛认证状态并跳转

同时也要说明前端的边界:

  • 前端负责展示和状态同步,不负责信任二维码本身
  • 用户身份确认、状态机并发控制、一次性消费和防重放,核心都在服务端

安全点怎么答

最值得主动提的安全点有:

  • 二维码 token 必须短期有效
  • token 只能使用一次,成功后立即置为 USED
  • 二维码不能直接携带最终登录凭证
  • App 端必须二次确认,不能一扫就直接登录
  • 确认页应展示设备、时间、地点等上下文,帮助用户识别异常请求
  • Web 登录态下发时要考虑 HttpOnly CookieSecureSameSite
  • 要防止重复 exchange、重放攻击和并发确认

异常场景

面试里再补几种异常情况,答案会显得更完整:

  • 二维码过期
  • 用户扫码后取消
  • 用户重复扫码
  • 多个 App 同时扫码
  • Web 页面刷新或关闭
  • Web 端轮询失败或网络中断
  • App 确认时二维码已过期
  • 登录成功后重复调用 exchange

这时可以顺手补一句:

前端要负责把这些异常状态诚实地展示出来,但真正保证状态一致性和原子流转的是服务端。

最短记忆方式

扫码登录 = Web 展码 + App 扫码确认 + 服务端状态机 + Web 监听状态 + 服务端签发登录态

创建于 2025/1/1 更新于 2026/5/27