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 拼接,登录成功后服务端用密钥签发,里面塞 userId、username 这些自包含的信息,签名保证它没法被篡改。后续请求把它放在 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: false是 TypeORM 层的默认隐藏——平时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: true:JwtService不光auth模块用——JwtAuthGuard是全局守卫(注册成APP_GUARD),它也要注入JwtService。不开global,就得每个用到的模块都imports: [JwtModule],啰嗦。 - secret 走 env:
jwt.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,常见密码(
123456、admin123)的 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 + SameSitecookie 最安全——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而不是verify:verifyAsync返回 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.getAllAndOverride读IS_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.ts把JwtAuthGuard注册成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 容器不接管它,reflector 和 jwtService 都注入不进去,跑到线上直接 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.ts 的 githubCallback),没有把 GitHub 用户 upsert 到本地 user 表,真实项目应该在 validate 里查 / 建本地用户、再签 token。这一章理解主流程就行,OAuth 的细节(passport、strategy、回调)单独成章再说。
这一章的成果
- 看
User实体的password双保险(select: false+@Exclude)——存敏感字段的标配。 JwtModule.registerAsync注册为global,secret 走 env 不进 git——JWT_SECRET是项目最高敏感配置之一。- 老实说 md5+salt 的弱点,给出 bcrypt/argon2 的生产升级代码——不改 learnhub,但你知道升级路径。
- 在真实
auth.service.ts里吃透双 token:access 短(30m)防泄露、refresh 长(7d)保体验,Promise.all并发签发,payload 严格克制(只userId+username)。 - 跑通 refresh 无感刷新:
verifyAsync验签 → 重签一对——并知道 learnhub 是「弱轮换」(没作废旧 refresh),生产要做强轮换。 - 看
JwtAuthGuard怎么消费auth.service生产的 token、把 payload 挂到req.user给@CurrentUser()用;@Public装饰器开白名单。 - 诚实学了 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 + SameSitecookie 最安全。放localStorage简单但有 XSS 风险。 verifyAsync和verify有啥区别:前者返回 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") 这种装饰器声明每个接口需要的权限。