likes
comments
collection
share

【Express+TS手写对象存储】第3章:用户管理模块(JWT、RBAC)

作者站长头像
站长
· 阅读数 19

欢迎阅读手写Express+TS手写对象存储专栏。这系列文章旨在为您提供一个全面的指南,带您一步步构建一个功能齐全的对象存储系统。对象存储系统在大数据时代发挥着重要的作用,能够高效地管理和存储大量非结构化数据,如图片、视频和文档等。

这是专栏中的第三节,本文将带您深入用户管理模块的开发。我们将一步步实现用户注册与登录功能,使用JWT进行用户认证与授权,并通过RBAC实现细粒度的角色与权限管理。此外,我们还将介绍如何编写单元测试与集成测试,以确保用户管理模块的稳定性和可靠性。

接口设计文档

Auth 接口设计文档

名称地址方法功能说明角色是否要求登录
用户注册/api/auth/registerPOST注册新用户任何用户
用户登录/api/auth/loginPOST用户登录任何用户

User 接口设计文档

名称地址方法功能说明角色是否要求登录
获取用户信息/api/user/profileGET获取当前用户信息已登录用户
更新用户信息/api/user/profilePUT更新当前用户信息已登录用户
修改密码/api/user/reset/passwordPUT修改当前用户密码已登录用户
用户退出登录/api/user/logoutPOST用户退出登录已登录用户
升级用户为管理员/api/user/upgrade2adminPUT升级普通用户为管理员管理员

接口实现

3.1 用户注册与登录功能

3.1.1 创建用户模型
  1. 安装必要的库

    npm install bcryptjs
    npm i --save-dev @types/bcryptjs
    
  2. 定义用户Schema,使用bcrypt进行密码加密

    models 目录下创建 UserModel.ts 文件:

    import mongoose, { Schema, Document } from "mongoose";
    import bcrypt from "bcryptjs";
    
    // 用户接口,包含字段和方法
    export interface IUser extends Document {
      username: string;
      email: string;
      password: string;
      comparePassword(candidatePassword: string): Promise<boolean>;
    }
    
    // 用户 Schema 定义
    const UserSchema: Schema = new Schema({
      username: { type: String, required: true, unique: true },
      email: { type: String, required: true, unique: true },
      password: { type: String, required: true },
    });
    
    // 保存用户前加密密码
    UserSchema.pre<IUser>("save", async function (next) {
      if (!this.isModified("password")) return next();
    
      const salt = await bcrypt.genSalt(10);
      const hash = await bcrypt.hash(this.password, salt);
      this.password = hash;
      next();
    });
    
    // 比较密码方法
    UserSchema.methods.comparePassword = function (
      candidatePassword: string
    ): Promise<boolean> {
      return bcrypt.compare(candidatePassword, this.password);
    };
    
    export default mongoose.model<IUser>("User", UserSchema);
    
    
3.1.2 实现注册和登录接口
  1. 创建注册和登录的路由和控制器

    controllers 目录下创建 authController.ts 文件:

    import { Request, Response } from "express";
    import UserModel from "../models/UserModel";
    
    export const register = async (req: Request, res: Response) => {
      const { username, email, password } = req.body;
    
      try {
        const user = new UserModel({ username, email, password });
        await user.save();
        res.status(201).json({ message: "User registered successfully" });
      } catch (error) {
        res.status(400).json({ message: 'User already exists' });
      }
    };
    
    export const login = async (req: Request, res: Response) => {
      const { email, password } = req.body;
    
      try {
        const user = await UserModel.findOne({ email });
        if (!user) {
          return res.status(400).json({ error: "Invalid credentials" });
        }
    
        const isMatch = await user.comparePassword(password);
        if (!isMatch) {
          return res.status(400).json({ error: "Invalid credentials" });
        }
    
        // Generate JWT token here (to be implemented in 3.2)
        res.status(200).json({ message: "Login successful" });
      } catch (error) {
        res.status(500).json({ error: "Server error" });
      }
    };
    

    routes 目录下创建 authRoutes.ts 文件:

    import { Router } from "express";
    import { register, login } from "../controllers/authController";
    
    const router = Router();
    
    router.post("/register", register);
    router.post("/login", login);
    
    export default router;
    
  2. 在主应用文件中使用路由

    app.ts 文件中添加路由:

    import express from "express";
    import cors from "cors";
    import morgan from "morgan";
    import helloRoute from "./routes/helloRoute";
    import { initializeMongoose } from "./dao/mongodb";
    import authRoutes from "./routes/authRoutes";
    
    const app = express();
    const port = process.env.PORT || 3000;
    
    // 初始化
    initializeMongoose();
    
    // 配置中间件
    app.use(express.json()); // 使用 Express 内置的 JSON 解析中间件
    app.use(express.urlencoded({ extended: true })); // 使用 Express 内置的 URL 编码解析中间件
    app.use(cors());
    app.use(morgan("dev"));
    
    // 配置路由
    app.use("/api", helloRoute);
    app.use("/api/auth", authRoutes);
    
    app.get("/", (req, res) => {
      res.send("Hello, Object Storage System!");
    });
    
    app.listen(port, () => {
      console.log(`Server is running on http://localhost:${port}`);
    });
    

3.2 使用JWT进行用户认证与授权

3.2.1 解释JWT的工作原理
  • JWT结构:JWT由三个部分组成:Header、Payload和Signature。
  • 生成过程:服务器生成JWT时,会对Header和Payload进行Base64编码,并使用Secret进行签名。
  • 验证过程:客户端发送请求时,会携带JWT,服务器验证签名和Payload的有效性。
3.2.2 生成和验证JWT的示例代码
  1. 安装JWT相关的库

    npm install jsonwebtoken
    npm i --save-dev @types/jsonwebtoken
    
  2. 生成和验证JWT

    controllers/authController.ts 文件中更新 login 方法,生成JWT:

    import jwt from 'jsonwebtoken';
    
    export const login = async (req: Request, res: Response) => {
      const { email, password } = req.body;
    
      try {
        const user = await UserModel.findOne({ email });
        if (!user) {
          return res.status(400).json({ error: "Invalid credentials" });
        }
    
        const isMatch = await user.comparePassword(password);
        if (!isMatch) {
          return res.status(400).json({ error: "Invalid credentials" });
        }
    
        const token = jwt.sign(
          { userId: user._id, role: user.role },
          "your_jwt_secret",
          {
            expiresIn: "1h",
          }
        );
        res.setHeader("token", token);
        res.status(200).json({ message: "Login successful" });
      } catch (error) {
        res.status(500).json({ error: "Server error" });
      }
    };
    
  3. 编写JWT验证中间件

    middlewares 目录下创建 authMiddleware.ts 文件:

    /* eslint-disable @typescript-eslint/no-namespace */
    /* eslint-disable @typescript-eslint/no-explicit-any */
    import { Request, Response, NextFunction } from "express";
    import jwt from "jsonwebtoken";
    
    declare global {
      namespace Express {
        interface Request {
          user?: {
            userId: string;
            role: "admin" | "user";
          };
        }
      }
    }
    
    export const authMiddleware = (
      req: Request,
      res: Response,
      next: NextFunction
    ) => {
      const token = req.header("Authorization")?.replace("Bearer ", "");
    
      if (!token) {
        return res.status(401).json({ error: "No token, authorization denied" });
      }
    
      try {
        const decoded: any = jwt.verify(token, "your_jwt_secret");
        req.user = decoded;
        next();
      } catch (error) {
        res.status(401).json({ error: "Token is not valid" });
      }
    };
    
  4. 保护API路由

    在需要保护的路由src/routes/helloRoute.ts中使用 authMiddleware

    import { Router } from "express";
    import { helloController } from "../controllers/helloController";
    import { authMiddleware } from "../middlewares/authMiddleware";
    
    const router = Router();
    
    router.get("/hello", authMiddleware, helloController);
    
    export default router;
    

这次我们再访问http://localhost:3000/api/hello就会拿到一个错误提示

{
    "error":"No token, authorization denied"
}

3.3 用户角色与权限管理

3.3.1 RBAC的实现
  1. 什么是RBAC

    RBAC(基于角色的访问控制,Role-Based Access Control)是一种访问控制方法,用于管理用户对计算机系统资源的权限。它通过将权限分配给角色,然后将角色分配给用户来实现权限管理。这样,用户的权限是通过其所属的角色间接获得的,而不是直接赋予用户。

    RBAC的主要优点包括:

    1. 简化权限管理:通过角色管理权限,而不是直接管理每个用户的权限,可以减少管理的复杂性。
    2. 提高安全性:通过严格定义角色和权限,可以更精细地控制用户对资源的访问。
    3. 灵活性和可扩展性:可以根据组织的需要动态调整角色和权限,适应不同的业务需求。

    RBAC的基本概念包括:

    1. 用户(User):系统的实际使用者。
    2. 角色(Role):一组权限的集合,代表某一类用户的职责和权限。
    3. 权限(Permission):对系统资源的访问权,通常包括读、写、执行等操作。
    4. 会话(Session):用户在特定时间段内激活的角色。

    RBAC的实施通常包括以下步骤:

    1. 定义角色:根据组织的业务需求和职能,定义不同的角色。
    2. 分配权限:将相应的权限分配给每个角色。
    3. 分配角色:将用户分配到相应的角色。
    4. 管理和维护:定期审查和更新角色、权限和用户的分配,以确保系统的安全性和有效性。
  2. 定义角色和权限

    更新 UserModel.ts 模型,添加角色字段:

    const UserSchema: Schema = new Schema({
      username: { type: String, required: true, unique: true },
      email: { type: String, required: true, unique: true },
      password: { type: String, required: true },
      role: { type: String, enum: ['user', 'admin'], default: 'user' }
    });
    
  3. 编写中间件进行权限控制

    middlewares 目录下创建 roleMiddleware.ts 文件:

    import { Request, Response, NextFunction } from "express";
    
    export const roleMiddleware = (requiredRole: string) => {
      return (req: Request, res: Response, next: NextFunction) => {
        if (!req.user || req.user.role !== requiredRole) {
          return res.status(403).json({ error: "Access denied" });
        }
        next();
      };
    };
    
  4. 保护API路由

    在需要权限控制的路由中使用 roleMiddleware

    import { Router } from "express";
    import { helloController } from "../controllers/helloController";
    import { authMiddleware } from "../middlewares/authMiddleware";
    import { roleMiddleware } from "../middlewares/roleMiddleware";
    
    const router = Router();
    
    router.get("/hello", authMiddleware, roleMiddleware("user"), helloController);
    router.get("/hello/admin", authMiddleware, roleMiddleware("admin"), helloController);
    
    export default router;
    

authMiddleware, roleMiddleware("admin")互换位置后访问http://localhost:3000/api/hello就能看到角色中间件的调用了

{"error":"Access denied"}

3.4 用户信息管理

3.4.1 获取用户信息
  1. 编写获取用户信息的控制器

    controllers 目录下创建 userController.ts 文件:

    import { Request, Response } from "express";
    import UserModel from "../models/UserModel";
    
    export const getUserProfile = async (req: Request, res: Response) => {
      try {
        const user = await UserModel.findById(req.user?.userId).select("-password");
        if (!user) {
          return res.status(404).json({ error: "User not found" });
        }
        res.status(200).json(user);
      } catch (error) {
        res.status(500).json({ error: "Server error" });
      }
    };
    
  2. 创建获取用户信息的路由

    routes 目录下创建 userRoutes.ts 文件:

    import { Router } from "express";
    import { getUserProfile } from "../controllers/userController";
    import { authMiddleware } from "../middlewares/authMiddleware";
    
    const router = Router();
    
    router.get("/profile", authMiddleware, getUserProfile);
    
    export default router;
    
  3. 在主应用文件中添加用户路由

    app.ts 文件中添加路由:

    import userRoutes from "./routes/userRoutes";
    
    app.use("/api/user", userRoutes);
    
3.4.2 更新用户信息
  1. 编写更新用户信息的控制器

    controllers/userController.ts 文件中添加 updateUserProfile 方法:

    export const updateUserProfile = async (req: Request, res: Response) => {
      const { username, email } = req.body;
    
      try {
        const user = await UserModel.findById(req.user?.userId);
        if (!user) {
          return res.status(404).json({ error: "User not found" });
        }
    
        user.username = username || user.username;
        user.email = email || user.email;
    
        await user.save();
        res.status(200).json({ message: "User updated successfully", user });
      } catch (error) {
        res.status(500).json({ error: "Server error" });
      }
    };
    
  2. 创建更新用户信息的路由

    routes/userRoutes.ts 文件中添加更新用户信息的路由:

    router.put("/profile", authMiddleware, updateUserProfile);
    

3.5 用户密码管理

3.5.1 修改密码功能
  1. 编写修改密码的控制器

    controllers/userController.ts 文件中添加 changePassword 方法:

    export const changePassword = async (req: Request, res: Response) => {
      const { currentPassword, newPassword } = req.body;
    
      try {
        const user = await UserModel.findById(req.user?.userId);
        if (!user) {
          return res.status(404).json({ error: "User not found" });
        }
    
        const isMatch = await user.comparePassword(currentPassword);
        if (!isMatch) {
          return res.status(400).json({ error: "Current password is incorrect" });
        }
    
        user.password = newPassword;
        await user.save();
        res.status(200).json({ message: "Password changed successfully" });
      } catch (error) {
        res.status(500).json({ error: "Server error" });
      }
    };
    
  2. 创建修改密码的路由

    routes/userRoutes.ts 文件中添加修改密码的路由:

    router.put("/reset/password", authMiddleware, changePassword);
    

3.6 用户退出登录功能

3.6.1 退出登录
  1. 编写用户注销的控制器

    controllers/userController.ts 文件中添加 logout 方法:

    export const logout = async (req: Request, res: Response) => {
      // 清除客户端保存的JWT Token
      res.status(200).json({ message: "User logged out successfully" });
    };
    
  2. 创建用户注销的路由

    routes/userRoutes.ts 文件中添加用户注销的路由:

    router.post("/logout", authMiddleware, logout);
    

3.8 创建管理员脚本

3.8.1 创建脚本文件
  1. src/models/UserModel.ts中扩展一下角色分类,新增super类

    // 用户 Schema 定义
    const UserSchema: Schema = new Schema({
      username: { type: String, required: true, unique: true },
      email: { type: String, required: true, unique: true },
      password: { type: String, required: true },
      role: { type: String, enum: ["user", "admin","super"], default: "user" },
    });
    
  2. src目录下创建一个 scripts 文件夹,并在其中创建一个 createAdmin.ts 文件:

import mongoose from "mongoose";
import readline from "readline";
import UserModel from "../models/UserModel";
import { initializeMongoose } from "../dao/mongodb";

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

const question = (query: string) =>
  new Promise<string>((resolve) => rl.question(query, resolve));

const createAdmin = async () => {
  try {
    await initializeMongoose();
    const username = await question("Enter username: ");
    const email = await question("Enter email: ");
    const password = await question("Enter password: ");
    
    const admin = new UserModel({
      username,
      email,
      password: password,
      role: "super",
    });

    await admin.save();
    console.log("Admin user created successfully");
  } catch (error) {
    console.error("Error creating admin user:", error);
  } finally {
    rl.close();
    mongoose.connection.close();
  }
};

createAdmin();
3.8.2 更新 package.json

package.json 文件中添加一个脚本命令:

"scripts": {
  "ca": "ts-node src/scripts/createAdmin.ts"
}

这样,运行 npm run ca 就会启动这个脚本,并提示用户输入用户名、邮箱和密码来创建管理员用户���

npm run ca

> ore@0.0.1 ca
> ts-node src/scripts/createAdmin.ts

MongoDB 连接成功...
Enter username: ar8d2336
Enter email: ar8d2336@163.com
Enter password: 123456
Admin user created successfully
Mongoose 已断开与数据库的连接

3.9 管理员升级普通用户为管理员的接口

3.9.1 编写控制器

controllers/userController.ts 文件中添加 upgradeToAdmin 方法:

export const upgradeToAdmin = async (req: Request, res: Response) => {
  const { userId } = req.body;

  try {
    const user = await UserModel.findById(userId);
    if (!user) {
      return res.status(404).json({ error: "User not found" });
    }

    user.role = "admin";
    await user.save();
    res.status(200).json({ message: "User upgraded to admin successfully" });
  } catch (error) {
    res.status(500).json({ error: "Server error" });
  }
};
3.9.2 扩展角色和抽象用户相关类型注解

src/types/user.d.ts 文件中添加 用户相关类型注解

export type Role = "admin" | "user" | "super";
export interface AuthUser {
  userId: string;
  role: Role;
}
3.9.3 改造auth中间件

src/middlewares/authMiddleware.ts 引入AuthUser注解并且改造中间件

/* eslint-disable @typescript-eslint/no-namespace */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { AuthUser } from "../types/user";

declare global {
  namespace Express {
    interface Request {
      user?: AuthUser; // --------------------------------这一行
    }
  }
}

export const authMiddleware = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const token = req.header("Authorization")?.replace("Bearer ", "");

  if (!token) {
    return res.status(401).json({ error: "No token, authorization denied" });
  }

  try {
    const decoded: any = jwt.verify(token, "your_jwt_secret");
    req.user = decoded;
    next();
  } catch (error) {
    res.status(401).json({ error: "Token is not valid" });
  }
};

更新角色鉴权中间件src/middlewares/roleMiddleware.ts,新增数组权限

import { Request, Response, NextFunction } from "express";
import { Role } from "../types/user";

export const roleMiddleware = (requiredRole: Role | Role[]) => {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(403).json({ error: "Access denied" });
    }

    if (typeof requiredRole === "string") {
      if (req.user.role !== requiredRole) {
        return res.status(403).json({ error: "Access denied" });
      }
    } else if (Array.isArray(requiredRole)) {
      if (!requiredRole.includes(req.user.role)) {
        return res.status(403).json({ error: "Access denied" });
      }
    }

    next();
  };
};
3.9.4 创建接口路由

routes/userRoutes.ts 文件中添加升级用户为管理员的路由:

import { Router } from "express";
import {
  changePassword,
  getUserProfile,
  logout,
  updateUserProfile,
  upgradeToAdmin,
} from "../controllers/userController";
import { authMiddleware } from "../middlewares/authMiddleware";
import { roleMiddleware } from "../middlewares/roleMiddleware";

const router = Router();

router.get(
  "/profile",
  authMiddleware,
  roleMiddleware(["admin", "super", "user"]),
  getUserProfile
);
router.put(
  "/profile",
  authMiddleware,
  roleMiddleware(["admin", "super", "user"]),
  updateUserProfile
);
router.put(
  "/reset/password",
  authMiddleware,
  roleMiddleware(["admin", "super", "user"]),
  changePassword
);
router.post(
  "/logout",
  authMiddleware,
  roleMiddleware(["admin", "super", "user"]),
  logout
);

router.put(
  "/upgrade2admin",
  authMiddleware,
  roleMiddleware("super"),
  upgradeToAdmin
);

export default router;

测试

用户管理模块:集成测试用例

正向功能用例

  1. 用户注册功能

    • 用例描述:用户可以通过提供用户名、邮箱和密码进行注册。
    • 前置条件:无。
    • 测试步骤
      1. 发送POST请求到/api/auth/register,提供有效的用户名、邮箱和密码。
      2. 验证响应状态码为201。
      3. 验证响应消息为"User registered successfully"。
    • 预期结果:用户注册成功,返回201状态码和成功消息。
  2. 用户登录功能

    • 用例描述:用户可以通过提供邮箱和密码进行登录。
    • 前置条件:用户已注册。
    • 测试步骤
      1. 发送POST请求到/api/auth/login,提供有效的邮箱和密码。
      2. 验证响应状态码为200。
      3. 验证响应消息为"Login successful"。
      4. 验证响应头中包含JWT token。
    • 预期结果:用户登录成功,返回200状态码和成功消息,并包含JWT token。
  3. 获取用户信息

    • 用例描述:用户可以通过提供有效的JWT token获取自己的信息。
    • 前置条件:用户已登录并获取到JWT token。
    • 测试步骤
      1. 发送GET请求到/api/user/profile,在请求头中提供JWT token。
      2. 验证响应状态码为200。
      3. 验证响应中包含用户信息(不包括密码)。
    • 预期结果:成功获取用户信息,返回200状态码和用户信息。
  4. 更新用户信息

    • 用例描述:用户可以更新自己的用户名和邮箱。
    • 前置条件:用户已登录并获取到JWT token。
    • 测试步骤
      1. 发送PUT请求到/api/user/profile,在请求头中提供JWT token,并在请求体中提供新的用户名和邮箱。
      2. 验证响应状态码为200。
      3. 验证响应消息为"User updated successfully"。
      4. 验证响应中包含更新后的用户信息。
    • 预期结果:成功更新用户信息,返回200状态码和成功消息,并包含更新后的用户信息。
  5. 修改密码

    • 用例描述:用户可以通过提供当前密码和新密码修改自己的密码。
    • 前置条件:用户已登录并获取到JWT token。
    • 测试步骤
      1. 发送PUT请求到/api/user/reset/password,在请求头中提供JWT token,并在请求体中提供当前密码和新密码。
      2. 验证响应状态码为200。
      3. 验证响应消息为"Password changed successfully"。
    • 预期结果:成功修改用户密码,返回200状态码和成功消息。
  6. 用户注销

    • 用例描述:用户可以注销登录。
    • 前置条件:用户已登录并获取到JWT token。
    • 测试步骤
      1. 发送POST请求到/api/user/logout,在请求头中提供JWT token。
      2. 验证响应状态码为200。
      3. 验证响应消息为"User logged out successfully"。
    • 预期结果:成功注销用户,返回200状态码和成功消息。
  7. 管理员升级普通用户为管理员

    • 用例描述:超级管理员可以将普通用户升级为管理员。
    • 前置条件:超级管理员已登录并获取到JWT token。
    • 测试步骤
      1. 发送PUT请求到/api/user/upgrade2admin,在请求头中提供超级管理员的JWT token,并在请求体中提供要升级的用户ID。
      2. 验证响应状态码为200。
      3. 验证响应消息为"User upgraded to admin successfully"。
    • 预期结果:成功将用户升级为管理员,返回200状态码和成功消息。

边界用例

  1. 用户注册 - 重复注册

    • 用例描述:用户不能使用已存在的用户名或邮箱进行注册。
    • 前置条件:用户已注册。
    • 测试步骤
      1. 发送POST请求到/api/auth/register,提供已存在的用户名或邮箱。
      2. 验证响应状态码为400。
      3. 验证响应消息为"User already exists"。
    • 预期结果:注册失败,返回400状态码和错误消息。
  2. 用户登录 - 错误密码

    • 用例描述:用户不能使用错误的密码登录。
    • 前置条件:用户已注册。
    • 测试步骤
      1. 发送POST请求到/api/auth/login,提供有效的邮箱和错误的密码。
      2. 验证响应状态码为400。
      3. 验证响应消息为"Invalid credentials"。
    • 预期结果:登录失败,返回400状态码和错误消息。
  3. 获取用户信息 - 无效JWT token

    • 用例描述:用户不能使用无效的JWT token获取用户信息。
    • 前置条件:无。
    • 测试步骤
      1. 发送GET请求到/api/user/profile,在请求头中提供无效的JWT token。
      2. 验证响应状态码为401。
      3. 验证响应消息为"Token is not valid"。
    • 预期结果:获取用户信息失败,返回401状态码和错误消息。
  4. 更新用户信息 - 无效JWT token

    • 用例描述:用户不能使用无效的JWT token更新用户信息。
    • 前置条件:无。
    • 测试步骤
      1. 发送PUT请求到/api/user/profile,在请求头中提供无效的JWT token,并在请求体中提供新的用户名和邮箱。
      2. 验证响应状态码为401。
      3. 验证响应消息为"Token is not valid"。
    • 预期结果:更新用户信息失败,返回401状态码和错误消息。
  5. 修改密码 - 当前密码错误

    • 用例描述:用户不能使用错误的当前密码修改密码。
    • 前置条件:用户已注册并登录。
    • 测试步骤
      1. 发送PUT请求到/api/user/reset/password,在请求头中提供JWT token,并在请求体中提供错误的当前密码和新密码。
      2. 验证响应状态码为400。
      3. 验证响应消息为"Current password is incorrect"。
    • 预期结果:修改密码失败,返回400状态码和错误消息。
  6. 用户注销 - 无效JWT token

    • 用例描述:用户不能使用无效的JWT token注销登录。
    • 前置条件:无。
    • 测试步骤
      1. 发送POST请求到/api/user/logout,在请求头中提供无效的JWT token。
      2. 验证响应状态码为401。
      3. 验证响应消息为"Token is not valid"。
    • 预期结果:注销登录失败,返回401状态码和错误消息。
  7. 管理员升级普通用户为管理员 - 非超级管理员操作

    • 用例描述:非超级管理员不能将普通用户升级为管理员。
    • 前置条件:普通管理员或普通用户已登录并获取到JWT token。
    • 测试步骤
      1. 发送PUT请求到/api/user/upgrade2admin,在请求头中提供普通管理员或普通用户的JWT token,并在请求体中提供要升级的用户ID。
      2. 验证响应状态码为403。
      3. 验证响应消息为"Access denied"。
    • 预期结果:升级用户为管理员失败,返回403状态码和错误消息。

这些测试用例涵盖了用户管理模块中注册、登录、获取用户信息、更新用户信息、修改密码、注销登录、以及管理员升级用户的正向功能和边界情景。通过执行这些测试,可以确保用户管理模块的功能和安全性。

测试结果

 npm test

> ore@0.0.1 test
> ts-node-dev tests/**.test.ts

[INFO] 13:14:50 ts-node-dev ver. 2.0.0 (using ts-node ver. 10.9.2, typescript ver. 5.5.3)
---------------------测试开始---------------------

测试 1: √ 成功 √
期望: {
  url: '/auth/register',
  method: 'post',
  data: {
    username: 'user',
    email: 'user@example.com',
    password: 'password123'
  },
  expectedStatusCode: 201,
  expectedData: { message: 'User registered successfully' }
}
------------------------------
测试 2: √ 成功 √
期望: {
  url: '/auth/login',
  method: 'post',
  data: { email: 'user@example.com', password: 'password123' },
  expectedStatusCode: 200,
  expectedData: { message: 'Login successful' }
}
------------------------------
测试 3: √ 成功 √
期望: {
  url: '/auth/register',
  method: 'post',
  data: {
    username: 'user',
    email: 'user@example.com',
    password: 'password123'
  },
  expectedStatusCode: 400,
  expectedData: { message: 'User already exists' }
}
------------------------------
测试 4: × 错误 ×
期望: {
  url: '/user/profile',
  method: 'get',
  headersFunc: [AsyncFunction: headersFunc],
  expectedStatusCode: 200,
  expectedData: { username: 'user', email: 'user@example.com', role: 'user' }
}
预期状态码: 200, 实际状态码: 200
预期数据: { username: 'user', email: 'user@example.com', role: 'user' }
实际数据: {
  _id: '668f57f4e1d9729fc7847182',
  username: 'user',
  email: 'user@example.com',
  role: 'user',
  __v: 0
}
详细差异: [
  {
    "_id": "668f57f4e1d9729fc7847182",
    "username": "user",
    "email": "user@example.com",
    "role": "user",
    "__v": 0
  }
]
------------------------------
测试 5: × 错误 ×
期望: {
  url: '/user/profile',
  method: 'put',
  data: { username: 'newuser', email: 'newuser@example.com' },
  headersFunc: [AsyncFunction: headersFunc],
  expectedStatusCode: 200,
  expectedData: {
    message: 'User updated successfully',
    user: { username: 'newuser', email: 'newuser@example.com', role: 'user' }
  }
}
预期状态码: 200, 实际状态码: 200
预期数据: {
  message: 'User updated successfully',
  user: { username: 'newuser', email: 'newuser@example.com', role: 'user' }
}
实际数据: {
  message: 'User updated successfully',
  user: {
    _id: '668f57f4e1d9729fc7847182',
    username: 'newuser',
    email: 'newuser@example.com',
    password: '$2a$10$XxU/Gm.lEv700Rt9t5yCguUVx4BWZpBDPTKLqe2OpDq9jcoBZsdcu',
    role: 'user',
    __v: 0
  }
}
详细差异: [
  {
    "message": "User updated successfully",
    "user": {
      "_id": "668f57f4e1d9729fc7847182",
      "username": "newuser",
      "email": "newuser@example.com",
      "password": "$2a$10$XxU/Gm.lEv700Rt9t5yCguUVx4BWZpBDPTKLqe2OpDq9jcoBZsdcu",
      "role": "user",
      "__v": 0
    }
  }
]
------------------------------
测试 6: √ 成功 √
期望: {
  url: '/user/reset/password',
  method: 'put',
  data: { currentPassword: 'password123', newPassword: 'newpassword123' },
  headersFunc: [AsyncFunction: headersFunc],
  expectedStatusCode: 200,
  expectedData: { message: 'Password changed successfully' }
}
------------------------------
测试 7: √ 成功 √
期望: {
  url: '/user/logout',
  method: 'post',
  headersFunc: [AsyncFunction: headersFunc],
  expectedStatusCode: 200,
  expectedData: { message: 'User logged out successfully' }
}
------------------------------
测试 8: × 错误 ×
期望: {
  url: '/user/upgrade2admin',
  method: 'put',
  data: { userId: 'xxx' },
  headersFunc: [AsyncFunction: headersFunc],
  expectedStatusCode: 200,
  expectedData: { message: 'User upgraded to admin successfully' }
}
预期状态码: 200, 实际状态码: 500
预期数据: { message: 'User upgraded to admin successfully' }
实际数据: { error: 'Server error' }
详细差异: [
  {
    "error": "Server error"
  }
]
------------------------------
测试 9: √ 成功 √
期望: {
  url: '/user/profile',
  method: 'get',
  headers: { Authorization: 'Bearer invalid_token' },
  expectedStatusCode: 401,
  expectedData: { error: 'Token is not valid' }
}
------------------------------
测试 10: √ 成功 √
期望: {
  url: '/user/profile',
  method: 'put',
  data: { username: 'anotheruser', email: 'anotheruser@example.com' },
  headers: { Authorization: 'Bearer invalid_token' },
  expectedStatusCode: 401,
  expectedData: { error: 'Token is not valid' }
}
------------------------------
测试 11: √ 成功 √
期望: {
  url: '/user/reset/password',
  method: 'put',
  data: { currentPassword: 'wrongpassword', newPassword: 'newpassword123' },
  headersFunc: [AsyncFunction: headersFunc],
  expectedStatusCode: 400,
  expectedData: { error: 'Current password is incorrect' }
}
------------------------------
测试 12: √ 成功 √
期望: {
  url: '/user/logout',
  method: 'post',
  headers: { Authorization: 'Bearer invalid_token' },
  expectedStatusCode: 401,
  expectedData: { error: 'Token is not valid' }
}
------------------------------
测试 13: √ 成功 √
期望: {
  url: '/user/upgrade2admin',
  method: 'put',
  data: { userId: 'user_id_to_upgrade' },
  headersFunc: [AsyncFunction: headersFunc],
  expectedStatusCode: 403,
  expectedData: { error: 'Access denied' }
}
------------------------------

---------------------测试结束---------------------

问题总结与解决方案

在接口测试结果中,我们发现了三个问题,升级为管理员的这个是因为用户id填写错误先略过。目前,我们将重点解决错误4和错误5。接口处理逻辑是正确的,但输出结果不符合预期。因此,我们需要统一处理输出字段。

新增辅助函数

src/utils/dict.ts 文件中新增一个辅助函数 extractKeysFromObject,该函数用于从输入对象中提取指定键的键值对,并返回一个新的对象。

// src/utils/dict.ts

/* eslint-disable @typescript-eslint/no-explicit-any */
/**
 * 从输入对象中提取指定键的键值对,返回新的对象。
 * @param inputObject - 输入对象
 * @param keysToExtract - 要提取的键的数组
 * @returns 包含提取键值对的新对象
 */
export function extractKeysFromObject(
  inputObject: Record<string, any>,
  keysToExtract: string[]
): Record<string, any> {
  // 创建一个空对象来存储提取的键值对
  const extractedObject: Record<string, any> = {};

  // 遍历 keysToExtract 数组
  keysToExtract.forEach((key) => {
    // 检查输入对象是否包含指定的键
    if (Object.prototype.hasOwnProperty.call(inputObject, key)) {
      // 如果包含,将键值对添加到提取的对象中
      extractedObject[key] = inputObject[key];
    }
  });

  // 返回包含提取键值对的新对象
  return extractedObject;
}

更新控制器函数

src/controllers/userController.ts 文件中,我们使用 extractKeysFromObject 函数来处理返回的字段。

import { Request, Response } from "express";
import UserModel from "../models/UserModel";
import { extractKeysFromObject } from "../utils/dict";

const userFields = ["username", "email", "role"];

export const getUserProfile = async (req: Request, res: Response) => {
  try {
    const user = await UserModel.findById(req.user?.userId).select("-password");
    if (!user) {
      return res.status(404).json({ error: "User not found" });
    }
    const filteredUser = extractKeysFromObject(user.toObject(), userFields);
    res.status(200).json(filteredUser);
  } catch (error) {
    res.status(500).json({ error: "Server error" });
  }
};

export const updateUserProfile = async (req: Request, res: Response) => {
  const { username, email } = req.body;

  try {
    const user = await UserModel.findById(req.user?.userId);
    if (!user) {
      return res.status(404).json({ error: "User not found" });
    }

    user.username = username || user.username;
    user.email = email || user.email;

    await user.save();
    const filteredUser = extractKeysFromObject(user.toObject(), userFields);
    res.status(200).json({ message: "User updated successfully", user: filteredUser });
  } catch (error) {
    res.status(500).json({ error: "Server error" });
  }
};

export const changePassword = async (req: Request, res: Response) => {
  const { currentPassword, newPassword } = req.body;

  try {
    const user = await UserModel.findById(req.user?.userId);
    if (!user) {
      return res.status(404).json({ error: "User not found" });
    }

    const isMatch = await user.comparePassword(currentPassword);
    if (!isMatch) {
      return res.status(400).json({ error: "Current password is incorrect" });
    }

    user.password = newPassword;
    await user.save();
    res.status(200).json({ message: "Password changed successfully" });
  } catch (error) {
    res.status(500).json({ error: "Server error" });
  }
};

export const logout = async (req: Request, res: Response) => {
  // 清除客户端保存的JWT Token
  res.status(200).json({ message: "User logged out successfully" });
};

export const upgradeToAdmin = async (req: Request, res: Response) => {
  const { userId } = req.body;

  try {
    const user = await UserModel.findById(userId);
    if (!user) {
      return res.status(404).json({ error: "User not found" });
    }

    user.role = "admin";
    await user.save();
    const filteredUser = extractKeysFromObject(user.toObject(), userFields);
    res.status(200).json({ message: "User upgraded to admin successfully", user: filteredUser });
  } catch (error) {
    res.status(500).json({ error: "Server error" });
  }
};

解释

  1. 引入 extractKeysFromObject 函数:我们在每个需要过滤返回字段的地方都使用这个函数。
  2. 定义 userFields:我们定义一个包含需要返回字段的数组 userFields,并在每个控制器函数中使用它。
  3. 调用 extractKeysFromObject:在获得用户数据后,我们将其转换为普通对象 (user.toObject()),然后调用 extractKeysFromObject 函数来提取指定字段。

通过这种方式,我们可以灵活地控制返回的数据字段,确保返回的数据结构与预期一致。

总结

本文详细探讨了如何在一个基于Node.js和TypeScript的Web应用中实现用户注册与登录、JWT认证与授权、RBAC权限管理、用户信息管理、密码管理、用户退出、管理员脚本创建以及管理员升级普通用户为管理员的接口等功能

在后续第4章中,我们将通过实际代码示例,展示如何从零开始实现文件上传和下载功能。我们将介绍如何配置和使用multer处理文件上传,并实现支持分片上传和断点续传的高级功能。此外,我们还将编写高效的文件下载接口,优化文件读取和传输过程,确保用户体验的流畅和系统的稳定。通过本章的学习,读者将能够掌握文件处理的核心技术,应用于各种实际项目中。

转载自:https://juejin.cn/post/7389955238058672155
评论
请登录