🔥 双Token登录鉴权体系深度解析:NestJS + React 实战详解

10 阅读8分钟

引言

在现代前后端分离架构中,用户认证与权限控制是系统安全的第一道防线。单 Token 方案虽然简单,但难以兼顾安全性用户体验:短有效期的 Token 会频繁中断用户操作,而长有效期的 Token 则存在被盗用后长期有效的风险。

双 Token 机制(access_token + refresh_token) 正是为解决这一矛盾而生——它通过两个职责分明、生命周期不同的 Token,在保障接口安全的同时,实现“无感续期”的丝滑体验。

本文将聚焦于 双 Token 登录模块的核心逻辑实现,基于 NestJS(后端)与 React(前端)技术栈,深入剖析从登录生成 Token、请求携带 Token、到过期自动刷新的完整链路。所有代码均来自真实项目,逻辑严谨、可直接复用,助你构建高安全、高可用的认证体系。


一、双Token机制:为什么是“最佳实践”?

1.1 核心角色分工

  • access_token(访问令牌)

    • 作用:用于访问受保护的 API 接口(如发布文章、修改资料)。
    • 特点短时效(通常 10~30 分钟),一旦泄露,攻击窗口极小。
    • 传输方式:放在 HTTP 请求头 Authorization: Bearer <token> 中。
  • refresh_token(刷新令牌)

    • 作用:仅用于调用 /auth/refresh 接口,换取新的 access_token(和新的 refresh_token)。
    • 特点长时效(如 7 天),但绝不用于业务接口,极大降低暴露风险。
    • 存储建议:前端应存于 HttpOnly Cookie 或安全的持久化存储(如加密 localStorage)。

安全优势:即使 access_token 被窃取,攻击者也无法长期使用;若 refresh_token 被盗,因其不参与业务请求,检测和撤销也更容易。

1.2 完整流程

用户登录时,前端发送账号密码至后端 /auth/login 接口。后端验证通过后,签发一对 Token:短时效(15分钟)的 access_token 用于访问业务接口,长时效(7天)的 refresh_token 仅用于刷新。前端将双 Token 持久化存储,并在后续请求头中自动携带 access_token

access_token 过期,后端返回 401。前端拦截该错误,若存在有效 refresh_token,则调用 /auth/refresh 获取新 Token 对(实现 Token 轮换,旧 refresh_token 立即失效)。刷新期间的并发请求被暂存队列,待新 access_token 返回后统一重发,全程用户无感。

refresh_token 也过期,则清空本地状态并跳转登录页。该机制兼顾安全(短 Token + 轮换)与体验(自动续期),是前后端分离架构下的认证最佳实践。


二、后端实现:NestJS 如何生成与验证双Token?

⚠️ 以下代码基于已搭建好的 NestJS 项目,仅聚焦认证模块核心逻辑。

2.1 登录接口:生成双Token对

登录成功后,后端需同时生成 access_tokenrefresh_token

// src/auth/auth.service.ts
private async generateTokens(id: string, name: string) {
  const payload = { sub: id, name }; // sub 是 JWT 规范字段,代表主体(用户ID)
  
  const [at, rt] = await Promise.all([
    this.jwtService.signAsync(payload, {
      expiresIn: '15m',
      secret: process.env.TOKEN_SECRET
    }),
    this.jwtService.signAsync(payload, {
      expiresIn: '7d',
      secret: process.env.TOKEN_SECRET
    })
  ]);

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

关键设计点:

  • Promise.all 并发生成:JWT 签名涉及加密运算,串行执行会增加响应时间,并发可提升性能。
  • 统一 Payload 结构:两个 Token 使用相同的载荷(用户 ID 和用户名),便于后续解析一致性。
  • sub 字段规范:sub 是 JWT 规范字段,代表主体(用户ID)

2.2 刷新接口:Token 轮换(Token Rotation)

access_token 过期,前端调用 /auth/refresh 获取新 Token:

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

为何要“轮换”?

  • 安全性增强:每次刷新都生成全新的 refresh_token,旧的立即失效。即使旧 refresh_token 被盗,也无法再次使用。
  • 防止 Token 无限续期:避免用户“永远不登出”,符合最小权限原则。

🛡️ 进阶建议:生产环境可将 refresh_token 存入 Redis,并设置 TTL(如 7 天),同时维护一个“黑名单”用于强制下线。

2.3 鉴权守卫:保护敏感接口

NestJS 通过 Guard + Strategy 实现声明式鉴权:

// jwt.strategy.ts
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.TOKEN_SECRET
    });
  }

  validate(payload) {
    return { id: payload.sub, name: payload.name }; // 挂载到 req.user
  }
}

在控制器中使用:

@Post()
@UseGuards(JwtAuthGuard)
createPost(@Req() req) {
  const userId = req.user.id; // 自动获取当前用户ID
  // ...
}

在 NestJS 中,Strategy(策略) 负责具体的认证逻辑,比如从请求头提取 JWT 并验证其有效性,验证通过后返回用户信息;它不控制请求流程,只做身份解析。而 Guard(守卫) 是 NestJS 的执行钩子,运行在控制器之前,用于决定是否放行请求。Guard 会调用对应的 Strategy:若认证成功,将用户信息挂载到 req.user 并允许进入路由;若失败(如 Token 无效或过期),则直接抛出 401 错误,中断后续操作。通常,JwtStrategy 实现 JWT 验证,JwtAuthGuard 封装该策略并作为守卫使用。二者分工明确:Strategy 是“验票员”,Guard 是“守门人”,共同实现安全、可复用的鉴权机制。

守卫工作流程:

  1. 请求到达时,守卫拦截;
  2. Authorization 头提取 Bearer <token>
  3. 验证签名、检查是否过期;
  4. 若有效,调用 validate(),结果挂载到 req.user
  5. 控制器方法中直接使用 req.user,无需手动解析 Token。

常见坑:若报错 Unknown authentication strategy "jwt",请确保 JwtStrategy 已在 AuthModuleproviders 中注册。


三、前端实现:React 如何管理与自动刷新 Token?

前端的核心挑战是:如何在 Token 过期时,自动刷新并重试失败请求,且不造成重复刷新或死循环?

3.1 Token 持久化:Zustand + persist

使用 Zustand 的 persist 中间件,将 Token 存入 localStorage

// useUserStore.ts
export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      accessToken: null,
      refreshToken: null,
      user: null,
      isLogin: false,
      login: async (credentials) => {
        const res = await doLogin(credentials);
        set({ ...res, isLogin: true }); // 存储 access_token, refresh_token, user
      },
      logout: () => { /* 清空状态 */ }
    }),
    {
      name: 'user-store',
      partialize: (state) => ({
        accessToken: state.accessToken,
        refreshToken: state.refreshToken,
        user: state.user,
        isLogin: state.isLogin
      })
    }
  )
);

优势:页面刷新后状态不丢失,用户无需重新登录。

3.2 Axios 拦截器:自动携带与智能刷新

这是前端最核心的逻辑——请求拦截器 + 响应拦截器协同工作

请求拦截器:自动注入 Token

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

响应拦截器:处理 401 与 Token 刷新

let isRefreshing = false;
let requestsQueue: ((token: string) => void)[] = [];

instance.interceptors.response.use(
  (res) => res.data,
  async (err) => {
    const { config, response } = err;
    
    if (response?.status === 401 && !config._retry) {
      config._retry = true;

      if (isRefreshing) {
        // 若正在刷新,将请求加入队列,等待新Token
        return new Promise((resolve) => {
          requestsQueue.push((token) => {
            config.headers.Authorization = `Bearer ${token}`;
            resolve(instance(config));
          });
        });
      }

      isRefreshing = true;
      try {
        const { refreshToken } = useUserStore.getState();
        if (!refreshToken) throw new Error('No refresh token');

        const { access_token, refresh_token } = await instance.post('/auth/refresh', {
          refresh_token: refreshToken
        });

        // 更新本地Token
        useUserStore.setState({ accessToken: access_token, refreshToken: refresh_token });

        // 重发队列中所有请求
        requestsQueue.forEach(cb => cb(access_token));
        requestsQueue = [];

        // 重发当前请求
        config.headers.Authorization = `Bearer ${access_token}`;
        return instance(config);
      } catch (e) {
        // 刷新失败:跳转登录页
        useUserStore.getState().logout();
        window.location.href = '/login';
        return Promise.reject(e);
      } finally {
        isRefreshing = false;
      }
    }
    return Promise.reject(err);
  }
);

这段代码实现了 Token 过期时的自动刷新与请求重试机制。当接口返回 401 且请求未被重试过(!config._retry)时,拦截器会尝试用 refresh_token 刷新获取新 Token。若此时已有刷新操作正在进行(isRefreshing = true),则将当前请求加入队列 requestsQueue,等待新 Token 返回后再执行。若无刷新任务,则发起 /auth/refresh 请求,成功后更新本地 Token,并遍历队列批量重发所有挂起的请求,最后重试当前失败请求。若刷新失败(如 refresh_token 过期),则清空登录状态并跳转至登录页。通过 isRefreshing 锁和请求队列,有效避免了重复刷新和请求丢失,确保用户体验无缝。

关键机制解析:

机制作用
config._retry防止 401 错误无限循环触发刷新
isRefreshing全局锁,避免多个 401 请求同时发起刷新
requestsQueue缓存刷新期间的所有请求,待新 Token 到手后统一重发

💡 用户体验优化:整个过程用户无感知,页面不会跳转,操作不中断。


四、避坑指南:那些年踩过的“雷”

后端篇

  1. JwtStrategy 未注册到模块 providers

    • 现象:调用受保护接口时报错 Unknown authentication strategy "jwt"
    • 原因JwtStrategy 是 Guard 的底层策略,必须在 AuthModule 的 providers 中显式注册。
    • 解决:在 AuthModule 的 providers 数组中加入 JwtStrategy
  2. 密码验证失败

    • 原因:注册时用 bcrypt.hash(password, 10),登录时却未用 bcrypt.compare
    • 正确做法:永远不要比对明文密码,必须用 compare 验证哈希值。
  3. JWT 密钥不一致或未加载

    • 现象:Token 签发成功,但验证时报 invalid signature
    • 原因JwtModule.register() 与 jwtService.verifyAsync() 使用的密钥不同,或 .env 未正确加载。
    • 解决:确保统一使用 process.env.TOKEN_SECRET,并在 main.ts 中调用 config() 加载环境变量(如使用 @nestjs/config)。

前端篇

  1. Token 持久化失效

    • 检查 partialize 是否包含 accessTokenrefreshToken
  2. 刷新死循环

    • 确保 config._retry = true 生效,且刷新失败后清空队列并跳转登录。
  3. 请求头格式错误

    • 必须为 Authorization: Bearer <token>Bearer 后有空格,否则后端无法解析。

五、总结:双Token 的价值与扩展

双 Token 机制之所以成为行业标准,是因为它精准平衡了安全与体验

  1. 安全性高:短时效 access_token 限制泄露后的攻击窗口,refresh_token 不参与业务请求,降低风险。
  2. 体验流畅:access_token 过期后可自动无感刷新,用户无需频繁重新登录,操作不中断。
  3. 灵活可控:支持 Token 轮换、强制下线、有效期独立配置,便于实现精细化安全策略。

🌟 结语:认证模块看似简单,实则暗藏玄机。一个健壮的双 Token 体系,不仅能抵御常见攻击,更能为用户提供“无感却安全”的体验。本文所分享的代码与逻辑,已在多个线上项目稳定运行,欢迎直接借鉴、优化、落地。