跳转到主要内容

Nest 使用笔记

第十一章:JWT 认证——双 token、无感刷新、Guard 衔接,以及 JWT 无状态的盲区

打开 learnhub 的 auth 模块,在真实代码里讲透 access + refresh 双 token、refresh 无感刷新、payload 该放什么、JwtAuthGuard 怎么消费 token;老实说 md5+salt 的弱点并给 bcrypt 升级路径,诚实讲清 JWT 登出/踢人下线为什么需要 Redis 黑名单或 token 版本号(learnhub 没实现,教你怎么补)。

  • Nest
  • 认证
  • JWT

第八章把数据层接通了,但 POST /api/v1/posts 谁都能调——还没「登录了才能用」这一层。这一章打开 learnhub 的 auth 模块,在真实代码里讲透一套完整的认证:注册、登录、双 token(access + refresh)、refresh 轮换、Guard 校验。中间会老老实实说 learnhub 用了 md5+salt(教学简化),教你怎么用 bcrypt 做生产升级——不改 learnhub,只教升级路径。最后一节旧版课程承诺过却从没做的事——登出 / 踢人下线 / 黑名单——也补上。

先搞懂:JWT 是什么 / 为什么需要 / 企业级怎么用

JWT 是什么:JSON Web Token,一种无状态的认证令牌。结构是 header.payload.signature 三段 base64 拼接,登录成功后服务端用密钥签发,里面塞 userIdusername 这些自包含的信息,签名保证它没法被篡改。后续请求把它放在 Authorization: Bearer xxx 里带上,服务端验签 + 查过期就能确认身份,不存 session

为什么需要它:传统 session 把登录态存在服务端内存里,一旦多机部署就得共享 session(粘性 session、Redis 同步),扩展又麻烦又脆。JWT 自包含无状态,服务端不存任何东西,多实例 / 多端 / 跨域天然支持。判断标准:多实例部署、移动 + Web 多端、前后端分离跨域 → JWT;单体小服务用 session 也行。

企业级怎么用(下面每一条这一章都会在 learnhub 里真做一遍,不是空头支票):

  • 双 token:access token(短期,几十分钟)放业务接口的 Authorization;refresh token(长期,几天)只用来换新 access。短期 token 被偷最多损失几分钟——learnhub 第四步落地。
  • 密钥走环境变量:JWT 签名密钥用复杂随机串放 env,绝不能写进代码或 git——learnhub 第二步落地。
  • payload 严格克制:只放认证必要的 userId / username绝不放密码、手机号、邮箱——payload 是 base64 不是加密,谁拿到 token 都能 atob 解开看原文。
  • 登出 / 改密下线靠黑名单或 token 版本号:JWT 一旦签发服务端没法主动让它失效——这是无状态的代价。生产要靠 Redis 黑名单或 user 表加 tokenVersion 字段。learnhub 没实现(用「access 短期 + refresh 轮换」降低风险),第七步老实说清楚。

这一章你会做出什么

  • 打开 learnhub 的 auth 模块,逐方法讲清楚:注册(md5+salt)、登录(签发双 token)、refresh(轮换)、profile(受保护)。
  • 吃透 access vs refresh 为什么要拆、过期值怎么定、refresh 轮换流程。
  • JwtAuthGuard 怎么消费 auth.service 生产的 token、把用户挂到 request.user(守卫的机制第五章讲过,这章聚焦「生产者 ↔ 消费者」的衔接)。
  • 老实说 md5 的弱点,给出 bcrypt/argon2 的生产升级代码——不改 learnhub,只教升级路径。
  • 学会 JWT 无状态登出 / 踢人下线的两种生产模式(Redis 黑名单、token 版本号),并知道 learnhub 为什么没做。

前置:第八章的 TypeORM + MySQL 在跑;第五章看过守卫和 @Public 装饰器。

第一步:装 JWT、看 User 实体的 password 双保险

cd learnhub
npm install @nestjs/jwt passport passport-github2   # JWT + 阶段12 的 GitHub OAuth

看 learnhub 的 User 实体,重点是 password 字段——它有两道独立的防线:

// learnhub/src/modules/user/entities/user.entity.ts
import { Exclude } from "class-transformer";
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity("user")
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 50, unique: true })
  username: string;

  // select:false:默认查询不出密码;需要时手动 addSelect
  // @Exclude:即使内存对象里有,序列化时也会被剥掉
  @Exclude()
  @Column({ length: 64, select: false, comment: "密码 MD5" })
  password: string;

  // ...其它字段
}

两道防线各管一段:

  • select: falseTypeORM 层的默认隐藏——平时 findOne / find 都不返回 password,必须显式 addSelect("u.password") 才出得来。learnhub 的 findByUsernameWithPassword 就是登录时手动 addSelect 拿密码做比对。
  • @Exclude()class-transformer 层的兜底——万一某个查询忘了 select: false、密码进了内存对象,控制器返回响应时 ClassSerializerInterceptor 会把它剥掉(第八章的 @UseInterceptors(ClassSerializerInterceptor) 就是干这个)。

注意:这两层不是冗余,是纵深防御。select: false 防「库里捞出来」,@Exclude 防「序列化发出去」——前者漏了还有后者兜底。生产里存敏感字段(密码、身份证、token)都建议这么双保险。

第二步:JwtModule.registerAsync——global、secret 走 ConfigService

新手写 JWT 配置十有八九是这样(别学这个):

// 反面教材:密钥硬编码、写进 git(别学这个)
JwtModule.register({
  secret: "my-secret",
  signOptions: { expiresIn: "7d" },
});

两个问题:密钥写死在代码里(一旦提交 git 就泄露,谁都能伪造你们的 token);只有一个 7 天的长 token(旧版课程就这么写,token 被偷 7 天内随便用——这就是为什么这一章要拆双 token)。learnhub 的真实写法是 registerAsync + ConfigService,配置全走 env:

// learnhub/src/modules/auth/auth.module.ts
import { ConfigModule, ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";

@Module({
  imports: [
    UserModule,
    PassportModule,
    JwtModule.registerAsync({
      global: true, // 全局可注入,不用每个模块再 import
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        secret: config.get<string>("jwt.secret"), // 走 env,绝不硬编码
        signOptions: {
          expiresIn: config.get<string>("jwt.accessExpires"), // 默认 access 过期
        },
      }),
    }),
  ],
  // ...
})
export class AuthModule {}

几个要点:

  • 为什么 registerAsync 不是 register:和第八章 TypeORM 的 forRootAsync 同理——ConfigService 是个 provider,要等 IoC 容器起来才能注入,同步的 register 拿不到。useFactory 把「等配置就绪 → 再注册 JwtModule」这件事交给 Nest。
  • 为什么 global: trueJwtService 不光 auth 模块用——JwtAuthGuard 是全局守卫(注册成 APP_GUARD),它也要注入 JwtService。不开 global,就得每个用到的模块都 imports: [JwtModule],啰嗦。
  • secret 走 envjwt.secret 来自 configuration.ts 读的 JWT_SECRET 环境变量,默认值只是本地兜底,生产必须覆盖。看 configuration.ts 的 jwt 段:
// learnhub/src/config/configuration.ts
jwt: {
  secret: process.env.JWT_SECRET || "learnhub_jwt_secret_change_me",
  accessExpires: process.env.JWT_ACCESS_EXPIRES || "30m",
  refreshExpires: process.env.JWT_REFRESH_EXPIRES || "7d",
},

access 默认 30 分钟、refresh 默认 7 天——这就是第四步要拆的双 token 的过期值来源。

注意(铁律):生产 JWT_SECRET 必须是足够长的随机串(openssl rand -hex 32 生成),放 .env 不进 git。密钥一旦泄露,谁都能伪造出能登录任何用户的 token——比数据库被拖还严重,因为不会留痕。

第三步:注册接口——md5+salt(教学)→ bcrypt(生产升级)

密码绝不能明文存。learnhub 的 register 用 md5 + 全局 salt:

// learnhub/src/modules/user/user.service.ts
async register(dto: RegisterDto): Promise<User> {
  const exists = await this.userRepo.existsBy({ username: dto.username });
  if (exists) throw new ConflictException("用户名已存在");

  const salt = this.config.get<string>("passwordSalt");
  const user = this.userRepo.create({
    username: dto.username,
    password: this.hashPassword(dto.password, salt), // md5 加盐
    email: dto.email,
  });
  // ...绑定默认角色、保存、发「user.registered」领域事件
  return saved;
}

private hashPassword(password: string, salt?: string): string {
  return createHash("md5").update(`${password}${salt ?? ""}`).digest("hex");
}

教学简化没问题,但得知道 md5 为什么不能上生产

  • 彩虹表:md5 出来固定 32 位 hex,常见密码(123456admin123)的 md5 早在公开彩虹表里,输个 hash 就能反查原文。
  • 全局 salt 不解决根本问题:learnhub 的 salt 是全局一份(PASSWORD_SALT),同一个密码 hash 出来一样。一旦 salt 泄露(代码或 env 被拿到),攻击者用这个 salt 预算一份新彩虹表,就能批量反查所有用户的密码。
  • md5 太快:md5 设计目标是快——这对密码存储反而是缺点。攻击者一秒能算几亿次,暴力穷举成本低。

生产升级(learnhub 教学用 md5):用 bcrypt,它两条根本不同——每用户随机 salt(同样密码每次 hash 出来不一样),且work factor 可调(专门设计得慢,让暴力穷举贵到不划算):

// 生产升级写法(learnhub 教学用 md5,不强行改它;新项目直接上 bcrypt/argon2)
import * as bcrypt from "bcrypt";
// 或 import * as argon2 from "argon2";  // 更新更强,抗 GPU/ASIC

// 注册:bcrypt 自带随机 salt,12 是 work factor(rounds),约 250ms 一次
const hash = await bcrypt.hash(password, 12);

// 登录:bcrypt.compare 内部从 hash 里取出 salt 和 rounds 再算一次比对
const ok = await bcrypt.compare(inputPassword, user.password);

两个要点:

  • bcrypt.hash 每次结果都不同(因为 salt 随机),所以不能 hash === user.password 直接比;必须用 bcrypt.compare,它自己从 user.password 里把 salt 和 rounds 解出来再算。
  • 12 是 work factor(2 的 12 次方轮),约 250ms 一次——慢是故意的,攻击者暴力穷举的代价指数级上升。硬件变快了就调到 13、14。不要轻易上 20+,否则登录接口本身被自己 DoS。

更新的选择是 argon2id(抗 GPU/ASIC 攻击更彻底),新项目可以直接上。learnhub 用 md5 是为了让课程聚焦「认证流程」本身、不被 hash 性能拖慢——但你要清楚这是教学债,生产里不算合格。

思考:既然 bcrypt 这么安全,为什么 learnhub 还留着全局 passwordSalt?——bcrypt 自己有 salt,全局 salt 在 bcrypt 路径下是冗余的。learnhub 留着是因为它的 md5 路径还需要;如果你升级到 bcrypt,全局 salt 可以删掉。

第四步:登录签发 access + refresh 双 token

这一节是和旧版课程差距最大的一节。旧章节承诺了 access + refresh,实际只签了一个 7 天的 token——这一节在 learnhub 真实代码里把它做出来。

为什么拆两个 token

  • access token 短期(learnhub 30m):每次业务请求都带它(Authorization: Bearer xxx),泄露窗口小——就算被偷,最多 30 分钟就过期。
  • refresh token 长期(learnhub 7d):只在调 /api/v1/auth/refresh 换新 access 时才带出来,平时不暴露在业务接口上。被偷了也只是能换新 access(仍然糟糕,但比直接拿业务权限好——而且可以靠第七步的黑名单 / 版本号补刀)。

如果只有一个长 token(旧版课程的 7 天),等于「token 被偷 = 7 天随便用」;拆开后,最常暴露的那个(access)最多损失 30 分钟。

learnhub 的实现:Promise.all 并发签两个

// learnhub/src/modules/auth/auth.service.ts
async login(dto: LoginDto): Promise<TokenPair> {
  const user = await this.userService.findByUsernameWithPassword(dto.username);
  if (!user) throw new UnauthorizedException("用户名或密码错误");

  // 比对密码(md5 加盐——教学简化,生产换 bcrypt,见第三步)
  const salt = this.config.get<string>("passwordSalt");
  const hash = createHash("md5").update(`${dto.password}${salt ?? ""}`).digest("hex");
  if (hash !== user.password) throw new UnauthorizedException("用户名或密码错误");

  return this.signTokens(user);
}

private async signTokens(user: User): Promise<TokenPair> {
  const payload = { userId: user.id, username: user.username };
  const [accessToken, refreshToken] = await Promise.all([
    this.jwtService.signAsync(payload, {
      expiresIn: this.config.get<string>("jwt.accessExpires"), // 30m
    }),
    this.jwtService.signAsync(payload, {
      expiresIn: this.config.get<string>("jwt.refreshExpires"), // 7d
    }),
  ]);
  return { accessToken, refreshToken };
}

几个细节值得讲:

  • 错误信息要模糊用户名或密码错误 不要写成 用户不存在 / 密码错误——后者等于告诉攻击者用户名是否存在,方便撞库。两边错都给同样的模糊信息。
  • Promise.all 并发签发:两个 signAsync 互不依赖,并发跑省几毫秒。这种「先把同步可计算的算完,再并发等异步」的写法在生产里很常见,比串行两个 await 干净。
  • payload 只放 userId + username:这是后面前端拿来做展示和后端做权限判断的最小必要信息。绝不放密码、邮箱、手机号、角色权限列表——payload 是 base64 编码,不是加密,谁拿到 token 用 atob 就能解开看到原文。

access 和 refresh 分别怎么存(前端的事,但后端得知道)

前端拿到 TokenPair 之后怎么存,决定了整套方案的安全性上限:

  • access token:放内存(Pinia/Redux/React state)或 sessionStorage,每次业务请求塞 Authorization: Bearer xxx。关页就丢,下次用 refresh 重换。
  • refresh token:放 httpOnly + Secure + SameSite cookie 最安全——JS 读不到(防 XSS 偷),浏览器只会在调 /auth/refresh 时带上。退一步放 localStorage 也行,但 XSS 一旦打中就被偷走。

注意:access token 放 localStorage 是最常见但有 XSS 风险的方案——一旦站点被注入恶意脚本,token 就被捞走。安全要求高的项目走「access 内存 + refresh httpOnly cookie」组合,CSRF 用 SameSite=Lax 挡掉。

第五步:refresh——无感刷新、token 轮换

access 30 分钟过期,用户每隔 30 分钟就被踢去重新登录?体验差。前端用 refresh token 调 /api/v1/auth/refresh 换一对新的,整个过程用户无感——这就是「无感刷新」。

// learnhub/src/modules/auth/auth.service.ts
/** 用 refresh_token 换新的双 token【节71 无感刷新】 */
async refresh(refreshToken: string): Promise<TokenPair> {
  let payload: { userId: number; username: string };
  try {
    payload = await this.jwtService.verifyAsync(refreshToken); // 先验签 + 查过期
  } catch {
    throw new UnauthorizedException("refresh_token 无效或已过期,请重新登录");
  }
  const user = await this.userService.findById(payload.userId);
  if (!user) throw new UnauthorizedException("用户不存在");
  return this.signTokens(user); // 重发一对新的
}
// learnhub/src/modules/auth/auth.controller.ts
@Public() // refresh 也公开——还没换到新 access 呢
@Post("refresh")
refresh(@Body() dto: RefreshDto): Promise<TokenPair> {
  return this.authService.refresh(dto.refreshToken);
}

流程是:access 过期 → 前端拦截到 401 → 拿 refresh token 调 /auth/refresh → 拿到新 access + 新 refresh → 重发刚才失败的请求。整个流程对用户透明。

几个关键点:

  • verifyAsync 而不是 verifyverifyAsync 返回 Promise,verify 是同步的(内部用 jsonwebtoken 的同步 API)。Nest 官方推荐 verifyAsync——错误能被 Promise 链正常 catch,不会抛同步异常打断事件循环。第六步的 guard 也用 verifyAsync
  • refresh 接口也 @Public:因为它本身就是要解决「access 过期」的问题,调用它的时候请求里没有有效 access——不能被 JwtAuthGuard 拦。
  • learnhub 的轮换是「弱轮换」:它签发一对新 token,但没有作废旧 refresh。也就是说如果攻击者偷了旧 refresh,在用户也用旧 refresh 换新的窗口期内,两边都能成功——这叫「refresh token 复用」,真实生产应该检测到「旧 refresh 被再次使用」就直接吊销整条链(怀疑被盗)。learnhub 没做这层,是教学简化;做这层需要把每次签发的 refresh 记进 Redis,下次来用就比对——第七步会讲到的 Redis 在这里也能复用。

思考:既然 refresh 用来换新 access,为什么不直接把 refresh 也搞成 30 分钟过期、access 搞成 7 天?——颠倒了。短期 = 频繁使用 = 易暴露长期 = 偶尔使用 = 暴露面小。把长期不暴露的 token 用于频繁的业务请求,被偷的概率大大上升。两 token 的核心是用频次区分暴露面,不是简单地「一个长一个短」。

第六步:JwtAuthGuard 消费 token + @Public 白名单

到这里 auth.service 生产了一堆 token,谁来消费?learnhub 的 JwtAuthGuard——第五章讲过守卫的机制,这里聚焦它和 auth.service 的衔接。

// learnhub/src/core/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly jwtService: JwtService, // 直接注入,能拿到是因为 auth.module 把 JwtModule 注册成 global
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    // 1. @Public 白名单:直接放行
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;

    // 2. 从 Authorization: Bearer xxx 取 token
    const req = this.getRequest(context);
    const auth = req.headers["authorization"] || "";
    if (!auth.startsWith("Bearer ")) {
      throw new UnauthorizedException("缺少登录凭证");
    }
    const token = auth.slice("Bearer ".length).trim();

    // 3. 验签 + 查过期;失败抛 401
    let payload: RequestUser;
    try {
      payload = await this.jwtService.verifyAsync(token); // 消费 auth.service.signAsync 生产的东西
    } catch {
      throw new UnauthorizedException("登录凭证无效或已过期");
    }

    // 4. 挂到 request.user,后续 @CurrentUser()、PermissionGuard 都用它
    req.user = { userId: payload.userId, username: payload.username };
    return true;
  }
  // ...getRequest 兼容 HTTP 和 GraphQL
}

几个关键点串起来:

  • 生产者 ↔ 消费者auth.service.signAsync(payload) 签发,jwt.service.verifyAsync(token) 验证——两边用的是同一个 JwtService 实例(因为 JwtModule 是 global 的)、同一份 secret。secret 不一致就永远验不过。
  • req.user 是衔接点:guard 把验出来的 payload 挂到 req.user,下游的 @CurrentUser() 装饰器从这里取(第二章讲过自定义参数装饰器):
    // learnhub/src/core/decorators/current-user.decorator.ts
    export interface RequestUser {
      userId: number;
      username: string;
    }
    export const CurrentUser = createParamDecorator((_data, ctx: ExecutionContext) => {
      return ctx.switchToHttp().getRequest().user as RequestUser;
    });
    控制器里 @CurrentUser() user: RequestUser 就能直接拿当前登录用户——业务代码不用再自己解 token。
  • @Public 让守卫放行:守卫第一步用 reflector.getAllAndOverrideIS_PUBLIC_KEY 元数据(装饰器写入的,第二章的 SetMetadata),handler 或 class 上标了 @Public() 就直接 return true。learnhub 的 register / login / refresh 都标了 @Public,因为它们本身就是要给「没登录的人」调的接口。
    // learnhub/src/core/decorators/public.decorator.ts
    export const IS_PUBLIC_KEY = "isPublic";
    export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
  • 全局注册需要 @Public 来开白名单:learnhub 在 app.module.tsJwtAuthGuard 注册成 APP_GUARD——所有接口默认都过它。这种「默认要登录」的策略很安全(漏标 @Public 顶多是 401,不会变成权限漏洞),代价就是公开接口必须显式 @Public
    // learnhub/src/app.module.ts
    { provide: APP_GUARD, useClass: JwtAuthGuard }, // 全局守卫:默认所有接口要登录
    { provide: APP_GUARD, useClass: PermissionGuard }, // 接着过权限守卫(下一章 RBAC)

注意:全局守卫一定要用 APP_GUARD provider 注册,不要 app.useGlobalGuards(new JwtAuthGuard())——后者是手动 new 出来的,Nest 的 DI 容器不接管它,reflectorjwtService 都注入不进去,跑到线上直接 NPE。

第七步:登出 / 踢人下线——JWT 无状态的盲区(learnhub 没实现)

这是旧版课程承诺「blacklist」却从没做的一节。JWT 是无状态的——signAsync 出来之后,token 就是个自包含的字符串,服务端没有任何记录它存在。这意味着:

  • 用户点「退出登录」:服务端没法让 token 失效——客户端把 token 删了就行,但如果 token 已经被偷了,退出登录对攻击者无效。
  • 用户改密码:旧 token 仍然有效——直到自然过期。
  • 管理员封号 / 踢人下线:同样做不到——服务端没法主动让某用户的 token 失效。

这是 JWT 无状态设计天生的代价,旧版课程一笔带过。生产里有两种主流方案:

方案一:Redis 黑名单(推荐)

每个 token 签发时加一个唯一 ID(jti,JWT 标准字段)。登出 / 封号时,把 jti 写进 Redis,TTL 设成 token 的剩余有效期(token 自然过期后黑名单条目也自动清掉)。守卫在 verifyAsync 之后多一步:查 Redis 里有没有这个 jti,有就拒绝。

// 生产加法(learnhub 没实现;这里给你升级路径)

// 签发时加 jti:
const payload = { userId: user.id, username: user.username, jti: randomUUID() };
const accessToken = await this.jwtService.signAsync(payload, { expiresIn: "30m" });

// 登出 / 封号时拉黑:
async logout(jti: string) {
  const remaining = 30 * 60; // 算 token 还剩多久过期
  await this.redis.set(`jwt:blacklist:${jti}`, "1", "EX", remaining);
}

// 守卫加一步查黑名单:
const payload = await this.jwtService.verifyAsync(token);
if (await this.redis.get(`jwt:blacklist:${payload.jti}`)) {
  throw new UnauthorizedException("token 已被吊销");
}

方案二:token 版本号

User 表加一列 tokenVersion: number(默认 0),签 token 时把 tokenVersion 也塞进 payload。改密码 / 踢人下线时 UPDATE user SET tokenVersion = tokenVersion + 1——所有旧 token 里的 tokenVersion 就和库里对不上了。守卫在 verifyAsync 之后查一下用户当前 tokenVersion,对不上就拒。

// 生产加法(learnhub 没实现)

// user.entity.ts
@Column({ default: 0 })
tokenVersion: number;

// auth.service.ts signTokens
const payload = { userId: user.id, username: user.username, v: user.tokenVersion };

// 改密码 / 踢人下线:
await this.userRepo.increment({ id: userId }, "tokenVersion", 1);

// 守卫:
const user = await this.userService.findById(payload.userId);
if (user.tokenVersion !== payload.v) {
  throw new UnauthorizedException("凭证已失效");
}

两种方案的取舍:

  • 黑名单:粒度细(能针对单个 token 吊销),但要 Redis、每次请求多一次 Redis 查询。
  • token 版本号:实现简单(不加基础设施),但粒度粗(一改密所有端都掉线,包括你刚改密码的当前 session——需要重登)。

learnhub 为什么都没做:教学项目,不想为了这章拖一个 Redis 进来。它的兜底是access 30 分钟——就算被偷、被遗忘、改密码没生效,最多 30 分钟后旧 token 自然失效。这对一个学习项目够了;生产项目得照着上面的方案补一套,尤其涉及支付、改密、敏感操作的。

思考:既然 access 短期、有 refresh、refresh 还能轮换,那为什么还需要主动失效(黑名单/版本号)?——因为 refresh token 也会被偷。攻击者拿到 refresh,就能在用户完全无感的情况下持续换新 access,相当于永久登录。无感刷新是把双刃剑:体验好,但 refresh 一旦泄露后果更严重。靠 access 短期挡不住这种攻击——必须有「让 refresh 也失效」的手段,这就是黑名单 / 版本号的真正用途。

总结:JWT 的「无状态」省了 session 存储,但登出 / 吊销这种需求天生和「无状态」冲突——你只能选「不要无状态」(加 Redis 或 DB 查询),别指望纯 JWT 能搞定。

第八步:跑通整个闭环

cd learnhub
docker compose -f docker/docker-compose.yml up -d mysql   # 起依赖
npm run migration:run                                       # 建表 + 灌种子
npm run start:dev                                           # 起服务
# 1. 注册(公开接口)
curl -X POST http://localhost:3000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"alice123456","email":"alice@example.com"}'
# {"id":1,"username":"alice",...}  注意:没有 password(@Exclude 剥掉了)

# 2. 登录,拿到双 token
curl -X POST http://localhost:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"alice123456"}'
# {"accessToken":"eyJ...","refreshToken":"eyJ..."}

# 3. 带 access 调受保护接口(/auth/profile 没标 @Public)
curl http://localhost:3000/api/v1/auth/profile \
  -H "Authorization: Bearer eyJ..."
# {"id":1,"username":"alice",...}

# 3a. 不带 token 调同一个接口 → 401
curl http://localhost:3000/api/v1/auth/profile
# {"message":"缺少登录凭证","statusCode":401}

# 4. 模拟 access 过期:用 refresh 换一对新的
curl -X POST http://localhost:3000/api/v1/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"eyJ...(登录时下发的 refresh)"}'
# {"accessToken":"eyJ...(新的)","refreshToken":"eyJ...(新的)"}

返回的 {"accessToken":"...","refreshToken":"..."} 就是 TokenPair——双 token 闭环跑通了。

GitHub OAuth:另一种登录方式(演示用)

learnhub 还有一个 GitHub 三方登录的 demo(/api/v1/auth/github),用 passport-github2 走 OAuth:

// learnhub/src/modules/auth/github.strategy.ts
@Injectable()
export class GitHubStrategy extends PassportStrategy(Strategy, "github") {
  constructor() {
    super({
      clientID: process.env.GITHUB_CLIENT_ID || "",
      clientSecret: process.env.GITHUB_CLIENT_SECRET || "",
      callbackURL:
        process.env.GITHUB_CALLBACK_URL ||
        "http://localhost:3000/api/v1/auth/github/callback",
      scope: ["user:email"],
    });
  }

  async validate(_accessToken, _refreshToken, profile, done) {
    // 演示:直接返回 profile;生产应按 profile.id upsert 本地 User(registerType=github)
    done(null, { provider: "github", githubId: profile.id, username: profile.username });
  }
}

它是用户名密码登录的补充,不是替代——让用户「用 GitHub 账号登录」省去注册。learnhub 这块是纯演示:控制器拿到 profile 后直接按 GitHub 数据签 JWT(auth.controller.tsgithubCallback),没有把 GitHub 用户 upsert 到本地 user 表,真实项目应该在 validate 里查 / 建本地用户、再签 token。这一章理解主流程就行,OAuth 的细节(passport、strategy、回调)单独成章再说。

这一章的成果

  1. User 实体的 password 双保险(select: false + @Exclude)——存敏感字段的标配。
  2. JwtModule.registerAsync 注册为 global,secret 走 env 不进 git——JWT_SECRET 是项目最高敏感配置之一。
  3. 老实说 md5+salt 的弱点,给出 bcrypt/argon2 的生产升级代码——不改 learnhub,但你知道升级路径。
  4. 在真实 auth.service.ts 里吃透双 token:access 短(30m)防泄露、refresh 长(7d)保体验,Promise.all 并发签发,payload 严格克制(只 userId + username)。
  5. 跑通 refresh 无感刷新:verifyAsync 验签 → 重签一对——并知道 learnhub 是「弱轮换」(没作废旧 refresh),生产要做强轮换。
  6. JwtAuthGuard 怎么消费 auth.service 生产的 token、把 payload 挂到 req.user@CurrentUser() 用;@Public 装饰器开白名单。
  7. 诚实学了 JWT 的盲区:无状态没法主动失效;两种生产方案(Redis 黑名单、token 版本号)的取舍;learnhub 用「access 30m」做兜底,生产要补一套。

常见问题

  • md5 安全吗:教学够用,生产不合格。换 bcrypt(每用户随机 salt + work factor)或 argon2id(更新更强)。learnhub 用 md5 是为了聚焦认证流程本身。
  • JWT 怎么主动失效(登出 / 改密下线 / 封号):纯 JWT 做不到,得加 Redis 黑名单(细粒度、要 Redis)或 user 表加 tokenVersion 字段(实现简单、粒度粗)。learnhub 没做,靠 access 30m 兜底。
  • token 放哪:access 放内存或 sessionStorage,每次业务请求塞 Authorization;refresh 放 httpOnly + Secure + SameSite cookie 最安全。放 localStorage 简单但有 XSS 风险。
  • verifyAsyncverify 有啥区别:前者返回 Promise、错误能被 try/catch 正常接住;后者是同步的、可能抛同步异常打断事件循环。Nest 官方推荐 verifyAsync
  • @Public 写在哪:handler 或 controller class 上都行——reflector.getAllAndOverride 同时看这两层。一般写在 handler 上(控制粒度细),整个 controller 公开就写在 class 上。
  • 全局守卫怎么注册:用 { provide: APP_GUARD, useClass: JwtAuthGuard } 走 DI;不要 app.useGlobalGuards(new JwtAuthGuard()),那个 DI 不管、依赖注入不进去。
  • 改密之后旧 token 还能用:是的,纯 JWT 没法主动失效。生产要靠第七步的黑名单 / token 版本号兜底。

下一章讲 权限(RBAC)——光「登录了」还不够,还要区分「普通用户」和「管理员」,不同角色能调不同接口。learnhub 的 PermissionGuard 紧跟在 JwtAuthGuard 后面,靠 @RequirePermission("post:create") 这种装饰器声明每个接口需要的权限。