第 17 章:JWT 认证
学习目标
- 用 @nestjs/jwt 实现登录
- 写 JwtStrategy 和 JwtAuthGuard
- 实现 token 刷新
- 避开 4 个 JWT 坑
一、什么是 JWT
JWT(JSON Web Token)是无状态 token,由三段组成:header.payload.signature,服务器只校验签名不存 session。
二、安装
bash
pnpm add @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
pnpm add -D @types/passport-jwt三、配置 JwtModule
typescript
// app.module.ts
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (cfg: ConfigService) => ({
secret: cfg.get('JWT_SECRET'),
signOptions: { expiresIn: '1h' }, // 1 小时过期
}),
}),
],
})
export class AppModule {}四、登录接口
typescript
// auth/auth.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('login')
async login(@Body() dto: LoginDto) {
const user = await this.auth.validate(dto.username, dto.password);
return this.auth.login(user); // 返回 token
}
@Post('register')
register(@Body() dto: RegisterDto) {
return this.auth.register(dto);
}
}typescript
// auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User) private readonly users: Repository<User>,
private readonly jwt: JwtService,
) {}
async validate(username: string, password: string) {
const user = await this.users.findOne({ where: { username } });
if (!user) throw new UnauthorizedException('用户不存在');
const ok = await bcrypt.compare(password, user.password);
if (!ok) throw new UnauthorizedException('密码错误');
return user;
}
async login(user: User) {
const payload = { sub: user.id, username: user.username };
return {
access_token: this.jwt.sign(payload),
expires_in: 3600,
};
}
async register(dto: RegisterDto) {
const hash = await bcrypt.hash(dto.password, 10);
const user = this.users.create({ username: dto.username, password: hash });
await this.users.save(user);
return { id: user.id, username: user.username };
}
}测试:
bash
curl -X POST http://localhost:3000/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"tom","password":"123456"}'
# {"access_token":"eyJhbGc...","expires_in":3600}⚠️ 坑 1:密码明文存数据库 → 用
bcrypt.hash(password, 10)加盐哈希。
五、JwtStrategy
typescript
// auth/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(cfg: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Bearer xxx
ignoreExpiration: false,
secretOrKey: cfg.get('JWT_SECRET'),
});
}
async validate(payload: any) {
// payload = { sub, username }
return { id: payload.sub, username: payload.username }; // 写入 req.user
}
}注册:
typescript
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({ /* ... */ }),
],
providers: [JwtStrategy, AuthService],
})
export class AuthModule {}六、JwtAuthGuard
typescript
// auth/jwt.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}挂载:
typescript
@Controller('profile')
@UseGuards(JwtAuthGuard)
export class ProfileController {
@Get()
me(@Req() req) { return req.user; } // { id, username }
}JwtGuard + JwtStrategy 配合关系:
| 类 | 职责 |
|---|---|
JwtStrategy | 怎么验证(secret、提取 token、validate 函数) |
JwtAuthGuard | 什么时候触发(路由拦截 → 触发 Passport) |
靠 'jwt' 字符串关联:
typescript
export class JwtStrategy extends PassportStrategy(Strategy);
// ^^^^^^^^
// PassportStrategy 内部注册成 'jwt' 名字
export class JwtAuthGuard extends AuthGuard('jwt');
// ^^^^
// 字符串 'jwt' 跟 Strategy 对应完整流程:
GET /api/profile Authorization: Bearer eyJ...
↓
NestJS 拦截 → JwtAuthGuard.canActivate()
↓
Guard 调 Passport → 找 'jwt' Strategy → 找到 JwtStrategy
↓
JwtStrategy 用 cfg.get('JWT_SECRET') 验证 token
↓
┌─ 失败 → 抛 401 Unauthorized
└─ 成功 → 调 validate(payload) → 拿到 { id, username }
↓
Passport 写到 req.user
↓
Guard 通过 → 到 handler
↓
return req.user; // { id, username }类比:
| 角色 | 比喻 |
|---|---|
| JwtStrategy | 保安培训手册(怎么验证身份证) |
| JwtAuthGuard | 门口的保安(执行手册) |
'jwt' 字符串 | 手册编号(两个对得上) |
记忆口诀:
- JwtStrategy = 怎么验证(token 提取 + secret + validate)
- JwtAuthGuard = 什么时候触发(路由拦截)
'jwt'字符串 = 两者的连接点,必须一致req.user= 来自validate()的返回值
测试:
bash
TOKEN="eyJhbGc..."
curl http://localhost:3000/profile -H "Authorization: Bearer $TOKEN"⚠️ 坑 2:
AuthGuard('jwt')字符串写错 → guard 不生效,接口裸奔。
七、刷新 token
typescript
@Post('refresh')
async refresh(@Body('token') token: string) {
try {
const payload = await this.jwt.verifyAsync(token, {
secret: this.cfg.get('JWT_REFRESH_SECRET'),
});
return this.auth.login({ id: payload.sub, username: payload.username });
} catch {
throw new UnauthorizedException('invalid refresh token');
}
}access token 用一个 secret,refresh 用另一个,refresh 有效期长(如 7 天)。
⚠️ 坑 3:access 和 refresh 用同一个 secret → refresh 被吊销时 access 也废。
八、Token 黑名单(强制下线)
JWT 无状态,要做主动失效只能维护一个黑名单:
typescript
// 把 token jti 塞 Redis,设过期时间
await this.redis.set(`black:${jti}`, '1', 'EX', 3600);校验前检查 Redis,命中就拒绝。
九、Passport 模块全局
typescript
import { PassportModule } from '@nestjs/passport';
@Module({
imports: [PassportModule.register({ session: false })],
})
export class AppModule {}十、实战:登录 + 受保护接口
typescript
@Controller('users')
export class UsersController {
@Get()
list() { return this.svc.findAll(); } // 公开
@Get('me')
@UseGuards(JwtAuthGuard)
me(@Req() req) { return req.user; } // 需登录
}⚠️ 坑 4:
@UseGuards写在类级别,公开接口也要走 guard → 用@Public()元数据跳过(见第 9 章)。
十一、本章小结
| 要点 | 关键 |
|---|---|
| 模块 | JwtModule.registerAsync({secret, expiresIn}) |
| 策略 | PassportStrategy(Strategy) + validate |
| Guard | AuthGuard('jwt') |
| 取用户 | req.user(validate 返回值) |
| 密码 | bcrypt.hash/compare |
| 安全 | access + refresh 双 token |
动手练习
- 登录:写
/auth/login,返回 JWT - 保护:写
/profile,用 JwtAuthGuard 保护 - 刷新:写
/auth/refresh,换新 token
下一章:第 18 章:RBAC 权限 →