
使用 nest 发邮件并实现邮箱和验证码登录
前言
在日常的产品体验中,经常会遇到使用邮箱登录的情况。在登录认证的过程中有的也会通过邮箱验证码的方式来进行登录,这种方式相对于传统的用户名密码登录来说,更加的安全,也更加的方便。本文将会介绍如何使用 nest 来实现邮箱验证码登录的功能。
前端项目
因为涉及到使用邮箱和验证码登录应用,所以需要一个前端项目来填写表单来进行登录。这对于前端来说是一个很简单的页面,所以这里就不再赘述了,大家自行实现即可。我这里使用的是的用 react 和 antd 写一个登录页面
这个简单的页面就达到发送和填写表单的功能,前端部分就到此为止了。
后端项目
既然是需要 nest 来收发邮件,那肯定是有一个后端服务项目的,先用 nest-cli 来创建一个项目
bash
nest new nest-email-login -p pnpm
用编辑器工具打开项目,因为前后端分离的原因,所以需要在项目中添加一个跨域的中间件,在 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
当然也有更简单的方式,那就是用客户端点点就行了,先生成一个容器然后在跑起来就行了
这样就可以了,然后在启动下服务,如果没有报错的话,那么就说明数据库连接成功了。然后还可以看到数据库中多了一个 user 表,并且控制台中也打印出了 sql 语句
然后手动在用户表中添加一条数据,这样就可以在后面的功能中使用了
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 等服务:
然后在帮助中心中搜索如何生成授权码
通过以上的配置就能拿到授权码了,然后就可以在代码中使用了
发送邮件
先在 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 中测试下这个接口,来发一个测试邮件
这样就可以在邮箱中收到一封邮件了
生成动态的验证码
实现验证码的原理就是使用一个随机数,然后把这个随机数发送到邮箱中,然后用户输入这个随机数,然后再和发送的随机数进行比较,如果相同就说明验证码正确,然后就可以登录了。所以先实现一个生成随机数的方法
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 来测试下看验证的情况如何
从图上可以看出来,验证 token 的接口都需要验证 token,而不需要验证 token 的接口不需要验证 token。
小结
到这里就完成了一个简单的用户注册和登录的功能,主要是利用邮箱发送验证码,然后在通过邮箱验证码登录,然后返回 token。然后通过守卫的方式对 token 进行验证