白留白WHITE SPACE
返回所有文章
NestJS / 后端开发

NestJS 实战 05:注册登录与 JWT 鉴权

实现 register 与 login 两条认证流程。登录后签发 JWT Token,并支持读取当前用户信息。建立 BBS 的身份认证主链路。

本文属于 NestJS BBS 实战教程,共 11 节,建议按顺序学习。

1. 这节课要实现的功能是什么

  • 实现 register 与 login 两条认证流程。
  • 登录后签发 JWT Token,并支持读取当前用户信息。
  • 建立 BBS 的身份认证主链路。

2. 这节课涉及的 NestJS 知识点有哪些(先讲概念)

2.1 先理解 Guard:它在认证里到底做什么

Guard 是请求阶段的“入口判断器”。它运行在 Controller 方法执行前,决定这个请求是否允许继续往下走。

在认证场景里,Guard 最重要的职责不是自己写完整校验逻辑,而是:

  • 拦截请求;
  • 触发对应认证策略;
  • 在校验成功后,把用户信息挂到请求对象上;
  • 在校验失败时,直接拒绝请求。

所以你可以把 Guard 理解成认证链路的“开关”。

2.2 再理解 Strategy:它负责定义“怎么验证”

Strategy 负责描述验证规则,而不是决定是否拦截请求。

在本课里有两种 Strategy:

  • LocalStrategy:校验登录时提交的邮箱和密码;
  • JwtStrategy:校验后续请求里携带的 JWT Token。

也就是说:

  • Guard 负责“什么时候触发校验”;
  • Strategy 负责“具体怎么校验”。

2.3 为什么认证里要同时有 Guard 和 Strategy

这是 Nest + Passport 认证模型的核心拆分。

如果没有 Guard,每个 Controller 方法都要自己手动校验身份;
如果没有 Strategy,每种认证方式的实现就会散落在各个接口里,无法复用。

两者配合后,请求链路会变得很清晰:

  1. 请求先进入 Guard;
  2. Guard 触发对应 Strategy;
  3. Strategy 完成身份校验;
  4. 成功后把结果放到 req.user;
  5. Controller 再读取 req.user 继续处理业务。

2.4 本课认证链路的职责拆分

  • AuthController:定义路由,接收请求;
  • AuthService:处理注册、密码比对、签发 Token;
  • LocalAuthGuard / JwtAuthGuard:拦截请求并触发认证;
  • LocalStrategy / JwtStrategy:定义具体验证规则;
  • RolesGuard:在已登录基础上继续做角色权限判断。

2.5 Local 与 JWT 的分工

  • Local:解决“你登录时是不是这个人”;
  • JWT:解决“你后续请求时还能不能证明自己是谁”。

所以登录请求和业务请求的验证方式并不一样。

2.6 一次请求里,用户是如何被验证的

以两类请求为例:

  1. POST /auth/login 登录请求先经过 LocalAuthGuard,再进入 LocalStrategy。
    LocalStrategy 会调用 AuthService.validateUser() 去查用户、比对密码。成功后把用户信息写入 req.user,Controller 再签发 JWT。

  2. GET /auth/profile 或带权限的业务接口 请求先经过 JwtAuthGuard,再进入 JwtStrategy。
    JwtStrategy 从请求头提取 Bearer Token,校验签名和过期时间,再把 token 中的用户信息恢复为 req.user。如果接口还用了 RolesGuard,会继续检查 req.user.role 是否满足要求。

2.7 JWT 的解析是如何实现的

在这个项目里,JWT 不是在 Controller 或 AuthService 里手动解析的,而是交给 JwtAuthGuard + JwtStrategy 这一套来完成。

执行过程可以拆成四步:

  1. JwtAuthGuard 拦截请求;
  2. Passport 根据 AuthGuard('jwt') 找到 JwtStrategy;
  3. JwtStrategy 从请求头里提取 Bearer Token,并完成签名与过期时间校验;
  4. 校验通过后,validate(payload) 把 payload 转成统一的用户对象,挂到 req.user。

所以你在 Controller 里看到的 req.user,并不是 Controller 自己生成的,而是 Guard 和 Strategy 在前面已经处理好的结果。

2.8 JwtModule.registerAsync 解决什么问题

JWT 密钥通常来自环境变量。registerAsync 允许你通过 ConfigService 动态读取配置,让开发、测试、生产环境使用不同密钥而不需要改代码。

2.9 如果希望“默认所有路由都需要登录”,应该怎么做

当前这节课的实现方式是“谁需要鉴权,就在谁的路由上显式挂 @UseGuards(JwtAuthGuard)”。
但在真实项目里,更常见的需求是:默认所有业务路由都需要登录,只有注册、登录这类接口例外。

这种场景的标准做法是:

  1. 把 JwtAuthGuard 注册成全局 Guard;
  2. 再提供一个 @Public() 装饰器,用来标记不需要登录的白名单路由。

这样设计的好处是:

  • 安全默认值更高,避免漏挂 Guard;
  • 新增业务接口时,默认就受保护;
  • 登录、注册、健康检查等公共接口可以显式放行。

2.10 canActivate() 到底是什么意思

canActivate() 可以直接理解成一句话:这个请求现在能不能继续往下执行。

它的返回值含义很明确:

  • 返回 true:放行,请求继续进入后续链路;
  • 返回 false:拒绝,请求不会进入 Controller;
  • 抛出异常:立即中断,并返回对应错误响应;
  • 返回 Promise<boolean> 或 Observable<boolean>:只是异步版本,语义不变。

所以当你看到 Guard 里实现 canActivate() 时,本质上就是在写“放行规则”。

3. 实际开发中这些 NestJS 知识点被如何使用(详细代码)

3.1 src/auth/auth.module.ts - 认证模块装配

import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { LocalAuthGuard } from './guards/local-auth.guard';
import { RolesGuard } from './guards/roles.guard';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';

@Module({
  imports: [
    UsersModule,
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_SECRET', 'dev-secret'),
        signOptions: { expiresIn: '1h' },
      }),
    }),
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    LocalStrategy,
    JwtStrategy,
    LocalAuthGuard,
    JwtAuthGuard,
    RolesGuard,
  ],
  exports: [AuthService, JwtAuthGuard, RolesGuard],
})
export class AuthModule {}

3.2 src/auth/auth.controller.ts - 在路由上挂 Guard

import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { LocalAuthGuard } from './guards/local-auth.guard';
import { AuthUser } from './types/auth-user';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('register')
  register(@Body() dto: RegisterDto) {
    return this.authService.register(dto);
  }

  @UseGuards(LocalAuthGuard)
  @Post('login')
  login(@Req() req: Request & { user: AuthUser }) {
    return this.authService.login(req.user);
  }

  @UseGuards(JwtAuthGuard)
  @Get('profile')
  profile(@Req() req: Request & { user: AuthUser }) {
    return req.user;
  }
}

这里最关键的一点是:
login() 和 profile() 进入方法体之前,用户身份已经由 Guard 完成验证。Controller 只消费 req.user。

3.3 src/auth/guards/local-auth.guard.ts - 登录入口守卫

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

这个类本身非常薄,因为真正的校验逻辑不写在 Guard 里,而是交给名为 local 的 Strategy。

3.4 src/auth/strategies/local.strategy.ts - 登录阶段校验

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from '../auth.service';
import { LoginDto } from '../dto/login.dto';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
  constructor(private readonly authService: AuthService) {
    super({ usernameField: 'email' });
  }

  async validate(email: string, password: string) {
    const dto: LoginDto = { email, password };
    return this.authService.validateUser(dto);
  }
}

3.5 src/auth/auth.service.ts - 注册、密码校验与签发 Token

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { UsersService } from '../users/users.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { AuthUser } from './types/auth-user';

@Injectable()
export class AuthService {
  constructor(
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService,
  ) {}

  async register(dto: RegisterDto): Promise<{
    accessToken: string;
    user: AuthUser;
  }> {
    const user = await this.usersService.create(dto);
    return this.login({
      userId: user.id,
      email: user.email,
      username: user.username,
      role: user.role,
    });
  }

  async validateUser(dto: LoginDto): Promise<AuthUser> {
    const user = await this.usersService.findOneByEmail(dto.email);
    if (!user) {
      throw new UnauthorizedException('Invalid credentials');
    }

    const matched = await bcrypt.compare(dto.password, user.passwordHash);
    if (!matched) {
      throw new UnauthorizedException('Invalid credentials');
    }

    return {
      userId: user.id,
      email: user.email,
      username: user.username,
      role: user.role,
    };
  }

  async login(
    user: AuthUser,
  ): Promise<{ accessToken: string; user: AuthUser }> {
    const payload = {
      sub: user.userId,
      email: user.email,
      username: user.username,
      role: user.role,
    };
    return {
      accessToken: await this.jwtService.signAsync(payload),
      user,
    };
  }
}

3.6 src/auth/guards/jwt-auth.guard.ts 与 src/auth/strategies/jwt.strategy.ts - 持续访问校验

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthUser } from '../types/auth-user';

interface JwtPayload {
  sub: number;
  email: string;
  username: string;
  role: 'user' | 'moderator' | 'admin';
}

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(private readonly configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get<string>('JWT_SECRET', 'dev-secret'),
    });
  }

  validate(payload: JwtPayload): AuthUser {
    return {
      userId: payload.sub,
      email: payload.email,
      username: payload.username,
      role: payload.role,
    };
  }
}

这一段代码正好对应 JWT 解析的四个动作:

  • ExtractJwt.fromAuthHeaderAsBearerToken():从 Authorization: Bearer <token> 中提取 token;
  • secretOrKey:使用密钥校验签名;
  • ignoreExpiration: false:要求 token 不能过期;
  • validate(payload):把解码结果转换成项目内部统一使用的 AuthUser,并交给后续请求链路使用。

3.7 src/auth/guards/roles.guard.ts - 登录之后再做权限判断

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import type { UserRole } from '../../users/users.repository';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
      ROLES_KEY,
      [context.getHandler(), context.getClass()],
    );
    if (!requiredRoles || requiredRoles.length === 0) {
      return true;
    }

    const request = context
      .switchToHttp()
      .getRequest<{ user?: { role?: UserRole } }>();

    return !!request.user?.role && requiredRoles.includes(request.user.role);
  }
}

这说明认证和鉴权是两层事情:

  • JwtAuthGuard 先确认“你是谁”;
  • RolesGuard 再确认“你有没有权限做这件事”。

3.8 一次请求的真实执行顺序

以 PATCH /users/:id/role 为例:

  1. 请求先进入 JwtAuthGuard;
  2. JwtAuthGuard 触发 JwtStrategy 校验 Token;
  3. 校验成功后,用户信息被放到 req.user;
  4. RolesGuard 继续检查 req.user.role 是否满足 @Roles('admin');
  5. 通过后,Controller 方法才开始执行;
  6. @CurrentUser() 只是把 req.user 取出来,并不负责验证。

3.9 进阶方案:把 JwtAuthGuard 变成全局 Guard

这不是当前分支已经落地的代码,而是你在真实项目中很常见的下一步演进方案。

第一步,定义 @Public() 装饰器:

import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

第二步,让 JwtAuthGuard 支持跳过公共路由:

import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private readonly reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (isPublic) {
      return true;
    }

    return super.canActivate(context);
  }
}

第三步,用 APP_GUARD 注册全局 Guard:

import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';

@Module({
  providers: [
    JwtAuthGuard,
    {
      provide: APP_GUARD,
      useExisting: JwtAuthGuard,
    },
  ],
})
export class AuthModule {}

第四步,在不需要登录的接口上显式标记:

@Public()
@Post('register')
register(@Body() dto: RegisterDto) {
  return this.authService.register(dto);
}

@Public()
@Post('login')
login(@Req() req: Request & { user: AuthUser }) {
  return this.authService.login(req.user);
}

这样之后,系统行为会变成:

  • 默认所有路由都先经过 JwtAuthGuard;
  • 标记了 @Public() 的路由直接放行;
  • 登录后的接口仍然可以继续叠加 RolesGuard 做角色权限控制。

3.10 本节落地价值

你已经把“登录一次、后续带 Token 访问”的标准认证模型跑通,这是任何真实后端系统的核心基础。

4. 总结这节课知识点

  • 你理解了 Guard 在认证里的入口职责。
  • 你掌握了 Guard -> Strategy -> req.user -> Controller 这条认证主链路。
  • 你理解了 Local/JWT Strategy 的职责边界。
  • 你知道了认证和鉴权是两层不同的校验。
  • 你知道了如何把 JwtAuthGuard 演进为“默认全局鉴权 + @Public() 白名单放行”模式。
  • 你可以继续扩展 Refresh Token、黑名单、第三方登录等能力。

5. 每节课分支

  • lesson-05-auth-jwt
  • 远程仓库:https://github.com/Shiuwn/nestjs-tutorial.git

返回课程目录

上一课:ConfigModule 与基础数据库生命周期

下一课:Guard、Decorator 与 RBAC 权限控制

— 感谢阅读 —