Skip to content

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

前言

在日常的产品体验中,经常会遇到使用邮箱登录的情况。在登录认证的过程中有的也会通过邮箱验证码的方式来进行登录

这种方式相对于传统的用户名密码登录来说,更加的安全,也更加的方便。本文将会介绍如何使用 nest 来实现邮箱验证码登录的功能。

前端项目

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

我这里使用的是的用 react 和 antd 写一个登录页面

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

后端项目

准备

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

  • 设置跨域
ts
app.enableCors();
  • 在本地启动中,前端项目占用了 3000 端口,所以后端服务更改下端口号
ts
await app.listen(3001);
  • 然后启动项目
ts
pnpm run start:dev

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

User 模块

项目基础架子搭建成功了,那么就开始着手实现邮箱验证码登录的功能。首先需要一个 user 模块来实现用户的登录功能,所以先创建一个 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 的依赖

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

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

ts
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 文件

自己创建好数据表

ts
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 中添加如下代码

ts
 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',
      },
    }),
  ]

email 模块

在 user 模块中,我们已经实现了用户的登录功能,但是这个登录功能是通过用户名和密码来登录的,而我们的需求是通过邮箱验证码来登录的

所以需要一个 email 模块来实现邮箱的发送和验证码的验证功能。所以先创建一个 email 模块

bash

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 文件,用来实现发送邮件的功能

ts
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

然后在帮助中心中搜索如何生成授权码 [生成授权码](https://service.mail.qq.com/detail/0/75)

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

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

ts
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 发送成功;
  }
}

生成动态的验证码

实现验证码的原理就是使用一个随机数,然后把这个随机数发送到邮箱中,然后用户输入这个随机数,

然后再和发送的随机数进行比较,如果相同就说明验证码正确,然后就可以登录了。所以先实现一个生成随机数的方法

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

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

邮箱验证码登录

现在发送验证码的功能已经实现了,那么就可以实现邮箱验证码登录了,当用邮箱获取验证码之后,把验证码存到 redis 中

然后在登录的时候,把 redis 中的验证码和用户输入的验证码进行比较,如果相同就说明验证码正确,然后就可以登录了。所以先安装 redis

ts
pnpm add --save redis

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

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

ts
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 的读取和写入

ts
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 服务

ts
@Inject()
private readonly redisService: RedisService

或者

constructor(private readonly redisService: RedisService) {}

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

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

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

ts
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 模块中添加一个验证码登录的接口

ts
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

ts
import { IsNotEmpty } from "class-validator";

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

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

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

ts
pnpm add --save class-validator class-transformer

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

ts
import { ValidationPipe } from "@nestjs/common";

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

登录的具体逻辑就是先从 redis 中获取验证码,然后再和用户输入的验证码进行比较,

如果相同就说明验证码正确,然后再去数据库看下有没有这个邮箱对应的用户,如果邮箱存在,验证码正确就可以正常登录了。

ts
@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 文件中实现一个根据邮箱查找用户的方法

ts
@InjectEntityManager()
private entityManager: EntityManager;

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

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

ts
pnpm add --save @nestjs/jwt

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

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

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

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

@Inject(JwtService)
private jwtService: JwtService;

然后在登录成功后返回 token

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

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

验证 token

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

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

定一个一个验证 token 的守卫

ts
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 失效,请重新登录");
    }
  }
}

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

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

验证 token 的接口都需要验证 token,而不需要验证 token 的接口不需要验证 token。

总结

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