NestJS 搭建博客系统(七)— 使用JWT实现注册登陆
前言
经过前面的开发,已经有一些基础设施,比如数据持久化、表单验证、数据返回以及文档,接下来可以比较愉快的开发一点业务了。 本章将实现简单的注册登陆鉴权功能,由于当前还是简单的单用户系统,所以这里只简单过一下,待后面升级为多用户再升级。
使用 JWT 实现注册登录
关于 JWT 的文章网上有很多,这里不作阐述
创建 USER 模块
创建模块
nest g mo modules/user
创建控制器
nest g co modules/user
创建服务
nest g s modules/user
创建 entity
// src/modules/user/entity/user.entity.ts
import {
Entity,
Column,
PrimaryGeneratedColumn,
UpdateDateColumn,
CreateDateColumn,
VersionColumn,
} from 'typeorm';
@Entity()
export class User {
// 主键id
@PrimaryGeneratedColumn()
id: number;
// 创建时间
@CreateDateColumn()
createTime: Date
// 更新时间
@UpdateDateColumn()
updateTime: Date
// 软删除
@Column({
default: false
})
isDelete: boolean
// 更新次数
@VersionColumn()
version: number
// 昵称
@Column('text')
nickname: string;
// 手机号
@Column('text')
mobile: string;
// 加密后的密码
@Column('text', { select: false })
password: string;
// 加密盐
@Column('text', { select: false })
salt: string;
}
复制代码
在 userModule 中引入 entity
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entity/user.entity';
import { UserController } from './user.controller';
import { UserService } from './user.service';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
],
controllers: [UserController],
providers: [UserService]
})
export class UserModule {}
复制代码
关于注册功能
注册相当于用户模块的新增,登陆就是提交账号密码与数据库一致时对其发放 token,访问资源的时候在请求头带上 token,我们可以对其进行授权和拦截访问资源。
登陆需要校验账号密码,站在用户角度,账号 123,密码 abc,用户提交的内容就是明文 123,abc,服务只需要校验 123 对应的是不是 abc 即可,服务端可以不校验直接给你发放token,也可以保存明文账号密码进行校验,也可以对密码加密,再校验加密后的密码。所以我们日常不应该使用同一个账号密码,因为一旦其中一个网站明文保存,就等于是所有的账号密码都泄漏了。
对于加密也分为对称加密和非对称加密,这个展开讲也非常庞大,这里也不展开,网上文章也很多。
注册登陆这一块有几个选择:
- 明文账号密码:数据库暴露,用户账号密码直接暴露
- 使用对称加密:数据库暴露,黑客进行对称解密,用户账号密码直接暴露
- 使用非对称加密:数据库暴露,黑客使用彩虹表暴力破解,随着时间流逝,大量用户密码都会暴露
- 使用非对称加密且加盐:数据库暴露,黑客使用彩虹表暴力破解,随着时间流逝,少量用户密码会被破解,但同一个明文密码的加密后的值都是一样的。
- 使用非对称加密且加随机盐:数据库暴露,黑客使用彩虹表暴力破解,少量用户密码被破解,但同一个明文密码的加密后的值都是不一样的。
根据上面的思路,我们选择最后一种方式。
实现注册功能
首先我们定义注册功能的入参和出参
// src/modules/user/dto/register.dto.ts
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsString, Matches } from "class-validator"
import { regMobileCN } from "src/utils/regex.util";
export class RegisterDTO {
@ApiProperty({
description: '手机号,唯一',
example: '13049153466'
})
@Matches(regMobileCN, { message: '请输入正确手机号' })
@IsNotEmpty({ message: '请输入手机号' })
readonly mobile: string;
@ApiProperty({
description: '用户名',
example: '斯提芬大狗'
})
@IsNotEmpty({ message: '请输入用户昵称' })
@IsString({ message: '名字必须是 String 类型'})
readonly nickname: string;
@ApiProperty({
description: '用户密码',
example: '123456',
})
@IsNotEmpty({ message: '请输入密码' })
readonly password: string;
@ApiProperty({
description: '二次输入密码',
example: '123456'
})
@IsNotEmpty({ message: '请再次输入密码' })
readonly passwordRepeat: string
}
复制代码
// src/modules/user/vo/user-info.vo.ts
import { ApiProperty } from "@nestjs/swagger";
export class UserInfoItem {
@ApiProperty({ description: '用户id', example: 1 })
id: number;
@ApiProperty({ description: '创建时间', example: '2021-07-21' })
createTime: Date
@ApiProperty({ description: '更新时间', example: '2021-07-21' })
updateTime: Date
@ApiProperty({ description: '手机号', example: '13088888888' })
mobile: string;
}
export class UserInfoVO {
@ApiProperty({ type: UserInfoItem })
info: UserInfoItem
}
export class UserInfoResponse {
@ApiProperty({ description: '状态码', example: 200, })
code: number
@ApiProperty({ description: '数据',
type: () => UserInfoVO, example: UserInfoVO, })
data: UserInfoVO
@ApiProperty({ description: '请求结果信息', example: '请求成功' })
message: string
}
复制代码
修改 userController
// src/modules/user/user.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { ApiBody, ApiOkResponse } from '@nestjs/swagger';
import { RegisterDTO } from './dto/register.dto';
import { UserService } from './user.service';
import { UserInfoResponse } from './vo/user-info.vo';
@Controller('user')
export class UserController {
constructor(
private userService: UserService
) {}
@ApiBody({ type: RegisterDTO })
@ApiOkResponse({ description: '注册', type: UserInfoResponse })
@Post('register')
async register(
@Body() registerDTO: RegisterDTO
): Promise<UserInfoResponse> {
return this.userService.register(registerDTO)
}
}
复制代码
加密我们使用 crypto
安装依赖
yarn add crypto-js @types/crypto-js
新建一个工具类
// src/utils/cryptogram.util.ts
import * as crypto from 'crypto';
// 随机盐
export function makeSalt(): string {
return crypto.randomBytes(3).toString('base64');
}
/**
* 使用盐加密明文密码
* @param password 密码
* @param salt 密码盐
*/
export function encryptPassword(password: string, salt: string): string {
if (!password || !salt) {
return '';
}
const tempSalt = Buffer.from(salt, 'base64');
return (
// 10000 代表迭代次数 16代表长度
crypto.pbkdf2Sync(password, tempSalt, 10000, 16, 'sha1').toString('base64')
);
}
复制代码
服务增加注册方法
// src/modules/user/user.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { encryptPassword, makeSalt } from 'src/utils/cryptogram.util';
import { Repository } from 'typeorm';
import { RegisterDTO } from './dto/register.dto';
import { User } from './entity/user.entity';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
){}
// 注册
async register(
registerDTO: RegisterDTO
): Promise<any> {
const { nickname, password, mobile } = registerDTO;
const salt = makeSalt(); // 制作密码盐
const hashPassword = encryptPassword(password, salt); // 加密密码
const newUser: User = new User()
newUser.nickname = nickname
newUser.mobile = mobile
newUser.password = hashPassword
newUser.salt = salt
return await this.userRepository.save(newUser)
}
}
复制代码
打开 swagger 测试一下注册功能,但是这里没有做限制,导致注册可以重复注册
增加一下校验
// src/modules/user/user.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { encryptPassword, makeSalt } from 'src/utils/cryptogram.util';
import { Repository } from 'typeorm';
import { RegisterDTO } from './dto/register.dto';
import { User } from './entity/user.entity';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
){}
// 校验注册信息
async checkRegisterForm(
registerDTO: RegisterDTO,
): Promise<any>{
if (registerDTO.password !== registerDTO.passwordRepeat) {
throw new NotFoundException('两次输入的密码不一致,请检查')
}
const { mobile } = registerDTO
const hasUser = await this.userRepository.findOne({ mobile })
if (hasUser) {
throw new NotFoundException('用户已存在')
}
}
// 注册
async register(
registerDTO: RegisterDTO
): Promise<any> {
await this.checkRegisterForm(registerDTO)
const { nickname, password, mobile } = registerDTO;
const salt = makeSalt(); // 制作密码盐
const hashPassword = encryptPassword(password, salt); // 加密密码
const newUser: User = new User()
newUser.nickname = nickname
newUser.mobile = mobile
newUser.password = hashPassword
newUser.salt = salt
return await this.userRepository.save(newUser)
}
}
复制代码
至此,注册功能完成
实现登陆并发放 TOKEN
登陆的流程是这样的:
- 用户提交 login 接口,参数包括 mobile password
- 服务通过 mobile 查询用户信息,并使用 password 与 对应随机盐校验 密码是否正确
- 验证通过,使用 用户信息 生成 token
- 返回 token
定义登陆接口
// src/modules/user/dto/login.dto.ts
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, Matches } from "class-validator"
import { regMobileCN } from "src/utils/regex.util";
export class LoginDTO {
@ApiProperty({
description: '手机号,唯一',
example: '13049153466'
})
@Matches(regMobileCN, { message: '请输入正确手机号' })
@IsNotEmpty({ message: '请输入手机号' })
readonly mobile: string;
@ApiProperty({
description: '用户密码',
example: '123456',
})
@IsNotEmpty({ message: '请输入密码' })
readonly password: string;
}
复制代码
// src/modules/user/vo/token.vo.ts
import { ApiProperty } from "@nestjs/swagger";
export class TokenItem {
@ApiProperty({ description: 'token', example: 'sdfghjkldasascvbnm' })
token: string;
}
export class TokenVO {
@ApiProperty({ type: TokenItem })
info: TokenItem
}
export class TokenResponse {
@ApiProperty({ description: '状态码', example: 200, })
code: number
@ApiProperty({ description: '数据',
type: () => TokenVO, example: TokenVO, })
data: TokenVO
@ApiProperty({ description: '请求结果信息', example: '请求成功' })
message: string
}
复制代码
在 userController 增加 login 方法
import { Body, Controller, Post } from '@nestjs/common';
import { ApiBody, ApiOkResponse } from '@nestjs/swagger';
import { LoginDTO } from './dto/login.dto';
import { RegisterDTO } from './dto/register.dto';
import { UserService } from './user.service';
import { TokenResponse } from './vo/token.vo';
import { UserInfoResponse } from './vo/user-info.vo';
@Controller('user')
export class UserController {
constructor(
private userService: UserService
) {}
@ApiBody({ type: RegisterDTO })
@ApiOkResponse({ description: '注册', type: UserInfoResponse })
@Post('register')
async register(
@Body() registerDTO: RegisterDTO
): Promise<UserInfoResponse> {
return this.userService.register(registerDTO)
}
@ApiBody({ type: LoginDTO })
@ApiOkResponse({ description: '登陆', type: TokenResponse })
@Post('login')
async login(
@Body() loginDTO: LoginDTO
): Promise<any> {
return this.userService.login(loginDTO)
}
}
复制代码
校验用户信息
// src/modules/user/user.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { encryptPassword, makeSalt } from 'src/utils/cryptogram.util';
import { Repository } from 'typeorm';
import { LoginDTO } from './dto/login.dto';
import { RegisterDTO } from './dto/register.dto';
import { User } from './entity/user.entity';
import { TokenVO } from './vo/token.vo';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly jwtService: JwtService
){}
// 校验注册信息
async checkRegisterForm(
registerDTO: RegisterDTO,
): Promise<any>{
if (registerDTO.password !== registerDTO.passwordRepeat) {
throw new NotFoundException('两次输入的密码不一致,请检查')
}
const { mobile } = registerDTO
const hasUser = await this.userRepository
.createQueryBuilder('user')
.where('user.mobile = :mobile', { mobile })
.getOne()
if (hasUser) {
throw new NotFoundException('用户已存在')
}
}
// 注册
async register(
registerDTO: RegisterDTO
): Promise<any> {
await this.checkRegisterForm(registerDTO)
const { nickname, password, mobile } = registerDTO;
const salt = makeSalt();
const hashPassword = encryptPassword(password, salt);
const newUser: User = new User()
newUser.nickname = nickname
newUser.mobile = mobile
newUser.password = hashPassword
newUser.salt = salt
const result = await this.userRepository.save(newUser)
delete result.password
delete result.salt
return {
info: result
}
}
// 登陆校验用户信息
async checkLoginForm(
loginDTO: LoginDTO
): Promise<any> {
const { mobile, password } = loginDTO
const user = await this.userRepository
.createQueryBuilder('user')
.addSelect('user.salt')
.addSelect('user.password')
.where('user.mobile = :mobile', { mobile })
.getOne()
if (!user) {
throw new NotFoundException('用户不存在')
}
const { password: dbPassword, salt } = user
const currentHashPassword = encryptPassword(password, salt)
if (currentHashPassword !== dbPassword) {
throw new NotFoundException('密码错误')
}
return user
}
async login(
loginDTO: LoginDTO
): Promise<any>{
const user = await this.checkLoginForm(loginDTO)
return {
info: {
token
}
}
}
}
复制代码
生成并返回token
安装依赖
yarn add @nestjs/passport passport passport-local @nestjs/jwt passport-jwt
yarn add -D @types/passport-local @types/passport-jwt
在 userModal 中使用 jwt 模块
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entity/user.entity';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
JwtModule.register({
secret: 'dasdjanksjdasd', // 密钥
signOptions: { expiresIn: '8h' }, // token 过期时效
}),
],
controllers: [UserController],
providers: [UserService]
})
export class UserModule {}
复制代码
实现登陆发放 token
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { encryptPassword, makeSalt } from 'src/utils/cryptogram.util';
import { Repository } from 'typeorm';
import { LoginDTO } from './dto/login.dto';
import { RegisterDTO } from './dto/register.dto';
import { User } from './entity/user.entity';
import { TokenVO } from './vo/token.vo';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly jwtService: JwtService
){}
// 校验注册信息
async checkRegisterForm(
registerDTO: RegisterDTO,
): Promise<any>{
if (registerDTO.password !== registerDTO.passwordRepeat) {
throw new NotFoundException('两次输入的密码不一致,请检查')
}
const { mobile } = registerDTO
const hasUser = await this.userRepository
.createQueryBuilder('user')
.where('user.mobile = :mobile', { mobile })
.getOne()
if (hasUser) {
throw new NotFoundException('用户已存在')
}
}
// 注册
async register(
registerDTO: RegisterDTO
): Promise<any> {
await this.checkRegisterForm(registerDTO)
const { nickname, password, mobile } = registerDTO;
const salt = makeSalt(); // 制作密码盐
const hashPassword = encryptPassword(password, salt); // 加密密码
const newUser: User = new User()
newUser.nickname = nickname
newUser.mobile = mobile
newUser.password = hashPassword
newUser.salt = salt
const result = await this.userRepository.save(newUser)
delete result.password
delete result.salt
return {
info: result
}
}
// 登陆校验用户信息
async checkLoginForm(
loginDTO: LoginDTO
): Promise<any> {
const { mobile, password } = loginDTO
const user = await this.userRepository
.createQueryBuilder('user')
.addSelect('user.salt')
.addSelect('user.password')
.where('user.mobile = :mobile', { mobile })
.getOne()
console.log({ user })
if (!user) {
throw new NotFoundException('用户不存在')
}
const { password: dbPassword, salt } = user
const currentHashPassword = encryptPassword(password, salt);
console.log({currentHashPassword, dbPassword})
if (currentHashPassword !== dbPassword) {
throw new NotFoundException('密码错误')
}
return user
}
// 生成 token
async certificate(user: User) {
const payload = {
id: user.id,
nickname: user.nickname,
mobile: user.mobile,
};
const token = this.jwtService.sign(payload);
return token
}
async login(
loginDTO: LoginDTO
): Promise<any>{
const user = await this.checkLoginForm(loginDTO)
const token = await this.certificate(user)
return {
info: {
token
}
}
}
}
复制代码
接口鉴权
新增 策略文件
// src/modules/user/jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: 'dasdjanksjdasd',
});
}
async validate(payload: any) {
return {
id: payload.id,
mobile: payload.mobile,
nickname: payload.nickname,
};
}
}
复制代码
在 userModule 中引用
// src/modules/user/user.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entity/user.entity';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { JwtModule } from '@nestjs/jwt';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
JwtModule.register({
secret: 'dasdjanksjdasd', // 密钥
signOptions: { expiresIn: '60s' }, // token 过期时效
}),
],
controllers: [UserController],
providers: [UserService, JwtStrategy]
})
export class UserModule {}
复制代码
把 文章列表 的新增修改删除方法增加登陆校验
// src/modules/atricle/article.controller.ts
import { Controller, Body, Query, Get, Post, UseGuards } from '@nestjs/common';
import { ArticleService } from './article.service';
import { ArticleCreateDTO } from './dto/article-create.dto';
import { ArticleEditDTO } from './dto/article-edit.dto';
import { IdDTO } from './dto/id.dto';
import { ListDTO } from './dto/list.dto';
import { ApiTags, ApiOkResponse, ApiHeader, ApiBearerAuth } from '@nestjs/swagger';
import { ArticleInfoVO, ArticleInfoResponse } from './vo/article-info.vo';
import { ArticleListResponse, ArticleListVO } from './vo/article-list.vo';
import { AuthGuard } from '@nestjs/passport';
@ApiTags('文章模块')
@Controller('article')
export class ArticleController {
constructor(
private articleService: ArticleService
) {}
@Get('list')
@ApiOkResponse({ description: '文章列表', type: ArticleListResponse })
async getMore(
@Query() listDTO: ListDTO,
): Promise<ArticleListVO> {
return await this.articleService.getMore(listDTO)
}
@Get('info')
@ApiOkResponse({ description: '文章详情', type: ArticleInfoResponse })
async getOne(
@Query() idDto: IdDTO
): Promise<ArticleInfoVO>{
return await this.articleService.getOne(idDto)
}
@UseGuards(AuthGuard('jwt'))
@Post('create')
@ApiBearerAuth()
@ApiOkResponse({ description: '创建文章', type: ArticleInfoResponse })
async create(
@Body() articleCreateDTO: ArticleCreateDTO
): Promise<ArticleInfoVO> {
return await this.articleService.create(articleCreateDTO)
}
@UseGuards(AuthGuard('jwt'))
@Post('edit')
@ApiBearerAuth()
@ApiOkResponse({ description: '编辑文章', type: ArticleInfoResponse })
async update(
@Body() articleEditDTO: ArticleEditDTO
): Promise<ArticleInfoVO> {
return await this.articleService.update(articleEditDTO)
}
@UseGuards(AuthGuard('jwt'))
@Post('delete')
@ApiBearerAuth()
@ApiOkResponse({ description: '删除文章', type: ArticleInfoResponse })
async delete(
@Body() idDto: IdDTO,
): Promise<ArticleInfoVO> {
return await this.articleService.delete(idDto)
}
}
复制代码
测试 列表和详情接口都可以调通,但是新增、修改、删除则返回401
在 main.ts 中增加swagger 对于 Auth 的支持
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './filters/http-exception.filter';
import { TransformInterceptor } from './interceptor/transform.interceptor';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe())
app.useGlobalInterceptors(new TransformInterceptor())
app.useGlobalFilters(new HttpExceptionFilter())
const options = new DocumentBuilder()
.setTitle('blog-serve')
.setDescription('接口文档')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, options);
SwaggerModule.setup('swagger-doc', app, document);
await app.listen(3000);
}
bootstrap();
复制代码
登陆后把 token 拷贝到 Authorize 即可在 使用了 @ApiBearerAuth() 装饰器的接口头部增加 token 信息
再次尝试请求 创建文章,可以看到创建成功