如何使用React和带有E2E加密的Socket IO创建一个实时的聊天应用程序

473 阅读10分钟

使用React和带有E2E加密的Socket IO创建一个实时聊天应用程序

本文将解释如何用Node.js和React创建一个简单的聊天应用程序,其中交换的信息将使用秘密密钥 进行端到端加密

近来,实时聊天应用程序得到了极大的发展。大多数组织已经采用它们进行交流。出于安全原因,在网络上交换的信息必须进行加密。

如果一个恶意程序试图非法窃听在网络上交换的信息,被截获的信息将是加密的格式,因此信息的内容将不会被泄露。

前提条件

要跟上这篇文章,你将需要以下条件。

  • 对[Node.js]、[Express]、[React.js]、[AES256加密]和[Socket.io]的工作知识。
  • 一个文本编辑器,最好是[VS代码编辑器]。
  • 一个用于测试的工作网络浏览器,最好是[谷歌浏览器]。
  • 安装了[Node.js]。

该应用程序如何工作

我们将创建一个秘密密钥,并将其存储在前台,用于演示目的。该密钥被保存在一个.ENV变量中,其中前端已被部署在服务器中。

每当用户发送或接收信息时,信息将使用相同的秘密密钥的AES256npm包进行加密或解密。

创建后端

对于后端,我们将使用Node.js和Express框架。需要Socket.io来提供后端服务器和前端之间的实时、双向的通信。

我们的后端文件夹结构将如下所示。

backend folder structure

编码后端

第1步

创建一个服务器目录,名称为chatbackend ,并浏览该目录。

mkdir chatbackend
cd chatbackend

通过在终端运行下面的命令来初始化服务器项目,这些命令将生成package.json文件。

npm init –y

第2步

接下来,让我们通过运行以下命令来安装所需的依赖项。

npm i socket.io express cors colors
npm i -D nodemon

让我们创建一个名为dummyuser.js 的文件,在这里我们创建一个空的用户数组并添加一个加入房间的用户。在用户断开连接的情况下,数组被清空。

const c_users = [];

// joins the user to the specific chatroom
function join_User(id, username, room) {
  const p_user = { id, username, room };

  c_users.push(p_user);
  console.log(c_users, "users");

  return p_user;
}

console.log("user out", c_users);

// Gets a particular user id to return the current user
function get_Current_User(id) {
  return c_users.find((p_user) => p_user.id === id);
}

// called when the user leaves the chat and its user object deleted from array
function user_Disconnect(id) {
  const index = c_users.findIndex((p_user) => p_user.id === id);

  if (index !== -1) {
    return c_users.splice(index, 1)[0];
  }
}

module.exports = {
  join_User,
  get_Current_User,
  user_Disconnect,
};

在上面的代码片段中,已经创建了以下处理用户的函数。

join_User() 函数将用户添加到上面代码中已经声明的用户数组中。它由三个键id、一个用户名和一个房间名称组成,其中房间名称告诉用户属于哪个房间或组。

get_Current_User() 函数将获取特定用户的id并返回其用户对象。

user_Disconnect() 函数中,如果一个用户断开连接或离开聊天,该函数将接受一个用户id,并从数组users中删除该用户对象。

第3步

在这一步,让我们创建一个名为server.js 的文件,初始化后端连接,确保房间内用户之间的通信。

const express = require("express");
const app = express();
const socket = require("socket.io");
const color = require("colors");
const cors = require("cors");
const { get_Current_User, user_Disconnect, join_User } = require("./dummyuser");

app.use(express());

const port = 8000;

app.use(cors());

var server = app.listen(
  port,
  console.log(
    `Server is running on the port no: ${(port)} `
      .green
  )
);

const io = socket(server);

//initializing the socket io connection 
io.on("connection", (socket) => {
  //for a new user joining the room
  socket.on("joinRoom", ({ username, roomname }) => {
    //* create user
    const p_user = join_User(socket.id, username, roomname);
    console.log(socket.id, "=id");
    socket.join(p_user.room);

    //display a welcome message to the user who have joined a room
    socket.emit("message", {
      userId: p_user.id,
      username: p_user.username,
      text: `Welcome ${p_user.username}`,
    });

    //displays a joined room message to all other room users except that particular user
    socket.broadcast.to(p_user.room).emit("message", {
      userId: p_user.id,
      username: p_user.username,
      text: `${p_user.username} has joined the chat`,
    });
  });

  //user sending message
  socket.on("chat", (text) => {
    //gets the room user and the message sent
    const p_user = get_Current_User(socket.id);

    io.to(p_user.room).emit("message", {
      userId: p_user.id,
      username: p_user.username,
      text: text,
    });
  });

  //when the user exits the room
  socket.on("disconnect", () => {
    //the user is deleted from array of users and a left room message displayed
    const p_user = user_Disconnect(socket.id);

    if (p_user) {
      io.to(p_user.room).emit("message", {
        userId: p_user.id,
        username: p_user.username,
        text: `${p_user.username} has left the room`,
      });
    }
  });
});

在上面的server.js 代码中,我们首先从文件dummyuser.js 中导入了模块和函数。该代码监听8000端口并初始化套接字。

在初始化套接字后,让我们设置下面列出的两个监听器。

  • joinRoom:当一个新的房间用户加入房间时,我们传递给socket.on(“joinRoom”) 的函数会运行。一个欢迎房间用户的消息将显示给用户。同时,一条*"用户名已加入 "*的消息将被广播给所有其他用户,除了加入房间的用户。

  • 聊天:我们传递给socket.on(“chat”) 的函数处理发送和接收消息。如果一个用户离开了聊天室,一个断开连接的消息会广播给所有其他房间的用户。

上述函数的事件监听器,joinRoomchat 将从前端的文件home.jschat.js 中触发,在本指南的后面会解释。

创建前台

我们将使用React、Redux库、socket.io-client和aes256来为前端的信息进行加密和解密。

我们客户端的文件夹结构将如下所示。

frontend folder structure

前台的编码

第1步

首先,让我们在终端运行以下命令,为我们的React应用创建一个客户端文件夹,即chatfrontend ,浏览创建的目录,并安装反应应用运行所需的必要依赖。

npx create-react-app chatfrontend
cd chatfrontend
npm i node-sass react-redux react-router-dom redux socket.io-client aes256

第2步

接下来,让我们修改文件/src/index.js ,以帮助在我们的react应用中实现还原器,正如本指南后面所解释的。

代码将如下所示。

import App from "./App";
import rootReducers from "./store/reducer/index";
import ReactDOM from "react-dom";
import { createStore } from "redux";
import { Provider } from "react-redux";
import React from "react";

//here we create an object to store the current state of the application
const store = createStore(rootReducers);
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);

在上面的代码中,我们已经添加了redux ,并从文件中导入了reducers/store/reducer/index.js

接下来,让我们创建一个文件/store/action/index.js ,该文件将定义动作对象并返回相同的动作,以避免每次需要时都写入对象。

代码如下所示。

//here we export the function process with the defined parameters and define action object PROCESS, which will return the same parameters as the payload.
export const process = (encrypt, text, cypher) => {
  return {
    type: "PROCESS",
    payload: {
      encrypt,
      text,
      cypher,
    },
  };
};

然后让我们创建一个文件/store/reducer/process.js ,它将是我们的还原器。它接收当前状态和我们刚刚创建的动作对象,以返回一个新的状态。

代码如下所示。

//initialiaze the function with two arguments
export const ProcessReducer = (state = {}, action) => {
  switch (action.type) {
    //returns updated state
    case "PROCESS":
      return { ...action.payload };
    //else the current state is retained
    default:
      return state;
  }
};

然后,让我们创建一个文件/store/reducer/index.js ,在这里我们导入我们刚刚创建的还原器并调用之前创建的动作对象。

代码如下所示。

// import the reducers
import { ProcessReducer } from "./process";
import { combineReducers } from "redux";
// define the object and call the action
const rootReducers = combineReducers({
  ProcessReducer: ProcessReducer,
});
// else return default root reducer
export default rootReducers;

在上面的代码片段中,我们将redux添加到我们的React App中,然后创建一个名为process 的动作。

该动作将帮助发送和接收传入和传出的消息,分别发送到文件aes.js ,该文件将加密和解密消息。

第3步

接下来,让我们创建文件App.js ,它负责获取用户名和房间名称的路由。

该文件显示如下。

import Chat from "./chat/chat";
import Process from "./process/process";
import Home from "./home/home";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import "./App.scss";
import React from "react";
import io from "socket.io-client";

const socket = io.connect('/');

function Appmain(props) {
  return (
    <React.Fragment>
      <div className="right">
        <Chat
          username={props.match.params.username}
          roomname={props.match.params.roomname}
          socket={socket}
        />
      </div>
      <div className="left">
        <Process />
      </div>
    </React.Fragment>
  );
}

function App() {
  return (
    <Router>
      <div className="App">
        <Switch>
          <Route path="/" exact>
            <Home socket={socket} />
          </Route>
          <Route path="/chat/:roomname/:username" component={Appmain} />
        </Switch>
      </div>
    </Router>
  );
}

export default App;

在上面的代码中,我们添加了路由并导入了组件(React, io, Chat, Process, Home)。我们渲染了首页组件,并从基础URL的路由中得到usernameroomname

在这个路径上,/chat/roomname/username ,AppMain组件被渲染,它返回两个div。第一个div是用于聊天框,另一个返回过程,分别用于显示加密和解密的、传入和传出的信息。

让我们为App.js ,添加一些风格设计。

我们将创建文件App.scss_globals.scss ,如下所示。

App.scss

@import "./globals";
.App {
  width: 100%;
  height: 100vh;
  background-color: $backgroundColor;
  display: flex;
  justify-content: center;
  align-items: center;
  .right {
    flex: 2;
  }
  .left {
    flex: 1;
  }
}

_globals.scss

@import url("https://fonts.googleapis.com/css2?family=Muli:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap");
* {
  margin: 0 auto;
  padding: 0;
  box-sizing: border-box;
  color: white;
  font-family: "Muli", sans-serif;
}

$backgroundColor: #282b34;
$redColor: #ff1e56;
$yellowColor: #ffac41;
$greyColor: #2d343e;

第4步

接下来,让我们对文件/home/home.js 进行编码,该文件作为我们的主页页面,用户在其中键入用户名和房间名称是加入。

该文件的代码应如下所示。

import React, { useState } from "react";
import "./home.scss";
import { Link } from "react-router-dom";

function Homepage({ socket }) {
  const [username, setusername] = useState("");
  const [roomname, setroomname] = useState("");
  //activates joinRoom function defined on the backend
  const sendData = () => {
    if (username !== "" && roomname !== "") {
      socket.emit("joinRoom", { username, roomname });
      //if empty error message pops up and returns to the same page
    } else {
      alert("username and roomname are must !");
      window.location.reload();
    }
  };

  return (
    <div className="homepage">
      <h1>Welcome to ChatApp</h1>
      <input
        placeholder="Input your user name"
        value={username}
        onChange={(e) => setusername(e.target.value)}
      ></input>
      <input
        placeholder="Input the room name"
        value={roomname}
        onChange={(e) => setroomname(e.target.value)}
      ></input>
      <Link to={`/chat/${roomname}/${username}`}>
        <button onClick={sendData}>Join</button>
      </Link>
    </div>
  );
}

export default Homepage;

从上面的代码中,我们获取用户名和房间名,然后调用函数socket.emit("joinRoom") ,并传递用户名和房间名。

该函数将激活定义在后台的joinRoom 函数。joinRoom 函数将把用户添加到房间中,并按照前面的解释在后台显示欢迎词。

现在,让我们为home.js 添加一些样式。

我们创建一个文件home.scss ,如下所示。

.homepage {
  width: 500px;
  height: 500px;
  padding: 2rem;
  background-color: #2d343e;
  display: flex;
  justify-content: space-evenly;
  flex-direction: column;
  border-radius: 5px;
  input {
    height: 50px;
    width: 80%;
    background-color: #404450;
    border: none;
    padding-left: 1rem;
    border-radius: 5px;
    &:focus {
      outline: none;
    }
  }
  button {
    font-size: 1rem;
    padding: 0.5rem 1rem 0.5rem 1rem;
    width: 100px;
    border: none;
    background-color: #ffac41;
    border-radius: 5px;

    color: black;
    &:hover {
      cursor: pointer;
    }
  }
}

第5步

接下来,让我们对文件/chat/chat.js 进行编码,因为一旦用户加入房间,它就会加载。这是一个主页面,用户可以使用聊天框与对方聊天。

该文件的代码如下所示。

import "./chat.scss";
import { to_Decrypt, to_Encrypt } from "../aes.js";
import { process } from "../store/action/index";
import React, { useState, useEffect, useRef } from "react";
import { useDispatch } from "react-redux";
//gets the data from the action object and reducers defined earlier
function Chat({ username, roomname, socket }) {
  const [text, setText] = useState("");
  const [messages, setMessages] = useState([]);

  const dispatch = useDispatch();
  
  const dispatchProcess = (encrypt, msg, cipher) => {
    dispatch(process(encrypt, msg, cipher));
  };

  useEffect(() => {
    socket.on("message", (data) => {
      //decypt the message
      const ans = to_Decrypt(data.text, data.username);
      dispatchProcess(false, ans, data.text);
      console.log(ans);
      let temp = messages;
      temp.push({
        userId: data.userId,
        username: data.username,
        text: ans,
      });
      setMessages([...temp]);
    });
  }, [socket]);

  const sendData = () => {
    if (text !== "") {
      //encrypt the message here
      const ans = to_Encrypt(text);
      socket.emit("chat", ans);
      setText("");
    }
  };
  const messagesEndRef = useRef(null);

  const scrollToBottom = () => {
    messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
  };

  useEffect(scrollToBottom, [messages]);

  console.log(messages, "mess");

  return (
    <div className="chat">
      <div className="user-name">
        <h2>
          {username} <span style={{ fontSize: "0.7rem" }}>in {roomname}</span>
        </h2>
      </div>
      <div className="chat-message">
        {messages.map((i) => {
          if (i.username === username) {
            return (
              <div className="message">
                <p>{i.text}</p>
                <span>{i.username}</span>
              </div>
            );
          } else {
            return (
              <div className="message mess-right">
                <p>{i.text} </p>
                <span>{i.username}</span>
              </div>
            );
          }
        })}
        <div ref={messagesEndRef} />
      </div>
      <div className="send">
        <input
          placeholder="enter your message"
          value={text}
          onChange={(e) => setText(e.target.value)}
          onKeyPress={(e) => {
            if (e.key === "Enter") {
              sendData();
            }
          }}
        ></input>
        <button onClick={sendData}>Send</button>
      </div>
    </div>
  );
}
export default Chat;

在上面的代码中,我们接受了用户的输入并将其传递给动作process ,然后将数据传递给aes 函数进行加密。

然后,加密后的数据被发送到socket.on("chat") 。同时,如果收到信息,它将被传递给aes 函数进行解密。

让我们给chat.js 添加一些样式。

让我们对文件chat.scss ,代码如下。

@import "../globals";
@mixin scrollbars(
  $size,
  $foreground-color,
  $background-color: mix($foreground-color, white, 50%)
) {
  //stylesheet for the display in Google Chrome
  &::-webkit-scrollbar {
    height: $size;
    width: $size;
  }

  &::-webkit-scrollbar-thumb {
    border-radius: 10px;
    background: $foreground-color;
  }

  &::-webkit-scrollbar-track {
    border-radius: 10px;
    background: $background-color;
  }

  // stylesheet for the display in Internet Explorer
  & {
    scrollbar-track-color: $background-color;
    scrollbar-face-color: $foreground-color;
  }
}
.chat {
  display: flex;
  width: 400px;
  padding: 1rem;
  justify-content: space-between;
  height: 600px;
  flex-direction: column;
  background-color: $greyColor;
  .user-name {
    width: 100%;
    text-align: start;
    h2 {
      border-bottom: 1px solid rgba(255, 255, 255, 0.1);
      font-weight: 300;
      padding-bottom: 1rem;
    }
  }
  .chat-message {
    @include scrollbars(5px, $backgroundColor, $yellowColor);
    height: 70%;
    display: flex;
    overflow-y: auto;
    align-content: flex-start;
    width: 100%;
    flex-direction: column;

    .message {
      padding-left: 0.5rem;
      max-width: 220px;
      margin-left: 0px;
      p {
        color: #b4b6be;
        font-size: 1rem;
        font-weight: 300;
        background-color: #250202;
        border-radius: 0px 10px 10px 10px;
        padding: 1rem;
      }

      span {
        color: #b4b6be;
        font-size: 0.6rem;
        padding-left: 0.5rem;
        font-weight: 200;
      }
    }
    .mess-right {
      display: flex;
      margin-left: auto;
      flex-direction: column;
      padding-right: 0.5rem;
      margin-right: 0px;
      max-width: 220px;
      p {
        background-color: $redColor;
        text-align: end;
        color: white;
        border-radius: 10px 0px 10px 10px;
      }
      span {
        padding-left: 0rem;
        width: 100%;
        padding-right: 0.5rem;
        text-align: end;
      }
    }
  }

  .send {
    height: 50px;
    display: flex;
    width: 100%;
    input {
      background-color: #404450;
      width: 80%;
      padding-left: 1rem;
      text-decoration: none;
      border-radius: 5px 0px 0px 5px;
      border: none;
      &:focus {
        outline: none;
      }
    }
    button {
      background-color: $yellowColor;
      width: 20%;
      border-radius: 0px 5px 5px 0px;
      border: none;
      &:hover {
        cursor: pointer;
      }
    }
  }
}

第6步

接下来,让我们创建文件aes.js ,该文件负责通过使用相同的秘密密钥对发出的信息进行加密,并对收到的信息进行解密,如下所示。

var aes256 = require("aes256");
//the secret key used for encrypting and decrypting messages
var secret_key = "uI2ooxtwHeI6q69PS98fx9SWVGbpQohO";
//returns the encrypted text
export const to_Encrypt = (text) => {
  var encrypted = aes256.encrypt(secret_key, text);
  return encrypted;
};
//welcome message is not decrypted
export const to_Decrypt = (cipher, username) => {
  if (cipher.startsWith("Welcome")) {
    return cipher;
  }

  if (cipher.startsWith(username)) {
    return cipher;
  }
  //decryped message is returned
  var decrypted = aes256.decrypt(secret_key, cipher);
  return decrypted;
};

在上面的代码中,我们从aes 模块中导入了aes256 ,并编写了传入的加密信息被解密和传出信息被加密的函数。

请注意,欢迎用户的信息是不需要加密的。

第7步

接下来我们将创建显示在聊天室右侧的文件/process/process.js 。它显示所使用的密匙,加密和解密的信息。

代码如下。

import "./process.scss";
import { useSelector } from "react-redux";
function Process() {
  // returns new state from the reducers
  const state = useSelector((state) => state.ProcessReducer);

  return (
    <div className="process">
      <h5>
        Secret Key : <span>"uI2ooxtwHeI6q69PS98fx9SWVGbpQohO"</span>
      </h5>
      <div className="incoming">
        <h4>Incoming Data</h4>
        <p>{state.cypher}</p>
      </div>
      <div className="crypt">
        <h4>Decypted Data</h4>
        <p>{state.text}</p>
      </div>
    </div>
  );
}
export default Process;

上面的代码是一个可选的组件,我们显示一个传入的加密信息,并使用我们的密匙解密。该文件process.js ,在侧边栏上显示传入的加密和解密的消息。

让我们给文件process.js 添加一些样式。

让我们创建文件/process/process.scss ,如下所示。

.process {
  align-items: center;
  min-height: 500px;
  padding: 2rem;
  width: 450px;
  flex-direction: column;
  display: flex;
  margin-right: 12rem;
  justify-content: space-evenly;

  h5 {
    span {
      color: yellow;
    }
    font-weight: 400;
    margin-bottom: 5rem;
    color: rgb(4, 238, 4);
  }
  h4 {
    font-weight: 400;
    color: rgb(4, 238, 4);
  }
  p {  
    font-size: 1rem;
    padding: 1.2rem;
    margin-top: 0.5rem;
    border-radius: 5px;
    background-color: rgba(0, 0, 0, 0.4);
    text-overflow: auto;
  }
  .incoming {
    width: 100%;
    margin-bottom: 15rem;
    overflow: auto;
    text-overflow: auto;
  }
  .crypt {
    width: 100%;
    overflow: auto;
    height: 100%;
  }
}

运行该应用程序

现在我们已经成功创建了一个实时聊天E2E应用程序,最后一步将是运行服务器和React应用程序来测试它。

我们需要注意,我们的服务器运行在8000端口,而我们的前端运行在3000端口。我们需要为我们的Node.js服务器代理连接,以便与我们的前端通信。

为了实现这一点,我们需要编辑React App的package.json 文件,该文件位于/chatfrontend/package.json ,并添加下面这行代码。

"proxy": "http://localhost:8000"

package.json 文件将显示如下。

package.json file

要运行我们的服务器,导航到后台目录,在终端键入以下命令。

cd chatbackend
node server.js

要运行前端,在终端键入下面的命令。

cd chatfrontend
npm start

该命令将编译该项目并运行React应用程序。一旦完成,打开网络浏览器,进入http://localhost:3000,键入一个用户名和一个房间名称。

启动另一个标签,进入http://localhost:3000,并且,键入一个不同的用户名,但键入相同的房间名称,测试该应用程序。

结论

总的来说,上面演示的应用程序相当简单,没有现代聊天应用程序所具有的许多功能。然而,这个想法、应用程序背后的代码和端到端加密可以用来实现一个真正的聊天应用程序。

从这个聊天应用程序中还可以添加很多东西,但概念和方法可以保持不变。