使用 nest 发邮件并实现邮箱和验证码登陆
前言
在日常的产品体验中,经常会遇到使用邮箱登录的情况。在登录认证的过程中有的也会通过邮箱验证码的方式来进行登录
这种方式相对于传统的用户名密码登录来说,更加的安全,也更加的方便。本文将会介绍如何使用 nest 来实现邮箱验证码登录的功能。
前端项目
因为涉及到使用邮箱和验证码登录应用,所以需要一个前端项目来填写表单来进行登录。这对于前端来说是一个很简单的页面,所以这里就不再赘述了,大家自行实现即可。
我这里使用的是的用 react 和 antd 写一个登录页面
这个简单的页面就达到发送和填写表单的功能,前端部分就到此为止了。
后端项目
准备
既然是需要 nest 来收发邮件,那肯定是有一个后端服务项目的,先用 nest-cli 来创建一个项目
- 设置跨域
app.enableCors();
- 在本地启动中,前端项目占用了 3000 端口,所以后端服务更改下端口号
await app.listen(3001);
- 然后启动项目
pnpm run start:dev
如果没有报错的话,然后页面访问下 http://localhost:3001
,如果能看到 Hello World!
页面,那么就说明项目启动成功了.
User 模块
项目基础架子搭建成功了,那么就开始着手实现邮箱验证码登录的功能。首先需要一个 user 模块来实现用户的登录功能,所以先创建一个 user 业务模块
结构如下
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 的依赖
pnpm add --save @nestjs/typeorm typeorm mysql2
然后在 app.module.ts 中添加 typeorm 的配置
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 文件
自己创建好数据表
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 中添加如下代码
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 模块
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 这个库,所以需要先安装下
pnpm add --save nodemailer
pnpm add --save-dev @types/nodemailer
然后在 email 模块中的 email.service.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 等服务:
通过以上的配置就能拿到授权码了,然后就可以在代码中使用了
发送邮件 先在 email.controller.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 发送成功;
}
}
生成动态的验证码
实现验证码的原理就是使用一个随机数,然后把这个随机数发送到邮箱中,然后用户输入这个随机数,
然后再和发送的随机数进行比较,如果相同就说明验证码正确,然后就可以登录了。所以先实现一个生成随机数的方法
const code = Math.random().toString().slice(2, 8);
这样一个带有动态二维码的邮件就发送成功了
邮箱验证码登录
现在发送验证码的功能已经实现了,那么就可以实现邮箱验证码登录了,当用邮箱获取验证码之后,把验证码存到 redis 中
然后在登录的时候,把 redis 中的验证码和用户输入的验证码进行比较,如果相同就说明验证码正确,然后就可以登录了。所以先安装 redis
pnpm add --save redis
然后在生成一个 redis 模块来定义一下 redis 的数据读取和写入的方法
在 redis 模块中定义一个 redis 提供者,用来连接 redis,还导出一个 redis 的服务,用来实现 redis 的读取和写入
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 的读取和写入
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 服务
@Inject()
private readonly redisService: RedisService
或者
constructor(private readonly redisService: RedisService) {}
然后在发送邮件的接口中,把验证码存到 redis 中
await this.redisService.set(`captcha_${address}`, code, 5 * 60);
这样就把验证码在发送之前把它存到了 redis 中。看下完整代码
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 模块中添加一个验证码登录的接口
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
import { IsNotEmpty } from "class-validator";
export class LoginUserDto {
@IsNotEmpty({ message: "邮箱不能为空" })
@IsEmail({}, { message: "邮箱格式不正确" })
readonly email: string;
@IsNotEmpty({ message: "验证码不能为空" })
@Length(6)
readonly captcha: string;
}
因为需要用到参数验证,所以需要安装一个参数验证的包
pnpm add --save class-validator class-transformer
需要在全局中开启参数验证
import { ValidationPipe } from "@nestjs/common";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
await app.listen(3000);
}
bootstrap();
登录的具体逻辑就是先从 redis 中获取验证码,然后再和用户输入的验证码进行比较,
如果相同就说明验证码正确,然后再去数据库看下有没有这个邮箱对应的用户,如果邮箱存在,验证码正确就可以正常登录了。
@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 文件中实现一个根据邮箱查找用户的方法
@InjectEntityManager()
private entityManager: EntityManager;
async findUserByEmail(email: string) {
return await this.entityManager.findOneBy(User, {
email
});
}
到这里的验证码邮箱登录逻辑就可以了,接下来就是返回 token 了。先安装一个 jwt 的包
pnpm add --save @nestjs/jwt
然后在 App 模块中引入 jwt 模块
@Module({
imports: [
JwtModule.register({
global:true,
secret: 'water',
signOptions: {
expiresIn: '7d'
}
})
]
})
然后在 user 控制器中引入 jwt 服务
import { JwtService } from '@nestjs/jwt';
@Inject(JwtService)
private jwtService: JwtService;
然后在登录成功后返回 token
const token = this.jwtService.sign({
id: user.id,
email: user.email,
});
然后就利用邮箱生成了 token,然后就可以在前端存到本地了,然后每次请求的时候都带上这个 token,然后后端就可以解析这个 token 了。
验证 token
在项目中不是所有的接口都需要验证 token,所以可以自动移一个验证 token 的守卫,然后在需要验证的接口上添加这个守卫
@Get('info')
@UseGuards(LoginGuard)
getUserInfo() {
return 'info'
}
定一个一个验证 token 的守卫
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 失效,请重新登录");
}
}
}
然后添加一个不需要验证的接口
@Get('water')
water() {
return 'water'
}
验证 token 的接口都需要验证 token,而不需要验证 token 的接口不需要验证 token。
总结
到这里就完成了一个简单的用户注册和登录的功能,主要是利用邮箱发送验证码,然后在通过邮箱验证码登录,然后返回 token。然后通过守卫的方式对 token 进行验证