Featured image of post 使用React和Stream视频聊天SDK构建远程医疗应用

使用React和Stream视频聊天SDK构建远程医疗应用

本文详细介绍了如何利用React和Stream视频聊天SDK构建一个功能完整的远程医疗应用,包括用户认证、视频通话、实时消息等功能实现。

使用React和Stream视频聊天SDK构建远程医疗应用

还记得COVID-19疫情期间所有事物都转移到线上的情形吗?包括医生问诊在内,居家成为了最安全的选择。这一时刻引发了医疗保健交付方式的重大转变。远程医疗不再只是权宜之计,而成为了现代医疗的核心组成部分。随着需求增长,开发者们正在构建安全、实时的平台,连接来自任何地方的患者和医疗服务提供者。

在本文中,您将学习如何使用Stream的React视频和聊天SDK构建远程医疗应用。您将设置认证、创建视频通话、启用消息功能,并设计一个模拟真实世界远程医疗工作流程的功能性用户界面。

大纲

  • 介绍
  • 前提条件
  • 应用结构
  • 项目设置
  • 后端设置
  • 前端设置
  • Stream聊天和视频集成
  • 聊天和视频功能(前端)
  • 项目演示
  • 结论

前提条件

开始本教程前,请确保您具备:

  • React基础知识
  • 电脑上已安装Node.js和npm/yarn
  • Stream免费账户
  • 熟悉Stream SDK
  • 了解Tailwind CSS用于样式设计
  • 使用VS Code和Postman(用于API测试)的经验

应用结构

在深入代码前,了解应用结构很有帮助。

应用流程结构

  • 登录页面
    • 导航
      • 首页
      • 关于
      • 注册
        • 验证账户
          • 登录
            • 仪表盘
              • Stream聊天
              • Stream视频
              • 登出

项目设置

开始前,创建两个文件夹:“Frontend"处理客户端代码,“Backend"处理服务器端逻辑。这种分离让您能高效管理应用的这两部分。

为前端设置React

创建文件夹后,您可以在Frontend文件夹中设置React应用。

首先使用命令cd Frontend导航到Frontend目录。

现在您可以初始化React项目。您将使用Vite,一个快速的React应用构建工具。

要创建React项目,运行以下命令:

1
npm create vite@latest [项目名称] -- --template react

接下来,使用命令导航到新项目文件夹:

1
cd [项目名称]

在那里,通过运行安装所需依赖:

1
npm install

此命令安装node_modules文件夹(包含所有项目包)和package-lock.json文件(记录安装包的确切版本)。

接下来,您需要安装Tailwind CSS进行样式设计。按照Tailwind文档获取逐步说明。

然后,是时候设置网站了。使用React,您将创建主页、登录/注册页面。两者都将使用React-router-dom嵌套在一起。

后端设置

安装所需包

在设置项目后端前,定义项目需要提供什么很重要。这将帮助您一次性安装所有必要包。

首先使用命令进入后端文件夹:cd Backend

在Backend目录内,使用npm install初始化Node.js项目。这将创建package.json文件,存储项目的元数据和依赖。

接下来,安装构建后端所需的所有依赖。运行以下命令:

1
npm i bcryptjs cookie-parser cors dotenv express jsonwebtoken mongoose nodemailer validator nodemon

以下是每个包的简要概述:

  • bcryptjs: 加密用户密码以安全存储
  • Cookie-parser: 处理应用中的cookie
  • CORS: 启用跨源请求的中间件 - 对前后端通信至关重要
  • dotenv: 将.env文件中的环境变量加载到process.env
  • Express: 构建服务器和API路由的核心框架
  • jsonwebtoken: 为认证生成和验证JWT令牌
  • Mongoose: 将应用连接到MongoDB数据库
  • nodemailer: 处理从应用发送电子邮件
  • Validator: 验证用户输入如电子邮件、字符串等
  • nodemon: 文件更改时自动重启服务器

安装包后,在后端目录中创建两个关键文件:App.js(包含应用逻辑、中间件和路由处理程序)和server.js(负责初始化和配置服务器)。

接下来,您必须更新package.json启动脚本。转到后端目录中的package.json文件并替换默认脚本:

1
2
3
"scripts": {
    "test": "echo\"Error: no test specified\" && exit 1"
}

替换为:

1
2
3
"scripts": {
    "start": "nodemon server.js"
}

此设置允许您使用nodemon运行服务器,在更改时自动重新加载。这有助于提高开发效率。

要检查后端设置是否正确,打开server.js文件并添加测试日志:console.log("您的日志消息")。然后,转到后端目录中的终端,运行npm start。您应该在终端中看到日志消息,确认后端正在运行。

App.js设置

在App.js文件中,首先导入您最初安装的包。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const express = require("express");
const cors = require("cors");
const cookieParser = require("cookie-parser");
const app = express();

app.use(
  cors({
    origin: ["http://localhost:5173"],
    credentials: true,
  })
);

app.use(express.json({ limit: "10kb" }));
app.use(cookieParser());

module.exports = app;

上面的代码作用:

  • require语句导入express、cors和cookie-parser,这些对创建后端服务器、处理跨源请求和解析cookie至关重要
  • const app = express();命令设置新的Express应用实例
  • app.use(cors({...}))允许来自前端的请求并启用应用前后端之间的cookie共享。这对认证很重要
  • app.use(express.json({ limit: "10kb" }));是确保服务器能处理传入JSON负载并防止可能用于DoS攻击的过大请求的中间件函数
  • app.use(cookieParser());命令使cookie通过req.cookies可用
  • module.exports = app;命令允许在其他文件中导入应用,特别是server.js,这是应用将启动的地方

Server.js设置

设置好App.js后,下一步是在名为server.js的新文件中创建和配置服务器。

在此之前,确保您已设置MongoDB数据库。如果还没有,可以按照此视频教程设置MongoDB数据库。

设置MongoDB后,您将收到用户名和密码。复制密码,转到后端目录,并创建.env文件存储它。

存储密码后,返回完成数据库设置。

接下来,点击"Create Database User"按钮,然后点击选择连接方法选项。由于我们在此项目中使用Node.js,选择"Drivers"选项。这将给您数据库连接字符串(您应该在No. 3看到它)。

然后转到您的.env并粘贴在那里,并在"net/“后立即添加auth。

看起来像这样:

1
mongodb+srv://<用户名>:<密码>@cluster0.qrrtmhs.mongodb.net/auth?retryWrites=true&w=majority&appName=Cluster0

最后,将您的IP地址加入白名单。这确保您的后端可以从本地机器或开发期间的任何环境连接到MongoDB。

要允许应用连接到数据库:

  1. 转到MongoDB仪表板安全侧边栏中的"Network Access"部分
  2. 点击"ADD IP ADDRESS”
  3. 选择"Allow Access from Anywhere”,然后点击Confirm

此时,您可以设置server.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//server.js
require("dotenv").config();
const mongoose = require("mongoose");
const dotenv = require("dotenv"); //管理环境变量

dotenv.config({ path: "./config.env" });

const app = require("./app");

const db = process.env.DB;
//使用MongoDB将应用连接到数据库

mongoose
  .connect(db)
  .then(() => {
    console.log("DB连接成功");
  })
  .catch((err) => {
    console.log(err);
  });

const port = process.env.PORT || 3000;

app.listen(port, () => {
  console.log(`应用运行在端口${port}`);
});

server.js文件负责处理所有与服务器相关的功能和逻辑。从上面的代码中,server.js文件使用dotenv加载环境变量,使用mongoose将后端连接到MongoDB,并启动Express服务器。它从config.env文件获取数据库URL和端口,连接到数据库,然后在指定端口(8000)上运行您的应用。

如果找不到指定端口,则回退到端口3000,并将确认消息打印到控制台,指示服务器已在指定端口上启动并运行。

将数据库连接到MongoDB Compass

首先,下载MongoDB Compass应用。(前往此处下载并安装:https://www.mongodb.com/try/download/compass)。MongoDB Compass应用使我们易于管理数据。

安装完成后,打开应用并点击Click to add new connection。转到您的.env文件,复制最初设置MongoDB时获得的连接字符串,将其粘贴到URL部分,然后点击"connect”。此设置帮助您在创建和删除用户时管理数据。

设置高级错误处理方法

您现在将创建一个高级错误处理机制。为此,在后端创建一个utils文件夹,在utils文件夹中创建一个catchAsync.js文件,并添加此代码:

1
2
3
4
5
6
//catchAsync.js
module.exports = (fn) => {
  return (req, res, next) => {
    fn(req, res, next).catch(next);
  };
};

接下来,仍在utils文件夹中创建一个appError.js文件。在appError.js文件中,添加以下命令:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);

    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith("4") ? "fail" : "error";
    this.isOperational = true;

    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;

上面的代码有助于跟踪和追踪错误。它还提供错误可能发生的URL和文件位置,有助于更清晰的错误处理和调试。

接下来,让我们创建一个全局错误处理程序。首先在后端目录中创建一个新文件夹,命名为"controller"。在controller文件夹中,创建您的全局错误处理文件。您可以随意命名。在此示例中,它称为globalErrorHandler.js。

您的globalErrorHandler文件将定义几个处理特定错误类型的函数,如数据库问题、验证失败甚至JWT问题,并为用户返回格式良好的错误响应。为了使globalErrorHandler正常工作,您必须创建一个控制器函数。

因此,接下来,创建一个errorController.js文件(仍在controller文件夹内)。errorController.js文件在捕获错误时向用户响应,以JSON格式发送错误。

globalErrorHandler.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// globalErrorHandler.js
const AppError = require("../utils/appError");

const handleCastErrorDB = (err) => {
  const message = `无效的${err.path}: ${err.value}.`;
  return new AppError(message, 400);
};

const handleDuplicateFieldsDB = (err) => {
  const value = err.keyValue ? JSON.stringify(err.keyValue) : "重复字段";
  const message = `重复字段值: ${value}. 请使用另一个值!`;
  return new AppError(message, 400);
};

const handleValidationErrorDB = (err) => {
  const errors = Object.values(err.errors).map((el) => el.message);
  const message = `无效输入: ${errors.join(". ")}`;
  return new AppError(message, 400);
};

const handleJWTError = () =>
  new AppError("无效令牌。请重新登录!", 401);
const handleJWTExpiredError = () =>
  new AppError("您的令牌已过期!请重新登录。", 401);

module.exports = {
  handleCastErrorDB,
  handleDuplicateFieldsDB,
  handleValidationErrorDB,
  handleJWTError,
  handleJWTExpiredError,
};

上面的代码作用:

  • const handleCastErrorDB = (err) =>..部分处理MongoDB CastError,通常在传递无效ID时发生,并使用您的AppError类返回400 Bad Request错误响应
  • const handleDuplicateFieldsDB = (err) =>...检查并处理MongoDB重复键错误,如尝试注册已使用的电子邮件或用户名,并返回400 Bad Request错误
  • const handleValidationErrorDB = (err) =>...处理Mongoose验证错误(如必填字段缺失或错误数据类型)。它收集所有单独的验证错误消息并将它们合并为一个
  • const handleJWTError = () =>const handleJWTExpiredError = () =>处理由于无效、篡改或过期的JWT令牌可能发生的错误,并返回401 Unauthorized错误响应
  • module.exports = {……};部分导出所有单独的错误处理程序,以便它们可以在errorController.js文件中使用

errorController.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//errorController.js
const errorHandlers = require("./globalErrorHandler");

const {
  handleCastErrorDB,
  handleDuplicateFieldsDB,
  handleValidationErrorDB,
  handleJWTError,
  handleJWTExpiredError,
} = errorHandlers;

module.exports = (err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  err.status = err.status || "error";

  let error = { ...err, message: err.message };

  if (err.name === "CastError") error = handleCastErrorDB(err);
  if (err.code === 11000) error = handleDuplicateFieldsDB(err);
  if (err.name === "ValidationError") error = handleValidationErrorDB(err);
  if (err.name === "JsonWebTokenError") error = handleJWTError();
  if (err.name === "TokenExpiredError") error = handleJWTExpiredError();

  res.status(error.statusCode).json({
    status: error.status,
    message: error.message,
    ...(process.env.NODE_ENV === "production" && { error, stack: err.stack }),
  });
};

为确保错误处理函数正常工作,转到您的App.js并添加命令:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
//import命令
const globalErrorHandler = require("./controller/errorController");
const AppError = require("./utils/appError");

//捕获未知路由
app.all("/{*any}", (req, res, next) => {
  next(new AppError(`找不到此服务器上的${req.originalUrl}!`, 404));
});

app.use(globalErrorHandler);

这确保所有错误都得到正确处理并将错误响应发送给用户。

创建用户模型

要构建用户模型,在后端目录中创建一个新文件夹并命名为"model"。在model文件夹内,创建一个名为userModel.js的新文件。

userModel.js文件主要为用户认证和安全而构建。它提供了使用Mongoose管理用户的验证丰富模式,映射用户数据在MongoDB数据库中的结构方式。它包括验证、密码哈希和安全比较用户密码的方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
//userModel.js
const mongoose = require("mongoose");
const validator = require("validator");
const bcrypt = require("bcryptjs");

const userSchema = new mongoose.Schema(
  {
    username: {
      type: String, 
      required: [true, "请提供用户名"], 
      trim: true, 
      minlength: 3, 
      maxlength: 30, 
      index: true,
    },
    email: {
      type: String, 
      required: [true, "请提供电子邮件"], 
      unique: true, 
      lowercase: true, 
      validate: [validator.isEmail, "请提供有效电子邮件"],
    },
    password: {
      type: String, 
      required: [true, "请提供密码"], 
      minlength: 8, 
      select: false,
    },
    passwordConfirm: {
      type: String, 
      required: [true, "请确认您的密码"],
      validate: {
        validator: function (el) {
          return el === this.password;
        },
        message: "密码不匹配",
      },
    },
    isVerified: {
      type: Boolean, 
      default: false,
    }, 
    otp: String,
    otpExpires: Date,
    resetPasswordOTP: String,
    resetPasswordOTPExpires: Date,
    createdAt: {
      type: Date, 
      default: Date.now,
    },
  }, 
  { timestamps: true }
);

// 保存前哈希密码
userSchema.pre("save", async function (next) {
  if (!this.isModified("password")) return next();

  this.password = await bcrypt.hash(this.password, 12);
  this.passwordConfirm = undefined; // 保存前移除passwordConfirm
  next();
});

const User = mongoose.model("User", userSchema);
module.exports = User;

认证控制器

现在,您可以创建管理用户认证过程的逻辑。此认证逻辑包括注册、登录(登录)、OTP等。

为此,首先转到您的controller文件夹并创建一个新文件。称之为authController.js,因为它处理项目的认证流程。

创建文件后,您将创建注册函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
//import
const User = require("../model/userModel");
const AppError = require("../utils/appError");
const catchAsync = require("../utils/catchAsync");
const generateOtp = require("../utils/generateOtp");
const jwt = require("jsonwebtoken");
const sendEmail = require("../utils/email")

exports.signup = catchAsync(async (req, res, next) => {
  const { email, password, passwordConfirm, username } = req.body;

  const existingUser = await User.findOne({ email });

  if (existingUser) return next(new AppError("电子邮件已注册", 400));

  const otp = generateOtp();

  const otpExpires = Date.now() + 24 * 60 * 60 * 1000; // OTP将过期的时间(1天)

  const newUser = await User.create({
    username,
    email,
    password,
    passwordConfirm,
    otp,
    otpExpires,
  });

  //配置电子邮件发送功能
  try {
    await sendEmail({
      email: newUser.email,
      subject: "电子邮件验证的OTP",
      html: `<h1>您的OTP是: ${otp}</h1>`,
    });

    createSendToken(newUser, 200, res, "注册成功");
  } catch (error) {
    console.error("发送电子邮件错误:", error);
    await User.findByIdAndDelete(newUser.id);
    return next(
      new AppError("发送电子邮件时出错。请重试", 500)
    );
  }
});

const { email, password, passwordConfirm, username } = req.body;从传入请求中提取必要的注册详细信息:电子邮件、密码、密码确认和用户名在用户注册期间。

const existingUser = await User.findOne({ email });检查数据库以查看具有给定电子邮件的用户是否已存在。如果是,它使用您的AppError实用程序发送错误响应。

const otp = generateOtp();生成OTP,const otpExpires = Date.now()…..用于设置OTP在指定时间或日期过期。

使用const newUser = await User.create({…});,新用户及其凭据和OTP信息保存在MongoDB中,密码自动哈希。

await sendEmail({…});向用户发送电子邮件。此电子邮件包含他们的登录OTP。如果电子邮件发送成功,createSendToken(newUser, 200, res, "注册成功");(这是一个实用函数)生成JWT令牌并在响应中发送它并带有成功消息。

如果电子邮件发送失败或出现问题,await User.findByIdAndDelete(newUser.id);从数据库中删除用户以保持清洁,并返回错误消息"There is an error sending the email. Try again", 500。

生成OTP

为确保用户的OTP成功发送给他们,在utils文件夹中,创建一个新文件并命名为generateOtp.js。然后添加代码:

1
2
3
module.exports = () => {
  return Math.floor(1000 + Math.random() * 9000).toString();
};

上面的代码是一个函数,生成用户的随机4位OTP并将其作为字符串返回。

完成上面的代码后,转到您的authController.js并确保在导入部分导入generateOtp.js。

创建用户令牌

接下来,将创建用户登录令牌,并在登录时分配给用户。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const signToken = (userId) => {
  return jwt.sign({ id: userId }, process.env.JWT_SECRET, {
    expiresIn: process.env.JWT_EXPIRES_IN || "90d",
  });
};

//创建令牌的函数
const createSendToken = (user, statusCode, res, message) => {
  const token = signToken(user._id);

  //生成cookie的函数
  const cookieOptions = {
    expires: new Date(
      Date.now() + process.env.JWT_COOKIE_EXPIRES_IN * 24 * 60 * 60 * 1000
    ),

    httponly: true,
    secure: process.env.NODE_ENV === "production", //仅在生产环境中安全
    sameSite: process.env.NODE_ENV === "production" ? "none" : "Lax",
  };

  res.cookie("token", token, cookieOptions);

  user.password = undefined;
  user.passwordConfirm = undefined;
  user.otp = undefined;

在上述代码正常工作之前,在您的.env文件中创建一个JWT。

1
2
3
4
//.env
JWT_SECRET = kaklsdolrnnhjfsnlsoijfbwhjsioennbandksd;
JWT_EXPIRES_IN = 90d
JWT_COOKIE_EXPIRES_IN = 90

上面的代码是.env文件应该看起来的样子。您的JWT_SECRET可以是任何内容,就像您在代码中看到的那样。

注意:用户令牌创建逻辑应在登录逻辑之前运行。因此,在这种情况下,signToken和createSendToken逻辑应放在注册逻辑之前的顶部。

发送电子邮件

接下来,您需要配置电子邮件发送功能,以便在用户登录时自动将用户的OTP发送到他们的电子邮件。要配置电子邮件,转到utils文件夹,创建一个新文件,并为其命名。在此示例中,名称为email.js。

在email.js中,我们将使用nodemailer包和Gmail作为提供商发送电子邮件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//email.js
const nodemailer = require('nodemailer');

const sendEmail = async (options) => {
  const transporter = nodemailer.createTransport({
    service: 'Gmail',
    auth: {
      user: process.env.HOST_EMAIL,
      pass: process.env.EMAIL_PASS
    }
  })

  //定义电子邮件选项和结构
  const mailOptions = {
    from: `"{HOST Name}" <{HOST Email} >`,
    to: options.email,
    subject: options.subject,
    html: options.html,
  };
  await transporter.sendMail(mailOptions);
};

module.exports = sendEmail;

从上面的代码中,const nodemailer = require('nodemailer');命令导入nodemailer包。这是用于发送电子邮件的流行Node.js库。

const transporter = nodemailer.createTransport({…..})是一个电子邮件传输器。由于我们将使用Gmail服务提供商,service将分配给Gmail,auth从存储它的.env文件中提取您的Gmail地址和密码。

注意:密码不是您实际的Gmail密码,而是您的Gmail应用密码。您可以在此处查看如何获取Gmail密码。

成功获取Gmail应用密码后,将其存储在.env文件中。

路由创建

此时,您已完成项目注册功能的设置。接下来,您需要使用Postman测试您的注册是否正常工作。但在那之前,让我们设置并定义一个将执行注册功能的路由。

要设置您的路由,在后端目录中创建一个名为"routes"的新文件夹和一个名为userRouter.js的文件。

1
2
3
4
5
6
7
const express = require("express");
const {signup} = require("../controller/authController");

const router = express.Router();
router.post("/signup", signup);

module.exports = router;

接下来,转到您的App.js文件并将路由器添加到其中。

1
2
const userRouter = require("./routes/userRouters"); //路由导入语句
app.use("/api/v1/users", userRouter) //所有认证的通用路由,即注册、登录、忘记密码等

设置路由后,您可以测试您的注册以查看它是否工作。这是一个post请求,路由URL将是http://localhost:8000/api/v1/users/signup。

上面的图像显示注册功能完美工作,状态码为200,OTP代码发送到用户的电子邮件。

恭喜您到达这一点!您可以检查您的MongoDB数据库以查看用户是否显示在那里。

从上面的图像中,您可以看到获得了用户详细信息,并且密码是加密形式,这确保用户凭据安全。

创建验证账户控制器函数

在本节中,您将创建一个验证账户控制器函数。此函数使用发送到用户电子邮件地址的OTP代码验证用户的账户。

首先,转到您的authController.js文件并添加:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
exports.verifyAccount = catchAsync(async (req, res, next) => {
  const { email, otp } = req.body;

  if (!email || !otp) {
    return next(new AppError("需要电子邮件和OTP", 400));
  }

  const user = await User.findOne({ email });

  if (!user) {
    return next(new AppError("找不到具有此电子邮件的用户", 404));
  }

  if (user.otp !== otp) {
    return next(new AppError("无效OTP", 400));
  }

  if (Date.now() > user.otpExpires) {
    return next(
      new AppError("OTP已过期。请请求新的OTP。", 400)
    );
  }

  user.isVerified = true;
  user.otp = undefined;
  user.otpExpires = undefined;

  await user.save({ validateBeforeSave: false });

  // ✅ 可选返回响应而不登录
  res.status(200).json({
    status: "success",
    message: "电子邮件已验证",
  });
});

接下来,创建一个中间件函数来认证当前登录的用户。

在您的后端目录中,创建一个名为middlewares的新文件夹。在middlewares文件夹内,创建一个名为isAuthenticated.js的文件。

添加以下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
//isAuthenticated.js
const jwt = require("jsonwebtoken");
const catchAsync = require("../utils/catchAsync");
const AppError = require("../utils/appError");
const User = require("../model/userModel");

const isAuthenticated = catchAsync(async (req, res, next) => {
  let token;

  // 1. 从cookie或Authorization头检索令牌
  if (req.cookies?.token) {
    token = req.cookies.token;
  } else if (req.headers.authorization?.startsWith("Bearer")) {
    token = req.headers.authorization.split(" ")[1];
  }

  if (!token) {
    return next(
      new AppError(
        "您未登录。请登录以访问此资源。",
        401
      )
    );
  }

  // 2. 验证令牌
  let decoded;
  try {
    decoded = jwt.verify(token, process.env.JWT_SECRET);
  } catch (err) {
    return next(
      new AppError("无效或过期的令牌。请重新登录。", 401)
    );
  }

// 3. 确认用户仍存在于数据库中
  const currentUser = await User.findById(decoded._id);
  if (!currentUser) {
    return next(
      new AppError("与此令牌关联的用户不再存在。", 401)
    );
  }

  // 4. 将用户信息附加到请求
  req.user = currentUser;
  req.user = {
    id: currentUser.id,
    name: currentUser.name,
  };

  next();
});

module.exports = isAuthenticated;

现在,转到您的userRouter.js文件并添加验证账户功能的路由:

1
2
3
const { verifyAccount} = require("../controller/authController");
const isAuthenticated = require("../middlewares/isAuthenticated");
router.post("/verify", isAuthenticated, verifyAccount);

这两组代码的作用是: 当用户向/verify路由发送请求时,isAuthenticated中间件首先运行。它检查cookie或授权头中是否存在有效令牌。如果未找到令牌,它会抛出错误:您未登录。请登录以访问此资源。

如果找到令牌,它会验证令牌并检查关联的用户是否仍然存在。如果不存在,则抛出另一个错误:“与此令牌关联的用户不再存在。”

如果用户存在且令牌有效,则他们的详细信息将附加到请求(req.user)。然后请求继续进行到verifyAccount控制器,该控制器处理OTP验证。

您可以使用Postman测试此端点,发送POST请求到:http://localhost:8000/api/v1/users/verify

上面的图像显示验证令牌功能正常工作,并显示状态码200。

登录功能

如果您已到达这一点,您已成功注册并验证了用户账户。

现在是时候创建登录功能,它允许已验证的用户访问其账户。以下是您可以做到这一点的方法:

转到您的authController.js文件并通过添加以下内容创建您的登录函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
exports.login = catchAsync(async (req, res, next) => {
  const { email, password } = req.body;

  // 1. 验证电子邮件和密码是否存在
  if (!email || !password) {
    return next(new AppError("请提供电子邮件和密码", 400));
  }

  // 2. 检查用户是否存在并包含密码
  const user = await User.findOne({ email }).select("+password");
  if (!user || !(await user.correctPassword(password, user.password))) {
    return next(new AppError("电子邮件或密码不正确", 401));
  }

  // 3. 创建JWT令牌
  const token = signToken(user._id);

  // 4. 配置cookie选项
  const cookieOptions = {
    expires: new Date(
      Date.now() +
        (parseInt(process.env.JWT_COOKIE_EXPIRES_IN, 10) || 90) * 24 * 60 * 60 * 1000
    ),
    httpOnly: true,
    // secure: process.env.NODE_ENV === "production",
    // sameSite: process.env.NODE_ENV === "production" ?
    //  "None" : "Lax",

    //在本地HTTP和跨源期间或设置为false
    secure: false,
    sameSite: "Lax",
  };

  // 5. 发送cookie
  res.cookie("token", token, cookieOptions);

});

if (!email || !password) {…}检查用户是否实际提供了电子邮件和密码。如果没有,它返回错误:请提供电子邮件和密码", 400。

const user = await User.findOne({ email }).select("+password");在数据库中搜索具有提供的电子邮件的用户,并明确包含密码字段,因为它在模式中通常默认隐藏。

if (!user || !(await user.correctPassword(…))) {…}检查用户是否存在以及输入的密码是否与数据库中存储的密码匹配(在哈希比较之后)。如果任一错误,它会抛出:电子邮件或密码不正确。

signToken(user._id)行使用用户的唯一ID生成JWT。cookieOptions对象配置cookie的行为 - 它设置cookie在.env文件中定义的天数后过期,将其标记为httpOnly以防止JavaScript访问以提高安全性,将secure设置为false因为应用当前处于开发中,并使用sameSite: “Lax"以允许在本地测试期间进行跨源请求。

最后,res.cookie(...)将令牌作为附加到HTTP响应的cookie发送,使客户端能够存储令牌以用于认证目的。

从上面的代码中,您可能已经注意到数据库中存储的密码是出于安全原因进行哈希处理的。这意味着当用户登录时,它与用户的密码看起来完全不同。因此,即使用户输入了正确的密码,它也不会通过简单的比较直接匹配存储的哈希。

要解决此问题,您需要使用bcryptjs包将输入的密码与哈希密码进行比较。

转到您的userModel.js文件并创建一个处理密码比较的方法。此方法将获取用户提供的纯文本密码并将其与数据库中存储的哈希密码进行比较。

1
2
3
4
5
6
//userModel.js
//创建一个负责比较数据库中存储的密码的方法

userSchema.methods.correctPassword = async function (password, userPassword) {
  return await bcrypt.compare(password, userPassword);
};

此correctPassword方法使用bcrypt.compare(),它在内部哈希纯密码并检查它是否与存储的哈希版本匹配。这确保登录验证正确且安全地工作,即使实际密码不以纯文本形式存储。

接下来,将登录功能添加到userRouter.js文件。

1
2
3
4
const {login} = require("../controller/authController");
const isAuthenticated = require("../middlewares/isAuthenticated");

router.post("/login", login);

您可以使用Postman测试此端点,发送POST请求到:http://localhost:8000/api/v1/users/login

登出功能

此时,您可以实现登出功能以安全地结束用户会话。为此,导航到您的authController.js文件并添加以下函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
//创建登出函数
exports.logout = catchAsync(async (req, res, next) => {
  res.cookie("token", "loggedout", {
    expires: new Date(0),
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
  });

  res.status(200).json({
    status: "success",
    message: "成功登出",
  });
});

此函数的工作原理是通过将名为token的认证cookie覆盖为值"loggedout"并使用new Date(0)将其过期时间设置为过去。这有效地使cookie无效并将其从浏览器中删除。

httpOnly: true标志确保无法通过JavaScript访问cookie,这保护它免受XSS攻击,而secure标志确保cookie仅在生产环境中通过HTTPS发送。一旦清除cookie,将返回带有消息"成功登出"的成功响应以确认操作。

接下来,将登出功能添加到您的路由:

1
2
3
4
const {logout} = require("../controller/authController");
const isAuthenticated = require("../middlewares/isAuthenticated");

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

然后,转到Postman测试您的登出功能,看看它是否工作。

前端设置

现在您的后端已启动并运行,您可以将其集成到前端应用中。

首先,使用命令cd Frontend导航到前端目录。

在src文件夹中创建一个新文件夹,您的认证相关文件将位于其中。根据您的偏好或应用结构,您可以将其命名为auth或pages之类。然后,创建一个名为NewUser.js的新文件。此文件将处理用户注册功能。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import axios from 'axios';
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Loader } from 'lucide-react';
import { useDispatch } from 'react-redux';
import { setAuthUser, setPendingEmail } from '../../../../store/authSlice';

const API_URL = import.meta.env.VITE_API_URL;

function NewUser() {
  const dispatch = useDispatch();
  const navigate = useNavigate();

  const [loading, setLoading] = useState(false);

  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: '',
    passwordConfirm: '',
  });

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
  };

  const submitHandler = async (e) => {
    e.preventDefault();
    setLoading(true);
    try {
      const response = await axios.post(`${API_URL}/users/signup`, formData, {
        withCredentials: true,
      });
      const user = response.data.data.user;
      dispatch(setAuthUser(user));
      dispatch(setPendingEmail(formData.email)); // 保存电子邮件用于OTP
      navigate('/verifyAcct'); // 导航到OTP验证页面
    } catch (error) {
      alert(error.response?.data?.message || '注册失败');
    } finally {
      setLoading(false);
    }
  };

  return (
<div>
// 访问前端Github仓库查看OTP验证的剩余代码
https://github.com/Derekvibe/Telehealth_Frontend/blob/main/src/pages/Auth/Join/NewUser.jsx
    </div>
  );
}

export default NewUser;

上面的代码呈现一个注册表单,包含用户名、电子邮件、密码和passwordConfirm字段。当用户提交表单时,前端使用Axios向后端的/users/signup端点发送POST请求。withCredentials: true选项确保像认证令牌这样的cookie由后端正确设置。

如果注册成功,用户数据使用setAuthUser()分派到Redux,并使用setPendingEmail()保存他们的电子邮件,以便在OTP验证期间使用。然后,用户被重定向到/verifyAcct路由,在那里他们可以输入他们的OTP。

OTP验证页面

OTP验证页面是用户认证流程中的下一步。一旦用户注册,他们将被重定向以输入发送到其电子邮件的4位OTP。这在他们允许登录访问之前验证他们的账户。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import React, { useState, useRef, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useNavigate, Link } from 'react-router-dom';
import axios from 'axios';
import { clearPendingEmail } from '../../../../store/authSlice';

const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000/api'; // 根据需要调整

function VerifyAcct() {
  const [code, setCode] = useState(['', '', '', '']);
  const [loading, setLoading] = useState(false);
  const [resendLoading, setResendLoading] = useState(false);
  const [timer, setTimer] = useState(60);

  const inputsRef = useRef([]);
  const dispatch = use
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计