NestJS 中实现 JWT 双 Token 机制:提升 API 安全性的实践

14 阅读6分钟

在现代后端开发中,用户认证是核心功能之一。NestJS 作为一款高效的 Node.js 框架,提供了内置的 JWT(JSON Web Token)模块,使得实现 token-based 认证变得简单可靠。相比传统的单 token 机制,双 token 机制access_tokenrefresh_token)能更好地应对安全挑战,如 token 被截获的风险。本文将基于一个典型的 Auth 模块场景,详细讲解 JWT 双token 的原理、实现步骤,以及在 NestJS 中的代码实践。我们会使用 Prisma 作为 ORM 处理用户数据,结合 bcrypt 进行密码哈希验证。同时,前端部分使用 Zustand 状态管理库存储 token。

JWT 认证基础

JWT 是一种开放标准(RFC 7519),用于在各方之间安全传输声明。它由三部分组成:Header(头部)、Payload(负载)和 Signature(签名)。在 NestJS 中,我们通过 @nestjs/jwt 模块集成 JWT 服务。这个模块需要单独安装(npm install @nestjs/jwt),但它是 NestJS 生态的一部分,提供 sign 和 verify 方法。

单 token 机制的痛点:一个 token 包含用户身份信息,如果被中间人攻击(如 MITM),攻击者可无限使用直到过期。笔记中提到 mockjs 使用 jsonwebtoken 的单 token sign/decode,这在简单原型中可行,但生产环境不安全。为此,双 token 机制应运而生:

  • access_token:短效(通常分钟级,如 15m),用于 API 请求。过期快,降低泄露风险。
  • refresh_token:长效(天级,如 7d),用于刷新 access_token。不直接用于 API,而是发送到后端换取新 token 对。
  • 流程:用户登录获 token 对;API 请求用 access_token;过期时,用 refresh_token 刷新获新对;refresh_token 过期需重登录。

这种机制增强安全性:即使 access_token 泄露,影响有限;refresh_token 只用于特定端点,且可服务器端黑名单管理。生成 token 时,使用 JwtService.signAsync 方法,传入 payload(如用户 ID 和 name)。

在性能上,双 token 生成可用 Promise.all 并发执行,减少延迟。如在 posts 查询中,并发 count 和 list;类似地,token 生成也耗时,Promise.all 优化明显。

Auth 模块配置

Auth 模块是认证的核心。我们在 AuthModule 中导入 JwtModule,注册 secret(从环境变量获取)。示例代码:

TypeScript

import {
  Module,
} from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtModule } from '@nestjs/jwt';

console.log(process.env.TOKEN_SECRET, "-=-=-=-=-=-=-=");
// 设计模式 面向对象企业级别开发 经验总结
// 23种 工厂模式 单例模式 装饰器模式(类添加属性和方法)
// 观察者模式(IntersectionObserver) 代理模式(Proxy)
// 订阅发布者模式(addEventListener)
@Module({
  imports: [JwtModule.register({
    secret: process.env.TOKEN_SECRET
  })],
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {
  
}

这里,JwtModule.register 配置 secret,用于签名和验证。笔记中注释提到设计模式,如装饰器模式(NestJS 大量使用 @Module 等装饰器添加元数据)、代理模式(Proxy 在 JWT 验证中类似)。console.log(process.env.TOKEN_SECRET) 用于调试,但这是一个安全隐患:在生产环境中,日志可能泄露 secret。建议移除或用环境检查工具替换。

模块导入后,JwtService 可在服务中注入。注意,secret 应从 .env 文件加载,避免硬编码。

Auth 控制器:处理登录和刷新

控制器定义 RESTful 端点:POST /auth/login 和 /auth/refresh。使用 DTO 验证输入,指定 HTTP 状态码。代码如下:

TypeScript

import {
  Controller,
  Post,
  Body,
  HttpCode,
  HttpStatus,
} from '@nestjs/common'

import { LoginDto } from './dto/login.dto'
import { AuthService } from './auth.service'


// restful
// method + URL(名词 有可读性且直指资源)

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}


  @Post('login')
  @HttpCode(HttpStatus.OK)  // 指定状态码为200
  async login(@Body() loginDto: LoginDto) {
    return this.authService.login(loginDto);
  }

  @Post('refresh')
  @HttpCode(HttpStatus.OK)
  async refresh(@Body('refresh_token') refresh_token: string) {
    return this.authService.refreshToken(refresh_token);
  }
}

注释强调 RESTful 原则:URL 用名词(如 'auth'),方法表示动作。@HttpCode(HttpStatus.OK) 覆盖默认 201,确保响应 200。refresh 端点只取 refresh_token,无需完整 DTO。

Auth 服务:核心逻辑实现

服务处理业务:用户验证、token 生成和刷新。注入 PrismaServiceJwtService ,使用 bcrypt 比对密码。完整代码:

TypeScript

import {
  Injectable,
  UnauthorizedException
} from '@nestjs/common';
import {
  PrismaService 
} from '../prisma/prisma.service';
import { LoginDto } from './dto/login.dto';
import * as bcrypt from 'bcrypt';
// nestjs 内置了jwt 模块 
// 需要安装的 性能比较好
// @nestjs 插件式  企业级同时保持小巧
// 注入的方式注入Auth模块
import { JwtService } from '@nestjs/jwt';


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

  async login(loginDto: LoginDto) {
    const { name, password } = loginDto;
    // name 查询
    const user = await this.prisma.user.findUnique({
      where: {
        name
      }
    })
    if(!user || !(await bcrypt.compare(password, user.password))) {
      throw new UnauthorizedException('用户名或密码错误')
    }
    console.log(user);
    // hased password 比对?

    // 颁发token
    // 模块化分离, 业务专注
    const tokens = await this.generateTokens(user.id.toString(), user.name);
    return {
      ...tokens,
      user:{
        id: user.id.toString(),
        name: user.name
      }
    }
  }

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

  // OOP private 方法 复杂度剥离
  private async generateTokens(id: string, name: string) {
    // 用户信息关键 JSON Object 
    // 马上用于签发token, 发令枪先装填弹药(payload),生成token,先要准备用户对象一样
    const payload = {
      sub: id, // subject 主题 JWT 中规定的 关键字端
      name
    };

    const [at, rt] = await Promise.all([
      // 颁发了两个token  access_token
      this.jwtService.signAsync(payload, {
        expiresIn: '15m', // 有效期 15分钟 更安全 被中间人攻击
        secret: process.env.TOKEN_SECRET
      }),
      // refresh_token  刷新
      // 7d 服务器接受我们,用于refresh 
      // 服务器再次生成两个token 给我们
      // 依然使用 15m token 请求 
      this.jwtService.signAsync(payload, {
        expiresIn: '7d',
        secret: process.env.TOKEN_SECRET
      }),
    ])
    return {
      access_token: at,
      refresh_token: rt
    }
  }
}

剖析:

  • login:查询用户,bcrypt.compare 验证密码(需预存哈希密码)。生成 token 对,返回带用户info。
  • refreshToken:verifyAsync 检查 refresh_token,提取 payload,生成新 token 对。
  • generateTokens:私有方法,使用 Promise.all 并发 signAsync。payload 用 sub (ID) 和 name。expiresIn 设置过期。

前端 token 管理:Zustand 与持久化

前端使用 Zustand 存储 token,用 persist 中间件持久到 localStorage。API 调用如 doLogin 处理登录。代码:

TypeScript

// localstorage
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { 
  doLogin
} from '@/api/user';
import type { User } from '@/types/index';
import type { Credential } from '@/types/index'


interface UserStore {
  accessToken: string | null;
  refreshToken: string | null;
  user: User | null;
  isLogin: boolean;
  login: (credentials: { name: string, password: string }) => Promise<void>;
}


// 高阶函数 柯里化
export const useUserStore = create<UserStore>() (
  persist((set) => ({  // state 对象
    accessToken: null,
    refreshToken: null,
    user: null,
    isLogin: false,
    login: async ({name, password}) => {
      const res = await doLogin({name, password});
      console.log(res, '??????');
      const { access_token, refresh_token, user } = res;
      // console.log(access_token, refresh_token, user);
      set({
        user: user,
        accessToken: access_token,
        refreshToken: refresh_token,
        isLogin: true,
      })
    }
  }), {
    name: 'user-store',
    partialize: (state) => ({
      accessToken: state.accessToken,
      refreshToken: state.refreshToken,
      user: state.user,
      isLogin: state.isLogin,
    })
  })
)

create 高阶函数(柯里化)包装 persist。login 调用 doLogin(假设 axios POST /auth/login),更新 state。partialize 选择持久字段。

双 Token 机制的完整流程

  1. 用户登录:前端发 name/password,后端验证,生成 token 对返回。
  2. API 请求:axios 拦截加 Authorization: Bearer access_token。
  3. 过期:后端返回 401,前端用 refresh_token POST /auth/refresh,获新对,更新 store,重试请求。
  4. refresh 过期:重定向登录。

优势:安全(短效 token)、无缝(自动刷新)。笔记中 Promise.all 面试例:posts count 和 list 并发;token 生成类似,减少延迟。

总结

通过 NestJS 的 JWT 双 token 机制,我们构建了安全的 Auth 系统。从模块配置到服务实现,再到前端存储,每步都注重模块化和性能。