前面的章节都是对 Nest 做一些基本的配置,从本节开始将正式进入业务的开发阶段。我们首先要开发的接口就是注册和登录接口。
注册
先看一下注册的实现,我们先修改一下 User 表的结构,因为我们开发环境数据库配置的 synchronize 字段是 true,所以说当我们改完 User 实体user.entity.ts之后,数据库中表结构也会随之改变。
我们在实体中新增包含 uuid,用户名,密码,头像,邮箱等等一些字段,这里便于测试先将除了用户名和密码的其它字段都设置为可以为空的状态
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity("user")
export class User {
@PrimaryGeneratedColumn("uuid")
id: number; // 标记为主键,值自动生成
@Column({ length: 30 })
username: string; //用户名
@Column({ nullable: true })
nickname: string; //昵称
@Column()
password: string; //密码
@Column({ nullable: true })
avatar: string; //头像
@Column({ nullable: true })
email: string; //邮箱
@Column({ nullable: true })
role: string; //角色
@Column({ nullable: true })
salt: string;
@Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" })
create_time: Date;
@Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" })
update_time: Date;
}
然后在 workbench 中查看一下 user 表
image.png
可以发现表结构已经发生了变化
接下来看一下注册基本逻辑的实现。注册无非就是新增一个用户,在user.controller.ts写一个路由为register的接口用来接收用户传来的参数
import { Controller, Post, Body } from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { ApiOperation, ApiTags, ApiOkResponse } from '@nestjs/swagger';
import { CreateUserVo } from './vo/create-user.vo';
@ApiTags('用户模块')
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
@ApiOperation({
summary: '添加用户', // 接口描述信息
})
@ApiOkResponse({
description: '返回示例',
type: CreateUserVo,
})
@Post('register')
create(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto);
}
}
这里的注册接口只接收用户名和密码,至于头像邮箱等可以在个人中心更新,后续后有专门的接口。
我们都知道,在调用接口的时候有些参数是不能为空的或者需要规定的格式才行,如果不符合则提示前端。而 NestJS 对请求做校验其实只需要写在dto中就行了,这里我们需要先安装两个包
npm install class-validator class-transformer -S
然后在 main.ts 中使用useGlobalPipes全局注册一下
之后就可以在 dto 中加校验规则了
//create-user.dto.ts
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, MinLength } from "class-validator";
export class CreateUserDto {
@IsNotEmpty({
message: "用户名不能为空",
})
@ApiProperty({
example: "admin",
description: "用户名",
})
username: string;
@IsNotEmpty({
message: "密码不能为空",
})
@MinLength(6, {
message: "密码不能少于6位",
})
@ApiProperty({
example: "123456",
description: "密码",
})
password: string;
}
启动项目,我们调用注册接口试一试效果。
image.png
我们发现怎么describe是个对象,还有 statusCode,error 等字段? 这是因为过滤器ValidationPipe内部抛出错误用的是BadRequestException,如
throw new BadRequestException("用户名不能为空");
这样的话在我们自定义异常过滤器http-exception.filter.ts中的exception.getResponse()获取的就是上面那种格式
image.png
所以这里我们可以做个简单的判断,让这种情况只返回 message 中的内容即可
再试一下就会发现得到了我们想要的效果
接下来在user.service.ts中的create函数写一下注册的逻辑,其实很简单,拿到前端传来的用户名和密码插入数据库就行了,插入数据库之前首先需要判断用户是否存在,存在的话抛出一个业务异常返回给前端一个自定义的业务状态码ApiErrorCode.USER_EXIST,不存在的话再向数据库添加一条数据,再此之前需要先在common/enums/api-error-code.enum自定义一个异常 code
然后写上注册的逻辑
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { User } from './entities/user.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApiException } from 'src/common/filter/http-exception/api.exception';
import { ApiErrorCode } from 'src/common/enums/api-error-code.enum';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
async create(createUserDto: CreateUserDto) {
const { username, password } = createUserDto;
const existUser = await this.userRepository.findOne({
where: { username },
});
if (existUser)
throw new ApiException('用户已存在', ApiErrorCode.USER_EXIST);
try {
const newUser = new User();
newUser.username = username;
newUser.password = password;
await this.userRepository.save(newUser);
return '注册成功';
} catch (error) {
throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
使用 apifox 请求看一下
提示注册成功,再来到数据库中查看一下发现数据已经插入了
一般来说当用户注册的时候,我们是不能保存用户的真实密码的,这也是为什么很多网站在当我们忘记密码的时候不告诉我们真正的密码而是让我们选择重置密码的原因。
因此我们需要先将用户密码加密后在存入数据库中,这时候我们可以使用 crypto 进行密码的加密,首先安装 crypto
npm i crypto -S
然后新建 src/utils/crypto.ts,写一个加密的工具函数,使用 sha256 算法进行加密
import * as crypto from "crypto";
export default (value: string, salt: string) =>
crypto.pbkdf2Sync(value, salt, 1000, 18, "sha256").toString("hex");
接收需要加密的值 value 和盐 salt,这个 salt 每次加密的时候都是随机的,因为所以需要存入数据库中,这也是为什么实体中会有 salt 这个字段的原因,在登录时候需要用到这个 salt 对密码加密与数据库中的密码进行对比
那么在什么时候调用这个方法加密呢? 肯定是插入数据库之前调用,我们可以写在user.service.ts注册的逻辑中
还可以写在user.entity.ts实体中,这里我们采用第二种方式,需要用到装饰器BeforeInsert即执行数据库插入之前的操作
import { Column, Entity, PrimaryGeneratedColumn, BeforeInsert } from "typeorm";
import encry from "../../utils/crypto";
import * as crypto from "crypto";
@Entity("user")
export class User {
//这里省略部分代码
@BeforeInsert()
beforeInsert() {
this.salt = crypto.randomBytes(4).toString("base64");
this.password = encry(this.password, this.salt);
}
}
这时候我们换个用户名再次调用注册接口
查看数据库种的数据
可以看到密码已经加密了,到这里我们的注册接口基本已经完成了.接下来我们看下登录如何做
登录
一般登录流程是:
用户输入用户名和密码,后端根据用户名获取到该用户盐(salt)和加密后的密码,然后使用这个 salt 以同样的方式对用户输入的密码进行加密,最后比较这两个加密后的密码是否一致。如果一致则代表用户登录成功,此时需要用到 jwt 根据用户名和密码生成一个限时的 token 返回给前端,前端获取到 token 缓存下来,后面需要登录验证的接口都添加带有这个 token 的请求头,后端来根据请求头的 token 来决定是否放行
首先安装配置一下JWT
pnpm i @nestjs/jwt -S
然后在.env 文件中配置一下 JWT 的密钥与过期时间
# JWT配置
JWT_SECRET = bacdefg
JWT_EXP = 2h
最后在app.module.ts导入JwtModule做一下配置就能使用了
JwtModule.registerAsync({
global: true,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
return {
secret: configService.get('JWT_SECRET'),
signOptions: {
expiresIn: configService.get('JWT_EXP'),
},
};
},
}),
我们分别在 dto 和 vo 写login-dto.ts和login-vo.ts规定前端传入的参数及后端返回的参数格式
//login-dto.ts
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, MinLength } from "class-validator";
export class LoginDto {
@IsNotEmpty({
message: "用户名不能为空",
})
@ApiProperty({
example: "admin",
description: "用户名",
})
username: string;
@IsNotEmpty({
message: "密码不能为空",
})
@MinLength(6, {
message: "密码不能少于6位",
})
@ApiProperty({
example: "123456",
description: "密码",
})
password: string;
}
//login-vo.ts
import { ApiProperty } from "@nestjs/swagger";
export class LoginVo {
@ApiProperty({ example: 200 })
code: number;
@ApiProperty({ example: "eyJhbG..." })
data: string;
@ApiProperty({ example: "请求成功" })
describe: string;
}
接下来就可以写登录的逻辑了,我们在user.controller.ts新增一个login接口并且调用user.service.ts种的 login 方法,然后再 login 方法种处理登录逻辑
//user.controller.ts
@Post('login')
@ApiOkResponse({
description: '返回示例',
type: CreateUserVo,
})
login(@Body() loginDto: LoginDto) {
return this.userService.login(loginDto);
}
首先需要判断登录用户是否存在,所以我们先定义一个findOne方法根据用户名查找数据库中的数据,如果用户不存在则抛出自定义异常,存在则直接返回用户的信息
//user.service.ts
async findOne(username: string) {
const user = await this.userRepository.findOne({
where: { username },
});
if (!user)
throw new ApiException('用户名不存在', ApiErrorCode.USER_NOTEXIST);
return user;
}
然后在 login 方法中使用该用户对应的 salt 进行对输入的密码加密与数据库中密码比较,相同则使用 jwt 生成一个 token 返回给前端
async login(loginDto: LoginDto) {
const { username, password } = loginDto;
const user = await this.findOne(username);
if (user.password !== encry(password, user.salt)) {
throw new ApiException('密码错误', ApiErrorCode.PASSWORD_ERR);
}
const payload = { username: user.username, sub: user.id };
return await this.jwtService.signAsync(payload);
}
这里我们用到了jwtService所以需要注入才能使用,同时加密方法 encry 也需要进行导入
接下来我们用前面注册的账户进行登录试试
image.png
可以发现我们拿到了 token
导航守卫 guard
我们都知道,有一些接口必须登录之后才能访问,也就是说必须验证了 token 之后才能调用。
当前端拿到 token 的时候,会将 token 放在后续接口的请求头上,后端要做的就是验证这个 token 是否有效,无效则让前端重新登录。
那么该如何做 token 验证呢? 有的同学就说了,在调用接口时拿到 token 直接验证不就行了? 当然可以,但是作为一个后台管理系统,几乎每个接口都要做 token 验证,难道每个接口都写一次验证吗?很显然这样做太麻烦,而在 Nest 中我们可以使用 guard 守卫进行拦截,它可以在不侵入业务逻辑的情况下进行一些通用逻辑处理,比如这里的 token 验证,如果验证通过则返回 true 放行否则直接抛出权限不足的异常。
这也是后端服务中常见的 AOP 架构(面向切面编程):它允许开发者通过定义切面(Aspects)来对应用程序的各个部分添加横切关注点(Cross-Cutting Concerns)。横切关注点是那些不属于应用程序核心业务逻辑,但在整个应用程序中多处重复出现的功能或行为。这样可以让我们在不侵入业务逻辑的情况下来加入一些通用逻辑。 除了Guard(守卫)之外还有Middleware(中间件)、Interceptor(拦截器)、ExceptionFilter(异常过滤器)、Pipe(管道),前面我们已经使用过ExceptionFilter(异常过滤器),后面遇到其它的AOP再详细讲解。
这里我们先看下Guard的使用,首先执行nest g gu user生成一个守卫user.guard.ts
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Observable } from "rxjs";
@Injectable()
export class UserGuard implements CanActivate {
canActivate(
context: ExecutionContext
): boolean | Promise<boolean> | Observable<boolean> {
return true;
}
}
当我们接口使用这个守卫时,如果返回 true 则代表放行,所以我们可以将 token 验证的逻辑放到这里,这里规定前端请求头字段为 authorization,并且以Bearer ${token}(约定俗称)这种形式传值,将 guard 改为
import {
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { Request } from 'express';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class UserGuard implements CanActivate {
constructor(
private jwtService: JwtService, // JWT服务,用于验证和解析JWT token
private configService: ConfigService, // 配置服务,用于获取JWT_SECRET
) {}
/**
* 判断请求是否通过身份验证
* @param context 执行上下文
* @returns 是否通过身份验证
*/
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest(); // 获取请求对象
const token = this.extractTokenFromHeader(request); // 从请求头中提取token
if (!token) {
throw new HttpException('验证不通过', HttpStatus.FORBIDDEN); // 如果没有token,抛出验证不通过异常
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get('JWT_SECRET'), // 使用JWT_SECRET解析token
});
request['user'] = payload; // 将解析后的用户信息存储在请求对象中
} catch {
throw new HttpException('token验证失败', HttpStatus.FORBIDDEN); // token验证失败,抛出异常
}
return true; // 身份验证通过
}
/**
* 从请求头中提取token
* @param request 请求对象
* @returns 提取到的token
*/
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? []; // 从Authorization头中提取token
return type === 'Bearer' ? token : undefined; // 如果是Bearer类型的token,返回token;否则返回undefined
}
}
然后我们在user.controller.ts随便定义一个测试接口使用一下这个守卫看一下效果,这里用到了装饰器 UseGuards
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { UserGuard } from './user.guard';
@Controller('user')
export class AuthController {
constructor(private readonly authService: AuthService) {}
//省略部分代码
@UseGuards(UserGuard)
@Post('test')
test() {
return 1;
}
}
启动项目请求一下user/test接口看一下
可以发现请求被拦截了。我们通过 login 接口获取一个 token 加到请求头中再试一下看看
可以看到验证通过了,符合我们的需求
上面固然可以实现我们所需要的结果,但是几乎所有接口都要加守卫,这样会显得很麻烦。所以我们可以将其改造为全局守卫即可解决,下面看如何将其注册成全局守卫
在user.module.ts引入APP_GUARD并将 UserGuard注入,这样它就成为了全局守卫
//省略部分代码
import { APP_GUARD } from "@nestjs/core";
import { UserGuard } from "./user.guard";
@Module({
controllers: [UserController],
providers: [
UserService,
{
provide: APP_GUARD,
useClass: UserGuard,
},
],
imports: [TypeOrmModule.forFeature([User]), CacheModule],
})
export class UserModule {}
这时候将user.controller.ts中的装饰器@UseGuards去掉
再次请求user/test发现守卫依然有效。
那么问题又来了,如果我们有的接口不要验证登录怎么办?比如注册和登录接口。 我们请求一下登录接口看看
什么?登录你也问我要 token? 不登陆我哪来的 token?!
别担心,我们可以通过自定义装饰器设置元数据来处理
执行nest g d public生成一个 public.decorator.ts 创建一个装饰器设置一下元数据 isPublic 为 true
import { SetMetadata } from "@nestjs/common";
export const Public = () => SetMetadata("isPublic", true);
然后在 user.guard.ts 中通过Reflector取出当前的 isPublic,如果为 true(即用了@Public 进行装饰过),则不再进行 token 判断直接放行
import { Reflector } from '@nestjs/core';
@Injectable()
export class UserGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
private reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
//即将调用的方法
context.getHandler(),
//controller类型
context.getClass(),
]);
if (isPublic) {
return true;
}
//...
}
}
然后在不需要 token 的接口加上我们自定义的装饰器
再次登录发现并没有拦截
后续如果是不需要登录就能访问的接口只需加上这个自定义装饰器@Public即可。
到这里我们已经基本完成了登录逻辑,但是有的时候为了防止恶意程序或机器人通过暴力破解等方式进行大规模的登录尝试,登录通常会加入图形验证码以证明其是真实的用户而不是自动化程序。同时为了防止机器批量注册我们也在注册的时候加上图形验证码
图形验证码
验证码验证的逻辑其实也很简单。当用户请求获取验证码接口时,后端将一个随机字符生成一个图片,同时随机生成一个 id 与当前用户关联,然后以 id 为 key 将随机字符存入 redis 或者 session 中。当用户注册或者登录的时候带上这个 id 和图片上的字符,后端再通过 redis 取值进行判断即可
首先我们安装一个可以生成随机字符图片的包svg-captcha
npm install svg-captcha --save
它的使用方法也很简单,直接调用 create 方法传入一些配置参数即可,新建utils/generateCaptcha.ts写一个生成图形验证码和随机字符 id 的函数
import { create } from "svg-captcha";
import * as crypto from "crypto";
export default () => {
const captcha = create({
size: 4, //生成字符数
ignoreChars: "Il", //过滤字符
noise: 2, //干扰线数量
background: "#999", //背景色
color: true, //字体颜色是否随机生成
width: 100,
height: 40,
});
const id = crypto.randomBytes(10).toString("hex");
return { captcha, id };
};
在user.controller.ts写一个获取验证码接口
@Public()
@Get('captcha')
getCaptcha() {
return this.userService.getCaptcha();
}
在user.service.ts实现getCaptcha方法
getCaptcha() {
const { id, captcha } = generateCaptcha();
return { id, img: captcha.data };
}
尝试调用一下user/captcha接口
可以看到前端拿到了 id 和 svg 图片,将 svg 字符串放到元素的v-html属性中就可以看到图片了
接下来我们需要将图片里的字符缓存在 redis 中,我们可以通过captcha.text拿到图片中的字符。同时需要在user.module.ts导入 redis 缓存模块
然后就可以在user.service.ts注入使用了
在我们获取图片验证码的时候进行缓存
getCaptcha() {
const { id, captcha } = generateCaptcha();
this.cacheService.set(id, captcha.text, 60);
return { id, img: captcha.data };
}
然后在注册和登录的 dto 中加上 id 和 captcha 字段
//create-user.dto.ts
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, MinLength } from "class-validator";
export class CreateUserDto {
@IsNotEmpty({
message: "用户名不能为空",
})
@ApiProperty({
example: "admin",
description: "用户名",
})
username: string;
@IsNotEmpty({
message: "密码不能为空",
})
@MinLength(6, {
message: "密码不能少于6位",
})
@ApiProperty({
example: "123456",
description: "密码",
})
password: string;
@IsNotEmpty({
message: "id不能为空",
})
@ApiProperty({
example: "934e51cfff7b71ffc8ea",
description: "id",
})
id: string;
@Length(4, 4, { message: "验证码必须4位" })
@ApiProperty({
example: "dasw",
description: "验证码",
})
captcha: string;
}
注册的时候判断验证码
//注册
async create(createUserDto: CreateUserDto) {
const { username, password, captcha, id } = createUserDto;
//缓存的验证码
const cacheCaptcha = await this.cacheService.get(id);
if (captcha !== cacheCaptcha) {
throw new ApiException('验证码错误', ApiErrorCode.COMMON_CODE);
}
const existUser = await this.userRepository.findOne({
where: { username },
});
if (existUser)
throw new ApiException('用户已存在', ApiErrorCode.USER_EXIST);
try {
const newUser = new User();
newUser.username = username;
newUser.password = password;
await this.userRepository.save(newUser);
return '注册成功';
} catch (error) {
throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
先调用获取验证码接口拿到图片中的 text 和 id,然后放到注册接口调用
可以发现注册成功了,同样的登录接口逻辑也一样
到这里我们就完成了注册与登录。
其实这里还有一个问题,我们通过 JWT 给 token 设置了过期时间,假如用户没有操作页面等了一段时间过期时间到了,然后用户再调用接口返回 token 过期让用户登录,这没问题。
但是如果用户一直在操作页面,一直在调用我们的接口,过期时间一到突然让用户重新登录,这合理吗?显然是不合理的,那么这里就需要给 token 续期了,下一节将教大家如何给 token 续期。
总结
本篇文章我们完成了注册与登录接口的实现。
介绍了如何使用 JWT(JSON Web Token)进行身份验证,同时介绍了如何使用Guard路由进行请求拦截与放行,最后给登录和注册接口加了图形验证码验证的逻辑
本节代码地址注册与登录