一、概述

双 Token 认证是一种安全的身份验证机制,通过同时使用 Access TokenRefresh Token 来平衡安全性和用户体验。

1.1 双 Token 设计原理

Token 类型

用途

过期时间

存储位置

Access Token

访问受保护 API

较短(如 15分钟)

请求头 Authorization

Refresh Token

刷新 Access Token

较长(如 7天)

前端存储(如 Pinia)

1.2 安全优势

  • 短期 Access Token:即使泄露,有效期短,风险可控

  • 长期 Refresh Token:仅用于刷新操作,不直接访问业务接口

  • Token 轮换:每次刷新生成新的双 Token,降低被盗用风险


二、项目结构

FE-WEB/
├── apps/web/src/apis/           # 前端 API 拦截器
│   ├── index.ts                 # 主拦截器配置
│   ├── auth/index.ts            # 认证相关 API
│   └── user/index.ts            # 用户相关 API
├── package/common/user/index.ts # 类型定义
└── BE-SERVER/
    ├── apps/server/src/
    │   ├── auth/                # Auth 模块
    │   ├── user/                # User 模块
    │   └── app.module.ts        # 应用根模块
    └── libs/shared/src/
        ├── auth/auth.guard.ts   # 认证守卫
        └── shared.module.ts     # 共享模块配置

三、后端实现

3.1 JWT 全局配置

文件: BE-SERVER/libs/shared/src/shared.module.ts

// 全局 JWT 配置
import { JwtModule } from '@nestjs/jwt'
import { ConfigModule, ConfigService } from '@nestjs/config'
/** 全局 JWT 配置: 需要异步加载环境变量注册配置 */
JwtModule.registerAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (configService: ConfigService) => ({
    global: true,                    // 全局模块
    secret: configService.get('SECRET_KEY'), // 密钥(环境变量)
    signOptions: { expiresIn: '15m' }, // 默认过期时间
  }),
})

配置说明

  • global: true:JWT 模块全局生效

  • secret:从环境变量读取密钥,避免硬编码

  • signOptions.expiresIn:默认过期时间 15 分钟

3.2 Token 类型定义

文件: package/common/user/index.ts

/** 令牌有效载荷接口 */
export type TokenPayload = Pick<User, "name" | "email"> & {
  userId: User["id"];
};

/** 带类型的令牌载荷接口 */
export type TypedTokenPayload = TokenPayload & {
  tokenType: "refresh" | "access";
};

/** 令牌接口 */
export type Token = {
  accessToken: string;
  refreshToken: string;
};

3.3 AuthService - Token 生成与验证

文件: BE-SERVER/apps/server/src/auth/auth.service.ts

@Injectable()
export class AuthService {
  constructor(private jwtService: JwtService) {}

  /**
   * 生成双 Token
   * @param payload 有效载荷(userId, name, email)
   * @returns Token 对象
   */
  generateToken(payload: TokenPayload): Token {
    return {
      // Access Token:使用默认配置(15分钟)
      accessToken: this.jwtService.sign<TypedTokenPayload>({
        ...payload,
        tokenType: 'access',
      }),
      // Refresh Token:自定义过期时间(7天)
      refreshToken: this.jwtService.sign<TypedTokenPayload>(
        { ...payload, tokenType: 'refresh' },
        { expiresIn: '7d' },
      ),
    };
  }

  /**
   * 验证 Token
   * @param token JWT 字符串
   * @returns 解码后的载荷或 null
   */
  verifyToken(token: string): TypedTokenPayload | null {
    try {
      // 该方法是同步执行并且抛出异常,需要捕 try catch 处理
      return this.jwtService.verify<TypedTokenPayload>(token);
    } catch {
      return null; // 验证失败返回 null,避免异常抛出
    }
  }
}

3.4 AuthGuard - 认证守卫

文件: BE-SERVER/libs/shared/src/auth/auth.guard.ts

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private readonly jwtService: JwtService) {}

  canActivate(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest();
    const headers = request.headers;

    // 1. 检查 Authorization 头是否存在
    if (!headers.authorization) {
      throw new UnauthorizedException('未授权');
    }

    // 2. 提取 Token(格式: Bearer xxx)
    const token = headers.authorization.split(' ')[1];

    try {
      // 3. 验证 Token 签名和过期时间
      const payload = this.jwtService.verify<TypedTokenPayload>(token);

      // 4. 验证 Token 类型必须为 access
      if (payload.tokenType !== 'access') {
        throw new UnauthorizedException('token无效');
      }

      // 5. 将用户信息挂载到 request 对象
      request.user = payload;
      return true;

    } catch {
      throw new UnauthorizedException('token认证失败');
    }
  }
}

使用方式: 在需要认证的控制器上使用 @UseGuards(AuthGuard) 装饰器




import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { WordBookService } from './word-book.service';
import { AuthGuard } from '@libs/shared';
@Controller('word-book')
export class WordBookController {
  constructor(private readonly wordBookService: WordBookService) {}

  @UseGuards(AuthGuard)
  @Get()
  findAll(@Query() query: WordQuery) {
    return this.wordBookService.findAll(query);
  }
}

3.5 UserService - 登录与刷新逻辑

文件: BE-SERVER/apps/server/src/user/user.service.ts

登录流程

async login(userLogin: UserLogin) {
  // ...... 登录逻辑
  // 生成双 Token
  const token = this.authService.generateToken({
    userId: updatedUser.id,
    name: updatedUser.name,
    email: updatedUser.email || '',
  });

  return this.response.success({ ...updatedUser, token });
}

刷新 Token 流程

async refreshToken(refreshToken: Omit<Token, 'accessToken'>) {
  try {
    // 1. 验证 Refresh Token
    const payload = this.authService.verifyToken(refreshToken.refreshToken);
    if (!payload || payload.tokenType !== 'refresh') {
      return this.response.error(null, '无效的刷新token');
    }

    // 2. 验证用户是否存在(防止用户已删除)
    const user = await this.prisma.user.findUnique({
      where: { id: payload.userId },
      select: this.select,
    });
    if (!user) {
      return this.response.error(null, '无效的刷新token');
    }

    // 3. 生成新的双 Token(Token 轮换)
    const token = this.authService.generateToken({
      userId: user.id,
      name: user.name,
      email: user.email || '',
    });

    return this.response.success(token);

  } catch (error) {
    return this.response.error(null, '刷新token失败');
  }
}

3.6 模块依赖关系

AppModule
  ├── SharedModule (全局)
  │   ├── JwtModule (全局)
  │   ├── PrismaModule
  │   └── ResponseModule
  ├── AuthModule
  │   └── AuthService (依赖 JwtService)
  └── UserModule
      └── UserService (依赖 AuthService)

四、前端实现

4.1 API 拦截器配置

文件: apps/web/src/apis/index.ts

请求拦截器

serverApi.int



erceptors.request.use(config => {
  const userStore = useUserStore();
  // 请求前自动添加 Authorization 头
  if (userStore.getAccessToken()) {
    config.headers['Authorization'] = `Bearer ${userStore.getAccessToken()}`;
  }
  return config;
});

响应拦截器(核心逻辑)

/** 刷新状态标志(互斥锁) */
let isRefreshing = false;
/** 等待刷新的请求队列 */
let requestsQueue: ((newAccessToken: string) => void)[] = [];

serverApi.interceptors.response.use(
  res => res.data,
  async err => {
    // 非 401 错误直接拒绝
    if (err.response?.status !== 401) {
      return Promise.reject(err);
    }

    const userStore = useUserStore();
    const accessToken = userStore.getAccessToken();
    const refreshToken = userStore.getRefreshToken();

    // Token 为空,直接退出登录
    if (!accessToken || !refreshToken) {
      userStore.logout();
      router.replace('/');
      return Promise.reject(err);
    }

    // 并发控制:如果正在刷新,加入队列等待
    if (isRefreshing) {
      return new Promise(resolve => {
        requestsQueue.push((newAccessToken: string) => {
          err.config.headers['Authorization'] = `Bearer ${newAccessToken}`;
          resolve(serverApi(err.config)); // 刷新成功后重发请求
        });
      });
    }

    // 开始刷新流程
    isRefreshing = true;
    try {
      // 调用刷新 Token API
      const newToken = await refreshTokenApi({ refreshToken });

      if (newToken.success) {
        // 更新本地 Token
        userStore.updateToken(newToken.data);
        
        // 重放队列中的所有请求
        requestsQueue.forEach(resolve => resolve(newToken.data.accessToken));
        
        // 重发当前触发刷新的请求
        return serverApi(err.config);
      } else {
        // 刷新失败,退出登录
        userStore.logout();
        router.replace('/');
        return Promise.reject(err);
      }
    } catch (error) {
      return Promise.reject(error);
    } finally {
      // 清理状态
      requestsQueue = [];
      isRefreshing = false;
    }
  }
);

4.2 刷新 Token API

文件: apps/web/src/apis/auth/index.ts

刷新 Token API: 是基于另一个axios实例创建的,专门用于刷新token。

设计优势:

  1. 与主 API 实例分离,避免主 API 实例被刷新 Token 操作影响

  2. 刷新 Token 操作独立于其他 API 请求,确保刷新 Token 操作的独立性,避免与其他API耦合

export const refreshTokenApi = (data: Omit<Token, 'accessToken'>) =>
  authApi.post('/user/refresh-token', data) as Promise<Response<Token>>;

4.3 Pinia 状态管理

// stores/user.ts
export const useUserStore = defineStore('user', () => {
  const accessToken = ref('');
  const refreshToken = ref('');

  const getAccessToken = () => accessToken.value;
  const getRefreshToken = () => refreshToken.value;

  const updateToken = (token: Token) => {
    accessToken.value = token.accessToken;
    refreshToken.value = token.refreshToken;
  };

  const logout = () => {
    accessToken.value = '';
    refreshToken.value = '';
  };

  return {
    accessToken,
    refreshToken,
    getAccessToken,
    getRefreshToken,
    updateToken,
    logout,
  };
});

五、完整工作流程图

登录流程

sequenceDiagram participant 用户 as 用户 participant 前端 as 前端 participant 后端 as 后端 用户->>前端: 输入手机号密码登录 前端->>后端: POST /user/login 后端->>后端: 验证密码 + 更新登录时间 后端->>后端: 生成 accessToken + refreshToken 后端-->>前端: 返回用户信息 + 双Token 前端->>前端: 存储到 Pinia

Token过期刷新流程

sequenceDiagram participant 前端请求 as 前端请求 participant 拦截器 as axios拦截器 participant 后端 as 后端 前端请求->>拦截器: 发送请求(带过期token) 拦截器->>后端: 请求到达后端 后端-->>拦截器: 401未授权 拦截器->>拦截器: 检查isRefreshing状态 alt 正在刷新中(isRefreshing=true) 拦截器->>拦截器: 加入请求队列等待 else 未在刷新(isRefreshing=false) 拦截器->>拦截器: 设置isRefreshing=true 拦截器->>后端: POST /user/refresh-token 后端-->>拦截器: 返回新的双Token 拦截器->>拦截器: 更新Pinia中的Token 拦截器->>拦截器: 遍历队列重发请求 拦截器->>拦截器: 重发当前请求 拦截器->>拦截器: 设置isRefreshing=false end

并发请求处理流程

flowchart TD A[6个并发请求到达] --> B{Token是否过期?} B -->|未过期| C[正常发送请求] B -->|已过期| D{isRefreshing?} D -->|false| E[设置isRefreshing=true] E --> F[发送refresh-token请求] F --> G[获取新Token] G --> H[更新Pinia] H --> I[遍历队列重发请求B/C/D/E/F] I --> J[重发第一个请求A] J --> K[设置isRefreshing=false] D -->|true| L[加入等待队列] L --> I


六、安全考虑

6.1 Token 轮换策略

每次刷新 Token 时生成全新的双 Token:

// 每次刷新都生成新的 Token
const token = this.authService.generateToken({
  userId: user.id,
  name: user.name,
  email: user.email || '',
});

优点

  • 即使 Refresh Token 泄露,有效期有限

  • 被盗用后,原用户刷新会使被盗用的 Token 失效

6.2 错误处理

遇到问题怎么处理:

  • Access Token 过期了:系统会自动刷新获取新的 Token,用户完全感觉不到

  • Refresh Token 也过期了:没办法,只能让用户重新登录

  • Token 被改了:签名验证会失败,直接返回 401 拒绝访问

  • 用户账号被删了:刷新时会检查用户是否存在,不存在就提示登录


七、实际效果

token未过期时,并发的请求处理

token过期时,并发的请求处理

由于access token的过期时间设置过短,测试没有把握好时机,导致会多次请求刷新token

八、总结

双 Token 认证机制通过以下方式实现安全与体验的平衡:

  1. 短期 Access Token:降低泄露风险

  2. 长期 Refresh Token:减少用户登录频率

  3. 并发控制:避免重复刷新请求

  4. Token 轮换:每次刷新生成新 Token

该实现覆盖了完整的认证流程:

  • 登录生成 Token

  • 请求携带 Token

  • 过期自动刷新

  • 失败强制登出