JWT 认证流程
一、JWT 结构
JWT(JSON Web Token)是三段 Base64 编码用 . 连接的字符串:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20ifQ.HMAC_SIGNATURE
↑ Header ↑ Payload ↑ Signaturejson
// Header
{ "alg": "HS256", "typ": "JWT" }
// Payload(Claims)
{
"sub": 1, // 用户 ID
"email": "alice@example.com",
"role": "user",
"iat": 1710000000, // issued at(发签时间)
"exp": 1710000900 // expiry(过期时间 = iat + 15min)
}重要:Payload 不加密,只是 Base64 编码! 不要存敏感信息(密码、银行卡号等)。
二、完整认证流程
注册与登录
POST /auth/register
→ 创建用户,密码 bcrypt 哈希存储
→ 返回 access_token + refresh_token
POST /auth/login
→ LocalStrategy 验证邮箱 + 密码
→ AuthService.generateTokens() 签发两个 Token
→ 返回 access_token(15分钟)+ refresh_token(7天)访问受保护接口
GET /users/me
Headers: Authorization: Bearer <access_token>
│
▼
JwtAuthGuard(全局守卫)
│
▼
JwtStrategy.validate(payload)
│ ├── Token 无效/过期 → 401
│ └── 有效 → req.user = { id, email, role }
▼
@CurrentUser() user → Controller 处理Token 刷新
POST /auth/refresh
Body: { refresh_token: "..." }
│
▼
JwtRefreshStrategy 验证
│ └── 有效 → 签发新的 access_token(和新的 refresh_token)
▼
返回新 Token 对三、AuthService 完整实现
typescript
// auth/auth.service.ts
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
private configService: ConfigService,
) {}
// ── 注册 ─────────────────────────────────────
async register(dto: RegisterDto) {
const existing = await this.usersService.findByEmail(dto.email);
if (existing) throw new ConflictException('该邮箱已注册');
const hashedPassword = await bcrypt.hash(dto.password, 12);
const user = await this.usersService.create({
...dto,
password: hashedPassword,
});
return this.generateTokens(user);
}
// ── 验证用户(供 LocalStrategy 调用)──────────
async validateUser(email: string, password: string) {
const user = await this.usersService.findByEmailWithPassword(email);
if (!user) return null;
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) return null;
// 返回用户信息,不含密码
const { password: _, ...result } = user;
return result;
}
// ── 生成 Token 对 ─────────────────────────────
generateTokens(user: { id: number; email: string; role: string }) {
const payload = { sub: user.id, email: user.email, role: user.role };
const accessToken = this.jwtService.sign(payload, {
secret: this.configService.get('JWT_SECRET'),
expiresIn: '15m',
});
const refreshToken = this.jwtService.sign(payload, {
secret: this.configService.get('JWT_REFRESH_SECRET'),
expiresIn: '7d',
});
return {
access_token: accessToken,
refresh_token: refreshToken,
expires_in: 15 * 60, // 秒
};
}
// ── 刷新 Token ────────────────────────────────
async refreshTokens(userId: number) {
const user = await this.usersService.findOne(userId);
if (!user) throw new UnauthorizedException('用户不存在');
return this.generateTokens(user);
}
// ── 登出(Token 黑名单,需要 Redis)─────────────
async logout(token: string) {
// 将 token 加入黑名单,直到其自然过期
const decoded = this.jwtService.decode(token) as { exp: number };
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await this.cacheManager.set(`blacklist:${token}`, true, ttl * 1000);
}
}
}四、AuthController 完整实现
typescript
// auth/auth.controller.ts
import { Controller, Post, Body, UseGuards, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
@ApiTags('认证')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@ApiOperation({ summary: '用户注册' })
@Post('register')
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@ApiOperation({ summary: '用户登录' })
@HttpCode(HttpStatus.OK) // 默认 POST 是 201,登录应该是 200
@UseGuards(AuthGuard('local'))
@Post('login')
login(@CurrentUser() user: Express.User) {
return this.authService.generateTokens(user as any);
}
@ApiOperation({ summary: '刷新 Token' })
@HttpCode(HttpStatus.OK)
@UseGuards(AuthGuard('jwt-refresh'))
@Post('refresh')
refresh(@CurrentUser('id') userId: number) {
return this.authService.refreshTokens(userId);
}
@ApiOperation({ summary: '登出' })
@ApiBearerAuth()
@HttpCode(HttpStatus.NO_CONTENT)
@Post('logout')
async logout(@Headers('authorization') auth: string) {
const token = auth?.replace('Bearer ', '');
if (token) await this.authService.logout(token);
}
}五、DTO 定义
typescript
// auth/dto/register.dto.ts
import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
export class RegisterDto {
@IsEmail({}, { message: '请输入有效的邮箱地址' })
email: string;
@IsString()
@MinLength(2, { message: '用户名至少 2 个字符' })
@MaxLength(50)
name: string;
@IsString()
@MinLength(8, { message: '密码至少 8 位' })
@MaxLength(100)
@Matches(/((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/, {
message: '密码必须包含大小写字母和数字',
})
password: string;
}
// auth/dto/login.dto.ts
export class LoginDto {
@IsEmail()
email: string;
@IsString()
@MinLength(1)
password: string;
}六、Token 安全存储策略
方案对比
| 存储位置 | XSS 风险 | CSRF 风险 | 推荐场景 |
|---|---|---|---|
| localStorage | 高(JS 可读) | 无 | 不推荐 |
| sessionStorage | 高(JS 可读) | 无 | 临时会话 |
| HttpOnly Cookie | 无(JS 不可读) | 有 | 同域 Web App |
| 内存(变量) | 无 | 无 | SPA(刷新丢失) |
最佳实践:双 Token + HttpOnly Cookie
typescript
// 服务端:将 refresh_token 放 HttpOnly Cookie
@Post('login')
login(@CurrentUser() user: User, @Res({ passthrough: true }) res: Response) {
const tokens = this.authService.generateTokens(user);
// refresh_token 存 Cookie(JS 不可读)
res.cookie('refresh_token', tokens.refresh_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS only
sameSite: 'strict', // 防 CSRF
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 天
path: '/auth/refresh', // 只在 /auth/refresh 路径发送
});
// access_token 返回给前端(存内存或 sessionStorage)
return { access_token: tokens.access_token };
}七、Token 黑名单(登出后失效)
JWT 是无状态的,默认无法在过期前使其失效。黑名单方案:
typescript
// auth/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(
private reflector: Reflector,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.replace('Bearer ', '');
// 检查黑名单
if (token) {
const isBlacklisted = await this.cacheManager.get(`blacklist:${token}`);
if (isBlacklisted) throw new UnauthorizedException('Token 已失效,请重新登录');
}
return super.canActivate(context) as Promise<boolean>;
}
}八、常见问题排查
问题 1:validate() 不被调用
原因:守卫拦截前 token 就验证失败了(格式错误、密钥不匹配)。
typescript
// 检查:
// 1. Authorization 头格式是否正确:Bearer <token>(注意大写 B 和空格)
// 2. JWT_SECRET 环境变量是否加载
// 3. 签发和验证用的 secret 是否一致问题 2:Token 过期时间不符预期
typescript
// 签发时间和过期时间是 Unix 时间戳(秒)
// expiresIn 可以是字符串 '15m' '7d' '1h' 或秒数 900
const decoded = jwtService.decode(token);
console.log(new Date(decoded.exp * 1000)); // 转为可读时间问题 3:刷新 Token 泄露
typescript
// Refresh Token 轮换:每次使用后签发新的,旧的失效
async refreshTokens(userId: number, oldRefreshToken: string) {
// 先验证旧 token 在数据库中存在且未使用
const stored = await this.userRepo.findOne({
where: { id: userId, refreshToken: hash(oldRefreshToken) },
});
if (!stored) throw new UnauthorizedException('Refresh Token 已失效');
const newTokens = this.generateTokens(stored);
// 更新数据库中的 refresh token
await this.userRepo.update(userId, {
refreshToken: hash(newTokens.refresh_token),
});
return newTokens;
}常见错误
| 错误 | 原因 | 解决 |
|---|---|---|
JsonWebTokenError: invalid signature | 签名密钥不一致(生成与验证用了不同 secret) | 统一从 ConfigService 读取,不要硬编码 |
TokenExpiredError: jwt expired | Token 已过期 | 捕获异常后返回 401,客户端用 refresh token 续签 |
UnauthorizedException 但 Token 正确 | JwtStrategy 的 validate() 返回了 null 或 false | 检查 validate 中的 DB 查询,用户可能已被删除 |
| BearerToken 传递但 Guard 没触发 | Controller 未加 @UseGuards(JwtAuthGuard) | 确认 Guard 装饰器存在;全局 Guard 需在 AppModule 配置 |
Passport LocalStrategy 报 Missing credentials | 默认字段是 username,但发送的是 email | super({ usernameField: 'email' }) |
| refresh token 重复使用 | 旋转策略未删除旧 token | 使用后立即从 Redis 删除旧 refresh token |
可运行 Demo:
practice/04-auth-system— register / login / refreshToken 完整流程(npm start→ :3001/api)