nest商城项目教程系列之 jwt验证 登录功能(三)

103 阅读8分钟

序言

现在nodejs的框架已经很多了,我之所以选择nestjs是因为感觉这个框架和Java写起来很像,想接触一下后台开发。我基于mall这个项目的框架来学习,打算完成和这个项目一样的商城项目,在这之中我会记录我自己学习的经历,希望可以帮助大家,当然也希望可以从大家的经验中学习,如何觉得这个项目对你说有所帮助,希望动动您发财的小手帮我Git star~~~~

唠叨两句

有一些简单的东西或者自己可以很容易查到的资料,我就不在文章中说了,我主要是基于我的项目来说,因为东一榔头西一棒槌的感觉就是再查资料,会把自己搞混,所以我的目标是能分享出自己这个项目遇到的问题就好。当然我也会把一些资料放到文章最后,也可以做个参考。

项目说明

开始之前,先说明一下我用到的环境,否则有可能下载项目以后出现无法运行的情况,我的项目分为nest写的后端项目和基于vue2的前端项目,需要说明的是这个vue项目mall_admin也是从mall拿过来的,我之所以放到我的Git上了,一个是因为我改了一个地方,另外一个是觉得mall这个项目可能会继续更新代码,到时候你们下载下来可能就和我的nestjs项目对应不上了。

nestjs项目 npm 6.14.7 nestjs 9.0.0 我觉得这个版本应该不是大版本就没事,只能说我接触的时候是9版本 node v14.8.0
vue管理系统 这个运行的时候,我需要说明一下,他这个项目用node:14.8.0运行不起来,我这里是切换成了node:12.6.0运行起来的,开发前端的应该都安装了nvm这个工具来切换版本了,就这里需要注意

开始吧

从前端调后台接口角度来讲,前端首先接口就是登录注册,然后获取用户信息,存储token,所以我的第一篇教程也是先从这方面开始。

一、 登录

登录需要做什么呢,除了用户名和密码,其实更重要的就是返回token给前端,作为访问接口的凭证,这需要nestjs判断账号和密码正确与否,然后再需要的地方加上token的校验. 客户端用户进行登录请求; 服务端拿到请求,根据参数查询用户表; 若匹配到用户,将用户信息进行签证,并颁发 Token; 客户端拿到 Token 后,存储至某一地方,在之后的请求中都带上 Token ; 服务端接收到带 Token 的请求后,直接根据签名进行校验,无需再查询用户信息;

  1. 首先创建相关的模块,通过这个命令可以快速创建出模块里面的module,service等,反正就是都有了
nest -g res auth .src/modules
  1. 创建数据库表对应的实体,这个对应的就是数据库里面的ums_admin表,有两个地方需要说明一下
  • hashPassword这里用到了BeforeInsert注解,目的是加密一下代码
  • password这个字段用到了这个@Exclude()注解,目的是返回数据的时候可以过滤到password字段,密码比较重要,尽量不要返回。
import { ApiProperty } from '@nestjs/swagger';
import { BeforeInsert, Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { Exclude } from 'class-transformer';

@Entity('ums_admin')
export class Auth {
  @ApiProperty({ description: '自增 id' })
  @PrimaryGeneratedColumn()
  id: number;

  @ApiProperty({ description: '名称' })
  @Column({ length: 500 })
  username: string;

  @ApiProperty({ description: '密码' })
  @Column({ length: 500 })
  @Exclude()
  password: string;

  @ApiProperty({ description: '邮箱' })
  @Column({ length: 20 })
  email: string;

  @ApiProperty({ description: '图标' })
  @Column({ length: 500 })
  icon: string;

  @ApiProperty({ description: '昵称' })
  @Column({ name: 'nick_name', length: 100 })
  nickName: string;

  @ApiProperty({ description: '标记' })
  @Column({ length: 500 })
  note: string;

  @Column({
    name: 'create_time',
    type: 'timestamp',
    default: () => 'CURRENT_TIMESTAMP',
  })
  createTime: Date;
  @BeforeInsert()
  createDate() {
    // 更新entity前更新LastUpdatedDate
    this.createTime = new Date();
  }
  @Column({
    name: 'login_time',
    type: 'timestamp',
    default: () => 'CURRENT_TIMESTAMP',
  })
  loginTime: Date;

  @ApiProperty({ description: '状态' })
  @Column()
  status: number;

  @BeforeInsert()
  private async hashPassword() {
    const salt = await bcrypt.genSalt();
    console.log('salt', salt, this.password);
    this.password = await bcrypt.hash(this.password, salt);
  }
}

上面说的不返回密码字段,需要在对应的方法上面使用 @UseInterceptors(ClassSerializerInterceptor)才会生效,对应的代码auth.controller.ts这个控制器里面.

  @ApiOperation({
    summary: '后台注册',
  })
  @ApiBody({ type: RegisterDto, description: '后台注册' })
  @SkipJwtAuth()
  @UseInterceptors(ClassSerializerInterceptor)
  @Post('register')
  async register(@Request() req, @Body() registerDto: RegisterDto) {
    return this.authService.register(registerDto);
  }

其实到这里,通过authController直接返回已经就可以调用接口了,但是我们要用到jwt验证所以需要继续修改

二、jkt验证

jwt是一种token验证的策略,每种语言都有对应的实现,到我的项目就是需要考虑三点,一是生成token返回前端 二是项目本身怎么去验证token 三是token的续期

1. 生成token

首先需要安装相关的库,

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

安装完成之后,和数据库什么一样,需要引入模块,然后配置参数

@Module({
  imports: [
  	//这里就是jwt的配置了
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => {
        console.log('configService.get', configService.get('jwt.secret'));
        return {
          secret: configService.get<string>('jwt.secret'),
        };
      },
    }),
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    LocalStrategy,
    JwtStrategy,
    { provide: APP_GUARD, useClass: JwtAuthGuard },
  ],
  exports: [AuthService],
})
export class AuthModule {}

然后创建策略文件,local的作用就是验证账号密码,通过以后把user信息存起来,给jkt生成token使用 仅仅有了这两个还不行,怎么才能让他们起作用呢,这就需要用到nestjs里面守卫guard,这里的守卫其实和前端路由守卫差不多一个意思,就是过滤掉不能访问角色或者权限。 下面是两个策略的文件,文件位置在src/modules/auth/strategies文件夹下 local.strategy.ts

import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { IStrategyOptions, Strategy } from 'passport-local';
import { User } from '@src/modules/user/entities/user.entity';
import { AuthService } from '../auth.service';
// 本地策略,验证用户名和密码
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super({
      usernameField: 'username',
      passwordField: 'password',
    } as IStrategyOptions);
  }
  // 这个是自带的方法,使用策略后会执行这个方法
  async validate(
    username: string,
    password: string,
  ): Promise<Omit<User, 'password'>> {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new HttpException('用户名或秘密错误', HttpStatus.OK);
    }

    return user;
  }
}

jwt.strategy.ts,,这里面的validate方法就是生成token的方法,把生成token需要的信息传过去就可以了

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UserService } from '@src/modules/user/user.service';
import { ConfigService } from '@nestjs/config';
import { RedisCacheService } from '@src/modules/redis-cache/redis-cache.service';
import { Request } from 'express';

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

  async validate(payload: any) {
    try {
      console.log('payload', payload);
      const existUser = this.userService.findOne(payload.sub);

      if (!existUser) {
        throw new UnauthorizedException();
      }
      return { ...payload, id: payload.sub };
    } catch (error) {
      console.log('error', error);
    }
  }
}

下面是两个守卫的文件,文件位置在src/modules/auth/guards文件夹下 local-auth.guard.ts

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

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

jwt-auth.guard.ts

import {
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../constants';
import { JwtService } from '@nestjs/jwt';
import { RedisCacheService } from '@src/modules/redis-cache/redis-cache.service';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(
    private reflector: Reflector,
    private configService: ConfigService,
    private redisCacheService: RedisCacheService,
    private jwtService: JwtService,
  ) {
    super();
  }

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }
    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);

    const payload = await this.jwtService.verifyAsync(token, {
      secret: this.configService.get<string>('jwt.secret'),
    });
    let tokenNotTimeOut = true;
    try {
      const key = `${payload.id}-${payload.username}`;
      const redis_token = await this.redisCacheService.cacheGet(key);
      if (redis_token !== token) {
        throw new UnauthorizedException('请重新登录');
      }
      this.redisCacheService.cacheSet(
        key,
        token,
        this.configService.get<number>('redis.token_expire'),
      );
    } catch (err) {
      tokenNotTimeOut = false;
      throw new UnauthorizedException('请重新登录');
    }
    return tokenNotTimeOut && (super.canActivate(context) as boolean);
  }
  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
  handleRequest(err, user) {
    // 处理 info
    if (err || !user) {
      throw err || new UnauthorizedException();
    }
    return user;
  }
}

2. 验证token

通过上面的步骤,已经有了token,这时候需要启用两个守卫才可以通过接口验证token,下面是authcontroller里面的相关接口,登录接口我们用了 @UseGuards(LocalAuthGuard) @SkipJwtAuth(),而另外一个接口什么也没有用,这是为什么,因为我们大部分接口都要验证,不能每一个接口都去单独加jwt守卫吧,而SkipJwtAuth就是不验证token的接口。

@ApiOperation({
    summary: '后台登录',
  })
  @ApiBody({ type: LoginDto, description: '后台登录' })
  @SkipJwtAuth()
  @UseGuards(LocalAuthGuard)
  @Post('login')
  async login(@Request() req) {
    console.log('configService', this.configService.get('jwt.secret'));
    return this.authService.login(req.user);
  }

  @ApiOperation({
    summary: '获取当前登录用户的菜单以及对应的角色list',
    description: '获取当前登录用户的菜单',
  })
  @Get('info')
  async getAdminInfo(@Request() req) {
    console.log('req.user', req.user);
    const user = req.user;
    const menuList = await this.permissionService.getMenuList(user.id);
    const roleList = await this.roleService.getRoleListById(user.id);
    const result = {
      username: user.username,
      menus: menuList,
      roles: roleList,
    };

    return result;
  }

全局验证需要再auth.module.ts中加上

  providers: [
    AuthService,
    RedisCacheService,
    LocalStrategy,
    JwtStrategy,
    { provide: APP_GUARD, useClass: JwtAuthGuard },
  ],

而不需要验证的文件是 auth/constants.ts,这里是在什么地方作用的,其实是在jwt-auth.guard.ts里面

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

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

上面文件jwt-auth.guard.ts里面的canActivatef方法,这个方法就是处理能不能访问这个接口的关键代码true就是通过了

    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }
总结

最后说几点上面没提的需要注意一点

  1. 我们必须引入auth实体才能使用对应的表, 如auht.module.ts文件,每次都不要忘了,因为我经常忘,所以老是报依赖错误
    
@Module({
  imports: [
    TypeOrmModule.forFeature([Auth]),
    UserModule,
    PermissionModule,
    PassportModule,
    RoleModule,
    RedisCacheModule,
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => {
        console.log('configService.get', configService.get('jwt.secret'));
        return {
          secret: configService.get<string>('jwt.secret'),
        };
      },
    }),
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    RedisCacheService,
    LocalStrategy,
    JwtStrategy,
    { provide: APP_GUARD, useClass: JwtAuthGuard },
  ],
  exports: [AuthService],
})
export class AuthModule {}
  1. 上面里面的secret: configService.get<string>('jwt.secret')这个是我弄的配置文件,再config->yaml里面。