使用 Express.js (Node.js) 构建登录和注销 API

2025-05-25

使用 Express.js (Node.js) 构建登录和注销 API

目录

  • 介绍
  • 身份验证和授权的概念
    • 验证
    • 授权
  • 设置开发文件、安装所需的包并创建数据库
  • 创建主路由和 API 服务器实例
    • 创建主路线
    • 为您的 API 创建服务器实例
  • 身份验证逻辑
  • 授权逻辑
  • 注销逻辑

介绍

我开始编写服务器代码时遇到的首要挑战之一是如何构建一个安全且简单的身份验证和授权 (AA) 流程,并且不依赖第三方服务。感谢像StackOverflow这样的技术社区和平台,让我获得了更深入的理解。

请注意,在应用中实现 AA 系统时,有更好的技术可供使用。如今,您无需自己编写 AA 代码,从而可以专注于应用的业务逻辑。

在本文中,您将学习身份验证和授权的基础知识,以及如何使用 ExpressJS 构建登录和注销系统。我选择 ExpressJS 作为本教程的语言,因为它灵活且学习曲线简单。

先决条件

  • Node(ExpressJS)后端开发基础知识
  • NOSQL 的基础知识 - Mongoose 或 MongoDB
  • JSON Web Tokens(JWT)的基础知识
  • 关于 Cookie 工作原理的基本知识

克隆项目存储库- https://github.com/mjosh51/authentication-authorization-expressjs-tutorial-starterfiles(可选)

在开始编写代码之前,我们先回顾一下一些理论。听起来不太好?没错,我也是实践爱好者。不过,我们先来定义几个概念。

身份验证和授权的概念

在本节中,您将简要了解什么是身份验证和授权,以及它们的区别。

验证

身份验证是核实用户身份的过程。本质上,它意味着确保用户的身份与其声称的身份相符。

您可以在实施身份验证时使用以下一种或多种方法:

  1. 一个人知道什么(密码或密码短语)。
  2. 一个人拥有的东西(一次性令牌或物理设备)。
  3. 人是什么(生物识别、指纹读取器、面部识别)。

授权

授权遵循身份验证。它确保登录用户有权执行特定操作或查看特定数据。例如,用户可能有权通过 Web 界面查看其个人信息,但不应被允许查看其他用户的数据。如果他只是普通用户,也不应该拥有管理功能的访问权限。如果用户能够通过更改参数或 ID 访问其他用户的帐户,则表明身份验证/授权流程存在缺陷。您可以访问https://learn.microsoft.com/en-us/aspnet/web-api/overview/security/authentication-and-authorization-in-aspnet-web-api了解更多信息。

现在,这些理论已经足够让我们开始行动了。


设置开发文件、安装所需的包并创建数据库。

在这里,您将设置您的开发文件,安装依赖项并创建一个 Mongo 数据库。

如果您已经从https://github.com/mjosh51/authentication-authorization-expressjs-tutorial-starterfiles克隆了启动文件,则可以跳过第一步和第二步,而是在项目文件夹终端中键入以安装所需的软件包。npm i

第一步

  • 为你的项目创建一个文件夹,并使用你最喜欢的代码编辑器打开它。在本教程中,我将使用 VSCode。
  • 在终端中输入npm init. (假设你的机器上安装了 node)
  • 对提示做出适当的回应。
  • 在项目根目录下创建一个文件夹,命名为“v1”。注意,创建文件夹是可选的,它只是用来组织应用的版本。其他可以使用的名称包括“app”和“src”……
  • 在您的 package.json 文件中,在“scripts”下,添加一个 dev 和/或 start 命令以使您能够启动您的 API。
  • "dev": "nodemon v1/server.js",在 package.json 文件的“scripts”下添加
  • 添加"type": "modules"到您的 package.json 文件中,使用 import 语句而不是 require。

第二步
安装所需的软件包:在项目目录终端中输入,

  • npm install express mongoose bcrypt cookie-parser dotenv cors express-validator jsonwebtoken
  • npm install --save-dev nodemon. (安装 Nodemon 作为开发依赖项)

现在您已经安装了本教程所需的工具。

简单解释一下您刚刚安装的一些工具

  • ExpressJS是一个最小且灵活的 Node.js Web 应用程序框架,它为 Web 和移动应用程序提供了一组强大的功能。https ://expressjs.com
  • Mongoose是一个适用于 MongoDB 和 Node.js 的对象数据建模 (ODM) 库。它管理数据之间的关系,提供模式验证,并用于在代码中的对象与 MongoDB 中的对象表示之间进行转换。https ://mongoosejs.com/docs/index.html
  • Bcrypt是一个帮助你哈希密码的库。它使用基于 Blowfish 密码的密码哈希函数。我们将使用它来哈希诸如密码之类的敏感信息。
  • Cookie-parser是一个中间件,用于解析 Cookie 头,并使用以 Cookie 名称为键的对象填充 req.cookies。您也可以选择通过传递一个 secret 字符串来启用签名 Cookie 支持,该字符串会将 req.secret 赋值给其他中间件,以便其他中间件可以使用。
  • Dotenv是一个零依赖模块,它将环境变量从 .env 文件加载到 process.env 中。
  • CORS是一个 node.js 包,用于提供 Connect/Express 中间件,可用于通过各种选项启用 CORS。由于我们只开发 API,因此并不一定需要它,不过,如果您需要实现 CORS,了解一下还是不错的。
  • Express-Validator是一组 express.js 中间件,封装了 validator.js 的验证器和清理函数。您可能需要检查请求体是否为空,或者验证甚至清理请求体。这个包非常适合这类需求。您稍后会在代码中添加一两个它的函数。
  • Nodemon是一款帮助开发基于 Node.js 应用程序的工具,当检测到目录中的文件更改时,它会自动重启 Node 应用程序。有了它,您无需在每次更改文件时手动重启应用程序。另一个在生产环境中使用最广泛的强大工具是 pm2。您可以了解一下pm2

第三步:
从 MongoDB 或你选择的数据库 获取URI 字符串。在本教程中,我使用 Mongoose。

  • 在MongoDB上创建一个帐户
  • 创建数据库。

完成后,它看起来应该是这样的,

数据库已创建

  • 点击“连接”
  • 将连接字符串复制到 .env 文件中作为 URI 的值。

现在,开始编码。


创建主路由和 API 服务器实例

在本节中,您将为主应用和其他小应用创建路由。在这里,将应用视为一条路由。这意味着您的 API 将有一个主路由,用于路由其他小应用。

路由是代码中处理 HTTP 请求(例如 GET、POST、DELETE、PUT 以及相关函数)的一部分。主路由就像 API 的欢迎页面。您可能已经知道,API 是客户端应用和数据库之间的中间人。

创建主路线

要创建主路线:

  • 在您的 v1 文件夹中创建一个名为“routes”的文件夹。
  • 在路线文件夹中创建一个文件,将其命名为“index.js”(不带引号)。
  • 创建主路由函数。它将服务器实例作为其参数。
  • 接下来编写首页路线逻辑。
  • 默认导出路由功能。

检查 GitHub 仓库中的启动文件,确认文件夹数量一致。注意:目前可以忽略 utils 文件夹。

您的代码应如下所示:

文件路径- v1/routes/index.js




const Router = (server) => {
// home route with the get method and a handler
server.get("/v1", (req, res) => {
    try {
        res.status(200).json({
            status: "success",
            data: [],
            message: "Welcome to our API homepage!",
        });
    } catch (err) {
        res.status(500).json({
            status: "error",
            message: "Internal Server Error",
        });
    }
})
};
export default Router;


Enter fullscreen mode Exit fullscreen mode

请注意,在上面的代码中,我们创建了一个处理程序,用于处理应用中的各种路由逻辑。它以服务器实例作为参数。稍后您将看到我们从哪里获取该实例。

创建环境和配置文件

另一个需要创建的重要文件是 env 文件。env 文件存储了您不希望暴露的密钥字符串的键值对。您可以将 API 密钥、密钥和令牌存储在其中,或者使用更安全的解决方案。

运行命令npm run env,使用启动文件中的 .env.example 文件模板创建一个 env 文件。

否则,

  • 在项目的根文件夹中创建一个新文件并将其命名为“.env”。
  • 复制并粘贴以下内容


#DATABASE_STRING
URI=xxxxxxx
#SERVER_PORT
PORT=5005
#TOKEN
SECRET_ACCESS_TOKEN=xxxxx


Enter fullscreen mode Exit fullscreen mode

您稍后会更改一些值。

您也可以创建一个配置文件(简称 config 文件)。它用于将环境变量组织到一个地方,方便使用。

为了做到这一点,

  • 在 v1 文件夹中创建一个文件夹,并将其命名为“config”
  • 在之前创建的文件夹中创建一个名为“index.js”的文件
  • 导入 dotenv 模块,并调用其 config 方法
  • 通过使用键来解构环境变量
  • 导出解构变量

您的文件应该如下所示,

文件路径- v1/config/index.js



import * as dotenv from "dotenv";
dotenv.config();

const { URI, PORT, SECRET_ACCESS_TOKEN } = process.env;

export { URI, PORT, SECRET_ACCESS_TOKEN };


Enter fullscreen mode Exit fullscreen mode

为您的 API 创建服务器实例

为了简单起见,我们将为主应用程序创建一个服务器实例。

  • 在 v1 文件夹中创建一个名为“server.js”的文件
  • 导入 express、cors、cookieParser、mongoose 模块
  • 从您之前创建的配置文件中导入端口和数据库字符串变量(注意:该文件是命名导出)
  • 从 routes/index.js 导入主应用程序路由
  • 创建服务器对象,例如const server = express()
  • 配置服务器头
  • 连接数据库
  • 将主路由连接到服务器
  • 启动服务器

将您的代码与以下代码进行比较:

文件路径- v1/server.js



import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import mongoose from "mongoose";
import { PORT, URI } from "./config/index.js";
import Router from "./routes/index.js";

// === 1 - CREATE SERVER ===
const server = express();

// CONFIGURE HEADER INFORMATION
// Allow request from any source. In real production, this should be limited to allowed origins only
server.use(cors());
server.disable("x-powered-by"); //Reduce fingerprinting
server.use(cookieParser());
server.use(express.urlencoded({ extended: false }));
server.use(express.json());

// === 2 - CONNECT DATABASE ===
// Set up mongoose's promise to global promise
mongoose.promise = global.Promise;
mongoose.set("strictQuery", false);
mongoose
    .connect(URI, {
        useNewUrlParser: true,
        useUnifiedTopology: true,
    })
    .then(console.log("Connected to database"))
    .catch((err) => console.log(err));

// === 4 - CONFIGURE ROUTES ===
// Connect Route handler to server
Router(server);

// === 5 - START UP SERVER ===
server.listen(PORT, () =>
    console.log(`Server running on http://localhost:${PORT}`)
);


Enter fullscreen mode Exit fullscreen mode

现在是时候启动你的 API 进行测试了。首先,在你的终端中运行以下命令 - npm run dev。你的服务器应该像这样启动:

服务器已启动

使用 Postman 或您常用的 API 测试平台,向http://localhost:5005/v1发送 GET 请求。我使用的是 VSCode 的 REST 客户端扩展。如果一切顺利,您应该会收到类似的响应,例如:

服务器主页 REST 客户端


身份验证逻辑

在这部分代码中,您将编写用于用户身份验证和输入验证的函数和路由。

为此,您需要完成一些任务,它们是:

  • 为您的 API 创建用户模型
  • 为您的 API 创建输入验证
  • 为您的 API 创建身份验证控制器和路由
    • 创建注册逻辑
    • 添加注册路线
    • 创建简单的登录逻辑
    • 添加登录路由
    • 将会话添加到登录逻辑

控制器是处理特定任务的逻辑。路由器接收请求并执行与其关联的控制器逻辑。

创建用户模型

首先,你必须创建用户模型或模式。该模式定义了存储在数据库中的用户配置文件。

要创建架构,

  • 在 v1 文件夹中创建一个名为“models”的文件夹
  • 在模型文件夹中,创建一个名为“User.js”的文件
  • 将以下代码复制到其中,

文件路径- v1/models/User.js



import mongoose from "mongoose";
import bcrypt from "bcrypt";

const UserSchema = new mongoose.Schema(
    {
        first_name: {
            type: String,
            required: "Your firstname is required",
            max: 25,
        },
        last_name: {
            type: String,
            required: "Your lastname is required",
            max: 25,
        },
        email: {
            type: String,
            required: "Your email is required",
            unique: true,
            lowercase: true,
            trim: true,
        },
        password: {
            type: String,
            required: "Your password is required",
            select: false,
            max: 25,
        },
        role: {
            type: String,
            required: true,
            default: "0x01",
        },
    },
    { timestamps: true }
);

UserSchema.pre("save", function (next) {
    const user = this;

    if (!user.isModified("password")) return next();
    bcrypt.genSalt(10, (err, salt) => {
        if (err) return next(err);

        bcrypt.hash(user.password, salt, (err, hash) => {
            if (err) return next(err);

            user.password = hash;
            next();
        });
    });
});

export default mongoose.model("users", UserSchema);


Enter fullscreen mode Exit fullscreen mode

这样,您就拥有了一个用户模型和一个函数,该函数会在用户注册或任何修改密码时对其进行哈希处理,然后再将其保存到数据库中。您肯定不希望将明文密码保存到数据库中。请注意,在前面的代码块中,我们为每个在应用上注册的用户添加了一个默认角色。我们将在本文后面通过编辑角色代码来手动将用户更改为管理员。

接下来,让我们编写一个函数来验证用户输入。

创建输入验证

验证用户输入是否存在任何错误信息或恶意尝试非常重要。为了确认用户提供的信息正确,

  • 在 v1 文件夹中创建一个名为“middleware”的文件夹。中间件是一个可以访问请求和响应对象的处理程序。它可以用来修改请求或响应。
  • 在中间件文件夹中创建“validate.js”文件
  • 从 express-validator导入validationResult。validationResult 将返回错误数组(如果有)。
  • 编写一个函数来验证请求对象并返回错误数组。

您的代码应类似于以下内容:

文件路径- v1/middleware/validate.js



import { validationResult } from "express-validator";

const Validate = (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        let error = {};
        errors.array().map((err) => (error[err.param] = err.msg));
        return res.status(422).json({ error });
    }
    next();
};
export default Validate;


Enter fullscreen mode Exit fullscreen mode

创建身份验证控制器和路由

您将为注册和登录创建身份验证(auth)逻辑以及它们的路由。

创建注册逻辑

要创建处理用户注册的身份验证逻辑,请完成以下任务:

  • 在 v1 文件夹中创建一个名为“controllers”的文件夹
  • 在 controllers 文件夹中创建一个名为“auth.js”的文件
  • 导入用户模型
  • 编写一个接受请求和响应对象的 Register 函数
  • 解构 HTTP 请求主体以获取用户提供的信息,例如电子邮件、密码、名字、姓氏等。
  • 在 try-and-catch 块中,使用请求的主体信息创建用户实例
  • 检查所提供的电子邮件是否已存在于数据库中。如果存在,则向客户端发送一条消息,通知其错误。否则,将新用户保存到数据库中,并发送相应的服务器响应。

您的代码应类似于以下内容,

文件路径- v1/controllers/auth.js



import User from "../models/User.js";

/**
 * @route POST v1/auth/register
 * @desc Registers a user
 * @access Public
 */
export async function Register(req, res) {
    // get required variables from request body
    // using es6 object destructing
    const { first_name, last_name, email, password } = req.body;
    try {
        // create an instance of a user
        const newUser = new User({
            first_name,
            last_name,
            email,
            password,
        });
        // Check if user already exists
        const existingUser = await User.findOne({ email });
        if (existingUser)
            return res.status(400).json({
                status: "failed",
                data: [],
                message: "It seems you already have an account, please log in instead.",
            });
        const savedUser = await newUser.save(); // save new user into the database
        const { role, ...user_data } = savedUser._doc;
        res.status(200).json({
            status: "success",
            data: [user_data],
            message:
                "Thank you for registering with us. Your account has been successfully created.",
        });
    } catch (err) {
        res.status(500).json({
            status: "error",
            code: 500,
            data: [],
            message: "Internal Server Error",
        });
    }
    res.end();
}


Enter fullscreen mode Exit fullscreen mode

很简单吧?没错,就是这么简单。

前往您的路由文件夹,您需要为身份验证逻辑包含一个路由。

添加注册路线

  • 在你的路由文件夹中,创建另一个名为“auth.js”的文件
  • 导入 express 模块
  • 从“controllers/auth.js”导入注册逻辑
  • 从 express-validator 导入检查函数
  • 从中间件导入 Validate 函数
  • 使用 POST 方法和“/register”路径编写注册路由。注意,由于我们想要获取用户输入并将其保存到数据库中,因此我们将使用 POST 方法。了解更多关于HTTP 请求方法的信息。您可能想知道POST 和 PUT 方法之间的区别。
  • 使用检查和验证函数验证输入
  • 导出路由器作为默认路由器

将您的代码与以下内容进行比较:

文件路径- v1/routes/auth.js



import express from "express";
import { Register } from "../controllers/auth.js";
import Validate from "../middleware/validate.js";
import { check } from "express-validator";

const router = express.Router();

// Register route -- POST request
router.post(
    "/register",
    check("email")
        .isEmail()
        .withMessage("Enter a valid email address")
        .normalizeEmail(),
    check("first_name")
        .not()
        .isEmpty()
        .withMessage("You first name is required")
        .trim()
        .escape(),
    check("last_name")
        .not()
        .isEmpty()
        .withMessage("You last name is required")
        .trim()
        .escape(),
    check("password")
        .notEmpty()
        .isLength({ min: 8 })
        .withMessage("Must be at least 8 chars long"),
    Validate,
    Register
);

export default router;


Enter fullscreen mode Exit fullscreen mode

现在,再次前往你的路线文件夹,并在index.js文件内执行以下操作,

  • 导入 Auth 路由

import Auth from './auth.js';

  • 调用use()应用程序对象上的方法并传递定义的路由路径

app.use('/v1/auth', Auth);

注册响应

您的 API 已成功注册用户。如果您不想返回密码,可以像这样调整 auth.js 文件(控制器):

文件路径- v1/controllers/auth.js



...
const savedUser = await newUser.save(); // save new user into the database
    const { password, role, ...user_data } = savedUser; // Return user's details but password
    res.status(200).json({
      status: 'success',
      data: [user_data],
      message:
        'Thank you for registering with us. Your account has been successfully created.',
    });
...


Enter fullscreen mode Exit fullscreen mode

接下来,您将学习如何为您的应用程序编写登录逻辑。

创建简单的登录逻辑

要创建用户登录的授权,请执行以下操作,

  • 导航到控制器文件夹内的“auth.js”文件
  • 导入 bcrypt 包(这将用于将用户登录时提供的密码与您在数据库中与用户电子邮件关联的密码进行比较)
  • 编写一个函数,首先检查请求主体中的电子邮件是否可以在数据库中找到。如果找不到,则返回一条明确指出错误的消息。否则,继续验证密码,将请求主体中的密码与存储的密码进行比较。如果结果无效,则相应地返回错误;否则,返回一条消息告知用户登录成功。

现在,将您的代码与以下代码块进行比较。

文件路径- v1/controllers/auth.js



import bcrypt from "bcrypt";

/**
 * @route POST v1/auth/login
 * @desc logs in a user
 * @access Public
 */
export async function Login(req, res) {
    // Get variables for the login process
    const { email } = req.body;
    try {
        // Check if user exists
        const user = await User.findOne({ email }).select("+password");
        if (!user)
            return res.status(401).json({
                status: "failed",
                data: [],
                message:
                    "Invalid email or password. Please try again with the correct credentials.",
            });
        // if user exists
        // validate password
        const isPasswordValid = await bcrypt.compare(
            `${req.body.password}`,
            user.password
        );
        // if not valid, return unathorized response
        if (!isPasswordValid)
            return res.status(401).json({
                status: "failed",
                data: [],
                message:
                    "Invalid email or password. Please try again with the correct credentials.",
            });
        // return user info except password
        const { password, ...user_data } = user._doc;

        res.status(200).json({
            status: "success",
            data: [user_data],
            message: "You have successfully logged in.",
        });
    } catch (err) {
        res.status(500).json({
            status: "error",
            code: 500,
            data: [],
            message: "Internal Server Error",
        });
    }
    res.end();
}


Enter fullscreen mode Exit fullscreen mode

默认情况下,mongoose 会在每次查询该文档时返回用户的密码,但我已在 User.js 模型中使用 禁用了此功能‘select: false’。这样,我只需在需要时调用即可。在您的登录逻辑中,您需要数据库中的用户密码,并将其与用户尝试登录时使用的密码进行比较。如果两者相同,您就可以说您认识该用户并允许他登录。

创建登录路由

在你的路由的 auth.js 文件中,

  • 导入登录功能
  • 添加登录路由

比较你的代码:

文件路径- v1/routes/auth.js



// Login route == POST request
router.post(
    "/login",
    check("email")
        .isEmail()
        .withMessage("Enter a valid email address")
        .normalizeEmail(),
    check("password").not().isEmpty(),
    Validate,
    Login
);


Enter fullscreen mode Exit fullscreen mode

发送 POST 请求到http://localhost:5005/v1/auth/login

登录响应

将会话添加到登录逻辑

到目前为止,您可以将用户登录到您的应用中。但是等等,每次请求时都需要重新验证用户身份吗?HTTP 是无状态的,因此服务器会将每个请求视为一个新请求。为了解决这个问题,您必须找到一种方法来让服务器知道特定请求来自同一个客户端。幸运的是,请求标头可以解决这个问题。请求标头包含有关客户端或所请求资源的信息。

对于每个请求,请求标头都会发送到服务器。您会发现,有些标头会随请求保留,而有些则不会。在本指南中,我们将使用 Cookie 标头。这意味着,如果您希望服务器信任客户端,则可以使用 Cookie。

Cookie可以用于多种用途。它们可以用于身份验证。Cookie 还允许您指定身份验证的持续时间。

在实际应用中,您需要允许服务器为客户端提供“会话”。该会话包含一个身份令牌和一个有效期(有效期过后,该令牌将被视为无效)。服务器会在每次客户端发出请求时,在请求头中查找此会话,如果找到且有效期在有效期内,则对客户端进行身份验证;否则,则拒绝身份验证。

在本文中,我们的服务器将使用 JSON Web Token (JWT) 为客户端提供身份令牌。我们将在每次登录时生成一个令牌,并将其设置为 Cookie 标头的值。

Json Web Tokens是一种开放的、行业标准的 RFC 7519 方法,用于在双方之间安全地表示声明。

现在,要将会话添加到您的登录逻辑,请按照以下步骤操作:

  • 创建密钥
  • 创建一个在登录时生成令牌的函数
  • 在登录逻辑中添加generate-token函数

创建密钥

JWT 技术允许您生成、解码和验证令牌。它对令牌进行数字签名,以确保其可信。JWT 可以使用密钥(采用 HMAC 算法)或使用 RSA 或 ECDSA 的公钥/私钥对进行签名。您可以在此处了解更多关于 JWT 的信息。JWT 通过检查签名来验证令牌。

要对令牌进行签名,请创建一个只有您的服务器知道的唯一密钥。您可以使用节点生成一个随机密钥。为此,

  • node在终端中输入,按回车键,
  • 类型crypto.randomBytes(20).toString(‘hex’)

对我来说,我得到了类似的东西,

访问令牌

  • 复制该值并将其粘贴到您的 .env 文件中作为 SECRET_ACCESS_TOKEN 的值

创建一个在登录时生成令牌的函数

要创建一个在登录时生成并签名令牌的函数,

  • 转到您的用户模型文件(models/User.js)
  • 导入 JWT 模块
  • 从配置文件导入密钥
  • 在 pre-hook 函数后添加一个 generate-token 函数

检查以下代码:

文件路径- v1/models/User.js



...
import jwt from 'jsonwebtoken';
import { SECRET_ACCESS_TOKEN } from '../config/index.js';
...


Enter fullscreen mode Exit fullscreen mode


...
UserSchema.methods.generateAccessJWT = function () {
  let payload = {
    id: this._id,
  };
  return jwt.sign(payload, SECRET_ACCESS_TOKEN, {
    expiresIn: '20m',
  });
};


Enter fullscreen mode Exit fullscreen mode

我们在 UserSchema 方法中添加了一个名为“generateAccessJWT”的新函数。该函数使用密钥对用户 ID 进行签名,并生成一个唯一的令牌,该令牌的有效期为 20 分钟。

现在您必须generateAccessJWT在登录函数中使用该函数(现在是 UserSchema 方法之一)。

在登录逻辑中添加generate-token函数

一旦用户提供了电子邮件和密码,并且 API 确认详细信息正确,我们就会为该用户生成一个唯一的令牌,并将生成的令牌发送给客户端。

检查以下代码:

文件路径- v1/controllers/auth.js



/**
 * @route POST v1/auth/login
 * @desc logs in a user
 * @access Public
 */
export async function Login(req, res) {
    // Get variables for the login process
    const { email } = req.body;
    try {
        // Check if user exists
        const user = await User.findOne({ email }).select("+password");
        if (!user)
            return res.status(401).json({
                status: "failed",
                data: [],
                message: "Account does not exist",
            });
        // if user exists
        // validate password
        const isPasswordValid = await bcrypt.compare(
            `${req.body.password}`,
            user.password
        );
        // if not valid, return unathorized response
        if (!isPasswordValid)
            return res.status(401).json({
                status: "failed",
                data: [],
                message:
                    "Invalid email or password. Please try again with the correct credentials.",
            });

        let options = {
            maxAge: 20 * 60 * 1000, // would expire in 20minutes
            httpOnly: true, // The cookie is only accessible by the web server
            secure: true,
            sameSite: "None",
        };
        const token = user.generateAccessJWT(); // generate session token for user
        res.cookie("SessionID", token, options); // set the token to response header, so that the client sends it back on each subsequent request
        res.status(200).json({
            status: "success",
            message: "You have successfully logged in.",
        });
    } catch (err) {
        res.status(500).json({
            status: "error",
            code: 500,
            data: [],
            message: "Internal Server Error",
        });
    }
    res.end();
}


Enter fullscreen mode Exit fullscreen mode

相同的登录功能,只是我们添加了在凭证确认后生成会话的功能。每当客户端成功登录时,都会生成一个新的会话。

我们现在有一个可以验证凭证并生成唯一会话的身份验证系统。


授权逻辑

让我们实现一个简单的授权流程。记住,授权是确保用户只能在应用上执行我们允许的操作。

我创建了另一个名为 Admin 的用户。我将手动将其角色升级为管理员(管理员的角色代码为 0x88),正如您可能猜到的那样,也就是 136。在生产环境中,您应该使用更复杂的代码来分配角色。

我以管理员身份登录,

管理员登录响应

接下来,您需要添加中间件函数来告诉您的 API 验证会话并验证角色或访问权限。

首先,在中间件文件夹中创建一个文件,并将其命名为“verify.js”。此文件将用于验证会话和角色。

验证会话

您的 API 必须判断会话是否有效。出于安全考虑,每次向受保护路由发出请求时,API 都必须执行此操作。

要验证用户会话,

  • 在 verify.js 文件中,导入 User 模型,
  • 导入 JWT
  • 编写验证函数
  • 创建用户路由
  • 将验证中间件添加到用户路由

检查以下代码:



import User from "../models/User.js";
import jwt from "jsonwebtoken";

export async function Verify(req, res, next) {
    try {
        const authHeader = req.headers["cookie"]; // get the session cookie from request header

        if (!authHeader) return res.sendStatus(401); // if there is no cookie from request header, send an unauthorized response.
        const cookie = authHeader.split("=")[1]; // If there is, split the cookie string to get the actual jwt

        // Verify using jwt to see if token has been tampered with or if it has expired.
        // that's like checking the integrity of the cookie
        jwt.verify(cookie, config.SECRET_ACCESS_TOKEN, async (err, decoded) => {
            if (err) {
                // if token has been altered or has expired, return an unauthorized error
                return res
                    .status(401)
                    .json({ message: "This session has expired. Please login" });
            }

            const { id } = decoded; // get user id from the decoded token
            const user = await User.findById(id); // find user by that `id`
            const { password, ...data } = user._doc; // return user object without the password
            req.user = data; // put the data object into req.user
            next();
        });
    } catch (err) {
        res.status(500).json({
            status: "error",
            code: 500,
            data: [],
            message: "Internal Server Error",
        });
    }
}


Enter fullscreen mode Exit fullscreen mode

接下来,创建一个用户路由并向其中添加验证中间件。

在 index.js 文件(routes 文件夹)内,添加:

文件路径- v1/routes/index.js



app.get("/v1/user", Verify, (req, res) => {
    res.status(200).json({
        status: "success",
        message: "Welcome to the your Dashboard!",
    });
});


Enter fullscreen mode Exit fullscreen mode

要测试此功能:

  • 向 /v1/user 发送一个 GET 请求,无论是否包含无效的 Cookie。你应该看到一个未授权的响应。为什么?因为 Cookie 无效或不存在。

访问用户仪表板的尝试失败

  • 使用有效用户登录
  • 向用户路由(/v1/user)发送 GET 请求。

如果一切顺利,您应该会看到一条欢迎消息。

授权访问用户仪表板

让我们解释一下我们现在的进展。我们编写了一个登录函数,它接收用户的凭证并与数据库进行校验。然后,它会生成一个用户会话,并以 Cookie 的形式发送给客户端。客户端会在后续发送给服务器的请求中包含这些信息。Verify 中间件指示服务器检查客户端的请求头中是否存在身份验证/授权 Cookie,对其进行解码,并将用户对象赋值给请求对象。如果验证中间件捕获到错误,则不会调用下一个中间件。

接下来,您要添加只有管理员才能访问的管理路线。

为此,首先创建一个验证角色中间件,

文件路径- v1/middleware/verify.js



export function VerifyRole(req, res, next) {
    try {
        const user = req.user; // we have access to the user object from the request
        const { role } = user; // extract the user role
        // check if user has no advance privileges
        // return an unathorized response
        if (role !== "0x88") {
            return res.status(401).json({
                status: "failed",
                message: "You are not authorized to view this page.",
            });
        }
        next(); // continue to the next middleware or function
    } catch (err) {
        res.status(500).json({
            status: "error",
            code: 500,
            data: [],
            message: "Internal Server Error",
        });
    }
}


Enter fullscreen mode Exit fullscreen mode

然后在 index.js 文件(routes 文件夹)中添加:

文件路径- v1/routes/index.js



import { Verify, VerifyRole } from "../middleware/verify.js";

app.get("/v1/admin", Verify, VerifyRole, (req, res) => {
    res.status(200).json({
        status: "success",
        message: "Welcome to the Admin portal!",
    });
});


Enter fullscreen mode Exit fullscreen mode

请注意,上述代码包含 Verify 中间件,然后是 VerifyRole 中间件。该 API 首先验证用户的会话,并返回一个可供该req.user对象访问的用户对象。VerifyRole 中间件会检查该用户对象,以确定用户是否为管理员。如果用户是管理员,则打开管理门户,否则用户将无法查看页面。

要测试此功能:

  • 使用低权限用户登录。(请记住,用户的角色代码默认为 0x01)
  • 向管理路由 (v1/admin) 发送 GET 请求

访问管理门户的尝试失败

您收到了未经授权的响应。

为什么?低权限用户的 Cookie 无法访问管理门户。

  • 现在,以管理员身份登录
  • 向管理路由 (v1/admin) 发送 GET 请求

授权访问管理门户


注销逻辑

最后,您可能想要添加注销功能。为此,您有两个基本选项。一是在注销时将请求的 Cookie 列入黑名单,二是通过向客户端发送无效的 Cookie 来使 Cookie 无效。后者不可取,因为如果之前的 Cookie 在注销前保存在某个地方,它仍然可以用于登录。在这里,您将实现第一种方法。

  • 在模型文件夹中创建另一个文件并将其命名为“Blacklist.js”,该文档将存储任何黑名单令牌。

文件路径- v1/models/Blacklist.js



import mongoose from "mongoose";
const BlacklistSchema = new mongoose.Schema(
    {
        token: {
            type: String,
            required: true,
            ref: "User",
        },
    },
    { timestamps: true }
);
export default mongoose.model("blacklist", BlacklistSchema);


Enter fullscreen mode Exit fullscreen mode

就是这样——注销时,用户的会话令牌将被列入黑名单,即添加到黑名单文档中。现在,每当用户返回访问受保护的路由时,您的 API 都会首先检查他的令牌是否在黑名单中。如果在,则会收到未授权的响应,需要重新进行身份验证;否则,将允许访问。您还可以寻找从客户端清除 Cookie 的方法。

这是一个简单的代码,将其添加到您的 auth.js 文件(控制器文件夹)。

文件路径- v1/controllers/auth.js



...
import Blacklist from '../models/Blacklist.js';
...
/**
 * @route POST /auth/logout
 * @desc Logout user
 * @access Public
 */
export async function Logout(req, res) {
  try {
    const authHeader = req.headers['cookie']; // get the session cookie from request header
    if (!authHeader) return res.sendStatus(204); // No content
    const cookie = authHeader.split('=')[1]; // If there is, split the cookie string to get the actual jwt token
    const accessToken = cookie.split(';')[0];
    const checkIfBlacklisted = await Blacklist.findOne({ token: accessToken }); // Check if that token is blacklisted
    // if true, send a no content response.
    if (checkIfBlacklisted) return res.sendStatus(204);
    // otherwise blacklist token
    const newBlacklist = new Blacklist({
      token: accessToken,
    });
    await newBlacklist.save();
    // Also clear request cookie on client
    res.setHeader('Clear-Site-Data', '"cookies"');
    res.status(200).json({ message: 'You are logged out!' });
  } catch (err) {
    res.status(500).json({
      status: 'error',
      message: 'Internal Server Error',
    });
  }
  res.end();
}


Enter fullscreen mode Exit fullscreen mode

还有一点,在你的验证函数中,检查 cookie 是否被列入黑名单。如果被列入黑名单,则要求用户重新登录;否则,调用下一个函数。

将您的代码与以下内容进行比较:



export async function Verify(req, res, next) {
    const authHeader = req.headers["cookie"]; // get the session cookie from request header

    if (!authHeader) return res.sendStatus(401); // if there is no cookie from request header, send an unauthorized response.
    const cookie = authHeader.split("=")[1]; // If there is, split the cookie string to get the actual jwt token
    const accessToken = cookie.split(";")[0];
    const checkIfBlacklisted = await Blacklist.findOne({ token: accessToken }); // Check if that token is blacklisted
    // if true, send an unathorized message, asking for a re-authentication.
    if (checkIfBlacklisted)
        return res
            .status(401)
            .json({ message: "This session has expired. Please login" });
    // if token has not been blacklisted, verify with jwt to see if it has been tampered with or not.
    // that's like checking the integrity of the accessToken
    jwt.verify(accessToken, SECRET_ACCESS_TOKEN, async (err, decoded) => {
        if (err) {
            // if token has been altered, return a forbidden error
            return res
                .status(401)
                .json({ message: "This session has expired. Please login" });
        }

        const { id } = decoded; // get user id from the decoded token
        const user = await User.findById(id); // find user by that `id`
        const { password, ...data } = user._doc; // return user object but the password
        req.user = data; // put the data object into req.user
        next();
    });
}


Enter fullscreen mode Exit fullscreen mode

不要忘记将注销功能添加到授权路由,就像这样,

文件路径- v1/routes/auth.js



...
// Logout route ==
router.get('/logout', Logout);


Enter fullscreen mode Exit fullscreen mode

如果你不喜欢在 cookies 中看到 JWT,你可以对其进行加密,或者使用 express-session 来替代普通的 JWT。

现在,您的 API 已经能够对用户进行身份验证和授权了。感谢您的阅读,想了解更多,请关注我。

其他链接:https://mjosh.hashnode.dev/build-a-login-and-logout-api-using-expressjs-nodejs

文章来源:https://dev.to/m_josh/build-a-jwt-login-and-logout-system-using-expressjs-nodejs-hd2
PREV
52 个前端面试题 - JavaScript
NEXT
使用 Nextjs 和 ChakraUI 创建专业的作品集网站