Skip to content

JWT 认证流程

一、JWT 结构

JWT(JSON Web Token)是三段 Base64 编码用 . 连接的字符串:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20ifQ.HMAC_SIGNATURE
    ↑ Header                  ↑ Payload                                         ↑ Signature
json
// 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(刷新丢失)
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 expiredToken 已过期捕获异常后返回 401,客户端用 refresh token 续签
UnauthorizedException 但 Token 正确JwtStrategy 的 validate() 返回了 nullfalse检查 validate 中的 DB 查询,用户可能已被删除
BearerToken 传递但 Guard 没触发Controller 未加 @UseGuards(JwtAuthGuard)确认 Guard 装饰器存在;全局 Guard 需在 AppModule 配置
Passport LocalStrategy 报 Missing credentials默认字段是 username,但发送的是 emailsuper({ usernameField: 'email' })
refresh token 重复使用旋转策略未删除旧 token使用后立即从 Redis 删除旧 refresh token

可运行 Demo: practice/04-auth-system — register / login / refreshToken 完整流程(npm start → :3001/api)

NestJS 深度学习体系