NestJS 搭建博客系统(七)— 使用JWT实现注册登陆

NestJS 搭建博客系统(七)— 使用JWT实现注册登陆

NestJS 搭建博客系统(七)— 使用JWT实现注册登陆

前言

经过前面的开发,已经有一些基础设施,比如数据持久化、表单验证、数据返回以及文档,接下来可以比较愉快的开发一点业务了。 本章将实现简单的注册登陆鉴权功能,由于当前还是简单的单用户系统,所以这里只简单过一下,待后面升级为多用户再升级。

使用 JWT 实现注册登录

关于 JWT 的文章网上有很多,这里不作阐述

创建 USER 模块

创建模块 nest g mo modules/user 创建控制器 nest g co modules/user 创建服务 nest g s modules/user 创建 entity

// src/modules/user/entity/user.entity.ts

import { 
  Entity, 
  Column, 
  PrimaryGeneratedColumn, 
  UpdateDateColumn,
  CreateDateColumn,
  VersionColumn,
} from 'typeorm';

@Entity()
export class User {
  // 主键id
  @PrimaryGeneratedColumn()
  id: number;

  // 创建时间
  @CreateDateColumn()
  createTime: Date

  // 更新时间
  @UpdateDateColumn()
  updateTime: Date

  // 软删除
  @Column({
    default: false
  })
  isDelete: boolean

  // 更新次数
  @VersionColumn()
  version: number

  // 昵称
  @Column('text')
  nickname: string;

  // 手机号
  @Column('text')
  mobile: string;

 // 加密后的密码
  @Column('text', { select: false })
  password: string;

  // 加密盐
  @Column('text', { select: false })
  salt: string;
}

复制代码

在 userModule 中引入 entity

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entity/user.entity';
import { UserController } from './user.controller';
import { UserService } from './user.service';

@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
  ],
  controllers: [UserController],
  providers: [UserService]
})
export class UserModule {}

复制代码

关于注册功能

注册相当于用户模块的新增,登陆就是提交账号密码与数据库一致时对其发放 token,访问资源的时候在请求头带上 token,我们可以对其进行授权和拦截访问资源。

登陆需要校验账号密码,站在用户角度,账号 123,密码 abc,用户提交的内容就是明文 123,abc,服务只需要校验 123 对应的是不是 abc 即可,服务端可以不校验直接给你发放token,也可以保存明文账号密码进行校验,也可以对密码加密,再校验加密后的密码。所以我们日常不应该使用同一个账号密码,因为一旦其中一个网站明文保存,就等于是所有的账号密码都泄漏了。

对于加密也分为对称加密和非对称加密,这个展开讲也非常庞大,这里也不展开,网上文章也很多。

注册登陆这一块有几个选择:

  • 明文账号密码:数据库暴露,用户账号密码直接暴露
  • 使用对称加密:数据库暴露,黑客进行对称解密,用户账号密码直接暴露
  • 使用非对称加密:数据库暴露,黑客使用彩虹表暴力破解,随着时间流逝,大量用户密码都会暴露
  • 使用非对称加密且加盐:数据库暴露,黑客使用彩虹表暴力破解,随着时间流逝,少量用户密码会被破解,但同一个明文密码的加密后的值都是一样的。
  • 使用非对称加密且加随机盐:数据库暴露,黑客使用彩虹表暴力破解,少量用户密码被破解,但同一个明文密码的加密后的值都是不一样的。

根据上面的思路,我们选择最后一种方式。

实现注册功能

首先我们定义注册功能的入参和出参

// src/modules/user/dto/register.dto.ts

import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsString, Matches } from "class-validator"
import { regMobileCN } from "src/utils/regex.util";

export class RegisterDTO {
  @ApiProperty({
    description: '手机号,唯一',
    example: '13049153466'
  })
  @Matches(regMobileCN, { message: '请输入正确手机号' })
  @IsNotEmpty({ message: '请输入手机号' })
  readonly mobile: string;

  @ApiProperty({
    description: '用户名',
    example: '斯提芬大狗'
  })
  @IsNotEmpty({ message: '请输入用户昵称' })
  @IsString({ message: '名字必须是 String 类型'})
  readonly nickname: string;

  @ApiProperty({
    description: '用户密码',
    example: '123456',
  })
  @IsNotEmpty({ message: '请输入密码' })
  readonly password: string;

  @ApiProperty({
    description: '二次输入密码',
    example: '123456'
  })
  @IsNotEmpty({ message: '请再次输入密码' })
  readonly passwordRepeat: string
}
复制代码
// src/modules/user/vo/user-info.vo.ts

import { ApiProperty } from "@nestjs/swagger";

export class UserInfoItem {
  @ApiProperty({ description: '用户id', example: 1 })
  id: number;

  @ApiProperty({ description: '创建时间', example: '2021-07-21' }) 
  createTime: Date

  @ApiProperty({ description: '更新时间', example: '2021-07-21' }) 
  updateTime: Date

  @ApiProperty({ description: '手机号', example: '13088888888' }) 
  mobile: string;
}

export class UserInfoVO {
  @ApiProperty({ type: UserInfoItem })
  info: UserInfoItem
}

export class UserInfoResponse {
  @ApiProperty({ description: '状态码', example: 200, })
  code: number

  @ApiProperty({ description: '数据',
    type: () => UserInfoVO, example: UserInfoVO, })
  data: UserInfoVO

  @ApiProperty({ description: '请求结果信息', example: '请求成功' })
  message: string
} 
复制代码

修改 userController

// src/modules/user/user.controller.ts

import { Body, Controller, Post } from '@nestjs/common';
import { ApiBody, ApiOkResponse } from '@nestjs/swagger';
import { RegisterDTO } from './dto/register.dto';
import { UserService } from './user.service';
import { UserInfoResponse } from './vo/user-info.vo';

@Controller('user')
export class UserController {
  constructor(
    private userService: UserService
  ) {}

  @ApiBody({ type: RegisterDTO })
  @ApiOkResponse({ description: '注册', type: UserInfoResponse })
  @Post('register')
  async register(
    @Body() registerDTO: RegisterDTO
  ): Promise<UserInfoResponse> {
    return this.userService.register(registerDTO)
  }
}
复制代码

加密我们使用 crypto

安装依赖 yarn add crypto-js @types/crypto-js

新建一个工具类

// src/utils/cryptogram.util.ts

import * as crypto from 'crypto';

// 随机盐
export function makeSalt(): string {
  return crypto.randomBytes(3).toString('base64');
}

/**
 * 使用盐加密明文密码
 * @param password 密码
 * @param salt 密码盐
 */
export function encryptPassword(password: string, salt: string): string {
  if (!password || !salt) {
    return '';
  }
  const tempSalt = Buffer.from(salt, 'base64');
  return (
    // 10000 代表迭代次数 16代表长度
    crypto.pbkdf2Sync(password, tempSalt, 10000, 16, 'sha1').toString('base64')
  );
}
复制代码

服务增加注册方法

// src/modules/user/user.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { encryptPassword, makeSalt } from 'src/utils/cryptogram.util';
import { Repository } from 'typeorm';
import { RegisterDTO } from './dto/register.dto';
import { User } from './entity/user.entity';

@Injectable()
export class UserService {

  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  ){}

  // 注册
  async register(
    registerDTO: RegisterDTO
  ): Promise<any> {

    const { nickname, password, mobile } = registerDTO;
    const salt = makeSalt(); // 制作密码盐
    const hashPassword = encryptPassword(password, salt);  // 加密密码

    const newUser: User = new User()
    newUser.nickname = nickname
    newUser.mobile = mobile
    newUser.password = hashPassword 
    newUser.salt = salt
    return await this.userRepository.save(newUser)
  }
}
复制代码

打开 swagger 测试一下注册功能,但是这里没有做限制,导致注册可以重复注册

增加一下校验

// src/modules/user/user.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { encryptPassword, makeSalt } from 'src/utils/cryptogram.util';
import { Repository } from 'typeorm';
import { RegisterDTO } from './dto/register.dto';
import { User } from './entity/user.entity';

@Injectable()
export class UserService {

  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  ){}

  // 校验注册信息
  async checkRegisterForm(
    registerDTO: RegisterDTO,
  ): Promise<any>{
    if (registerDTO.password !== registerDTO.passwordRepeat) {
      throw new NotFoundException('两次输入的密码不一致,请检查')
    }
    const { mobile } = registerDTO
    const hasUser = await this.userRepository.findOne({ mobile })
    if (hasUser) {      
      throw new NotFoundException('用户已存在')
    }
  }

  // 注册
  async register(
    registerDTO: RegisterDTO
  ): Promise<any> {

    await this.checkRegisterForm(registerDTO)

    const { nickname, password, mobile } = registerDTO;
    const salt = makeSalt(); // 制作密码盐
    const hashPassword = encryptPassword(password, salt);  // 加密密码

    const newUser: User = new User()
    newUser.nickname = nickname
    newUser.mobile = mobile
    newUser.password = hashPassword 
    newUser.salt = salt
    return await this.userRepository.save(newUser)
  }

}
复制代码

至此,注册功能完成

实现登陆并发放 TOKEN

登陆的流程是这样的:

  1. 用户提交 login 接口,参数包括 mobile password
  2. 服务通过 mobile 查询用户信息,并使用 password 与 对应随机盐校验 密码是否正确
  3. 验证通过,使用 用户信息 生成 token
  4. 返回 token

定义登陆接口

// src/modules/user/dto/login.dto.ts

import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, Matches } from "class-validator"
import { regMobileCN } from "src/utils/regex.util";

export class LoginDTO {
  @ApiProperty({
    description: '手机号,唯一',
    example: '13049153466'
  })
  @Matches(regMobileCN, { message: '请输入正确手机号' })
  @IsNotEmpty({ message: '请输入手机号' })
  readonly mobile: string;

  @ApiProperty({
    description: '用户密码',
    example: '123456',
  })
  @IsNotEmpty({ message: '请输入密码' })
  readonly password: string;
}
复制代码
// src/modules/user/vo/token.vo.ts

import { ApiProperty } from "@nestjs/swagger";

export class TokenItem {
  @ApiProperty({ description: 'token', example: 'sdfghjkldasascvbnm' }) 
  token: string;
}

export class TokenVO {
  @ApiProperty({ type: TokenItem })
  info: TokenItem
}

export class TokenResponse {
  @ApiProperty({ description: '状态码', example: 200, })
  code: number

  @ApiProperty({ description: '数据',
    type: () => TokenVO, example: TokenVO, })
  data: TokenVO

  @ApiProperty({ description: '请求结果信息', example: '请求成功' })
  message: string
} 

复制代码

在 userController 增加 login 方法

import { Body, Controller, Post } from '@nestjs/common';
import { ApiBody, ApiOkResponse } from '@nestjs/swagger';
import { LoginDTO } from './dto/login.dto';
import { RegisterDTO } from './dto/register.dto';
import { UserService } from './user.service';
import { TokenResponse } from './vo/token.vo';
import { UserInfoResponse } from './vo/user-info.vo';

@Controller('user')
export class UserController {
  constructor(
    private userService: UserService
  ) {}

  @ApiBody({ type: RegisterDTO })
  @ApiOkResponse({ description: '注册', type: UserInfoResponse })
  @Post('register')
  async register(
    @Body() registerDTO: RegisterDTO
  ): Promise<UserInfoResponse> {
    return this.userService.register(registerDTO)
  }

  @ApiBody({ type: LoginDTO })
  @ApiOkResponse({ description: '登陆', type: TokenResponse })
  @Post('login')
  async login(
    @Body() loginDTO: LoginDTO
  ): Promise<any> {
    return this.userService.login(loginDTO)
  }
}
复制代码

校验用户信息

// src/modules/user/user.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { encryptPassword, makeSalt } from 'src/utils/cryptogram.util';
import { Repository } from 'typeorm';
import { LoginDTO } from './dto/login.dto';
import { RegisterDTO } from './dto/register.dto';
import { User } from './entity/user.entity';
import { TokenVO } from './vo/token.vo';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class UserService {

  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,

    private readonly jwtService: JwtService
  ){}

  // 校验注册信息
  async checkRegisterForm(
    registerDTO: RegisterDTO,
  ): Promise<any>{
    if (registerDTO.password !== registerDTO.passwordRepeat) {
      throw new NotFoundException('两次输入的密码不一致,请检查')
    }
    const { mobile } = registerDTO
    const hasUser = await this.userRepository
      .createQueryBuilder('user')
      .where('user.mobile = :mobile', { mobile })
      .getOne()
    if (hasUser) {      
      throw new NotFoundException('用户已存在')
    }
  }

  // 注册
  async register(
    registerDTO: RegisterDTO
  ): Promise<any> {

    await this.checkRegisterForm(registerDTO)

    const { nickname, password, mobile } = registerDTO;
    const salt = makeSalt();
    const hashPassword = encryptPassword(password, salt);

    const newUser: User = new User()
    newUser.nickname = nickname
    newUser.mobile = mobile
    newUser.password = hashPassword 
    newUser.salt = salt
    const result = await this.userRepository.save(newUser)
    delete result.password
    delete result.salt
    return {
      info: result
    }
  }

  // 登陆校验用户信息
  async checkLoginForm(
    loginDTO: LoginDTO
  ): Promise<any> {
    const { mobile, password } = loginDTO
    const user = await this.userRepository
      .createQueryBuilder('user')
      .addSelect('user.salt')
      .addSelect('user.password')
      .where('user.mobile = :mobile', { mobile })
      .getOne()

    if (!user) {
      throw new NotFoundException('用户不存在')
    }
    const { password: dbPassword, salt } = user
    const currentHashPassword = encryptPassword(password, salt)
    if (currentHashPassword !== dbPassword) {
      throw new NotFoundException('密码错误')
    }

    return user
  }

  async login(
    loginDTO: LoginDTO
  ): Promise<any>{
    const user = await this.checkLoginForm(loginDTO)
    return {
      info: {
        token
      }
    }
  }
}

复制代码

生成并返回token

安装依赖

yarn add @nestjs/passport passport passport-local @nestjs/jwt passport-jwt

yarn add -D @types/passport-local @types/passport-jwt

在 userModal 中使用 jwt 模块

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entity/user.entity';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { JwtModule } from '@nestjs/jwt';

@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
    JwtModule.register({
      secret: 'dasdjanksjdasd', // 密钥
      signOptions: { expiresIn: '8h' }, // token 过期时效
    }),
  ],
  controllers: [UserController],
  providers: [UserService]
})
export class UserModule {}
复制代码

实现登陆发放 token

import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { encryptPassword, makeSalt } from 'src/utils/cryptogram.util';
import { Repository } from 'typeorm';
import { LoginDTO } from './dto/login.dto';
import { RegisterDTO } from './dto/register.dto';
import { User } from './entity/user.entity';
import { TokenVO } from './vo/token.vo';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class UserService {

  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,

    private readonly jwtService: JwtService
  ){}

  // 校验注册信息
  async checkRegisterForm(
    registerDTO: RegisterDTO,
  ): Promise<any>{
    if (registerDTO.password !== registerDTO.passwordRepeat) {
      throw new NotFoundException('两次输入的密码不一致,请检查')
    }
    const { mobile } = registerDTO
    const hasUser = await this.userRepository
      .createQueryBuilder('user')
      .where('user.mobile = :mobile', { mobile })
      .getOne()
    if (hasUser) {      
      throw new NotFoundException('用户已存在')
    }
  }

  // 注册
  async register(
    registerDTO: RegisterDTO
  ): Promise<any> {

    await this.checkRegisterForm(registerDTO)

    const { nickname, password, mobile } = registerDTO;
    const salt = makeSalt(); // 制作密码盐
    const hashPassword = encryptPassword(password, salt);  // 加密密码

    const newUser: User = new User()
    newUser.nickname = nickname
    newUser.mobile = mobile
    newUser.password = hashPassword 
    newUser.salt = salt
    const result = await this.userRepository.save(newUser)
    delete result.password
    delete result.salt
    return {
      info: result
    }
  }

  // 登陆校验用户信息
  async checkLoginForm(
    loginDTO: LoginDTO
  ): Promise<any> {
    const { mobile, password } = loginDTO
    const user = await this.userRepository
      .createQueryBuilder('user')
      .addSelect('user.salt')
      .addSelect('user.password')
      .where('user.mobile = :mobile', { mobile })
      .getOne()
    
    console.log({ user })

    if (!user) {
      throw new NotFoundException('用户不存在')
    }
    const { password: dbPassword, salt } = user
    const currentHashPassword = encryptPassword(password, salt);
    console.log({currentHashPassword, dbPassword})
    if (currentHashPassword !== dbPassword) {
      throw new NotFoundException('密码错误')
    }

    return user
  }

  // 生成 token
  async certificate(user: User) {
    const payload = { 
      id: user.id,
      nickname: user.nickname,
      mobile: user.mobile,
    };
    const token = this.jwtService.sign(payload);
    return token
  }

  async login(
    loginDTO: LoginDTO
  ): Promise<any>{
    const user = await this.checkLoginForm(loginDTO)
    const token = await this.certificate(user)
    return {
      info: {
        token
      }
    }
  }
}

复制代码

接口鉴权

新增 策略文件

// src/modules/user/jwt.strategy.ts

import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: 'dasdjanksjdasd',
    });
  }
  
  async validate(payload: any) {
    return { 
      id: payload.id,
      mobile: payload.mobile,
      nickname: payload.nickname,
    };
  }
}
复制代码

在 userModule 中引用

// src/modules/user/user.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entity/user.entity';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { JwtModule } from '@nestjs/jwt';
import { JwtStrategy } from './jwt.strategy';

@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
    JwtModule.register({
      secret: 'dasdjanksjdasd', // 密钥
      signOptions: { expiresIn: '60s' }, // token 过期时效
    }),
  ],
  controllers: [UserController],
  providers: [UserService, JwtStrategy]
})
export class UserModule {}
复制代码

把 文章列表 的新增修改删除方法增加登陆校验

// src/modules/atricle/article.controller.ts

import { Controller, Body, Query, Get, Post, UseGuards } from '@nestjs/common';
import { ArticleService } from './article.service';
import { ArticleCreateDTO } from './dto/article-create.dto';
import { ArticleEditDTO } from './dto/article-edit.dto';
import { IdDTO } from './dto/id.dto';
import { ListDTO } from './dto/list.dto';
import { ApiTags, ApiOkResponse, ApiHeader, ApiBearerAuth } from '@nestjs/swagger';
import { ArticleInfoVO, ArticleInfoResponse } from './vo/article-info.vo';
import { ArticleListResponse, ArticleListVO } from './vo/article-list.vo';
import { AuthGuard } from '@nestjs/passport';

@ApiTags('文章模块')
@Controller('article')
export class ArticleController {
  constructor(
    private articleService: ArticleService
  ) {}

  @Get('list')
  @ApiOkResponse({ description: '文章列表', type: ArticleListResponse })
  async getMore(
    @Query() listDTO: ListDTO,
  ): Promise<ArticleListVO> {
    return await this.articleService.getMore(listDTO)
  }

  @Get('info')
  @ApiOkResponse({ description: '文章详情', type: ArticleInfoResponse })
  async getOne(
    @Query() idDto: IdDTO
  ): Promise<ArticleInfoVO>{
    return await this.articleService.getOne(idDto)
  }

  @UseGuards(AuthGuard('jwt'))
  @Post('create')
  @ApiBearerAuth()
  @ApiOkResponse({ description: '创建文章', type: ArticleInfoResponse })
  async create(
    @Body() articleCreateDTO: ArticleCreateDTO
  ): Promise<ArticleInfoVO> {
    return await this.articleService.create(articleCreateDTO)
  }

  @UseGuards(AuthGuard('jwt'))
  @Post('edit')
  @ApiBearerAuth()
  @ApiOkResponse({ description: '编辑文章', type: ArticleInfoResponse })
  async update(
    @Body() articleEditDTO: ArticleEditDTO
  ): Promise<ArticleInfoVO> {
    return await this.articleService.update(articleEditDTO)
  }

  @UseGuards(AuthGuard('jwt'))
  @Post('delete')
  @ApiBearerAuth()
  @ApiOkResponse({ description: '删除文章', type: ArticleInfoResponse })
  async delete(
    @Body() idDto: IdDTO,
  ): Promise<ArticleInfoVO> {
    return await this.articleService.delete(idDto)
  }
}
复制代码

测试 列表和详情接口都可以调通,但是新增、修改、删除则返回401

在 main.ts 中增加swagger 对于 Auth 的支持

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './filters/http-exception.filter';
import { TransformInterceptor } from './interceptor/transform.interceptor';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(new ValidationPipe())
  app.useGlobalInterceptors(new TransformInterceptor())
  app.useGlobalFilters(new HttpExceptionFilter())

  const options = new DocumentBuilder()
    .setTitle('blog-serve')
    .setDescription('接口文档')
    .setVersion('1.0')
    .addBearerAuth()
    .build();
  const document = SwaggerModule.createDocument(app, options);
  SwaggerModule.setup('swagger-doc', app, document);

  await app.listen(3000);
}
bootstrap();
复制代码

登陆后把 token 拷贝到 Authorize 即可在 使用了 @ApiBearerAuth() 装饰器的接口头部增加 token 信息

再次尝试请求 创建文章,可以看到创建成功

参考

系列

分类:
前端
标签: