上一篇文章已经对 Nest 做了一个基本的配置,本篇文章将带领大家完成一个小的实战,使用 JWT 实现注册登录的接口。(PS:最近更新可能不会那么频繁,因为我在学习大佬刚出的小册Nest 通关秘籍),等我学完了再给大家继续更新这一个专栏,当然,如果你也想从零学习 Nest 的话也可以看一下Nest 通关秘籍
注册
先看一下注册的实现,先用nest的命令创建一个 user 模块
nest g res user
然后就生成了 user 模块,在它的实体中创建一个用户表user.entity.ts
,包含 uuid
,用户名
,密码
,头像
,邮箱
等等一些字段,这里便于测试先将除了用户名和密码的其它字段都设置为可以为空的状态
import { Column, Entity, PrimaryGeneratedColumn, BeforeInsert, BeforeUpdate, } from 'typeorm'; @Entity('user') export class User 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; }
接下来看一下注册基本逻辑的实现。注册无非就是新增一个用户,在user.controller.ts
规定一个路由/user/register
接收用户传来的参数
import { Controller, Post, Body } from '@nestjs/common'; import { UserService } from './user.service'; import { CreateUserDto } from './dto/create-user.dto'; @Controller('user') export class UserController { constructor(private readonly userService: UserService) {} @Post('register') create(@Body() createUserDto: CreateUserDto) { return this.userService.create(createUserDto); } }
并将CreateUserDto
暂时规定传两个参数
//create-user.dto export class CreateUserDto { username: string; password: string; }
在user.service.ts
写注册的逻辑
首先判断用户是否存在,存在的话抛出一个业务异常返回给前端一个自定义的业务状态码
ApiErrorCode.USER_EXIST
,不存在的话再向数据库添加一条数据
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 } = createUserDto; const existUser = await this.userRepository.findOne({ where: { username }, }); if (existUser) throw new ApiException('用户已存在', ApiErrorCode.USER_EXIST); try { const newUser = await this.userRepository.create(createUserDto); return await this.userRepository.save(newUser); } catch (error) { throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR); } } }
启动项目,使用apifox
模拟一个注册请求试一下
可以看到我们的数据添加成功了,而且 user 表中也有了我们刚刚注册的用户数据
但是,这么简单的注册肯定不能用于实际项目中的,在实际项目中用户注册的时候密码是需要进行加密的,并且后台保存的也只是加密后的密码,这时候我们可以使用crypto
进行密码的加密,首先安装crypto
pnpm i crypto
然后新建 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');
这个加密应该写在什么时候呢? 聪明的小伙伴肯定会说写在 service 中插入数据库之前对密码进行加密。这种做法肯定是对的,但是我们还可以写在user.entity.ts
数据库实例中,只需要引入一个装饰器BeforeInsert
即可执行数据库插入之前的操作,注意这里还保存了加密所需要的 salt 到数据库中,因为登录的时候需要用到这个 salt 对密码加密与数据库中的密码进行对比
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); } }
这时候我们在测试一下注册的效果
可以看到插入的密码已经进行了加密,这里我们可以看到返回给前端的是数据库插入的数据,但是注册接口前端并不需要这些数据,我们可以自定义一下返回成功的数据,只需要修改一下 service 即可
登录
一般登录的流程是
用户输入用户名和密码,后端根据用户名获取到该用户盐(salt)和加密后的密码,然后使用这个 salt 以同样的方式对用户输入的密码进行加密,最后比较这两个加密后的密码是否一致。如果一致则代表用户登录成功,此时需要用到 jwt 根据用户名和密码生成一个限时的 token 返回给前端,前端获取到 token 缓存下来,后面需要登录验证的接口都添加带有这个 token 的请求头,后端来根据请求头的 token 来决定是否放行
接下来我们先安装@nestjs/jwt
pnpm i @nestjs/jwt
然后在.env 文件中配置一下 JWT 的密钥
# JWT密码 JWT_SECRET = bacdefg
使用nest g res auth
生成一个 auth 模块来写我们的登录
在auth.module.ts
导入 JWT 模块以及user
模块(这样就能调用 user 模块的方法了)
import { Module } from '@nestjs/common'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { JwtModule } from '@nestjs/jwt'; import { UserModule } from 'src/user/user.module'; import { ConfigModule, ConfigService } from '@nestjs/config'; @Module({ controllers: [AuthController], providers: [AuthService], imports: [ UserModule, JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: async (configService: ConfigService) => { return { secret: configService.get('JWT_SECRET'), global: true, signOptions: { expiresIn: '2h', }, }; }, }), ], }) export class AuthModule {}
注意 这里使用工厂模式导入了 JwtModule,我们可以从 ConfigService 获取.env 中的值
在 auth.controller.ts
定义一个auth/login
接口用于接收登录传来的参数
import { Controller, Post, Body } from '@nestjs/common'; import { AuthService } from './auth.service'; import { LoginAuthDto } from './dto/login-auth.dto'; @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} @Post('login') login(@Body() loginAuthDto: LoginAuthDto) { return this.authService.login(loginAuthDto); } }
由于后面后调用 user 模块中查询用户是否存在的方法,所以先在user.service.ts
加一个查询用户信息的方法
async findOne(username: string) { const user = await this.userRepository.findOne({ where: { username }, }); if (!user) throw new HttpException('用户名不存在', HttpStatus.BAD_REQUEST); return user; }
同时需要在user.module.ts
将UserService
导出才能给别的模块使用
@
Module({ controllers: [UserController], providers: [UserService], imports: [ TypeOrmModule.forFeature([User]), JwtModule.register({ secret: process.env.JWT_SECRET }), ], exports: [UserService], })
然后在auth.service.ts
写登录逻辑,这里首先判断用户是否存在,然后再使用该用户对应的 salt 进行加密比较,最后生成一个 token 返回给前端
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { UserService } from 'src/user/user.service'; import { LoginAuthDto } from './dto/login-auth.dto'; import encry from '../utils/crypto'; @Injectable() export class AuthService { constructor( private userService: UserService, private jwtService: JwtService, ) {} async login(loginAuthDto: LoginAuthDto) { const { username, password } = loginAuthDto; const user = await this.userService.findOne(username); if (user?.password !== encry(password, user.salt)) { throw new HttpException('密码错误', HttpStatus.UNAUTHORIZED); } const payload = { username: user.username, sub: user.id }; return await this.jwtService.signAsync(payload); } }
我们试一下这个接口,发现获取到了返回的 token
使用守卫 guard 验证 token
当前端拿到 token 的时候,会将 token 放在后续接口的请求头上,后端要做的就是验证这个 token 是否有效,无效返回给前端重新登录。
在 nest 中可以使用 guard 守卫进行拦截
执行nest g gu auth
生成一个 auth 守卫auth.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Observable } from 'rxjs'; @Injectable() export class AuthGuard implements CanActivate { canActivate( context: ExecutionContext, ): boolean | Promise<boolean> | Observable<boolean> { return true; } }
当返回 true 时则放行,否则将拦截,所以我们可以在这里写校验 token 的逻辑,这里规定前端请求头字段为authorization
,并且以Bearer ${token}
这种形式传值
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 AuthGuard implements CanActivate { constructor( private jwtService: JwtService, private configService: ConfigService, ) {} async canActivate(context: ExecutionContext): Promise<boolean> { const request = context.switchToHttp().getRequest(); const token = this.extractTokenFromHeader(request); if (!token) throw new HttpException('验证不通过', HttpStatus.FORBIDDEN); try { const payload = await this.jwtService.verifyAsync(token, { secret: this.configService.get('JWT_SECRET'), }); request['user'] = payload; } catch { throw new HttpException('token验证失败', HttpStatus.FORBIDDEN); } return true; } private extractTokenFromHeader(request: Request): string | undefined { const [type, token] = request.headers.authorization?.split(' ') ?? []; return type === 'Bearer' ? token : undefined; } }
然后我们在auth.controller.ts
定义一个测试接口使用一下这个守卫,这里用到了装饰器UseGuards
import { Controller, Post, Body } from '@nestjs/common'; import { AuthService } from './auth.service'; import { LoginAuthDto } from './dto/login-auth.dto'; import { UseGuards } from '@nestjs/common'; import { AuthGuard } from './auth.guard'; @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} @Post('login') login(@Body() loginAuthDto: LoginAuthDto) { return this.authService.login(loginAuthDto); } @UseGuards(AuthGuard) @Post('/test') test() { return 1; } }
不加 token 请求一下
我们发现验证没有通过。 我们通过 login 接口获取一个 token 加到请求头中再试一下
验证通过了
全局守卫 guard
上面固然可以实现我们所需要的结果,但是几乎所有接口都要加守卫,这样会显得很麻烦,所以我们可以将其改造为全局守卫即可解决
在auth.module.ts
引入APP_GUARD
并将 AuthGuard 注入,这样它就成为了全局守卫
//.. import { APP_GUARD } from '@nestjs/core'; import { AuthGuard } from '../auth/auth.guard'; @Module({ controllers: [AuthController], providers: [ AuthService, { provide: APP_GUARD, useClass: AuthGuard, }, ], //... }) export class AuthModule {}
将auth.controller.ts
中的装饰器UseGuards
去掉,发现守卫依然有效。这样的话问题又来了,如果我们有的接口不要验证登录怎么办,比如注册和登录接口,这里可以通过设置元数据处理
执行nest g d public
生成一个 public.decorator.ts 创建一个装饰器设置一下元数据 isPublic 为 true
i
mport { SetMetadata } from '@nestjs/common'; export const Public = () => SetMetadata('isPublic', true);
然后在 auth.guard.ts 中通过Reflector
取出当前的 isPublic,如果为 true(即用了@Public 进行装饰过),则直接放行
import { Reflector } from '@nestjs/core'; @Injectable() export class AuthGuard 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; } //... }
我们在 test 接口加上@Public
测试一下
@Public() @Post('test') test() { return 1; }
会发现没有加 token 接口同样返回值了,这样我们就可以在不需要登录验证的接口上加@Public
修饰即可
写在最后
到这里 nestjs 的登录注册功能基本完成了,由于篇幅原因,里面可能涉及到很多东西并没有详细讲解。当然如果你想更深层次的学习 NestJS 的话强烈建议你学习Nest 通关秘籍这本小册。同时欢迎关注专栏Vue3+NestJS 全栈开发后台权限管理系统,我会慢慢进行更新的~