学完了 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 的登录功能。