学完了 mysql、typeorm、jwt/session、发邮件、redis 之后,来做个综合案例:登录注册。本节涵盖两大类登录注册方案:
首先,创建个新的 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: 'caching_sha2_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 则是每次更新都会修改这个时间戳。
然后跑一下看生成的建表 sql:
npm run start:devnpm run start:dev 就是 nest start --watch。然后需要在 UserModule 引入 TypeOrmModule.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 来接收参数。虽然都是 user,但是 login 和 register 的处理不同:
@Post('register')
async register(@Body() user: RegisterDto) {
return await this.userService.register(user);
}在 UserService 里实现 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 包来实现(md5)。
@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。否则,返回找到的用户。
登录成功之后要把用户信息放在 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;
}这节通过 mysql + typeorm + jwt + ValidationPipe 实现了登录注册的功能。
typeorm 通过 @PrimaryGeneratedColumn、@Column、@CreateDateColumn、@UpdateDateColumn 声明和数据库表的映射。通过 TypeOrmModule.forRoot、TypeOrmModule.forFeature 的动态模块添加数据源,拿到 User 的 Repository,然后用 Repository 来做增删改查,实现注册和登录的功能。
登录之后,把用户信息通过 jwt 的方式放在 authorization 的 header 里返回。然后 LoginGuard 里面取出 header 来做验证,token 正确的话才放行。此外,参数的校验使用 ValidationPipe + class-validator 来实现。
Node 发邮件最常见的场景就是邮箱验证码。比如登录的时候除了可以通过用户名、密码来验证身份,还可以通过邮箱验证码来验证。
通过 create-vite 创建个项目:
npm create vite@latest email-login-frontend -- --template react然后进入项目把开发服务跑起来:
npm run dev安装 antd:
npm install antd --save然后来写下登录 UI:
import React from 'react';
import { Button, Form, Input } from 'antd';
const login = (values) => {
console.log('Success:', values);
};
const sendEmailCode = () => {
console.log('send email code')
}
const App = () => (
<div style={{width: '500px', margin: '100px auto'}}>
<Form onFinish={login}>
<Form.Item
label="邮箱"
name="email"
rules={[
{
required: true,
message: '请输入邮箱地址',
},
]}
>
<Input />
</Form.Item>
<Form.Item
label="验证码"
name="code"
rules={[
{
required: true,
message: '请输入验证码',
},
]}
>
<Input/>
</Form.Item>
<Form.Item>
<Button onClick={sendEmailCode}>发送验证码</Button>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">登录</Button>
</Form.Item>
</Form>
</div>
);
export default App;创建个 nest 项目:
nest new email-login-backend -p npm在 main.ts 启用跨域,并且修改下端口号(因为前端项目开发服务也用这个端口号)。然后在 front 项目安装 axios 调用接口。
创建 user 模块并安装 typeorm 依赖:
nest g resource user
npm install --save @nestjs/typeorm typeorm mysql2在 AppModule 引入 TypeOrmModule:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './user/user.module';
@Module({
imports: [
UserModule,
TypeOrmModule.forRoot({
type: "mysql",
host: "localhost",
port: 3306,
username: "root",
password: "guang",
database: "email_login_test",
synchronize: true,
logging: true,
entities: [],
poolSize: 10,
connectorPackage: 'mysql2',
extra: {
authPlugin: 'caching_sha2_password',
}
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}然后在 mysql workbench 创建个新的 database,指定字符集为 utf8mb4。
改下 User 的 entity:
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 50,
comment: '用户名'
})
username: string;
@Column({
length: 50,
comment: '密码'
})
password: string;
@Column({
length: 50,
comment: '邮箱地址'
})
email: string;
}user 表有 id、username、password、email 这 4 个字段。在 TypeOrm 的 entities 注册下,保存后 nest 服务会自动重新跑,可以看到 user 表被创建了。
这次手动插入下数据(邮箱要改成你自己的,因为待会要发邮件用):
INSERT INTO `email_login_test`.`user`
(`id`, `username`, `password`, `email`)
VALUES ('1', 'aaaa', 'bbbb', 'xxx@xx.com');添加个 email 模块:
nest g resource email然后安装 nodemailer 包来发邮件:
npm install --save nodemailer
npm install --save-dev @types/nodemailer在 EmailService 里来发邮件:
import { Injectable } from '@nestjs/common';
import { createTransport, Transporter} from 'nodemailer';
@Injectable()
export class EmailService {
transporter: Transporter
constructor() {
this.transporter = createTransport({
host: "smtp.qq.com",
port: 587,
secure: false,
auth: {
user: 'xx@xx.com',
pass: '你的授权码'
},
});
}
async sendMail({ to, subject, html }) {
await this.transporter.sendMail({
from: {
name: '系统邮件',
address: 'xx@xx.com'
},
to,
subject,
html
});
}
}把邮箱和授权码改成你自己的。然后添加一个 controller 方法:
import { Controller, Get, Query } from '@nestjs/common';
import { EmailService } from './email.service';
@Controller('email')
export class EmailController {
constructor(private readonly emailService: EmailService) {}
@Get('code')
async sendEmailCode(@Query("address") address) {
await this.emailService.sendMail({
to: address,
subject: '登录验证码',
html: '<p>你的登录验证码是 123456</p>'
});
return '发送成功';
}
}现在是把邮箱相关信息直接写在代码里了,实际上这些应该从配置读取。安装下配置模块:
npm install --save @nestjs/config在 AppModule 里引入,并且把它声明为全局的:
ConfigModule.forRoot({
isGlobal: true,
envFilePath: 'src/.env'
})在 src 下添加这个 .env 文件,然后在 EmailService 里注入 ConfigService,从中读取配置:
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createTransport, Transporter} from 'nodemailer';
@Injectable()
export class EmailService {
transporter: Transporter
constructor(private configService: ConfigService) {
this.transporter = createTransport({
host: "smtp.qq.com",
port: 587,
secure: false,
auth: {
user: this.configService.get('email_user'),
pass: this.configService.get('email_password')
},
});
}
async sendMail({ to, subject, html }) {
await this.transporter.sendMail({
from: {
name: '系统邮件',
address: this.configService.get('email_user')
},
to,
subject,
html
});
}
}用了 .env 配置文件之后有个问题:dist 目录下没有这个文件。.env 需要配置下 assets 才会复制过去。
改下 nest-cli.json:添加 assets 为 *.env 这样就会在编译的时候把 src 下的 .env 文件复制到 dist 下。
注意,assets 只支持 src 下的文件复制。如果你是放在根目录,那就要自己复制了。
改了编译配置需要重新跑服务:
npm run start:dev这时就可以在 dist 下看到这个文件了。但现在你改了 src 下的 .env 之后,dist 下的 .env 不会跟着改,需要重新跑服务才可以。这时候可以加一个 watchAssets。这样改了 src 下的 .env 就会立刻复制了。
也就是说,如果你用到了 .env 文件或者 yaml 等文件来配置,需要在 nest-cli.json 里配置下 assets 和 watchAssets。
首先,验证码要随机,通过 Math.random 来生成:
const code = Math.random().toString().slice(2,8);在前端,通过 useForm 创建 form 实例,然后通过 form.getFieldsValue 拿到表单值。点击发送验证码的时候,验证下邮箱是否为空,不为空就调用后端接口来发送验证码:
import { message } from 'antd';
const App = () => {
const [form] = Form.useForm();
const sendEmailCode = async () => {
const email = form.getFieldValue('email');
console.log(email)
if(!email) {
message.error('邮箱不能为空');
return;
}
const res = await axios.get('http://localhost:3001/email/code', {
params: {
address: email
}
});
message.info(res.data);
}用邮箱验证码验证用户身份的流程是这样的:
用户填入邮箱地址,点击发送验证码,后端会生成验证码,发送邮件。并且还要把这个验证码存入 redis,以用户邮箱地址为 key。
之后用户输入验证码,点击登录。后端根据邮箱地址去 redis 中查询下验证码,和用户传过来的验证码比对下,如果一致,就从 mysql 数据库中查询该用户的信息,放入 jwt 中返回。
创建个 redis 模块并安装 redis 的包:
nest g resource redis --no-spec
npm install redis --save把 RedisModule 声明为全局模块,并导出 RedisService,然后添加一个 provider:
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';
import { RedisController } from './redis.controller';
import { createClient } from 'redis';
@Global()
@Module({
controllers: [RedisController],
providers: [RedisService, {
provide: 'REDIS_CLIENT',
async useFactory() {
const client = createClient({
socket: {
host: 'localhost',
port: 6379
}
});
await client.connect();
return client;
}
}],
exports: [RedisService]
})
export class RedisModule {}
在 RedisService 里封装 redis 的 get、set 方法:
import { Inject, Injectable } from '@nestjs/common';
import { RedisClientType } from 'redis';
@Injectable()
export class RedisService {
@Inject('REDIS_CLIENT')
private redisClient: RedisClientType;
async get(key: string) {
return await this.redisClient.get(key);
}
async set(key: string, value: string | number, ttl?: number) {
await this.redisClient.set(key, value);
if(ttl) {
await this.redisClient.expire(key, ttl);
}
}
}然后修改下发送邮箱验证码的逻辑:注入 RedisService,并且发送验证码之前把它存入 redis,key 为 captcha_邮箱地址(captcha 就是验证码的意思),过期时间为 5 分钟:
import { Controller, Get, Inject, Query } from '@nestjs/common';
import { RedisService } from '../redis/redis.service';
import { EmailService } from './email.service';
@Controller('email')
export class EmailController {
constructor(private readonly emailService: EmailService) {}
@Inject()
private redisService: RedisService;
@Get('code')
async sendEmailCode(@Query("address") address) {
const code = Math.random().toString().slice(2,8);
await this.redisService.set(`captcha_${address}`, code, 5 * 60);
await this.emailService.sendMail({
to: address,
subject: '登录验证码',
html: `<p>你的登录验证码是 ${code}</p>`
});
return '发送成功';
}
}在 UserController 里添加一个路由:
@Post('login')
login(@Body() loginUserDto: LoginUserDto) {
console.log(loginUserDto);
return 'success';
}定义这个 LoginUserDto,然后对它做校验,安装 class-validator 和 class-transformer:
npm install --save class-validator class-transformer在 main.ts 里全局启用 ValidationPipe,然后给 LoginUserDto 添加一些约束:
import { IsEmail, IsNotEmpty, Length } from "class-validator";
export class LoginUserDto {
@IsNotEmpty()
@IsEmail()
email: string;
@IsNotEmpty()
@Length(6)
code: string;
}来实现下具体的验证逻辑:
@Inject(RedisService)
private redisService: RedisService;
@Post('login')
async login(@Body() loginUserDto: LoginUserDto) {
const { email, code } = loginUserDto;
const codeInRedis = await this.redisService.get(`captcha_${email}`);
if(!codeInRedis) {
throw new UnauthorizedException('验证码已失效');
}
if(code !== codeInRedis) {
throw new UnauthorizedException('验证码不正确');
}
const user = await this.userService.findUserByEmail(email);
console.log(user);
return 'success';
}从 redis 中查找这个邮箱对应的验证码。如果没查找,就返回验证码已失效。查到的话和用户传过来的验证码对比,如果不一致,就返回验证码不正确。验证码通过之后,从数据库中查询这个用户的信息。
在 UserService 里实现下这个方法:
@InjectEntityManager()
private entityManager: EntityManager;
async findUserByEmail(email: string) {
return await this.entityManager.findOneBy(User, {
email
});
}然后在前端代码里调用下:
const login = async (values) => {
const res = await axios.post('http://localhost:3001/user/login', {
email: values.email,
code: values.code
});
if(res.data === 'success') {
message.success('登录成功');
} else {
message.error(res.data.message);
}
};整体流程:输入邮箱,点击发送验证码。收到了验证码邮件,并且 redis 里也存了这个 key。带上这个验证码请求,服务端通过了校验,并从数据库中查询出了用户信息。接下来只要返回对应的 jwt token 就好了(这部分参考前面 jwt 登录章节的内容)。
邮箱验证码登录综合用到了 mysql、redis、typeorm、nodemailer 等技术,并且使用 @nestjs/config 包的 ConfigModule 来封装配置。
要注意的是,如果用了 .env 文件,需要保证它在 src 下,并且要在 nest-cli.json 里配置 assets 和 watchAssets,不然 build 的时候不会复制到 dist 下。
这节实现的功能,前后端代码都有,是一个不错的综合练习。