「项目实战」从0搭建NestJS后端服务(七):JWT身份验证以及管道验证

92 阅读2分钟

前言

hello, 我是elk。本文将详细介绍如何在NestJS项目中实现身份验证,包括令牌认证、管道验证、本地认证以及JWT认证。我们还将探讨如何结合Redis来增强令牌验证的安全性。

我们将验证单独作为一个模块服务,里面包含登录、登出、获取验证码等相关验证操作

# 生成auth专属的模块
nest g res auth

一、管道验证

管道验证是NestJS中一种强大的验证机制,它允许我们在请求到达控制器之前对请求参数进行验证。如果参数不符合规则,管道会直接抛出错误,阻止请求继续执行。

插件安装

pnpm install --save class-validator class-transformer

创建验证dto

  • create-auth.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import {
  IsNotEmpty,
  IsString,
  MinLength,
  MaxLength,
  Matches,
} from 'class-validator';
export class CreateAuthDto {
  @ApiProperty({ description: '用户名', required: true })
  @IsNotEmpty({ message: '用户名不能为空' })
  @IsString({ message: '用户名必须为字符串' })
  username: string;
  @ApiProperty({ description: '密码', required: true })
  @IsString({ message: '密码必须为字符串' })
  @IsNotEmpty({ message: '密码不能为空' })
  @Matches(/^[a-zA-Z0-9]{6,20}$/, {
    message: '密码必须为6-20位字母或数字',
  })
  password: string;
  @ApiProperty({ description: '验证码' })
  @MinLength(4, { message: '验证码长度不能小于4' })
  @MaxLength(4, { message: '验证码长度不能大于4' })
  code: string;
}

创建参数验证管道

src/common/pipes/params-verify.pipe.ts中,我们创建了一个ParamsVerifyPipe类,用于验证请求参数:

  • src/common/pipes/params-verify.pipe.ts
import {
  ArgumentMetadata,
  Injectable,
  PipeTransform,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
@Injectable()
export class ParamsVerifyPipe implements PipeTransform {
  // 参数转换和验证方法
  async transform(value: any, metadata: ArgumentMetadata) {
    // 验证是否为类
    if (!metadata.metatype || !this.toValidate(metadata.metatype)) {
      return value;
    }
    // 将普通对象转换为DTO实例
    const DTO = plainToInstance(metadata.metatype, value);
    // 验证DTO实例
    const errors = await validate(DTO);
    // 如果有验证错误,抛出异常
    if (errors.length > 0) {
      throw new HttpException(this.handleError(errors), HttpStatus.BAD_REQUEST);
    }
    // 验证通过,返回原始值
    return value;
  }
​
  // 判断是否需要验证的类型
  private toValidate(metatype: Function): boolean {
    // 不需要验证的基本类型
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
​
  // 处理验证错误信息
  private handleError(errors: any[]) {
    // 将错误信息转换为更友好的格式
    const messages = errors.map((error) => {
      return Object.values({
        paramName: error.property, // 参数名称
        message: Object.values(error.constraints), // 验证失败信息
      });
    });
    return messages;
  }
}

参数验证管道的使用

auth.controller.ts中,我们使用ParamsVerifyPipe来验证登录请求的参数:

  • auth.controller.ts
import { ParamsVerifyPipe } from '@/common/pipes/params-verify.pipe';

@Post('login')
  @ApiOperation({ summary: '登录', description: '登录接口获取token' })
  @ApiBody({ type: CreateAuthDto })
  signIn(@Body(new ParamsVerifyPipe()) createAuthDto: CreateAuthDto) {
    return this.authService.signIn(createAuthDto);
  }

二、本地认证

本地认证是JWT认证的前提,它用于验证用户的用户名和密码是否正确。如果验证成功,用户将获得一个JWT令牌,否则直接抛出无效访问,这样可以做到有效用户才能获取jwt,防止令牌滥用

插件安装

# 主要工具
pnpm install --save @nestjs/passport passport passport-local
# 类型提示
pnpm install --save-dev @types/passport-local

验证用户

authService中,我们创建了一个validateUser方法,用于验证用户的用户名和密码:

// auth.service.ts
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
@Injectable()
export class AuthService {
  constructor(private readonly usersService: UsersService) {}
  async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }
}

注意:在auth服务中使用user服务中的service方法,需要在authModule中引入userModule『user服务没有全局模块抛出』

本地验证策略

auth/strategies/local.strategy.ts中,我们创建了一个LocalStrategy类,用于本地认证:

import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
​
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super();
  }
​
  async validate(username: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

注册策略

auth.module.ts中,我们注册了LocalStrategy

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy';
​
@Module({
  imports: [UsersModule, PassportModule],
  providers: [AuthService, LocalStrategy],
})
export class AuthModule {}

本地认证守卫

auth/guards/local.guard.ts中,我们创建了一个LocalAuthGuard类,用于本地认证守卫:

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
​
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

启动本地认证

auth.controller.ts中,我们使用LocalAuthGuard来保护登录接口:

import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { CreateAuthDto } from './dto/create-auth.dto';
import { UpdateAuthDto } from './dto/update-auth.dto';
import { ParamsVerifyPipe } from '@/common/pipes/params-verify.pipe';
import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger';
​
// 引入本地验证守卫
import { LocalAuthGuard } from './guards/local.guard';
​
@Controller('auth')
@ApiTags('鉴权模块')
export class AuthController {
  constructor(private readonly authService: AuthService) {}
​
  @UseGuards(LocalAuthGuard)  // 使用本地验证守卫
  @Post('login')
  @ApiOperation({ summary: '登录', description: '登录接口获取token' })
  @ApiBody({ type: CreateAuthDto })
  signIn(@Body(new ParamsVerifyPipe()) createAuthDto: CreateAuthDto) {
    return this.authService.signIn(createAuthDto);
  }
}
​

三、JWT认证

JWT令牌

  • 允许用户使用用户名/密码进行身份验证,返回jwt以便后续调用受保护的api接口使用
  • 创建受保护的api路由,检查是否存在有效的jwt 进行访问

插件安装

pnpm install --save @nestjs/jwt passport-jwt
# 类型提示
pnpm install -D @types/passport-jwt  
  • @nestjs/jwt:jwt操作,生成和验证jwt

  • passport系列:用于jwt认证策略

配置文件JWT

1、环境变量文件新增jwt配置

.env文件中,我们添加了JWT相关的配置:

# jwt配置
JWT_SECRET=elk_secret  # JWT加密密钥「一般是加密的字符串,不会这么短」
JWT_EXPIRES_IN=30m  # 访问令牌有效期
JWT_REFRESH_EXPIRES_IN=7d  # 刷新令牌有效期
JWT_COOKIE_SECURE=false  # 是否仅在HTTPS下传输cookie
JWT_COOKIE_HTTPONLY=true  # 是否禁止JavaScript访问cookie

2、新增jwt专属配置文件

src/config/jwt.config.ts中,我们创建了一个JwtConfig类,用于加载JWT配置:

import { registerAs } from '@nestjs/config';
​
export interface JwtConfig {
  secret: string;
  expiresIn: string;
  refreshSecret: string;
  refreshExpiresIn: string;
  cookieSecure: boolean;
  cookieHttpOnly: boolean;
}
​
export default registerAs('jwt', () => ({
  secret: process.env['JWT_SECRET'],
  expiresIn: process.env['JWT_EXPIRES_IN'],
  refreshExpiresIn: process.env['JWT_REFRESH_EXPIRES_IN'],
  cookieSecure: process.env['JWT_COOKIE_SECURE'],
  cookieHttpOnly: process.env['JWT_COOKIE_HTTPONLY'],
}));

环境变量校验文件新增相关jwt信息

  • src/config/schema.config.ts
import * as joi from 'joi';
// 定义配置文件的类型
export interface EnvironmentVariables {
  NODE_ENV: 'development' | 'production' | 'staging';
  PORT: number;
  HOST: string;
  DB_TYPE: string;
  DB_HOST: string;
  DB_PORT: number;
  DB_USER: string;
  DB_PASSWORD: string;
  DB_NAME: string;
  any?: any;
}
// 定义配置文件的校验规则
export const configJoiSchema = joi.object({
  NODE_ENV: joi.string().valid('development', 'production', 'test').required(),
  PORT: joi.number().required().default(3000),
  HOST: joi.string().required().default('localhost'),
  
  JWT_SECRET: joi.string().required(),
  JWT_EXPIRES_IN: joi.string().required(),
  JWT_REFRESH_EXPIRES_IN: joi.string().required(),
  JWT_COOKIE_SECURE: joi.boolean().required().default(false),
  JWT_COOKIE_HTTPONLY: joi.boolean().required().default(true),
});
​
# 中间宫格信息为 数据库和redis配置相关信息

JWT认证策略

auth/strategies/jwt.strategy.ts中,我们创建了一个JwtStrategy类,用于JWT认证:

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import type { JwtConfig } from '@/config/jwt.config';
​
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false, // 不忽略过期时间
      secretOrKey: configService.get<JwtConfig>('jwt').secret,
    });
  }
  async validate(payload: any) {
    return payload;
  }
}

JWT路由守卫

我们上面添加了一个jwt的身份认证策略,但是这个策略还没有执行,我们通过创建一个jwt守卫来调用这个策略

auth/guards/jwt.guard.ts中,我们创建了一个JwtGuard类,用于JWT认证守卫:

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
​
@Injectable()
export class JwtGuard extends AuthGuard('jwt') {}

后台管理中,一般这个认证守卫除了登录接口,其他接口都需要jwt进行认证,可以将它启动为全局身份验证

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UserModule } from '../user/user.module';
import { AuthController } from './auth.controller';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtGuard } from './guards/jwt.guard';
@Module({
  imports: [
    UserModule,
    PassportModule,
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => {
        const { secret, expiresIn } = configService.get('jwt');
        return {
          global: true,
          secret,
          signOptions: {
            expiresIn,
          },
        };
      },
    }),
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    LocalStrategy,
    JwtStrategy,
    // 开启全局认证
    {
      provide: 'APP_GUARD',
      useClass: JwtGuard,
    },
  ],
})
export class AuthModule {}

此时Nest会自动将JwtGuard绑定到所有接口上

公共路由装饰器

为了允许某些接口跳过JWT认证,我们在src/common/decorators/jwt.decorator.ts中创建了一个Public装饰器:

import { SetMetadata } from '@nestjs/common';
​
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

然后,我们修改JwtGuard类,使其支持跳过认证:

import {
  Injectable,
  ExecutionContext,
  UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '@/common/decorators/jwt.decorator';
​
@Injectable()
export class JwtGuard extends AuthGuard('jwt') {
  constructor(private readonly reflector: Reflector) {
    super();
  }
  /**
   * 检查路由是否可以被激活
   * @param {ExecutionContext} context - 执行上下文
   * @returns {boolean | Promise<boolean> | Observable<boolean>} - 如果路由是公开的或者用户通过验证,则返回true,否则返回false
   */
  canActivate(context: ExecutionContext) {
    // 检查路由处理程序或控制器类上是否有 @Public 装饰器
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    // 如果路由是公开的,则直接返回true,否则调用父类的 canActivate 方法进行验证
    return isPublic || super.canActivate(context);
  }
​
  /**
   * 处理验证请求的结果
   * @param {Error} err - 验证过程中发生的错误
   * @param {any} user - 验证通过后的用户信息
   * @param {any} info - 验证过程中的额外信息
   * @returns {any} - 如果验证通过,则返回用户信息,否则抛出 UnauthorizedException
   */
  handleRequest(err, user, info) {
    // 如果有错误或者用户信息不存在,则抛出错误或者 UnauthorizedException
    if (err || !user) {
      throw err || new UnauthorizedException();
    }
    // 验证通过,返回用户信息
    return user;
  }
}

结合redis

流程就是登录接口「login」signIn方法中将token存储在redis中,并设置一个过期时间,将token的过期时间交给redis去验证,我们可以在jwt的认证策略中去进行token过去时间验证,如果查询不到则过期,抛出一样,前端得到重新登陆

示例auth.controller.ts | auth.service.ts|jwt.strategy.ts

1、在auth.controller.ts中,我们使用@Public()装饰器跳过JWT认证:

// auth.controller.ts
import {
  Controller,
  Post,
  Body,
  UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { CreateAuthDto } from './dto/create-auth.dto';
import { UpdateAuthDto } from './dto/update-auth.dto';
import { ParamsVerifyPipe } from '@/common/pipes/params-verify.pipe';
import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger';

// 引入本地验证守卫
import { LocalAuthGuard } from './guards/local.guard';

import { Public } from '@/common/decorators/jwt.decorator';

@Controller('auth')
@ApiTags('鉴权模块')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @UseGuards(LocalAuthGuard) // 使用本地验证守卫
  @Public() // 跳过鉴权
  @Post('login')
  @ApiOperation({ summary: '登录', description: '登录接口获取token' })
  @ApiBody({ type: CreateAuthDto })
  signIn(@Body(new ParamsVerifyPipe()) createAuthDto: CreateAuthDto) {
    return this.authService.signIn(createAuthDto);
  }
}

2、在auth.service.ts中,我们生成令牌并将其存储在Redis中:

// auth.service.ts
import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { CreateAuthDto } from './dto/create-auth.dto';
import { UpdateAuthDto } from './dto/update-auth.dto';

import { JwtService } from '@nestjs/jwt';
import { RedisService } from '@/module/common/redis/redis.service'
 
@Injectable()
export class AuthService {
  constructor(
    private readonly userService: UserService,
    private readonly jwtService: JwtService,
    private readonly redis: RedisService,
  ) {}

  async validateUser(username: string, pass: string) {
    const user = await this.userService.findOne({ username });
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }
  async signIn(createAuthDto: CreateAuthDto) {
    const { username } = createAuthDto;
    const user = await this.userService.findOne({ username });
    const payload = {
      username,
      sub: user.userid,
      timeOut: new Date().getTime() + 1000 * 60 * 60 * 24 * 7,
    };
    // 获取token
    const token = this.jwtService.sign(payload);
    // 缓存token
    await this.redis.set(username, token, 1000 * 60 * 60 * 24 * 7);
    return {
      access_token: token,
    };
  }
}

3、在jwt.strategy.ts中,我们从Redis中查询令牌并验证其有效性:

// jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import type { JwtConfig } from '@/config/jwt.config';
import { RedisService } from '@/module/common/redis/redis.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private readonly configService: ConfigService,
    private readonly redis: RedisService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false, // 不忽略过期时间
      secretOrKey: configService.get<JwtConfig>('jwt').secret,
    });
  }
  async validate(payload: any) {
    // redis查询token
    const token = await this.redis.get(`${payload.username}&${payload.sub}`);
    // 如果token不存在,则抛出异常
    if (!token) {
      throw new UnauthorizedException('token 已过期');
    }
    return payload;
  }
}

📍 下期预告

《从0搭建NestJS后端服务(八):静态资源访问以及文件上传》
我们将探讨:

🛡️ 文件类型白名单验证
🔐 病毒扫描集成方案
📦 分布式文件存储实践
🌐 CDN加速最佳配置


🤝 互动时间

你在JWT身份验证实践中遇到过哪些“坑”?欢迎留言讨论! 常见问题示例:

  • 如何平衡令牌有效期与用户体验?
  • JWT令牌在传输过程中如何加密?
  • 生物认证(指纹/面部)如何与JWT集成?

欢迎在评论区留下你的实战经验!🚀