Nest成长足迹(三):用户登录注册认证颁发jwt

639 阅读9分钟

1、前言

本文章记录自己学习Nest的过程,适于前端及对后端没有基础但对Nest感兴趣的同学,如有错误,欢迎各位大佬指正

2、Nest成长足迹系列:

3、前文回顾

  • 在第一篇文章已经初步介绍了Nest以及其swagger文档的使用,第二篇开始讲述Nest如何连接数据库、在创建实体中使用Pipe管道进行参数校验
  • 那么本文开始讲述在Nest中用户登录颁发jwt、校验jwt,本地鉴权

4、项目目录

  • Nestjs一开始的目录结构是这样的:使用命令nest g res user,会直接生成一个user模块,像controllerservice都集成到一起了,与我之前的习惯不同,我还是更喜欢将controllerservice抽到一个单独的文件夹中,舍弃每个模块的module,只保留一个app.module.ts(当然了,后续也可按情况保留其他模块的module),没有module的模块就直接在app.module.ts文件中进行注入 user
  • 以下是我个人的项目目录
    • auth是授权相关内容
    • common是通用模块,我一般放一些异常过滤器和统一全局响应输出
    • controller就是控制层了(包含路由)
    • dto用于定义系统内的接口或者输入和输出,方便在swagger上进行测试使用
    • entities数据库实体
    • middleware中间件
    • services服务层
    • utils工具类函数
  • image.png

5、用户注册、登录,颁发Jwt

5.1、创建用户实体

首先我们需要创建一个用户实体,用于存储用户的相关信息,例如用户名、密码、角色等等。在NestJS项目中创建一个 user.entity.ts 文件,内容如下:

import {
  Entity,
  Column,
  JoinTable,
  ManyToMany,
  PrimaryGeneratedColumn,
  CreateDateColumn,
  UpdateDateColumn
} from 'typeorm';
import { Exclude } from 'class-transformer';

import { Role } from './role.entity';

// @Entity()装饰器自动从所有类生成一个SQL表,以及他们包含的元数据
// @Entity('users') // sql表名为users
@Entity() // sql表名为user
export class User {
  // 主键装饰器,也会进行自增
  @PrimaryGeneratedColumn()
  id: number;

  // 列装饰器
  @Column()
  username: string;

  // 请求返回数据时将密码这个字段隐藏
  @Exclude()
  @Column()
  password: string;

  // 定义与其他表的关系
  // name 用于指定创中间表的表名
  // JoinTable只在关系一边
  @JoinTable({ name: 'user_roles' })
  // 指定多对多关系
  /**
   * 关系类型,返回相关实体引用
   * cascade: true,插入和更新启用级联,也可设置为仅插入或仅更新
   * ['insert']
   */
  @ManyToMany(() => Role, (role) => role.users, { cascade: true })
  roles: Role[];

  @CreateDateColumn()
  createAt: Date;

  @UpdateDateColumn()
  updateAt: Date;
}

如果对实体中一些字段不清楚,可以翻看这个系列的第二篇文章:篇幅二:TypeORM操作数据库、Pipe校验参数

5.2、创建user模块,实现注册等接口

  • 这个模块包含user.module.tsuser.controller.tsuser.service.ts三个文件的内容,可以使用Nestjs提供的命令进行创建,可以看本系列的第一篇:篇幅一: Nest介绍、swagger的使用,这部分模块有以下几个接口:

    • 用户注册、获取用户列表、根据用户id获取用户信息、更新用户数据、删除用户数据
  • user.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

import { UserService } from '@/services/user.service';
import { UserController } from '@/controllers/user.controller';

import { User } from '@/entities/user.entity';
import { Role } from '@/entities/role.entity';

@Module({
  imports: [TypeOrmModule.forFeature([User, Role])],
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService]
})
export class UserModule {}
  • user.controller.ts
    • 在这个文件中,像UseGuardsAuthGuardJwtAuthGuard都是在认证颁发jwt和角色权限校验中需要使用到的
import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  Query,
  UseInterceptors,
  ClassSerializerInterceptor,
  UseGuards
} from '@nestjs/common';
import { ApiOperation, ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { UserService } from '@/services/user.service';
import { CreateUserDto } from '@/dto/user/create-user.dto';
import { UpdateUserDto } from '@/dto/user/update-user.dto';
import { PaginationQueryDto } from '@/common/dto/pagination-query.dto';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
import { BUSINESS_ERROR_CODE } from '@/common/exceptions/business.error.codes';
import { BusinessException } from '@/common/exceptions/business.exception';

// 设置swagger文档标签分类
@ApiTags('用户模块')
// 使用装饰器修饰类(路由)
@Controller('user')
export class UserController {
  // 依赖注入的方式,引入service
  constructor(private readonly userService: UserService) {}

  @Post('create')
  @ApiOperation({
    summary: '添加用户' // 接口描述信息
  })
  // 请求返回数据时将密码这个字段隐藏
  @UseInterceptors(ClassSerializerInterceptor)
  // @Body是指获取到(http请求)客户端传递过来的body体中的数据,将数据给createUserDto这个变量,CreateUserDto是TS类型约束
  // createUserDto可自定义
  create(@Body() createUserDto: CreateUserDto) {
    try {
      return this.userService.create(createUserDto);
    } catch (error) {
      throw new BusinessException({
        code: BUSINESS_ERROR_CODE.COMMON,
        message: '用户添加失败'
      });
    }
  }

  @Get('list')
  @ApiBearerAuth()
  @UseGuards(JwtAuthGuard) // 验证token
  // @UseGuards(AuthGuard('jwt'))
  @ApiOperation({
    summary: '获取user列表'
  })
  findAll(@Query() paginationsQuery: PaginationQueryDto) {
    console.log(paginationsQuery, '5-进行接口请求');
    try {
      return this.userService.getUserList(paginationsQuery);
    } catch (error) {
      throw new BusinessException({
        code: BUSINESS_ERROR_CODE.COMMON,
        message: '获取用户列表失败'
      });
    }
  }

  @Get('list/:id')
  @ApiOperation({
    summary: '根据id获取user'
  })
  findOne(@Param('id') id: string) {
    try {
      return this.userService.findOneById(+id);
    } catch (error) {
      throw new BusinessException({
        code: BUSINESS_ERROR_CODE.COMMON,
        message: '获取用户失败'
      });
    }
  }

  @Patch('list/:id')
  @ApiOperation({
    summary: '根据id修改user'
  })
  update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
    try {
      return this.userService.update(+id, updateUserDto);
    } catch (error) {
      throw new BusinessException({
        code: BUSINESS_ERROR_CODE.COMMON,
        message: '修改用户失败'
      });
    }
  }

  @Delete('list/:id')
  @ApiOperation({
    summary: '根据id删除user'
  })
  remove(@Param('id') id: string) {
    try {
      return this.userService.remove(+id);
    } catch (error) {
      throw new BusinessException({
        code: BUSINESS_ERROR_CODE.COMMON,
        message: '删除用户失败'
      });
    }
  }
}
  • user.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';

import { CreateUserDto } from '@/dto/user/create-user.dto';
import { UpdateUserDto } from '@/dto/user/update-user.dto';
import { User } from '@/entities/user.entity';
import { Role } from '@/entities/role.entity';
import { PaginationQueryDto } from '@/common/dto/pagination-query.dto';

import { BUSINESS_ERROR_CODE } from '@/common/exceptions/business.error.codes';
import { BusinessException } from '@/common/exceptions/business.exception';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    @InjectRepository(Role)
    private readonly roleRepository: Repository<Role>
  ) {}

  async create(createUserDto: CreateUserDto) {
    // const user = await this.userRepository.create({ ...createUserDto });
    // return this.userRepository.save(user);
    try {
      const roles = await Promise.all(
        createUserDto.roles.map((name) => this.preloadRoleByName(name))
      );

      const user = this.userRepository.create({ ...createUserDto, roles });

      return this.userRepository.save(user);
    } catch (error) {
      console.log(error);
      throw new BusinessException({
        code: BUSINESS_ERROR_CODE.COMMON,
        message: '用户添加失败'
      });
    }
  }

  async getUserList(paginationsQuery: PaginationQueryDto) {
    const { limit, offset } = paginationsQuery;
    return await this.userRepository.find({
      // 新的方式,指定关系才能查到roles
      // 1
      relations: ['roles'],
      // relations: {
      //   roles: true
      // },
      skip: offset,
      take: limit
    });
  }

  async findOneById(id: number) {
    return await this.userRepository.findOne({
      where: { id },
      relations: { roles: true }
    });
  }

  async findOneByUserName(username: string) {
    return await this.userRepository.findOne({
      where: { username },
      relations: { roles: true }
    });
  }

  async update(id: number, updateUserDto: UpdateUserDto) {
    // 因为更新的每个项都是可选的,所以需要确保role一定存在
    try {
      const roles =
        updateUserDto.roles &&
        (await Promise.all(
          updateUserDto.roles.map((name) => this.preloadRoleByName(name))
        ));

      const user = await this.userRepository.preload({
        id: id,
        ...updateUserDto,
        roles
      });
      if (!user) {
        throw new NotFoundException(`${id} not found`);
      }
      return this.userRepository.save(user);
    } catch (error) {
      console.log(error);
      throw new BusinessException({
        code: BUSINESS_ERROR_CODE.COMMON,
        message: '用户修改失败'
      });
    }
  }

  async remove(id: number) {
    const user = await this.userRepository.findOneBy({ id });
    if (!user) {
      throw new NotFoundException(`${id} not found`);
    }
    return await this.userRepository.remove(user);
  }

  // 私有方法,将角色名作为入参并返回
  private async preloadRoleByName(name: string): Promise<Role> {
    const existingRole = await this.roleRepository.findOne({ where: { name } });
    if (existingRole) {
      return existingRole;
    }
    return this.roleRepository.create({ name });
  }
}

5.3 用户登录/认证、颁发JWT

创建auth授权模块,用户登录接口是可以写在用户模块的,但我把它归为授权的一类,所以写到了授权模块,点击查看nestjs中文文档认证模块

5.3.1首先安装依赖包

npm install --save @nestjs/passport passport passport-local
npm install --save-dev @types/passport-local
npm install @nestjs/jwt passport-jwt
npm install @types/passport-jwt --save-dev

5.3.2创建auth模块

  • 这个模块包含auth.controller.tsauth.service.tsauth.module.ts三大块
  • auth.controller.ts
    • 在这有一个登录路由,并且@UseGuards(LocalAuthGuard)启用了本地身份验证,登录时会调用auth.service.ts中的login方法
import {
  Controller,
  Post,
  UseGuards,
  Body
} from '@nestjs/common';
import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';

import { LocalAuthGuard } from '@/auth/guards/local-auth.guard';
import { AuthService } from '@/services/auth.service';
import { LoginUserDto } from '@/dto/user/Login-user.dto';

import { BUSINESS_ERROR_CODE } from '@/common/exceptions/business.error.codes';
import { BusinessException } from '@/common/exceptions/business.exception';

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

  @Post('login')
  @UseGuards(LocalAuthGuard) // 启用本地身份验证
  @ApiBody({ type: LoginUserDto })
  @ApiOperation({
    summary: '登录'
  })
  async login(@Body() loginBody: LoginUserDto) {
    console.log('2-请求登陆', loginBody);
    try {
      return await this.authService.login(loginBody);
    } catch (error) {
      throw new BusinessException({
        code: BUSINESS_ERROR_CODE.COMMON,
        message: '登录失败'
      });
    }
  }
}
  • auth.service.ts主要有以下几个功能
    • 通过用户id从数据库中查找这个用户的信息
    • 验证用户是否存在
    • 处理jwt签证,使用了@nestjs/jwt提供的sign()函数,用于从用户对象属性的子集生成jwt
    • 校验token
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { UserService } from '@/services/user.service';
import { md5password } from '@/utils/password-handle';
import { BusinessException } from '@/common/exceptions/business.exception';
import { LoginUserDto } from '@/dto/user/login-user.dto';
import { User } from '@/entities/user.entity';

@Injectable()
export class AuthService {
  constructor(
    private readonly userService: UserService,
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    private readonly jwtService: JwtService
  ) {}

  async findOneById(id: number) {
    return await this.userRepository.findOne({
      where: { id },
      relations: { roles: true }
    });
  }

  // 验证用户是否存在
  async validateUser(username: string, password: string): Promise<any> {
    const user = await this.userService.findOneByUserName(username);
    if (user && user.password === md5password(password)) {
      return user;
    }
    throw new BusinessException('用户名或密码错误');
  }

  async checkLogin(loginBody: LoginUserDto) {
    const { username, password } = loginBody;
    const user = await this.userRepository.findOne({
      where: { username },
      relations: { roles: true }
    });
    if (user && user.password === md5password(password)) {
      return user;
    }
    throw new BusinessException('用户名或密码错误');
  }

  // 处理jwt签证
  async login(user: any) {
    console.log('3-处理jwt签证');
    const result = await this.validateUser(user.username, user.password);
    const payload = {
      id: result.id,
      username: result.username,
      roles: result.roles
    };
    return {
      message: '登录成功!',
      username: user.username,
      token: this.jwtService.sign(payload)
    };
  }

  // 校验token
  async verifyToken(token: string) {
    if (token) {
      const jwt = token.replace('Bearer', '');
      const id = this.jwtService.verify(jwt);
      return id;
    }
    throw new BusinessException('token不存在!');
  }
}

  • auth.module.ts
    • secret: process.env.JWT_KEY表示设置密钥(从环境变量中获取JWT_KEY作为密钥)
    • signOptions: { expiresIn: '8h' }表示这个JWT八个小时后过期
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';

import { AuthService } from '@/services/auth.service';
import { AuthController } from '@/controllers/auth.controller';
import { UserModule } from '@/modules/user.module';
import { LocalStrategy } from '@/auth/strategies/local.strategy';
import { JwtStrategy } from '@/auth/strategies/jwt.strategy';
import { User } from '@/entities/user.entity';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true
    }),
    PassportModule,
    JwtModule.register({
      secret: process.env.JWT_KEY,
      signOptions: { expiresIn: '8h' }
    }),
    TypeOrmModule.forFeature([User]),
    UserModule
  ],
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService]
})
export class AuthModule {}

5.3.3实现本地身份验证策略

  • 在上述auth.module.ts文件中出现了LocalStrategyJwtStrategy,这两分别是本地验证策略和JWT验证策略
  • auth文件夹中创建一个名为 local.strategy.ts 文件,本地身份验证策略,将在登录前执行
  • local.strategy.ts
// 实现 Passport 本地身份验证策略
// 本地策略主要是验证账号和密码是否存在,如果存在就登陆,返回token
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';

import { AuthService } from '@/services/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);
    console.log('1-登录前进行了本地身份验证');
    if (!user) {
      throw new UnauthorizedException('账号或密码错误!');
    }
    return user;
  }
}
  • auth文件夹中创建一个名为 jwt.strategy.ts 文件,本地身份验证策略,将在登录前执行
    • jwtFromRequest:提供从请求中提取 JWT 的方法。我们将使用在 API 请求的授权头中提供token的标准方法。
    • ignoreExpiration:我们选择默认的 false,进行验证token是否过期,它将确保 JWT 没有过期的责任委托给 Passport 模块。这意味着,如果我们的路由提供了一个过期的JWT,请求将被拒绝,并发送 401 未经授权的响应。
    • secretOrKey:我们用来加密JWT的密钥。
  • jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';

import { AuthService } from '@/services/auth.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false, // 为true则不验证token的到期时间
      secretOrKey: process.env.JWT_KEY
    });
  }

  // 验证回调
  // 对于 JWT 策略,Passport 首先验证 JWT 的签名并解码 JSON 。然后调用我们的 validate() 方法,该方法将解码后的 JSON 作为其单个参数传递。根据 JWT 签名的工作方式,我们可以保证接收到之前已签名并发给有效用户的有效 token 令牌。
  async validate(payload: any) {
    /**
     * payload是用户信息和登录时间以及过期时间
     */
    const existUser = await this.authService.findOneById(payload.id);
    console.log('4-验证回调');
    if (!existUser) {
      throw new UnauthorizedException('token不正确');
    }
  }
}

image.png

image.png

  • 源码地址,在源码中还有一些异常过滤器的使用、中间件的使用、角色权限验证等