跳到主要内容

MySQL + TypeORM + JWT 实现登录注册

学完了 mysql、typeorm、jwt/session 之后,我们来做个综合案例:登录注册。

首先,创建个新的 database:

CREATE SCHEMA login_test DEFAULT CHARACTER SET utf8mb4;

create schema 或者 create database 都可以,一个意思。

指定默认字符集 ,这样创建表的时候就不用指定字符集了。

utf8 最多存 3 个字节的字符,而 utf8mb4 最多 4 个字符,可以存储一些 emoji 等特殊字符。

刷新后就可以看到这个数据库了:

然后我们创建个 nest 项目:

nest new login-and-register -p npm

安装 typeorm 相关的包:

npm install --save @nestjs/typeorm typeorm mysql2

然后在 AppModule 里引入 TypeOrmModule,传入 option:

import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";

@Module({
imports: [
TypeOrmModule.forRoot({
type: "mysql",
host: "localhost",
port: 3306,
username: "root",
password: "guang",
database: "login_test",
synchronize: true,
logging: true,
entities: [],
poolSize: 10,
connectorPackage: "mysql2",
extra: {
authPlugin: "sha256_password",
},
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

之后创建个 user 的 CRUD 模块:

nest g resource user

引入 User 的 entity:

然后给 User 添加一些属性:

import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from "typeorm";

@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;

@Column({
length: 50,
comment: "用户名",
})
username: string;

@Column({
length: 50,
comment: "密码",
})
password: string;

@CreateDateColumn({
comment: "创建时间",
})
createTime: Date;

@UpdateDateColumn({
comment: "更新时间",
})
updateTime: Date;
}

id 列是主键、自动递增。

username 和 password 是用户名和密码,类型是 VARCHAR(50)。

createTime 是创建时间,updateTime 是更新时间。

这里的 @CreateDateColumn 和 @UpdateDateColumn 都是 datetime 类型。

@CreateDateColumn 会在第一次保存的时候设置一个时间戳,之后一直不变。

而 @UpdateDateColumn 则是每次更新都会修改这个时间戳。

用来保存创建时间和更新时间很方便。

然后我们跑一下:

npm run start:dev

npm run start:dev 就是 nest start --watch:

可以看到打印了 create table 的建表 sql:

用 mysql workbench 可以看到生成的表是对的:

然后我们在 UserModule 引入 TypeOrm.forFeature 动态模块,传入 User 的 entity。

这样模块内就可以注入 User 对应的 Repository 了:

然后就可以实现 User 的增删改查。

我们在 UserController 里添加两个 handler:

import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
} from "@nestjs/common";
import { UserService } from "./user.service";

@Controller("user")
export class UserController {
constructor(private readonly userService: UserService) {}

@Post("login")
login() {}

@Post("register")
register() {}
}

其余的 handler 用不到,都可以去掉。

然后添加两个 dto:

export class LoginDto {
username: string;
password: string;
}
export class RegisterDto {
username: string;
password: string;
}

在 handler 里使用这两个 dto 来接收参数:

我们先在 postman 里测试下:

post 请求 /user/login 接口,body 传入用户信息。

服务端打印了收到的 user:

然后 post 请求 /user/register:

也是一样的。

虽然都是 user,但是 login 和 register 的处理不同:

  • register 是把用户信息存到数据库里
  • login 是根据 username 和 password 取匹配是否有这个 user

先实现注册:

@Post('register')
async register(@Body() user: RegisterDto) {
return await this.userService.register(user);
}

在 UserSerice 里实现 register 方法:

import { RegisterDto } from "./dto/register.dto";
import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { User } from "./entities/user.entity";
import * as crypto from "crypto";

function md5(str) {
const hash = crypto.createHash("md5");
hash.update(str);
return hash.digest("hex");
}

@Injectable()
export class UserService {
private logger = new Logger();

@InjectRepository(User)
private userRepository: Repository<User>;

async register(user: RegisterDto) {
const foundUser = await this.userRepository.findOneBy({
username: user.username,
});

if (foundUser) {
throw new HttpException("用户已存在", 200);
}

const newUser = new User();
newUser.username = user.username;
newUser.password = md5(user.password);

try {
await this.userRepository.save(newUser);
return "注册成功";
} catch (e) {
this.logger.error(e, UserService);
return "注册失败";
}
}
}

先根据 username 查找下,如果找到了,说明用户已存在,抛一个 HttpException 让 exception filter 处理。

否则,创建 User 对象,调用 userRepository 的 save 方法保存。

password 需要加密,这里使用 node 内置的 crypto 包来实现。

我们测试下:

服务返回了注册成功,并且打印了 insert 的 sql:

可以看到,数据库 user 表插入了这个用户的信息,并且指定了 createTime 和 udpateTime。

然后我们再次调用:

会提示用户已经存在。

这就是注册。

然后再实现下登录:

添加一个 handler:

@Post('login')
async login(@Body() user: LoginDto) {
const foundUser = await this.userService.login(user);

if(foundUser) {
return 'login success';
} else {
return 'login fail';
}
}

然后再添加对应的 service:

async login(user: LoginDto) {
const foundUser = await this.userRepository.findOneBy({
username: user.username,
});

if(!foundUser) {
throw new HttpException('用户名不存在', 200);
}
if(foundUser.password !== md5(user.password)) {
throw new HttpException('密码错误', 200);
}
return foundUser;
}

根据用户名查找用户,没找到就抛出用户不存在的 HttpException、找到但是密码不对就抛出密码错误的 HttpException。

否则,返回找到的用户。

我们试一下:

用户名、密码正确:

用户名不存在:

用户名存在但密码错误:

可以看到,服务端打印了 3 条 select 的 sql:

登录成功之后我们要把用户信息放在 jwt 或者 session 中一份,这样后面再请求就知道已经登录了。

安装 @nestjs/jwt 的包:

npm install @nestjs/jwt

在 AppModule 里引入 JwtModule:

global:true 声明为全局模块,这样就不用每个模块都引入它了,指定加密密钥,token 过期时间。

在 UserController 里注入 JwtService:

然后在登录成功后,把 user 信息放到 jwt 通过 header 里返回。

@Post('login')
async login(@Body() user: LoginDto, @Res({passthrough: true}) res: Response) {
const foundUser = await this.userService.login(user);

if(foundUser) {
const token = await this.jwtService.signAsync({
user: {
id: foundUser.id,
username: foundUser.username
}
})
res.setHeader('token', token);
return 'login success';
} else {
return 'login fail';
}
}

再次访问:

登录成功之后返回了 jwt 的 token。

我们有一些接口是只有登录才能访问的。

我们在 AppController 里添加两个路由:

@Get('aaa')
aaa() {
return 'aaa';
}

@Get('bbb')
bbb() {
return 'bbb';
}

现在不需要登录就可以访问:

我们可以加个 Guard 来限制访问:

nest g guard login --no-spec --flat

然后实现 jwt 校验的逻辑:

import { JwtService } from "@nestjs/jwt";
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { Request } from "express";
import { Observable } from "rxjs";

@Injectable()
export class LoginGuard implements CanActivate {
@Inject(JwtService)
private jwtService: JwtService;

canActivate(
context: ExecutionContext
): boolean | Promise<boolean> | Observable<boolean> {
const request: 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 as any).user = info.user;
return true;
} catch (e) {
throw new UnauthorizedException("登录 token 失效,请重新登录");
}
}
}

取出 authorization 的 header,验证 token 是否有效,token 有效返回 true,无效的话就返回 UnauthorizedException。

把这个 Guard 应用到 handler:

@Get('aaa')
@UseGuards(LoginGuard)
aaa() {
return 'aaa';
}

@Get('bbb')
@UseGuards(LoginGuard)
bbb() {
return 'bbb';
}

我们先登录一下,拿到 token:

然后请求 /aaa 的时候通过 authorization 的 header 带上 token:

访问成功。

如果不带 token,就失败了:

这样我们就实现了登录注册的流程。

但是,现在我们并没有对参数做校验,这个用 ValidationPipe + class-validator 来做。

安装 class-validator 和 class-transformer 的包:

npm install class-validator class-transformer

然后给 /user/login 和 /user/register 接口添加 ValidationPipe:

在 dto 里声明参数的约束:

import { IsNotEmpty, IsString, Length, Matches } from "class-validator";

export class RegisterDto {
@IsString()
@IsNotEmpty()
@Length(6, 30)
@Matches(/^[a-zA-Z0-9#$%_-]+$/, {
message: "用户名只能是字母、数字或者 #、$、%、_、- 这些字符",
})
username: string;

@IsString()
@IsNotEmpty()
@Length(6, 30)
password: string;
}

注册的时候,用户名密码不能为空,长度为 6-30,并且限定了不能是特殊字符。

登录就不用限制了,只要不为空就行:

import { IsNotEmpty } from "class-validator";

export class LoginDto {
@IsNotEmpty()
username: string;

@IsNotEmpty()
password: string;
}

我们测试下:

ValidationPipe 生效了。

这样,我们就实现了登录、注册和鉴权的完整功能。

案例代码在小册仓库

总结

这节我们通过 mysql + typeorm + jwt + ValidationPipe 实现了登录注册的功能。

typeorm 通过 @PrimaryGeneratedKey、@Column、@CreateDateColumn、@UpdateDateColumn 声明和数据库表的映射。

通过 TypeOrmModule.forRoot、TypeOrmModule.forFeature 的动态模块添加数据源,拿到 User 的 Repository。

然后用 Repository 来做增删改查,实现注册和登录的功能。

登录之后,把用户信息通过 jwt 的方式放在 authorization 的 header 里返回。

然后 LoginGuard 里面取出 header 来做验证,token 正确的话才放行。

此外,参数的校验使用 ValidationPipe + class-validator 来实现。

这样,就实现了注册和基于 JWT 的登录功能。