重生之我在NestJS代码世界搞 ---- “登录鉴权”!

258 阅读1分钟

本文完整源码


一、项目框架搭建

Nest中文网

关于Nest对node版本要求,以及Nest CLI的安装,这里不再陈述。

1. 创建项目

nest new nest-example

2. 创建和引入模块Module

若不知道如何创建module、controller、service、middleware等,可直接运行命令 nest g --help 查看

2.1 创建

# admin:管理系统模块
nest g module module/admin

2.2 引入

创建完成后,程序会在 src/app.module.ts中自动引入。

import { Module } from '@nestjs/common';
import { AdminModule } from './module/admin/admin.module';

@Module({
  imports: [AdminModule],
})
export class AppModule {}

3. 创建和引入控制器Controller

值得注意的是:创建服务Service前,将 src/app.module.tscontrollers: [AppController] 配置删除,暂时用不到。

3.1 创建

# 用户 users 控制器
nest g controller module/admin/controller/admin

3.2 引入

创建完成后,程序会在对应的module文件(src/module/admin/admin.module.tssrc/module/default/default.module.ts)中自动引入。

4. 创建服务Service

4.1 创建公共工具 Service

# 命令
nest g service service/tools
// 先安装插件:npm i @types/svg-captcha @types/md5

import {Injectable} from '@nestjs/common';
import {Response} from 'express';
import * as svgCaptcha from "svg-captcha";
import * as md5 from "md5";

interface ArgType {
    type?: string,
    redirectUrl?: string,
    message?: string,
}

@Injectable()
export class ToolsService {
    // 获取图形验证码
    getCaptcha() {
        return svgCaptcha.create({
            size: 1,
            fontSize: 50,
            width: 100,
            height: 34,
            background: '#cc9966',
            ignoreChars: '0o1i',
            noise: 2
        })
    }

    // Md5加密
    getMd5(str: string) {
        return md5(str)
    }

    // 公共提示页面
    async tips(res: Response, arg: ArgType) {
        const {type = '', redirectUrl = '', message = ''} = arg
        await res.render(`admin/public/${type}`, {redirectUrl, message})
    }
}

4.2 创建其他 Service

# 用户 users 服务
nest g service service/users

值得注意的是:创建服务Service后,程序会在 src/app.module.ts 中自动引入 providers: [AppService, UsersService],,这里我们直接删除,后期会在单独的模块入口(例如:module/**/**.module.ts)文件中引入。

5. 静态资源配置

根目录下创建public目录,并在 src/main.ts 中配置,其中 NestFactory.create 不要忘记指定基于 Express 平台

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import {NestExpressApplication} from "@nestjs/platform-express";
import * as path from "path";

async function bootstrap() {
  // 创建 NestJS 应用实例,并指定基于 Express 平台
  const app = await NestFactory.create<NestExpressApplication>(AppModule);

  // 静态资源目录配置
  app.useStaticAssets(path.join(__dirname, '..', 'public'), {
    prefix: '/static/' // 为静态资源文件添加一个虚拟路径前缀,可选
  });

  await app.listen(3000);
}
bootstrap();

在public中添加一图片(logo.png),此时运行项目 npm run start:dev,即可访问静态资源:http://localhost:3000/static/logo.png

6. 模板引擎(EJS)安装配置以及ejs文档创建

EJS -- 嵌入式 JavaScript 模板引擎 | EJS 中文文档

6.1 安装

npm install ejs

6.2 配置

根目录下创建views目录,并在 src/main.ts 中配置

// 其他代码...
async function bootstrap() {
    // 其他代码...

    // 配置模板引擎
    app.setBaseViewsDir('views');
    app.setViewEngine('ejs');

    // 其他代码...
}

6.3 ejs文档创建、基本布局、路由关联

  • views 文件夹下创建以下文件夹:
views
    ├─ dashboard.ejs
    ├─ login.ejs
    ├─ userinfo.ejs
    ├─ users.ejs
    │
    └─public
     ├─ error.ejs
     ├─ header.ejs
     └─ success.ejs
  • 布局代码

此处不贴代码,参考源码即可

7. mysql + typeorm 安装配置以及实体类创建

7.1 数据库准备

/*  
 Navicat Premium Data Transfer  
  
 Source Server         : my-ali  
 Source Server Type    : MySQL  
 Source Server Version : 50650  
 Source Host           : 8.137.14.201:3306  
 Source Schema         : nest_example  
  
 Target Server Type    : MySQL  
 Target Server Version : 50650  
 File Encoding         : 65001  
  
 Date: 07/03/2025 16:26:22  
*/  
  
SET NAMES utf8mb4;  
SET FOREIGN_KEY_CHECKS = 0;  
  
-- ----------------------------  
-- Table structure for users  
-- ----------------------------  
DROP TABLE IF EXISTS `users`;  
CREATE TABLE `users`  (  
  `id` int(11) NOT NULL AUTO_INCREMENT,  
  `username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,  
  `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,  
  `nickname` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,  
  `email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,  
  `status` tinyint(4) NULL DEFAULT 1 COMMENT '用户状态,1: 正常, 0: 禁用',  
  `create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP,  
  PRIMARY KEY (`id`) USING BTREE,  
  UNIQUE INDEX `username`(`username`) USING BTREE,  
  UNIQUE INDEX `email`(`email`) USING BTREE  
) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Compact;  
  
-- ----------------------------  
-- Records of users  
-- ----------------------------  
INSERT INTO `users` VALUES (1, 'superadmin', 'e10adc3949ba59abbe56e057f20f883e', 'VincentLee', 'admin@example.com', 1, '2025-02-28 15:43:34');  
INSERT INTO `users` VALUES (2, 'zhangsan', 'e10adc3949ba59abbe56e057f20f883e', '张三', 'zhangsan@example.com', 1, '2025-03-07 16:24:05');  
INSERT INTO `users` VALUES (3, 'lisi', 'e10adc3949ba59abbe56e057f20f883e', '李四', 'lisi@example.com', 1, '2025-03-07 16:24:48');  
INSERT INTO `users` VALUES (4, 'wangwu', 'e10adc3949ba59abbe56e057f20f883e', '王五', 'wangwu@example.com', 1, '2025-03-07 16:25:51');  
  
SET FOREIGN_KEY_CHECKS = 1;

7.2 插件安装

npm install mysql typeorm @nestjs/typeorm

7.3 配置数据库链接

src/app.module.ts 文件下配置

// 其他代码 ...
import {TypeOrmModule} from "@nestjs/typeorm";

@Module({
    imports: [
        // 其他代码 ...
        
        // 配置数据库链接
        TypeOrmModule.forRoot({
            type: 'mysql',
            host: 'localhost',
            port: 3306,
            username: 'root',
            password: '123456',
            database: 'nest_example',
            entities: [__dirname + '/**/*.entity{.ts,.js}'],
        })
    ],
    // 其他代码 ...
})
export class AppModule {
}

7.4 实体类和接口创建

7.4.1 实体类创建

src 新建 一个目录 entity,并新建 users 实体类

// src/entity/users.entity.ts

import {Entity, PrimaryGeneratedColumn, Column, CreateDateColumn} from 'typeorm';

@Entity('users') // 指定表名为 'users'
export class User {
    @PrimaryGeneratedColumn({type: 'int', unsigned: true}) // 自增主键
    id: number;

    @Column({type: 'varchar', length: 50}) // 用户名
    username: string;

    @Column({type: 'varchar', length: 255}) // 密码
    password: string;

    @Column({type: 'varchar', length: 50, nullable: true}) // 昵称(允许为空)
    nickname: string;

    @Column({type: 'varchar', length: 100, unique: true}) // 邮箱(唯一)
    email: string;

    @Column({type: 'tinyint', width: 4, default: 1}) // 状态(默认值为 1)
    status: number;

    @CreateDateColumn({type: 'timestamp', default: () => 'CURRENT_TIMESTAMP'}) // 创建时间
    create_time: Date;
}
7.4.2 创建接口 interface 并添加配置
  • users
# 执行命令
nest g interface interface/users
export interface UsersInterface {
    id?: number;
    username?: string;
    password?: string;
    nickname?: string;
    email?: string;
    status?: number;
    page?: number;
    pageSize?: number;
}

8. 配置Cookie、Session

8.1 安装插件

npm i cookie-parser express-session @types/cookie-parser @types/express-session

8.2 配置

src/main.ts 中配置

// 其他代码...

import * as cookieParser from "cookie-parser";
import * as session from "express-session";

async function bootstrap() {
    // 其他代码...

    // 配置Cookie、Session
    app.use(cookieParser('CF8136B13D46495CA6AFF3297FD4D8FC'))
    app.use(session({
        secret: 'B125C1B83DEAB9B667E571489421CB71',
        resave: false,
        saveUninitialized: false,
        cookie: {maxAge: 3600000, httpOnly: true},
        rolling: false
    }))

    // 其他代码...
}
// 其他代码...

9. 扩展方法

在目录 src 下新建文件 extend/helper.ts,后期将公共放入此处,方便调用。

// 先安装插件:npm i dayjs

import * as dayjs from "dayjs";

export class Helper {
    static formatDate(date: string | number | Date | dayjs.Dayjs, template: string) {
        return dayjs(date).format(template)
    }
}

二、登录鉴权

1. 配置中间件(限制页面访问 + 初始化中间件)

1.1 创建中间件并编码

# 命令
nest g middleware middleware/adminauth
nest g middleware middleware/init
  • adminauth 中间件
    该中间件通过 req.session.userinfo 获取用户信息判断用户是否已登录,如果未登录,则重定向到登录页面(除非请求的路由在 excludes 数组中),通过 excludes 数组,可以配置不需要权限验证的路由(例如登录页面)。
import {Injectable, NestMiddleware} from '@nestjs/common';

@Injectable()
export class AdminauthMiddleware implements NestMiddleware {
    use(req: any, res: any, next: () => void) {
        const excludes: string[] = ['/admin/auth/login', '/admin/auth/doLogin', '/admin/auth/code'];

        const userinfo = req.session.userinfo;

        if (userinfo && userinfo.username) {
            res.locals.userinfo = userinfo;
            next();
        } else {
            if (excludes.includes(req.baseUrl)) {
                next();
            } else {
                res.redirect(301, '/admin/auth/login')
            }
        }
    }
}
  • init 中间件
    该中间件主要是将自定义的工具类 Helper 挂载到 res.locals 上,以便在后续的请求处理中使用;其他工具类也可在此处添加。
import {Injectable, NestMiddleware} from '@nestjs/common';
import {Helper} from "../../extend/helper";

@Injectable()
export class InitMiddleware implements NestMiddleware {
    use(req: any, res: any, next: () => void) {
        res.locals.helper = Helper
        next();
    }
}

1.2 配置中间件

// 其他代码...
import {Module, NestModule, MiddlewareConsumer} from '@nestjs/common';
import {AdminauthMiddleware} from "./middleware/adminauth/adminauth.middleware";
import {InitMiddleware} from "./middleware/init/init.middleware";


// 其他代码...
export class AppModule implements NestModule {
    configure(consumer: MiddlewareConsumer) {
        consumer
            .apply(AdminauthMiddleware)
            .forRoutes('/admin/*')
            .apply(InitMiddleware)
            .forRoutes('*');
    }
}

2. users 服务 Service

2.1 源码

import {Injectable} from '@nestjs/common';
import {InjectRepository} from "@nestjs/typeorm"; // 用于注入 TypeORM 的 Repository
import {Repository} from "typeorm"; // TypeORM 的 Repository 类,用于操作数据库
import {Users} from "../../entity/users.entity";
import {UsersInterface} from "../../interface/users/users.interface"; // 用户实体类,对应数据库中的用户表

@Injectable()
export class UsersService {
    constructor(
        // 注入 Users 实体的 Repository
        @InjectRepository(Users)
        // 声明一个私有的只读属性,用于操作 Users 实体
        private readonly usersRepository: Repository<Users>
    ) {
    }

    async findAll(json: UsersInterface = {}) {
        const {page = 1, pageSize = 10} = json;
        return await this.usersRepository.findAndCount({
            skip: (page - 1) * pageSize,
            take: pageSize,
            order: {id: 'DESC'}
        })
    }

    async findDetail(id) {
        return await this.usersRepository.findBy({id})
    }

    async findOne(where) {
        return await this.usersRepository.findOne({ where });
    }

    async save(createUserDto: Partial<Users>) {
        const user = this.usersRepository.create(createUserDto)
        return await this.usersRepository.save(user);
    }

    async update(id: number, updateUserDto: Partial<Users>) {
        return await this.usersRepository.update(id, updateUserDto);
    }

    async del(id) {
        return await this.usersRepository.delete(id)
    }
}

2.2 代码简单解释

2.2.1 导入依赖

简单解释一下以下代码:
其他方法可参考官网文档:typeorm.bootcss.com/

  • Injectable:来自 @nestjs/common,用于将 UsersService 类标记为可注入的服务。
  • InjectRepository:来自 @nestjs/typeorm,用于将 TypeORM 的 Repository 注入到服务中。
  • Repository:来自 typeorm,是 TypeORM 的核心类,用于操作数据库。
  • Users:用户实体类,对应数据库中的用户表。
  • UsersInterface:用户相关的接口定义,可能用于类型检查或数据传输。

2.2.2 UsersService 类

  • @Injectable() :将 UsersService 标记为一个可注入的服务,NestJS 的依赖注入系统会管理它的生命周期。

  • constructor:通过 @InjectRepository(Users) 注入 Users 实体的 Repository,并将其赋值给 usersRepository 属性。

    • usersRepository 是 Repository<Users> 类型的实例,用于操作 Users 实体对应的数据库表。

2.2.3 方法

a. findAll 方法
  • 功能:分页查询用户列表。

  • 参数

    • json:包含分页参数的对象,默认值为 {}
    • page:当前页码,默认为 1
    • pageSize:每页显示的记录数,默认为 10
  • 实现

    • 使用 findAndCount 方法查询用户列表并返回总记录数。
    • skip:跳过前面的记录数,用于分页。
    • take:每页显示的记录数。
    • order:按 id 降序排列。
b. findDetail 方法
  • 功能:根据用户 ID 查询用户详情。
  • 参数
    • id:用户的唯一标识符。
  • 实现
    • 使用 findBy 方法查询符合条件的用户记录。
c. findOne 方法
  • 功能:根据条件查询单个用户。
  • 参数
    • where:查询条件对象。
  • 实现
    • 使用 findOne 方法查询符合条件的单个用户记录。
d. save 方法
  • 功能:创建新用户。
  • 参数
    • createUserDto:包含用户信息的对象,类型为 Partial<Users>(即 Users 实体的部分属性)。
  • 实现
    • 使用 create 方法创建一个新的用户实体。
    • 使用 save 方法将用户实体保存到数据库。
e. update 方法
  • 功能:更新用户信息。
  • 参数
    • id:用户的唯一标识符。
    • updateUserDto:包含更新信息的对象,类型为 Partial<Users>
  • 实现: - 使用 update 方法更新指定 ID 的用户记录。
f. del 方法
  • 功能:删除用户。
  • 参数
    • id:用户的唯一标识符。
  • 实现
    • 使用 delete 方法删除指定 ID 的用户记录。

3. auth 控制器 Controller

3.1 重点源码解析

3.1.1 生成验证码
  • 路由GET /admin/auth/code
  • 功能:生成验证码图片并返回给客户端。
    • this.toolsService.getCaptcha():调用工具服务生成验证码(返回一个包含验证码文本和 SVG 数据的对象)。
    • req.session.captcha:将验证码文本存储到会话(session)中,用于后续验证。
    • res.type('image/svg+xml'):设置响应类型为 SVG 图片。
    • res.send(svgCaptcha.data):将生成的 SVG 图片发送给客户端。
@Get('code')
getCode(@Request() req, @Response() res) {
    const svgCaptcha = this.toolsService.getCaptcha();
    req.session.captcha = svgCaptcha.text;
    res.type('image/svg+xml');
    res.send(svgCaptcha.data);
}
3.1.2 处理登录请求
  • 路由POST /admin/auth/doLogin
  • 功能:处理用户登录请求。
    • 步骤 1:验证验证码
      • 从请求体中获取 usernamepassword 和 captcha
      • 检查验证码是否为空或与会话中存储的验证码不一致(忽略大小写)。
      • 如果验证码错误,调用 this.toolsService.tips 方法返回错误提示,并重定向到登录页面。
    • 步骤 2:验证用户名和密码
      • 调用 this.usersService.findOne 方法查询用户信息。
      • 对用户输入的密码进行 MD5 加密后与数据库中的密码进行比较。
      • 如果用户不存在或密码错误,返回错误提示并重定向到登录页面。
    • 步骤 3:登录成功
      • 将用户信息存储到会话中(req.session.userinfo)。
      • 重定向到仪表盘页面(/admin/dashboard)。
@Post('doLogin')
async doLogin(@Body() body, @Request() req, @Response() res) {
    const {username, password, captcha} = body;
    if (!captcha || captcha.toUpperCase() !== req.session.captcha.toUpperCase()) {
        await this.toolsService.tips(res, {
            type: 'error',
            redirectUrl: '/admin/auth/login',
            message: '验证码错误!'
        })
        return;
    }
    const resp = await this.usersService.findOne({
        username,
        password: this.toolsService.getMd5(password),
    })
    if (!resp || !resp.id) {
        await this.toolsService.tips(res, {
            type: 'error',
            redirectUrl: '/admin/auth/login',
            message: '账号或密码错误!'
        })
        return;
    }
    req.session.userinfo = resp;
    res.redirect('/admin/dashboard')
}

3.2 完整源码

import {Body, Controller, Get, Post, Query, Render, Request, Response} from '@nestjs/common';
import {ToolsService} from "../../../../service/tools/tools.service";
import {UsersService} from "../../../../service/users/users.service";

@Controller('admin/auth')
export class AuthController {
    constructor(
        private readonly toolsService: ToolsService,
        private readonly usersService: UsersService
    ) {
    }

    // 生成验证码图片并返回给客户端。
    @Get('code')
    getCode(@Request() req, @Response() res) {
        const svgCaptcha = this.toolsService.getCaptcha();
        req.session.captcha = svgCaptcha.text; // 将验证码文本存储到会话(session)中,用于后续验证。
        res.type('image/svg+xml'); // 设置响应类型为 SVG 图片。
        res.send(svgCaptcha.data); // 将生成的 SVG 图片发送给客户端。
    }


    // 登录页面渲染
    @Get('login')
    @Render('login')
    login() {
        return {}
    }

    // 处理登录请求
    @Post('doLogin')
    async doLogin(@Body() body, @Request() req, @Response() res) {
        const {username, password, captcha} = body;
        // 步骤 1:验证验证码
        if (!captcha || captcha.toUpperCase() !== req.session.captcha.toUpperCase()) {
            await this.toolsService.tips(res, {
                type: 'error',
                redirectUrl: '/admin/auth/login',
                message: '验证码错误!'
            })
            return;
        }
        // 步骤 2:验证用户名和密码
        const resp = await this.usersService.findOne({
            username,
            password: this.toolsService.getMd5(password),
        })
        if (!resp || !resp.id) {
            await this.toolsService.tips(res, {
                type: 'error',
                redirectUrl: '/admin/auth/login',
                message: '账号或密码错误!'
            })
            return;
        }
        // 登录成功
        req.session.userinfo = resp;
        res.redirect('/admin/dashboard')
    }

    // 退出登录
    @Get('logout')
    async logout(@Request() req, @Response() res) {
        req.session.userinfo = null;
        res.redirect('/admin/auth/login')
    }
}

三、系统用户管理

基于以上登录鉴权结果,对当前系统中的用户进行维护(增删改查),以下是源码:

import {Body, Controller, Get, Post, Query, Render, Request, Response} from '@nestjs/common';
import {UsersService} from "../../../../service/users/users.service";
import {ToolsService} from "../../../../service/tools/tools.service";

@Controller('admin/users')
export class UsersController {
    constructor(
        private readonly usersService: UsersService,
        private readonly toolsService: ToolsService
    ) {
    }

    // 查询用户列表
    @Get()
    @Render('users')
    async list() {
        // 调用 usersService 的 findAll 方法获取用户列表和总数
        const [list, count] = await this.usersService.findAll();

        // 返回用户列表和总数,用于渲染 users 模板
        return {
            list,
            count
        }
    }

    // 查询用户详细信息
    @Get('userinfo')
    @Render('userinfo')
    async userinfo(@Query() query) {
        // 如果查询参数中没有 id,返回空对象
        if (!query.id) return {info: {}};

        // 调用 usersService 的 findDetail 方法,根据 id 查询用户详细信息
        const infoArray = await this.usersService.findDetail(query.id);

        // 返回用户信息,用于渲染 userinfo 模板
        return {info: infoArray[0]}
    }

    // 添加用户
    @Post('add')
    async add(@Body() body, @Response() res, @Request() req) {
        // 从请求体中获取用户名、密码、昵称和邮箱
        const {username, password, nickname, email} = body;

        // 检查用户名是否已存在
        const isExist = await this.usersService.findOne({username});
        if (isExist && isExist.username) {
            // 如果用户名已存在,返回错误提示并重定向到用户信息页面
            await this.toolsService.tips(res, {
                type: 'error',
                message: '当前用户名已存在',
                redirectUrl: `/admin/users/userinfo`
            })
            return;
        }

        // 检查表单是否填写完整
        if (!Object.values(body).filter(it => it).length) {
            // 如果表单未填写完整,返回错误提示并重定向到用户信息页面
            await this.toolsService.tips(res, {
                type: 'error',
                message: '请将表单每一项填写完整',
                redirectUrl: `/admin/users/userinfo`
            })
            return;
        }

        // 对密码进行 MD5 加密
        const pwd = await this.toolsService.getMd5(password);

        // 调用 usersService 的 save 方法保存用户信息
        const resp = await this.usersService.save({
            username, password: pwd, nickname, email
        });

        // 根据保存结果返回提示信息
        if (resp) {
            await this.toolsService.tips(res, {
                type: 'success',
                redirectUrl: '/admin/users',
            })
        } else {
            await this.toolsService.tips(res, {
                type: 'error',
                message: '修改失败,请稍后重试',
                redirectUrl: '/admin/users',
            })
        }
    }

    // 编辑用户信息
    @Post('editUserinfo')
    async editUserinfo(@Body() body, @Response() res, @Request() req) {
        // 从请求体中获取用户 id
        const {id} = body;

        // 构建更新对象,过滤掉空值并对密码进行 MD5 加密
        const obj = {};
        Object.entries(body).forEach(it => {
            const [key, value = ''] = it;
            if (value) {
                if (key === 'password') obj[key] = this.toolsService.getMd5(value)
                else obj[key] = value
            }
        });

        // 调用 usersService 的 update 方法更新用户信息
        const resp = await this.usersService.update(id, obj);

        // 根据更新结果返回提示信息
        if (resp) {
            const arg = {
                type: 'success',
                redirectUrl: '/admin/users',
            };

            // 如果当前登录用户修改了自己的信息,清空 session 并提示重新登录
            if (Number(req.session.userinfo.id) === Number(id)) {
                req.session.userinfo = null;
                Object.assign(arg, {
                    redirectUrl: '/admin/auth/login',
                    message: '已修改用户信息,请重新登录!'
                })
            }

            await this.toolsService.tips(res, arg)
        } else {
            await this.toolsService.tips(res, {
                type: 'error',
                message: '修改失败,请稍后重试',
                redirectUrl: `/admin/auth/userinfo?id=${id}`
            })
        }
    }

    // 删除用户
    @Get('delete')
    async del(@Query() query, @Response() res) {
        // 调用 usersService 的 del 方法删除用户
        const resp = await this.usersService.del(query.id);

        // 根据删除结果返回提示信息
        if (resp) {
            await this.toolsService.tips(res, {
                type: 'success',
                redirectUrl: '/admin/users',
            })
        } else {
            await this.toolsService.tips(res, {
                type: 'error',
                message: '删除失败,请稍后重试',
                redirectUrl: '/admin/users',
            })
        }
    }
}

4839e4642dbcaa47fbb21a8b51e4d65b.gif