使用Nest.js实现注册、登录-上篇

2,144 阅读6分钟

注册登录功能是我们在开发系统时遇到的常用功能,并且很可能是我们在开发业务模块前就先要开发好的功能,也因为这个原因,基本上我入职一家公司后注册登录模块早已开发完成,导致对注册登录流程不了解,所以决定参考下网上的资料(<Nest.js 从零到壹系列>),来实现注册和登录功能。

本次分享实现注册功能,需求:

  1. 前端使用umi,登录表单有三个字段:用户名、密码、确认密码
  2. 后端使用nest,注册时发现已存在相同的用户名则页面给出提示,否则注册成功

前端部分不复杂,我们先开发后端部分。后端部分的注册逻辑是

  1. 编写注册接口,接收前端传递的三个参数(用户名、密码、确认密码)
  2. 对参数初步校验,通过后判断用户名是否已存在,存在则提示已存在,不存在就插入一条记录,提示注册成功。
  3. 密码存入数据库时要加密

前置知识

对于刚开始学习后端的前端了来说,先百度安装一下mysql和navicat,然后有必要先学习一些后端基础内容

  1. 基础sql语句。虽然sequelize有提供简便方法使得我们可以不学sql也能操作数据库,但是一旦库有更新就要再去看相关文档,所以还是选择学一下sql,这样即使库的方法有更新也无需关注了。
  2. 编写nest模块
  3. 使用navicat创建表
  4. 使用sequelize操作数据库。

SQL语句

SQL语句是由一些简单的英文单词构成的,其中最常用的语句是SELECT语句。我们在判断用户名是否存在时,就要去数据库进行查询,SELECT语句就是用来检索数据的。如果用户名不存在,就要往表里插入一行数据,所以也要学习INSERT语句。

SELECT语句

想使用SELECT语句,我们需要给出两条信息-想查询什么,从什么地方查询。例如我们想从产品表中查询出所有产品名称:

SELECT prod_name
FROM Products;

这样则会返回Products表里所有的prod_name字段

由于我们的需求是需要查询用户名是否已存在,也就是我们要查询出特定的某条记录,也即过滤数据。WHERE子句可以过滤数据,例如想从产品表查询出产品价格为10的产品有哪些:

SELECT prod_name
FROM PRODUCTS
WHERE prod_price = 10;

这样就会返回产品价格为10的行,不会返回所有的行。

INSERT语句

我们要向表中插入一行数据时,需要用到INSERT语句。例如向产品表中插入一行数据,包括产品id,产品名称,产品价格:

INSERT INTO PRODUCTS(prod_id,
                    prod_name,
                    prod_price)
VALUES(1001,
       '小夜灯',
       15);

这样就可以往产品表里插入一行记录。

编写nest模块

nest里的一个模块由Controller、Service、Module组成。Controller负责路由和简单的参数验证,Service负责业务逻辑,Module把Controller和Service组装在一起形成一个模块。想创建一个新的Controller、Service、Module时,我们可以使用nest-cli提供的指令:

nest g service user users 
nest g controller user users
nest g module user users

这样就会在src下创建/users/user/user.service.ts,controller和module同理

在service里编写简单业务:定义一个queryUser方法,返回Hello + 传入的参数

import { Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
  queryUser(username: string): string {
    return `Hello ${username}!`;
  }
}

在Controller里定义路由:

import { Controller, Post, Body } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
    constructor(private readonly usersService: UserService) {}

    @Post('queryUser')
    queryUser(@Body() body: any) {
        return this.usersService.queryUser(body.username);
    }
}

在Module里把Service和Controller组装起来:

import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';

@Module({
    controllers: [UserController],
    providers: [UserService],
    exports: [UserService]
})
export class UserModule {

}

使用nest-cli的指令生成Service、Controller、Module时,app.module会默认导入Service、Controller、Module,在UserModule里我们已经导入了UserController和UserService了,所以在app.module里只需要导入UserModule无需再导入相关Controller和Service了,所以删除掉。

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './users/user/user.module';

@Module({
  imports: [UserModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

效果:

简单service业务postman测试.png 这样就成功创建了一个新模块,编写新业务时都是创建新模块或者在原有模块添加新的方法。

使用navicat创建表

创建新数据库star:

3-创建新数据库.png 新建数据库和表.png

新建数据库star,点击新建查询,将以下代码复制到框中,点击运行。

CREATE TABLE `admin_user` (
  `user_id` smallint(6) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
  `account_name` varchar(24) NOT NULL COMMENT '用户账号',
  `passwd` char(32) NOT NULL COMMENT '密码',
  `passwd_salt` char(6) NOT NULL COMMENT '密码盐',
  PRIMARY KEY (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='后台用户表';

3-创建新表.png 这样就创建好了一张表,由于只做简单实例,所以这里没加入创建人、创建时间、是否删除这些常用字段。

现在往表里插入一条数据,点击新建查询,复制以下代码:

INSERT INTO admin_user(user_id,
                       account_name,
                       passwd,
                       passwd_salt)
VALUES(1001,
       'jerry',
       '123456',
       'abc');

效果:

5-插入一行数据.png

使用Sequelize操作数据库

安装Sequelize

npm i sequelize sequelize-typescript mysql2 -S

新建Sequelize实例,传入数据库信息相关参数,之后就可以使用实例的方法来操作数据库。

// src/database/sequelize.ts
import { Sequelize } from 'sequelize-typescript';

const sequelize = new Sequelize(
    'star', // 数据库名称
    'root', // user名称
    '123456', // password
    {
        // 自定义主机; 默认值: localhost
        host: 'localhost', // 数据库地址
        // 自定义端口; 默认值: 3306
        port: 3306,
        dialect: 'mysql',
        pool: {
            max: 10, // 连接池中最大连接数量
            min: 0, // 连接池中最小连接数量
            acquire: 30000,
            idle: 10000, // 如果一个线程 10 秒钟内没有被使用过的话,那么就释放线程
        },
        timezone: '+08:00', // 东八时区
    },
);

export default sequelize;

现在把queryUser方法改成去数据库查询用户

 async queryUser(username: string): Promise<any> {
    const sql = `
      SELECT user_id, account_name
      FROM admin_user
      WHERE account_name = '${username}'
    `
    const [results] = await sequelize.query(sql)
    const [user] = results
    
    console.log('results', results)
    if (!user) {
      return {
        code: '000500',
        message: '未找到'
      }
    }

    
    return {
      code: '000000',
      data: {
        ...user as object
      },
      message: 'success'
    }
  }

效果:

6-查找用户.gif

由于表中只有用户名为jerry一条数据,所以输入tom时查找不到记录,输入jerry能返回用户信息。

编写注册接口

定义接口

现在可以开始开发注册接口了,注册接口接收三个参数(用户名,密码,确认密码),在Controller定义路由,在Service定义方法,并对参数做简单校验。

// user.controller.ts
@Controller('user')
export class UserController {
    @Post('register')
    register(@Body() body: any) {
        return this.usersService.register(body);
    }
}

// user.service.ts
@Injectable()
export class UserService {
  isValidValue(val) {
    if (val === null || val === undefined || val === '') {
      return false
    }

    return true
  }

  async register(body: any) {
    const { accountName, password, rePassword } = body
    if (!this.isValidValue(accountName) || !this.isValidValue(password) || !this.isValidValue(rePassword)) {
      return {
        code: '000500',
        message: '请输入必填项'
      }
    }
    if (password !== rePassword) {
      return {
        code: '000500',
        message: '两次密码输入不一致'
      }
    }

    return {
      code: '000000'
    }
  }
}

接口业务逻辑

对参数进行校验后,就可以根据用户名去数据库查询是否有这条记录,查找到了相同用户名则给出提示用户名已存在,未找到则插入一行记录,提示注册成功。

async register(body: any) {
    ...

    const querySql = `
      SELECT account_name
      FROM admin_user
      WHERE account_name = '${accountName}';
    `
    const [queryResults] = await sequelize.query(querySql)
    const [user] = queryResults
    if (user) {
      return {
        code: '000500',
        message: '用户名已存在'
      }
    }

    const insertSql = `
      INSERT INTO admin_user(account_name,
                             passwd,
                             passwd_salt)
      VALUES('${accountName}',
             '${password}',
             'test')
    `
    const [insertResults] = await sequelize.query(insertSql)

    return {
      code: '000000',
      message: '注册成功'
    }
}

效果:

7-注册接口.gif

8-数据库生成了一条新纪录.png

这时刷新数据库,已经生成了一条新记录,userId我们在插入数据时没有指定,它会根据之前jerry那条记录的userId进行自增1(这里自增为1004是因为进行演示之前我已经生成了两条记录然后把那两条记录删除了),插入行成功后insertResults为新增记录的userId,前端不需要使用到就无需返回了。

对密码加密

加密包括生成随机盐,然后根据盐再对密码加密,再插入行数据前生成盐和加密后的密码,然后再插入数据库。

由于生成随机盐和加密后的密码长度较长,我们把之前的表删除后扩大盐和密码长度再重新生成admin_user表。

async register(body: any) {
    ...
    
    const salt = crypto.randomBytes(6).toString('base64');
    const encryPassword = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('base64');
    const insertSql = `
      INSERT INTO admin_user(account_name,
                             passwd,
                             passwd_salt)
      VALUES('${accountName}',
             '${encryPassword}',
             '${salt}')
    `
    const [insertResults] = await sequelize.query(insertSql)

    return {
      code: '000000',
      message: '注册成功'
    }
  }

效果:

9-生成随机盐和加密密码.gif

现在数据库中存储的就是随机盐和加密后的密码了。

开发前端登录表单

前端这里直接使用了umi+dva+antd,直接复制antd官网的Form组件再改成自己的表单字段

开发表单

// src/pages/Login.tsx
import React from "react"
import { Form, Input, Button, message } from 'antd'
import { connect } from 'umi'

const layout = {
    labelCol: {
      span: 8,
    },
    wrapperCol: {
      span: 16,
    },
}

const Login = (props) => {
    const [form] = Form.useForm()

    const onFinish = (values: any) => {
        const { password, rePassword } = values
        if (password !== rePassword) {
            message.error('两次密码输入不一致')
            return
        }

        props.dispatch({
            type: 'user/register',
            payload: {
                ...values,
            },
            cb: (res: any) => {
                if (res.code !== '000000') {
                    message.error(res.message)
                    return
                }

                message.success(res.message)
            }
        })
    }

    const handleRegister = () => {
        form.submit()
    }

    return (
        <div>
            <Form
                {...layout}
                form={form}
                onFinish={onFinish}
                style={{
                    maxWidth: 600,
                }}
                >
                <Form.Item
                    name="accountName"
                    label="用户名"
                    rules={[{ required: true }]}
                >
                    <Input />
                </Form.Item>
                <Form.Item
                    name="password"
                    label="密码"
                    rules={[{ required: true }]}
                >
                    <Input.Password />
                </Form.Item>
                <Form.Item
                    name="rePassword"
                    label="确认密码"
                    rules={[{ required: true }]}
                >
                    <Input.Password />
                </Form.Item>
                <Form.Item
                    wrapperCol={{
                        offset: 8,
                        span: 16,
                    }}
                    >
                    <Button type="primary" onClick={handleRegister}>注册</Button>
                </Form.Item>
            </Form>
        </div>
    )
}

export default connect(({ user }) => ({
    user,
}))(Login)

// src/models/users.js
import { register } from '../services/user';

export default {
  namespace: 'user',
  state: {
    user: {},
  },

  effects: {
    *register({ payload, cb }, { call, put }) {
      const res = yield call(register, payload)
      cb(res)
    },
  },
};

// services/user/index.js

import { request } from '@umijs/max'
export const register = (params) => {
  return request('/api/user/register', {
      method: 'POST',
      data: {
        ...params,
      },
  })
}

image.png 然后会看到报了跨域的错误,我们可以使用umi的代理

// .umirc.ts
export default defineConfig({
  ...
  
  proxy: {
    '/api': {
      target: 'http://localhost:3000',
      pathRewrite: { '^/api': '' },
      changeOrigin: true
    }
  },
});

// src/services/user/index.js
export const register = (params) => {
  return request('/api/user/register', {
      method: 'POST',
      data: {
        ...params,
      },
  })
}

效果:

10-联调.gif

对密码加密

现在功能基本上开发完成了,就差前端对密码加密。

// src/tools/crypto.js
import CryptoJS from 'crypto-js'
const key = 'star' //加密钥匙
const iv = '1234567887654321'

// 加密函數
export function encrypt (text) {
    return CryptoJS.AES.encrypt(text, CryptoJS.enc.Utf8.parse(key), {
      iv: CryptoJS.enc.Utf8.parse(iv),
      mode: CryptoJS.mode.CBC,
      padding: CryptoJS.pad.Pkcs7
    }).toString()
}

// src/pages/Login/index.tsx
import { encrypt } from '@/tools/crypto'

const onFinish = (values: any) => {
    const { accountName, password, rePassword } = values
    if (password !== rePassword) {
        message.error('两次密码输入不一致')
        return
    }

    props.dispatch({
        type: 'user/register',
        payload: {
            accountName,
            password: encrypt(password),
        },
        cb: (res: any) => {
            if (res.code !== '000000') {
                message.error(res.message)
                return
            }

            message.success(res.message)
        }
    })
}

效果: 11-前端密码加密.png

这样前端也不会明文把密码传给后端了,注册功能开发完成,下篇分享登录功能开发。