Skip to content
本页目录

使用 nest 发邮件并实现邮箱和验证码登录

前言

在日常的产品体验中,经常会遇到使用邮箱登录的情况。在登录认证的过程中有的也会通过邮箱验证码的方式来进行登录,这种方式相对于传统的用户名密码登录来说,更加的安全,也更加的方便。本文将会介绍如何使用 nest 来实现邮箱验证码登录的功能。

前端项目

因为涉及到使用邮箱和验证码登录应用,所以需要一个前端项目来填写表单来进行登录。这对于前端来说是一个很简单的页面,所以这里就不再赘述了,大家自行实现即可。我这里使用的是的用 react 和 antd 写一个登录页面

img.png

这个简单的页面就达到发送和填写表单的功能,前端部分就到此为止了。

后端项目

既然是需要 nest 来收发邮件,那肯定是有一个后端服务项目的,先用 nest-cli 来创建一个项目

bash
nest new nest-email-login -p pnpm

img_1.png

用编辑器工具打开项目,因为前后端分离的原因,所以需要在项目中添加一个跨域的中间件,在 main.ts 中添加如下代码

typescript
app.enableCors()

在本地启动中,前端项目占用了 3000 端口,所以后端服务更改下端口号

typescript
await app.listen(3001)

然后把基础项目启动起来

bash
pnpm run start:dev

如果没有报错的话,然后页面访问下 http://localhost:3001,如果能看到Hello World!页面,那么就说明项目启动成功了.

user 模块

项目基础架子搭建成功了,那么就开始着手实现邮箱验证码登录的功能。首先需要一个 user 模块来实现用户的登录功能,所以先创建一个 user 业务模块

bash
nest g resource user

生成的目录结构如下

ts
src
├── app.controller.spec.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
├── main.ts
└── user
    ├── dto
    │   ├── create-user.dto.spec.ts
    │   └── create-user.dto.ts
    ├── user.controller.spec.ts
    ├── user.controller.ts
    ├── user.module.ts
    ├── user.service.spec.ts
    └── user.service.ts

在 user 功能中肯定需要使用到 mysql 数据库,因为要来存储用户的信息,所以需要先安装下 mysql 的依赖

bash
pnpm add --save @nestjs/typeorm typeorm mysql2

然后在 app.module.ts 中添加 typeorm 的配置

typescript
import { Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { AppController } from './app.controller'
import { AppService } from './app.service'
import { UserModule } from './user/user.module'

@Module({
  imports: [
    UserModule,
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: '123456',
      database: 'email_login_test',
      synchronize: true,
      logging: true,
      entities: [],
      poolSize: 10,
      connectorPackage: 'mysql2',
      extra: {
        authPlugin: 'sha256_password'
      }
    })
  ],
  controllers: [AppController],
  providers: [AppService]
})
export class AppModule {}

typeorm 配置好之后,既然要在数据库中存用户的信息,那么就需要先创建一个 user 的实体类,所以在 user 目录下 entities 目录下创建一个 user.entity.ts 文件

typescript
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number

  @Column({
    length: 50,
    comment: '用户名'
  })
  username: string

  @Column({
    length: 50,
    comment: '密码'
  })
  password: string

  @Column({
    length: 50,
    comment: '邮箱地址'
  })
  email: string
}

这个实体表示了用户的信息,包括用户名、密码和邮箱地址。实体创建好之后需要在 typeorm 的配置中添加进去,所以在 app.module.ts 中添加如下代码

typescript
TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: '123456',
      database: 'email_login_test',
      synchronize: true,
      logging: true,
      // 添加实体
      entities: [User],
      poolSize: 10,
      connectorPackage: 'mysql2',
      extra: {
        authPlugin: 'sha256_password',
      },
    }),
  ]

这时候你的服务如果报错的话,那多半是本地的 mysql 没有启动,这里笔者是使用的 docker 来启动的 mysql,所以需要先启动下 mysql 服务

bash
docker run -p 3306:3306 --name mysql -e MYSQL_ROOT_PASSWORD=123456 -d mysql:5.7

当然也有更简单的方式,那就是用客户端点点就行了,先生成一个容器然后在跑起来就行了

img_2.png

这样就可以了,然后在启动下服务,如果没有报错的话,那么就说明数据库连接成功了。然后还可以看到数据库中多了一个 user 表,并且控制台中也打印出了 sql 语句

img_3.png

然后手动在用户表中添加一条数据,这样就可以在后面的功能中使用了

img_4.png

email 模块

在 user 模块中,我们已经实现了用户的登录功能,但是这个登录功能是通过用户名和密码来登录的,而我们的需求是通过邮箱验证码来登录的,所以需要一个 email 模块来实现邮箱的发送和验证码的验证功能。所以先创建一个 email 模块

bash
nest g resource email

生成的目录结构如下,不需要生成默认的 curd 代码

ts
src
├── app.controller.spec.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
├── email
│   ├── email.controller.spec.ts
│   ├── email.controller.ts
│   ├── email.module.ts
│   ├── email.service.spec.ts
│   └── email.service.ts
├── main.ts
└── user
    ├── dto
    │   ├── create-user.dto.spec.ts
    │   └── create-user.dto.ts
    ├── entities
    │   └── user.entity.ts
    ├── user.controller.spec.ts
    ├── user.controller.ts
    ├── user.module.ts
    ├── user.service.spec.ts
    └── user.service.ts

对于发邮件来说是使用的 nodemailer 这个库,所以需要先安装下

bash
pnpm add --save nodemailer

pnpm add --save-dev @types/nodemailer

然后在 email 模块中的 email.service.ts 文件,用来实现发送邮件的功能

typescript
import { Injectable } from '@nestjs/common'
import { createTransport, Transporter } from 'nodemailer'

@Injectable()
export class EmailService {
  transporter: Transporter

  constructor() {
    this.transporter = createTransport({
      host: 'smtp.qq.com',
      port: 587,
      secure: false,
      auth: {
        user: 'xx@xx.com',
        pass: '你的授权码'
      }
    })
  }

  async sendMail({ to, subject, html }) {
    await this.transporter.sendMail({
      from: {
        name: '验证发送邮件请勿回复',
        address: 'xx@xx.com'
      },
      to,
      subject,
      html
    })
  }
}

把这里的邮箱和授权码改成你自己的就可以使用了

qq 邮箱授权码的获取

首先,要开启 smtp、imap 等服务,这里以 qq 邮箱举例(其他邮箱也类似):

在邮箱帮助中心 service.mail.qq.com 可以搜到如何开启 smtp、imap 等服务:

配置 smtp/imap/pop3

img_5.png

然后在帮助中心中搜索如何生成授权码

生成授权码

img_6.png

通过以上的配置就能拿到授权码了,然后就可以在代码中使用了

发送邮件

先在 email.controller.ts 中添加一个发送邮件的接口

typescript
import { Controller, Get } from '@nestjs/common'
import { EmailService } from './email.service'

@Controller('email')
export class EmailController {
  constructor(private readonly emailService: EmailService) {}

  @Get('code')
  async sendMail(@Query('address') address) {
    await this.emailService.sendMail({
      to: address, // 收件人
      subject: '登录验证码', // 邮件主题
      html: '<h1>测试邮件</h1>' // 邮件内容
    })
    return 发送成功
  }
}

然后在 postman 中测试下这个接口,来发一个测试邮件

img_7.png

这样就可以在邮箱中收到一封邮件了

img_8.png

生成动态的验证码

实现验证码的原理就是使用一个随机数,然后把这个随机数发送到邮箱中,然后用户输入这个随机数,然后再和发送的随机数进行比较,如果相同就说明验证码正确,然后就可以登录了。所以先实现一个生成随机数的方法

typescript
const code = Math.random().toString().slice(2, 8)

这样一个带有动态二维码的邮件就发送成功了

邮箱验证码登录

现在发送验证码的功能已经实现了,那么就可以实现邮箱验证码登录了,当用邮箱获取验证码之后,把验证码存到 redis 中,然后在登录的时候,把 redis 中的验证码和用户输入的验证码进行比较,如果相同就说明验证码正确,然后就可以登录了。所以先安装 redis

bash
pnpm add --save redis

然后在生成一个 redis 模块来定义一下 redis 的数据读取和写入的方法

bash
nest g resource redis  --no-spec

在 redis 模块中定义一个 redis 提供者,用来连接 redis,还导出一个 redis 的服务,用来实现 redis 的读取和写入

typescript
import { Global, Module } from '@nestjs/common'
import { RedisService } from './redis.service'
import { RedisController } from './redis.controller'
import { createClient } from 'redis'

@Global()
@Module({
  controllers: [RedisController],
  providers: [
    RedisService,
    {
      provide: 'REDIS_CLIENT',
      async useFactory() {
        const client = createClient({
          socket: {
            host: 'localhost',
            port: 6379
          }
        })
        await client.connect()
        return client
      }
    }
  ],
  exports: [RedisService]
})
export class RedisModule {}

然后在 redis 服务中实现 redis 的读取和写入

typescript
import { Inject, Injectable } from '@nestjs/common'
import { RedisClientType } from 'redis'

@Injectable()
export class RedisService {
  @Inject('REDIS_CLIENT')
  private redisClient: RedisClientType

  async get(key: string) {
    return await this.redisClient.get(key)
  }

  async set(key: string, value: string | number, ttl?: number) {
    await this.redisClient.set(key, value)

    if (ttl) {
      await this.redisClient.expire(key, ttl)
    }
  }
}

然后在 email 模块中的 email.controller.ts 文件中,把验证码先生成然后存到 redis 中,先注册下 redis 服务

typescript
@Inject()
private readonly redisService: RedisService

或者

constructor(private readonly redisService: RedisService) {}

然后在发送邮件的接口中,把验证码存到 redis 中

typescript
await this.redisService.set(`captcha_${address}`, code, 5 * 60)

这样就把验证码在发送之前把它存到了 redis 中。看下完整代码

typescript
import { Controller, Get, Inject, Query } from '@nestjs/common'
import { EmailService } from './email.service'
import { RedisService } from '../redis/redis.service'

@Controller('email')
export class EmailController {
  constructor(private readonly emailService: EmailService, private redisService: RedisService) {}

  // @Inject()
  // private redisService: RedisService;
  @Get('code')
  async sendEmailCode(@Query('address') address) {
    const code = Math.random().toString().slice(2, 8)

    await this.redisService.set(`captcha_${address}`, code, 5 * 60)
    await this.emailService.sendMail({
      to: address,
      subject: '登录验证码',
      html: `<p>你的登录验证码是 ${code}</p>`
    })
    return '发送成功'
  }
}

现在验证码也存到 redis 了,然后也发送到前端了,接下来就可以实现验证码登录了,先在 user 模块中添加一个验证码登录的接口

typescript
import { Controller, Get, Post, Query } from '@nestjs/common'
import { UserService } from './user.service'

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

  @Post('login')
  async login(@body() loginUserDto: LoginUserDto) {
    console.log(loginUserDto)
    return success
  }
}

然后在 DTO 文件夹中定义一个登录的 DTO

typescript
import { IsNotEmpty } from 'class-validator'

export class LoginUserDto {
  @IsNotEmpty({ message: '邮箱不能为空' })
  @IsEmail({}, { message: '邮箱格式不正确' })
  readonly email: string

  @IsNotEmpty({ message: '验证码不能为空' })
  @Length(6)
  readonly captcha: string
}

因为需要用到参数验证,所以需要安装一个参数验证的包

bash
pnpm add --save class-validator class-transformer

需要在全局中开启参数验证

typescript
import { ValidationPipe } from '@nestjs/common'

async function bootstrap() {
  const app = await NestFactory.create(AppModule)
  app.useGlobalPipes(new ValidationPipe())
  await app.listen(3000)
}
bootstrap()

登录的具体逻辑就是先从 redis 中获取验证码,然后再和用户输入的验证码进行比较,如果相同就说明验证码正确,然后再去数据库看下有没有这个邮箱对应的用户,如果邮箱存在,验证码正确就可以正常登录了。

typescript
@Inject(RedisService)
private redisService: RedisService;

@Post('login')
async login(@Body() loginUserDto: LoginUserDto) {

    const { email, code } = loginUserDto;

    const codeInRedis = await this.redisService.get(`captcha_${email}`);

    if(!codeInRedis) {
      throw new UnauthorizedException('验证码已失效');
    }
    if(code !== codeInRedis) {
      throw new UnauthorizedException('验证码不正确');
    }

    const user = await this.userService.findUserByEmail(email);

    console.log(user);

    return 'success';
}

在 user.service.ts 文件中实现一个根据邮箱查找用户的方法

typescript
@InjectEntityManager()
private entityManager: EntityManager;

async findUserByEmail(email: string) {
    return await this.entityManager.findOneBy(User, {
      email
    });
}

到这里的验证码邮箱登录逻辑就可以了,接下来就是返回 token 了。先安装一个 jwt 的包

bash
pnpm add --save @nestjs/jwt

然后在 App 模块中引入 jwt 模块

typescript
@Module({
    imports: [
        JwtModule.register({
            global:true,
            secret: 'water',
            signOptions: {
                expiresIn: '7d'
            }
        })
    ]
})

然后在 user 控制器中引入 jwt 服务

typescript
import { JwtService } from '@nestjs/jwt';

@Inject(JwtService)
private jwtService: JwtService;

然后在登录成功后返回 token

typescript
const token = this.jwtService.sign({
  id: user.id,
  email: user.email
})

然后就利用邮箱生成了 token,然后就可以在前端存到本地了,然后每次请求的时候都带上这个 token,然后后端就可以解析这个 token 了。

验证 token

在项目中不是所有的接口都需要验证 token,所以可以自动移一个验证 token 的守卫,然后在需要验证的接口上添加这个守卫

typescript
@Get('info')
@UseGuards(LoginGuard)
getUserInfo() {
  return 'info'
}

定一个一个验证 token 的守卫

typescript
import {
  CanActivate,
  ExecutionContext,
  Inject,
  Injectable,
  UnauthorizedException
} from '@nestjs/common'
import { Observable } from 'rxjs'
import { JwtService } from '@nestjs/jwt'

@Injectable()
export class LoginGuard implements CanActivate {
  @Inject()
  private jwtService: JwtService
  canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest()

    const authorization = request.header('authorization') || ''
    const bearer = authorization.split(' ')

    if (!bearer || bearer.length < 2) {
      throw new UnauthorizedException('登录 token 错误')
    }

    const token = bearer[1]

    try {
      const info = this.jwtService.verify(token)
      request.user = info.user
      return true
    } catch (error) {
      throw new UnauthorizedException('登录 token 失效,请重新登录')
    }
  }
}

然后添加一个不需要验证的接口

typescript
@Get('water')
water() {
  return 'water'
}

然后通过 postman 来测试下看验证的情况如何 img_9.pngimg_10.png

从图上可以看出来,验证 token 的接口都需要验证 token,而不需要验证 token 的接口不需要验证 token。

小结

到这里就完成了一个简单的用户注册和登录的功能,主要是利用邮箱发送验证码,然后在通过邮箱验证码登录,然后返回 token。然后通过守卫的方式对 token 进行验证

如有转载或 CV 的请标注本站原文地址