JWT 双 Token 无感刷新:兼顾安全与体验的全栈认证方案

19 阅读7分钟

全栈项目中的 JWT 双 Token 无感刷新认证方案

在构建 Web 应用时,用户认证(Authentication)就像是给你的系统装一扇门。如何既保证这扇门足够安全(不让坏人进),又让它足够智能(不让好人每次进出都掏钥匙),是每个开发者都要面对的挑战。

在我们的全栈项目中,我们经历了一个从 Mock 单 Token 到生产级双 Token 的演进过程。本文将带你深入这套机制的每一个齿轮,揭示它是如何平衡“安全性”与“体验感”的。


单 Token 的认证机制

最初写项目时,我们在 mock 中使用了单 Token 的机制,通过 sign 直接签发一个长期的 Token,而拦截器中只要碰到 401,就直接强制登出,返回登录页:

const token = jwt.sign({
  user: { // json 对象
    id: 1,
    name: 'admin',
    avatar: "https://p9-passport.byteacctimg.com/img/user-avatar/09aff03aaa33dd9d311511bcbd12535f~50x50.awebp"
  }
  // 加盐  
}, secret, {
  expiresIn: 86400 * 7 // 有效时间 7天
});

这样的单 Token 模式却暴露了很多缺陷:

  1. 安全性问题:长期 Token 存在被截获后被冒用的风险
  2. 用户体验问题:用户每次操作都需要重新登录,影响用户体验
  3. 服务端压力问题:每次请求都需要验证 Token,服务端压力增大

进阶:JWT 双 Token 认证机制

在单 Token 时代,我们总是面临一个两难的选择:Token 有效期设短了,用户体验差;设长了,安全风险大。双 Token 机制巧妙地引入了两把不同用途的“钥匙”来解决这个问题。

后端部分核心实现

后端部分负责 Token 的签发与验证。

Token 的签发

每当用户进行登录时,会同时签发一对 Token:

  • Access Token:短期 Token,就像是用户的临时密码,用于验证用户身份。所有的操作都只需要验证 Access Token 即可。
  • Refresh Token:长期 Token,就像是用户的长期密码,用于刷新 Access Token。
// AuthService: 生成双 Token
private async generateTokens(userId: string, username: string) {
  // Payload (载荷):钥匙里包裹的信息
  const payload = { sub: userId, name: username };

  // 使用 Promise.all 并行执行,效率翻倍
  const [at, rt] = await Promise.all([
    // 制作手环 (Access Token):15分钟过期
    this.jwtService.signAsync(payload, {
      expiresIn: '15m',
      secret: process.env.TOKEN_SECRET
    }),
    // 制作凭证 (Refresh Token):7天过期
    this.jwtService.signAsync(payload, {
      expiresIn: '7d',
      secret: process.env.TOKEN_SECRET
    })
  ]);

  return { access_token: at, refresh_token: rt };
}

核心代码分析

  • jwtService:这是 NestJS 中提供的 JWT 服务,用于签发 Token
  • signAsync:JWT 服务提供的异步方法,用于签发 Token
  • expiresIn:Token 的过期时间。15 分钟用于短期认证,7 天用于长期存储
  • secret:Token 的密钥,用于签名,确保安全性(保存在 .env 文件中)
  • payload:Token 中包含的信息。sub 是标准 JWT Subject 字段,表示用户 ID
  • Promise.all:并行签发两个 Token,提高效率,只有两者都成功才返回前端

短期的 Token 通常以分钟为单位,长期的 Token 通常以天为单位,根据实际情况进行设置。


Token 的刷新

当 Access Token 过期时,用户可以使用 Refresh Token 来重新颁发一对 Token:

async refreshToken(rt: string) {
  try {
    // decoded
    const payload = await this.jwtService.verifyAsync(rt, {
      secret: process.env.TOKEN_SECRET
    });
    if (payload) {
      return this.generateTokens(payload.sub, payload.name);
    }
  } catch (e) {
    throw new UnauthorizedException('Refresh token 已失效,请重新登录');
  }
}

核心代码解析

  • verifyAsync 方法:NestJS JWT 服务提供的异步方法,用于验证 Refresh Token 的有效性
  • payload:若能成功解码并获取用户信息,说明 Refresh Token 有效,即可调用 generateTokens 重新签发

NestJS 鉴权处理:策略与守卫
配备守卫

NestJS 提供了默认的 Guard,用于解析 req.headers.Authorization 中的 Token。我们只需继承该 Guard,并指定使用 JWT 策略:

import { Injectable } from "@nestjs/common";
// NestJS 默认提供的 guard,自动解析 req Authorization
import { AuthGuard } from "@nestjs/passport";

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

只需在需要鉴权的路由上加上装饰器即可:

@UseGuards(JwtAuthGuard)  // 路由守卫
createPost(
  @Body('title') title: string,
  @Body('content') content: string,
  @Req() req
) {...}
制定策略

仅有守卫还不够,我们还需要制定一套规则,让系统知道如何鉴权:

import { Injectable } from '@nestjs/common';
// 定义和集成 Passport 身份验证策略的基类
import { PassportStrategy } from '@nestjs/passport';
// 身份验证策略选择 jwt
import { Strategy, ExtractJwt } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.TOKEN_SECRET || ""
    });
  }

  // JWT 用户对象
  async validate(payload) {
    return {
      id: payload.sub,
      name: payload.name
    };
  }
}

核心代码解析

  • PassportStrategy(Strategy):NestJS 提供的基类,用于定义 Passport 策略
  • Strategy:来自 passport-jwt,表示使用 JWT 策略
  • jwtFromRequest:指定从 Authorization Header 中提取 Bearer Token
  • secretOrKey:用于验证 Token 签名的密钥(来自环境变量)
  • validate:验证成功后返回用户上下文,挂载到 req.user

例如,在业务代码中可直接使用:

@Post()
@UseGuards(JwtAuthGuard)
createPost(
  @Body('title') title: string,
  @Body('content') content: string,
  @Req() req
) {
  const { user } = req;
  return this.postsService.createPost({
    title,
    content,
    userId: user.id
  });
}

前端部分核心实现

前端的挑战在于如何管理这些 Token,并在 Token 过期时“悄无声息”地完成刷新,让用户感觉不到任何中断。

Token 的存储与状态管理

我们使用 Zustand 库进行状态管理,因为它轻量且支持中间件。

关键代码解析 (useUserStore):

export const useUserStore = create(
  persist((set) => ({
    accessToken: null,
    refreshToken: null,
    // ...
  }), {
    name: 'user-store', // LocalStorage 中的 key
    partialize: (state) => ({ 
      // 白名单:只持久化这两个 Token,不持久化临时状态
      accessToken: state.accessToken,
      refreshToken: state.refreshToken,
    })
  })
);
  • 持久化 (Persist) :确保刷新页面后,用户依然是登录状态。Token 自动保存到 localStorage
  • 白名单 (Partialize) :最佳实践。只存 Token,避免将临时状态(如 isLoading)误存,导致状态错乱。

拦截器实现 Token 无感刷新

为什么在拦截器里做?
因为拦截器是所有 HTTP 请求的总关口。在这里处理,可以统一拦截所有 API 的 401 错误,业务代码完全不需要关心 Token 是否过期,做到了逻辑解耦

import axios from 'axios';
import { useUserStore } from '@/store/useUserStore';

const instance = axios.create({
  baseURL: 'http://localhost:5173/api'
});

// 每次请求自动带上 token
instance.interceptors.request.use(config => {
  const token = useUserStore.getState().accessToken;
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

let isRefreshing = false;
let requestsQueue: any[] = []; 

// 响应拦截器
instance.interceptors.response.use(res => {
  return res.data;
}, async (err) => {
  const { config, response } = err;
  // _retry 刻意标记是否是重试的请求,避免 retry 死循环
  if (response?.status === 401 && !config._retry) {
    // 如果在刷新中,把后续请求放到队列中
    if (isRefreshing) {
      return new Promise((resolve) => {
        requestsQueue.push((token: string) => {
          config.headers.Authorization = `Bearer ${token}`;
          resolve(instance(config));
        });
      });
    }

    config._retry = true; // retry 开关 
    isRefreshing = true;

    try {
      const { refreshToken } = useUserStore.getState();
      if (refreshToken) {
        // 无感刷新 token
        const { access_token, refresh_token } = await instance.post('auth/refresh', {
          refresh_token: refreshToken
        });

        useUserStore.setState({
          accessToken: access_token,
          refreshToken: refresh_token,
          isLogin: true
        });

        requestsQueue.forEach(callback => callback(access_token));
        requestsQueue = [];

        config.headers.Authorization = `Bearer ${access_token}`;
        return instance(config);
      }
    } catch (err) {
      useUserStore.getState().logout();
      window.location.href = '/login';
      return Promise.reject(err);
    } finally {
      isRefreshing = false;
    }
  }
  return Promise.reject(err);
});

export default instance;
代码拆解讲解
  1. 请求拦截器
    让每次请求都自动带上 Token:

    instance.interceptors.request.use(config => {
      const token = useUserStore.getState().accessToken;
      if (token) {
        config.headers.Authorization = `Bearer ${token}`;
      }
      return config;
    });
    
  2. 响应拦截器

    关键变量

    • isRefreshing:是否正在刷新 Token
    • requestsQueue:等待刷新完成后的请求队列

    为什么需要这个队列?
    当多个并发请求因 Token 过期失败时,不能每个都去刷新 Token。应只触发一次刷新,其余请求排队等待新 Token。

    逻辑步骤

    • 判断是否需要刷新 Token

      if (response?.status === 401 && !config._retry) { ... }
      

      _retry 标记防止死循环:若重试后仍 401,不再重复刷新。

    • 处理并发请求

      if (isRefreshing) {
        return new Promise((resolve) => {
          requestsQueue.push((token: string) => {
            config.headers.Authorization = `Bearer ${token}`;
            resolve(instance(config));
          });
        });
      }
      config._retry = true;
      isRefreshing = true;
      
    • 签发新 Token

      const { refreshToken } = useUserStore.getState();
      if (refreshToken) {
        const { access_token, refresh_token } = await instance.post('auth/refresh', {
          refresh_token: refreshToken
        });
        useUserStore.setState({
          accessToken: access_token,
          refreshToken: refresh_token,
          isLogin: true
        });
      }
      
    • 重放排队请求

      requestsQueue.forEach(callback => callback(access_token));
      requestsQueue = [];
      
    • 重试当前请求

      config.headers.Authorization = `Bearer ${access_token}`;
      return instance(config);
      
    • 处理刷新失败

      catch (err) {
        useUserStore.getState().logout();
        window.location.href = '/login';
        return Promise.reject(err);
      } finally {
        isRefreshing = false;
      }
      
    • 拦截器出口

      return Promise.reject(err);
      

总结

JWT 双 Token 无感刷新机制,本质上是一种在安全性和用户体验之间取得精妙平衡的工程实践。通过将认证职责拆分为短期有效的 Access Token 与长期有效的 Refresh Token,我们既避免了长期持有敏感凭证带来的安全风险,又消除了用户频繁登录的体验断层。

后端通过 NestJS 的守卫(Guard)与策略(Strategy)体系,构建了一套清晰、可复用的鉴权基础设施;前端则借助 Axios 拦截器与状态管理库 Zustand,在请求层面实现了“透明”的 Token 刷新逻辑——业务代码无需感知认证细节,所有 401 错误都被拦截器自动处理,真正做到了“无感”。

这套方案的核心价值在于:安全不妥协,体验不打折。Access Token 的短生命周期极大降低了泄露后的危害窗口,而 Refresh Token 的独立存储与严格校验则保障了凭证刷新的安全边界。同时,通过队列机制与重试标记,前端有效解决了并发请求下的 Token 竞态问题,确保系统在高负载下依然稳定可靠。

当然,任何方案都不是银弹。双 Token 机制虽好,仍需配合以下措施,才能构筑起完整的认证安全防线:

  • HTTPS 传输:防止 Token 在传输中被窃听
  • Refresh Token 黑名单或数据库记录:支持主动吊销
  • 合理的过期策略:根据业务敏感度调整时效
  • 客户端安全存储:如使用 HttpOnly Cookie 防 XSS(若适用)

但在大多数中大型全栈应用中,这套 双 Token + 无感刷新 的组合,无疑是当前兼顾安全性、可维护性与用户体验的优选方案。