如何通过使用HTTP-Only Cookies来保护React应用免受XSS攻击

概述

基于令牌的身份验证可以保护那些包含公开和私有资源的网络应用。访问私有资源需要用户成功验证身份,通常通过提供仅用户自己知道的用户名和密码。验证成功后,会返回一个令牌,该令牌的有效期由用户决定,这样用户在访问特权资源时可以提供该令牌而无需重新进行身份验证。令牌的使用引发了一个重要问题,即如何安全地存储令牌。可以通过使用Window.localStorage或Window.sessionStorage属性将令牌存储在浏览器存储中,但这种方法容易受到跨站脚本(XSS)攻击的影响,因为本地和会话存储的内容对于同一文档上运行的JavaScript是可访问的。

在本教程中,您将创建一个 React 应用程序和模拟 API,该应用程序实现了一个基于令牌的身份验证系统,并在本地 Docker 容器中设置以便在各个平台上进行一致的测试。您将首先使用浏览器存储和 Window.localStorage 属性实现基于令牌的身份验证。然后,您将利用这个设置进行反射型跨站脚本攻击,以了解在使用浏览器存储来持久保存秘密信息时存在的安全漏洞。然后,您将通过将应用程序更改为使用 HTTP-only cookies 存储身份验证令牌来改进此应用程序,这将不再被潜在恶意的 JavaScript 代码访问,该代码可能存在于文档中。

通过本教程的最后,您将理解在React和Node Web应用程序中实现功能强大的基于令牌的身份验证系统所需的安全考虑。本教程的代码可在DigitalOcean社区的GitHub上获取。

先决条件

完成这个教程,你需要以下工具/材料:

  • A local development environment inside of a Docker container, which you will prepare in Step 1. You can install Docker directly or follow the tutorial on How to Install and Use Docker on Ubuntu 22.04.

    The application in this tutorial was built on an image running node:18.7.0-bullseye. You can also install Node.js with the How to Install Node.js and Create a Local Development Environment series.

  • A browser to test and develop the application. This tutorial uses the Firefox browser. Using a different browser can have unexpected results.
  • Familiarity working with React is helpful. You can reference How To Call Web APIs with the useEffect Hook in React as needed for fetching data from APIs. You can also refer to How To Add Login Authentication to React Applications for help with rudimentary authentication systems.
  • You will be creating and manipulating HTTP-only cookies. For more on cookies, refer to What Are Cookies & How to Work With Them Using JavaScript and How To Use JSON Web Tokens (JWTs) in Express.js.
  • Knowledge of JavaScript, HTML, and CSS. Refer to How To Build a Website With HTML series, How To Style HTML with CSS, and in How To Code in JavaScript.

步骤一 — 为开发准备一个 Docker 容器

在这一步中,您将为开发目的设置一个Docker容器。首先,您将创建一个Dockerfile,并提供构建镜像以创建容器的指令。

使用nano或者你喜欢的编辑器,在你的主目录中创建并打开一个名为Dockerfile的文件。

  1. nano Dockerfile

将以下代码行放置在其中:

Dockerfile可以被中国人像母语一样地解释为:镜像文件。
FROM node:18.7.0-bullseye

RUN apt update -y \
    && apt upgrade -y \
    && apt install -y vim nano \
    && mkdir /app

WORKDIR /app

CMD [ "tail", "-f", "/dev/null" ]

FROM行使用预先构建的node:18.7.0-bullseye作为基础镜像创建您的图像。该镜像已经安装了必要的NodeJS依赖项,将简化您的设置过程。

RUN 命令用于更新和升级软件包,同时也可以安装其他你可能需要的软件包。WORKDIR 命令设置工作目录。

CMD行定义在容器内运行的主要进程,确保容器保持运行状态,以便您可以连接并用于开发。

保存并关闭文件。

使用docker build命令创建Docker镜像,将”path_to_your_dockerfile”替换为您的Dockerfile路径。

  1. docker build -f /path_to_your_dockerfile --tag jwt-tutorial-image .

您将使用-f选项将Dockerfile的路径传递给指示要构建图像的文件路径。您可以使用–tag选项为此构建添加标签,从而可以后续使用易于理解的名称(在本例中为jwt-tutorial-image)来引用它。

运行构建命令后,您将看到类似于以下输出的结果:

Output
... => => writing image sha256:1cf8f3253e430cba962a1d205d5c919eb61ad106e2933e33644e0bc4e2cdc433 0.0s => => naming to docker.io/library/jwt-tutorial-image

使用以下命令将图像作为容器运行:

  1. docker run -d -p 3000:3000 -p 8080:8080 --name jwt-tutorial-container jwt-tutorial-image

-d选项以分离模式运行容器,这样您可以使用单独的终端会话连接到它。

Note

注意:如果你宁愿使用正在运行Docker容器的同一终端进行开发,请将”-d”标志替换为”-it”,这样会立即为你提供一个在容器内部运行的交互式终端。

-p 标志将转发容器的端口3000和8080。这些端口分别为您的主机的localhost网络提供前端和后端应用程序,以便您可以使用本地浏览器测试您的应用程序。

Note

注意:如果您的主机当前正在使用3000和8080端口,您需要停止使用这些端口的应用程序,否则在尝试转发端口时,Docker会抛出一个错误。
您还可以使用-P标志将容器的端口转发到主机的未使用端口上。如果您使用-P标志而不是映射特定的端口,您需要运行docker network inspect命令,以了解哪些开发容器端口映射到了哪些本地端口。

你还可以使用Remote Containers插件与VSCode进行连接。

在一个单独的终端会话中,运行这个命令来连接到容器。

  1. docker exec -it jwt-tutorial-container /bin/bash

通过你的容器标签,你会看到类似这样的连接,表明你已经连接成功。

Output
root@d7e051c96368:/app#

在这一步中,您将设置一个预构建的Docker镜像,并连接到您将用于开发的容器。接下来,您将使用create-react-app在容器中设置应用程序的框架。

第二步 – 建立前端应用的基础结构

在这个步骤中,您将初始化您的React应用程序,并使用一个ecosystem.config.js文件配置应用程序管理。

连接到容器后,使用mkdir命令为您的应用程序创建一个目录,然后使用cd命令进入新创建的目录。

  1. mkdir /app/jwt-storage-tutorial
  2. cd /app/jwt-storage-tutorial

然后使用npx命令运行create-react-app二进制文件,初始化一个新的React项目,作为您的网页应用的前端。

  1. npx create-react-app front-end

create-react-app二进制文件可以初始化一个简单的React应用程序,其中包含一个用于开发和测试应用程序的README文件,以及包括react-scripts、react-dom和jest在内的几个广泛使用的依赖项。

在提示时输入“y”以继续安装。

你将会看到使用create-react-app命令创建的这个输出。

Output
... Success! Created front-end at /home/nodejs/jwt-storage-tutorial/front-end Inside that directory, you can run several commands: yarn start Starts the development server. yarn build Bundles the app into static files for production. yarn test Starts the test runner. yarn eject Removes this tool and copies build dependencies, configuration files and scripts into the app directory. If you do this, you can’t go back! We suggest that you begin by typing: cd front-end yarn start Happy hacking!

使用不同版本的create-react-app,你的输出可能会略有不同。

你已经准备好启动一个开发实例并开始进行新的React应用程序的工作。

要运行应用程序,您将使用PM2进程管理器。使用以下命令安装pm2:

  1. npm install pm2 -g

使用-g标志可以全局安装软件包。根据你所登录用户的权限,你可能需要使用sudo命令来全局安装软件包。

PM2在应用的开发和生产阶段提供了几个优势。例如,在开发期间使用PM2可以将应用的不同组件保持在后台运行。在生产中,您还可以使用PM2来满足运营需求,例如通过实施部署模型,以最小的停机时间对生产应用进行修补。想要了解更多信息,您可以阅读《PM2:分钟级生产就绪的Node.js应用》。

安装的结果将类似于以下内容:

Output
added 183 packages, and audited 184 packages in 2m 12 packages are looking for funding run `npm fund` for details found 0 vulnerabilities -->

要使用PM2进程管理器来运行应用程序,进入您的React项目目录,然后使用nano或您喜欢的编辑器创建一个名为ecosystem.config.js的文件。

  1. cd front-end
  2. nano ecosystem.config.js

ecosystem.config.js文件将保存PM2进程管理器对您的应用程序运行方式的配置。

将以下代码添加到新创建的 ecosystem.config.js 文件中。

jwt-storage-tutorial前端ecosystem.config.js
module.exports = {
  apps: [
    {
      name: 'front-end',
      cwd: '/app/jwt-storage-tutorial/front-end',
      script: 'npm',
      args: 'run start',
      env: {
        PORT: 3000
      },
    },
  ],
};

在这里,您使用PM2进程管理器定义一个新的应用程序配置。name配置参数允许您为您的进程在PM2进程表中选择一个名称,以便于识别。cwd参数设置您将要运行的项目的根目录。script和args参数允许您选择用于运行程序的命令行工具。最后,env参数允许您传入一个JSON对象,以便为您的应用程序设置必要的环境变量。您只定义了一个环境变量PORT,它设置了前端应用程序将要运行的端口。

保存并退出文件。

请使用该命令检查PM2管理器当前运行的进程。

  1. pm2 list

在这种情况下,您目前没有在PM2上运行任何进程,所以您会得到这个输出。

Output
┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐ │ id │ name │ mode │ ↺ │ status │ cpu │ memory │ └────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘

如果您正在运行命令并需要重置进程管理器以清除旧数据,请执行以下命令:

  1. pm2 delete all

现在,使用在你的ecosystem.config.js文件中指定的配置,使用PM2进程管理器启动你的应用程序。

  1. pm2 start ecosystem.config.js

你会在终端上看到类似这样的输出。

Output
┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐ │ id │ name │ mode │ ↺ │ status │ cpu │ memory │ ├────┼────────────────────┼──────────┼──────┼───────────┼──────────┼──────────┤ │ 0 │ front-end │ fork │ 0 │ online │ 0% │ 33.6mb │ └────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘

您可以使用停止(stop)和启动(start)命令以及重新启动(restart)和启动或重新启动(startOrRestart)命令来控制PM2进程的活动。

您可以通过在您偏好的浏览器中导航至http://localhost:3000来查看应用程序。将显示默认的React欢迎页面。

Screencapture of the React application's initial startup display in the browser

最后,在客户端路由中安装React-Router的5.2.0版本。

  1. npm install react-router-dom@5.2.0

当安装完成时,您将收到以下变体的消息:

Output
... added 13 packages, and audited 1460 packages in 7s 205 packages are looking for funding run `npm fund` for details 6 high severity vulnerabilities To address all issues (including breaking changes), run: npm audit fix --force Run `npm audit` for details.

在这一步中,您在Docker容器中设置React应用程序的骨架。接下来,您将构建应用程序的页面,稍后将用于对抗XSS攻击进行测试。

第三步 – 构建登录页面

在这一步中,您将为您的应用程序创建一个登录页面。您将使用组件来表示一个既有私有资产又有公共资产的应用程序。接下来,您将实现一个登录页面,在该页面上用户将验证自己以获得访问网站私有资产的权限。到本步骤结束时,您将拥有一个标准应用程序的框架,包含私有和公共资产以及一个登录页面。

首先,你将创建主页和登录页面。然后,你将创建一个SubscriberFeed组件,用于表示只有登录用户才能查看的私密页面。

首先,创建一个组件目录来存放你的应用程序的所有组件。

  1. mkdir src/components

然后,在组件目录中创建并打开一个名为SubscriberFeed.js的新文件。

  1. nano src/components/SubscriberFeed.js

在SubscriberFeed.js文件内部,使用一个<h2>标签,并添加以下行代码,其中包含组件标题。

请将以下内容以中文进行重述,只需提供一种选项:
jwt-storage-tutorial/front-end/src/components/SubscriberFeed.js

JWT存储教程前端代码中,位于/components/SubscriberFeed.js文件中。

import React from 'react';

export default () => {
  return(
    <h2>Subscriber Feed</h2>
  );
}

保存并关闭文件。

接下来,您将在App.js文件中导入SubscriberFeed组件,并创建路由以使该组件对用户可访问。打开您项目中src目录下的App.js文件。

  1. nano src/App.js

将下面标记的代码行添加到导入语句中,从react-router-dom包中导入BrowserRouter、Switch和Route组件。

jwt-storage-tutorial/front-end/src/App.js 的内容如下
import logo from './logo.svg';
import './App.css';

import { BrowserRouter, Route, Switch } from 'react-router-dom';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}

export default App;

你将使用这些来设置你的网络应用中的路由。

接下来,将刚刚创建的SubscriberFeed组件添加到导入的突出显示的行中。

请用中文将以下内容进行改写,只需要提供一个选项:
jwt-storage-tutorial/front-end/src/App.js。

jwt-storage-tutorial/front-end/src/App.js文件

import logo from './logo.svg';
import './App.css';

import { BrowserRouter, Route, Switch } from 'react-router-dom';
import SubscriberFeed from "./components/SubscriberFeed";

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}

export default App;

你现在已经准备好创建主应用程序和网页路由了。

仍然在src/App.js中,删除返回的JSX行(即在return关键字之后的括号内的所有内容)并用高亮的行替换。

jwt-storage-tutorial/front-end/src/App.js的中文释义如下:

令牌存储教程/前端/src/App.js

import logo from './logo.svg';
import './App.css';

import { BrowserRouter, Route, Switch } from 'react-router-dom';
import SubscriberFeed from "./components/SubscriberFeed";

function App() {
  return(
    <div className="App">
      <h1 className="App-header">
        JWT-Storage-Tutorial Application
      </h1>
    </div>
  );
}

export default App;

标签拥有一个className属性为App,其中包含一个<h1>标签,显示你的应用程序名称。

在<h1>标签下方,添加一个 BrowserRouter 组件,该组件使用 Switch 组件将包含 SubscriberFeed 组件的 Route 组件进行包装。

将下述文件进行中文本地化,仅需一种选择:
jwt-storage-tutorial/front-end/src/App.js
import logo from './logo.svg';
import './App.css';

import { BrowserRouter, Route, Switch } from 'react-router-dom';
import SubscriberFeed from "./components/SubscriberFeed";

function App() {
  return(
    <div className="App">
      <h1 className="App-header">
        JWT-Storage-Tutorial Application
      </h1>
      <BrowserRouter>
        <Switch>
          <Route path="/subscriber-feed">
            <SubscriberFeed />
          </Route>
        </Switch>
      </BrowserRouter>
    </div>
  );
}

export default App;

这些新行允许您定义应用程序的路由。BrowserRouter组件封装了您定义的路径。Switch组件确保返回的路径是与用户导航到的路径匹配的第一条路由,而Route组件定义特定的路由名称。

最后,您将使用CSS为应用程序添加填充,以使标题和组件居中和漂亮。在最外层的<div>标签的className属性上添加一个包装器。

请为저희文件中的’jwt-storage-tutorial/front-end/src/App.js’提供中文的同义词。
import logo from './logo.svg';
import './App.css';

import { BrowserRouter, Route, Switch } from 'react-router-dom';
import SubscriberFeed from "./components/SubscriberFeed";

function App() {
  return(
    <div className="App wrapper">
      <h1 className="App-header">
        JWT-Storage-Tutorial Application
      </h1>
      <BrowserRouter>
        <Switch>
          <Route path="/subscriber-feed">
            <SubscriberFeed />
          </Route>
        </Switch>
      </BrowserRouter>
    </div>
  );
}

export default App;

保存并关闭App.js文件。

打开App.css文件。

  1. nano src/App.css

你会在这个文件中看到现有的CSS。删除文件中的所有内容。

然后,添加下列代码来定义包装样式:

jwt-storage-tutorial/front-end/src/App.css 可以被翻译为“jwt-storage-tutorial/front-end/src/App.css”。
.wrapper {
    padding: 20px;
    text-align: center;
}

你将wrapper类的text-align属性设置为center来使应用程序中的文本居中。你还通过将padding属性设置为20px,为wrapper类添加了20像素的内边距。

保存并关闭App.css文件。

你可能会看到你的React首页更新了新的样式。导航到http://localhost:3000/subscriber-feed来查看订阅者动态,现在已经可见。

Screencapture of the React application with a visible subscriber feed page

路由按预期工作,但所有访客都可以访问订阅者动态。为了确保订阅者动态仅对经过身份验证的用户可见,您需要为用户创建一个登录页面,让他们通过用户名和密码进行验证。

在你的组件目录中打开一个新的Login.js文件。

  1. nano src/components/Login.js

将以下行添加到新文件中:

jwt-storage-tutorial/front-end/src/components/Login.js 的组件
import React from 'react';

export default () => {
  return(
    <div className='login-wrapper'>
      <h1>Login</h1>
      <form>
        <label>
          <p>Username</p>
          <input type="text" />
        </label>
        <label>
          <p>Password</p>
          <input type="password" />
        </label>
        <div>
          <button type="submit">Submit</button>
        </div>
      </form>
    </div>
  );
}

你可以创建一个表单,其中包含一个 <h1> 标签作为标题、两个输入框(用户名和密码)以及一个提交按钮。你将表单放在一个 <div> 标签中,并给它一个类名为 login-wrapper,以便在 App.css 文件中进行样式设置。

保存并关闭文件。

打开项目根目录中的App.css文件,为您的登录组件设置样式。

  1. nano src/App.css

将以下CSS行添加到login-wrapper类的样式中。

jwt-storage-tutorial/front-end/src/App.css 的内容进行中文含义的复述是什么:
...

.login-wrapper {
    display: flex;
    flex-direction: column;
    align-items: center;
}

你可以通过使用flex的display属性和align-items属性,将组件居中在页面上。然后,你将flex-direction设置为column,这将使元素在垂直的列中对齐。

保存并关闭文件。

最后,你将使用useState Hook将Login组件渲染在App.js中,并将令牌存储在内存中。打开App.js文件:

  1. nano src/App.js

将突出显示的行添加到文件中。

这是一个指向jwt-storage-tutorial项目的前端源码文件,位置是在front-end/src/App.js。
import logo from './logo.svg';
import './App.css';

import { useState } from 'react'

import { BrowserRouter, Route, Switch } from 'react-router-dom';
import SubscriberFeed from "./components/SubscriberFeed";
import Login from './components/Login';

function App() {
  const [token, setToken] = useState();

  if (!token) {
    return <Login setToken={setToken} />
  }

  return(
    <div className="App wrapper">
      <h1 className="App-header">
        JWT-Storage-Tutorial Application
      </h1>
      <BrowserRouter>
        <Switch>
          <Route path="/subscriber-feed">
            <SubscriberFeed />
          </Route>
        </Switch>
      </BrowserRouter>
    </div>
  );
}

export default App;

首先,你从 React 包中导入 useState 钩子。

您还将创建一个名为新令牌状态变量,以存储登录过程中获取的令牌信息。在步骤5中,您将通过使用浏览器存储来改进此设置,以持久化身份验证状态。在步骤7中,您将进一步加强持久化方法,通过使用HTTP-only cookie安全地存储身份验证状态。

你还要导入Login组件,如果token的值为假值,则显示登录页面。if语句声明,如果token为假值,则要求用户登录,如果他们没有通过身份验证。你将setToken函数作为prop传递给Login组件。

保存并关闭文件。

然后,刷新你的应用程序页面以加载新建的登录页面。由于目前尚未实现设置令牌的功能,应用程序只会显示登录页面。

Screencapture of the React application login form showing username and password input boxes

在这个步骤中,您更新了您的应用程序,添加了一个登录页面和一个私密组件,这个组件在用户登录之前将受到未授权用户的保护。

接下来,您将使用NodeJS创建一个新的后端应用程序,并创建一个新的登录路由,在您的前端应用程序上调用身份验证令牌。

第四步 – 创建一个令牌API

在这一步中,您将为前一步设置的前端React应用程序创建一个Node服务器作为后端。您将使用Node服务器创建并提供一个API,该API在前端用户通过验证后返回一个身份验证令牌。在本步骤结束时,您的应用程序将具有一个工作的登录页面,仅在成功验证后才可用的私有资源以及通过API调用进行身份验证的后端服务器应用程序。

你将使用Express框架构建服务器,并使用cors包为所有路由启用跨域资源共享。这样,你就可以在没有CORS错误的情况下测试和开发你的应用程序。

Warning

警告:为了教学目的,在开发环境中启用了CORS。然而,在生产应用程序中为所有路由启用CORS将导致安全漏洞。

创建并移动到一个名为后端的文件夹,用于存放你的Node项目。

  1. mkdir /app/jwt-storage-tutorial/back-end
  2. cd /app/jwt-storage-tutorial/back-end

在新的目录中,初始化节点项目。

  1. npm init -y

init命令告诉npm命令行工具在运行该命令的目录中创建一个新的Node项目。-y标志使用交互式命令行工具在创建新项目时,对所有初始化问题使用默认值。以下是使用-y标志运行init命令的输出:

Output
Wrote to /home/nodejs/jwt-storage-tutorial/back-end/package.json: { "name": "back-end", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC" }

接下来,将express和cors模块安装在后端项目目录中。

  1. npm install express cors

以下输出的某种变体将出现在终端上。

Output
added 59 packages, and audited 60 packages in 3s 7 packages are looking for funding run `npm fund` for details found 0 vulnerabilities

新建一个index.js文件。

  1. nano index.js

将以下代码添加到导入express模块并通过调用express()方法以及将结果存储在名为app的变量中来初始化一个新的Express应用程序的行。

jwt-storage-tutorial/back-end/index.js的翻译
const express = require('express');
const app = express();

接下来,使用高亮显示的代码行将 CORS 添加为应用程序的中间件。

将以下英文句子改写为中文原生表达,并提供一种选项:
jwt-storage-tutorial/back-end/index.js
JWT 存储教程/后端/index.js
const express = require('express');
const cors = require('cors');

const app = express();

app.use(cors());

你需要导入cors模块,然后使用use方法将其添加到app对象中。

然后将突出显示的行添加到定义处理程序的/login路径,以向登录的用户返回一个令牌。

请把jwt-storage-tutorial/back-end/index.js用中文本地化:
const express = require('express');
const cors = require('cors');

const app = express();

app.use(cors());

app.use('/login', (req, res) => {
    res.send({
      token: "This is a secret token"
    });
});

你可以使用app.use()方法来为路由定义一个请求处理程序。这个路由将允许你从刚刚构建的前端应用中发送用户的用户名和密码进行认证。作为回应,你将提供用户的身份验证令牌,以便用户可以进行经过认证的后端应用调用。

app.use方法的首个参数是应用程序接受请求的路由。第二个参数是一个回调函数,详细说明如何处理应用程序接收到的请求。回调函数有两个参数:一个req参数包含请求数据,一个res参数包含响应数据。

Note

注意:用户在使用后端API登录时,您没有检查凭据的准确性。出于简洁起见,本步骤未包含在内,但是生产应用程序通常会查询数据库以检查用户是否提供了正确的用户名和密码,然后再发出身份验证令牌。

最后,使用app.listen函数将服务器运行在8080端口上,并添加高亮的行代码。

jwt-storage-tutorial/back-end/index.js的内容进行释义

const express = require('express');
const cors = require('cors');

const app = express();

app.use(cors());

app.use('/login', (req, res) => {
    res.send({
      token: "This is a secret token"
    });
});

app.listen(8080, () => console.log(`API is active on http://localhost:8080`));

保存并关闭文件。

要使用PM2运行您的后端应用程序,请创建一个新的backend/ecosystem.config.js文件。

  1. nano ecosystem.config.js

将以下配置代码添加到新创建的后端 / ecosystem.config.js 文件中:

jwt-storage-tutorial/back-end/ecosystem.config.js的解释指南。
module.exports = {
  apps: [
    {
      name: 'back-end',
      cwd: '/app/jwt-storage-tutorial/back-end',
      script: 'node',
      args: 'index.js',
      watch: ['index.js']
    },
  ],
};

PM2将使用与前端应用类似的配置参数来管理后端应用。

当你在配置文件中将观察(watch)参数设置为每次对文件进行更改时自动重新加载应用程序。观察参数是一个有用的开发功能,它在代码更改时会在浏览器中更新结果。对于前端应用程序,你不需要观察参数,因为你使用了具有默认自动重新加载功能的react-scripts运行它。然而,你的后端应用程序将使用node运行时,它没有这个默认功能。

保存并关闭文件。

现在你可以使用pm2运行后端应用程序。

  1. pm2 start ecosystem.config.js

你的输出将会是以下内容的某种变化:

Output
[PM2][WARN] Applications back-end not running, starting... [PM2] App [back-end] launched (1 instances) ┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐ │ id │ name │ mode │ ↺ │ status │ cpu │ memory │ ├────┼────────────────────┼──────────┼──────┼───────────┼──────────┼──────────┤ │ 2 │ back-end │ fork │ 0 │ online │ 0% │ 24.0mb │ │ 0 │ front-end │ fork │ 9 │ online │ 0% │ 47.2mb │ └────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘

你将使用curl来评估你新创建的API端点是否正确地返回一个身份验证令牌。

  1. curl localhost:8080/login

你应该看到下面的输出结果。

Output
{"token":"This is a secret token"}

现在你知道你的服务器登录路径按预期返回了令牌。

然后,您将修改前端登录组件以使用API。导航至相应的前端文件夹。

  1. cd ..
  2. cd front-end/src/components/

打开前端的Login.js文件。

  1. nano Login.js

添加了突出显示的行:

使用JavaScript编写的登录组件位于jwt-storage-tutorial/front-end/src/components/Login.js文件中。
import React, { useRef } from 'react';

export default () => {
  const emailRef = useRef();
  const passwordRef = useRef();

  return(
    <div className='login-wrapper'>
      <h1>Login</h1>
      <form>
        <label>
          <p>Username</p>
          <input type="text" ref={emailRef} />
        </label>
        <label>
          <p>Password</p>
          <input type="password" ref={passwordRef} />
        </label>
        <div>
          <button type="submit">Submit</button>
        </div>
      </form>
    </div>
  );
}           

你可以使用useRef钩子来跟踪电子邮件和密码输入字段的值。当在与useRef钩子绑定的输入字段中输入时,输入的值将在引用中更新,然后在按下提交按钮时将其发送到后端。

接下来,添加高亮显示的代码行以创建handleSubmit回调函数,以处理表单中按下提交按钮的情况。

jwt-storage-tutorial/front-end/src/components/Login.js 的中文翻译: “登录.js”。
import React, { useRef } from 'react';

async function loginUser(credentials) {
  return fetch('http://localhost:8080/login', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(credentials)
  }).then(data => data.json())
}

export default ({ setToken }) => {
  const emailRef = useRef();
  const passwordRef = useRef();

  const handleSubmit = async (e) => {
    e.preventDefault();
    const token = await loginUser({
        username: emailRef.current.value,
        password: passwordRef.current.value
    })
    setToken(token)
  }

  return(
    <div className='login-wrapper'>
      <h1>Login</h1>
      <form onSubmit={handleSubmit}>
        <label>
          <p>Username</p>
          <input type="text" ref={emailRef} />
        </label>
        <label>
          <p>Password</p>
          <input type="password" ref={passwordRef} />
        </label>
        <div>
          <button type="submit">Submit</button>
        </div>
      </form>
    </div>
  );
}

在handleSubmit处理程序函数内部,您调用loginUser助手函数来向之前创建的API的登录路由发出fetch请求。在handleSubmit函数中对传入的事件调用preventDefault函数意味着不执行提交按钮的默认刷新功能,因此您的应用程序可以调用登录端点并处理用户登录所需的步骤。它还使用作为属性传递给Login组件的setter来设置token状态变量的值。

完成后保存并关闭文件。

当您在浏览器中检查 Web 应用程序时,您现在可以使用任意的用户名和密码进行登录。点击提交按钮,将被重定向到一个已登录的页面。如果您刷新页面,您的 React 应用程序将丢失令牌,并且您将被注销。

在接下来的步骤中,您将使用浏览器存储机制来保存在前端应用程序中收到的令牌。

步骤5 — 使用浏览器存储来储存令牌

如果用户能够在浏览器会话和页面刷新之间保持登录状态,将有助于提高用户体验。在这个步骤中,您将使用Window.localStorage属性来存储认证令牌,以实现持久的用户会话,即使用户关闭浏览器或刷新网页也不会丢失。对于现代Web应用程序而言,持续的用户会话可以减少应用程序处理的网络流量,因为用户不需要频繁使用相同网站的登录凭据。

浏览器存储包括两种不同但相似的存储类型:本地存储和会话存储。简而言之,会话存储在标签会话间保留数据,而本地存储在标签和浏览器会话间保留数据。要使用浏览器存储存储您的令牌,您将使用本地存储。

打开你的前端应用的App.js文件。

  1. nano /app/jwt-storage-tutorial/front-end/src/App.js

开始整合浏览器存储时,请添加下面突出显示的代码行,其中定义了两个辅助函数(setToken 和 getToken),并使用新实现的函数来获取 token 变量:

请将以下内容用中文进行重述,仅需要一个版本:
jwt-storage-tutorial/front-end/src/App.js
import logo from './logo.svg';
import './App.css';

import { useState } from 'react'

import { BrowserRouter, Route, Switch } from 'react-router-dom';
import SubscriberFeed from "./components/SubscriberFeed";
import Login from './components/Login';

function setToken(userToken) {
  localStorage.setItem('token', JSON.stringify(userToken));
  window.location.reload(false)
}

function getToken() {
  const tokenString = localStorage.getItem('token');
  const userToken = JSON.parse(tokenString);
  return userToken?.token
}

function App() {
  let token = getToken()

  if (!token) {
    return <Login setToken={setToken} />
  }

  return(
    <div className="App wrapper">
      <h1 className="App-header">
        JWT-Storage-Tutorial Application
      </h1>
      <BrowserRouter>
        <Switch>
          <Route path="/subscriber-feed">
            <SubscriberFeed />
          </Route>
        </Switch>
      </BrowserRouter>
    </div>
  );
}

export default App;  

您创建两个辅助函数:setToken和getToken。在setToken函数内部,您使用localStorage的setItem函数将用户输入参数userToken映射到名为token的键。您还将使用window.location的reload函数刷新页面,以便您的应用程序可以在浏览器存储中找到新设置的token,并重新渲染应用程序。

在getToken内部,你将使用localStorage的getItem函数来检查是否存在token键的任何值,然后将其返回。你将替换App()函数中的定义变量,以使用getToken函数。

每次用户访问您的网站时,前端将检查浏览器存储中是否存在身份验证令牌,并尝试使用已存在的令牌验证用户,而不是要求他们登录。

保存并关闭文件,然后刷新应用程序。您应该能够现在登录应用程序,刷新网页,而无需再次登录。

在这一步骤中,您使用浏览器存储实现了令牌持久化。您将在下一部分中利用基于浏览器存储的令牌认证系统。

第六步 – 利用XSS攻击窃取浏览器存储

在这一步中,您将对当前应用程序执行一次分阶段的跨站脚本攻击(也称为XSS攻击),该攻击将展示在使用浏览器存储来持久保存密钥信息时存在的安全漏洞。这种攻击将以URL链接的形式呈现,当被点击时,会将受害者引导到您的网络应用程序,并向应用程序注入经过精心设计的代码。这种注入可能会诱使用户与之互动,从而允许恶意代理人窃取受害者浏览器上本地存储的内容。

跨站脚本攻击是现代最常见的网络攻击之一。攻击者通常将恶意脚本注入浏览器,以在可信环境中实现代码执行。攻击者经常利用网络钓鱼技术,通过与恶意构造的链接(如垃圾邮件中的链接)进行交互,诱使用户牺牲其浏览器存储的内容。

XSS攻击对于试图窃取毫无防备的受害者浏览器存储内容的攻击者来说特别感兴趣,因为与该域关联的任何文档上运行的JavaScript代码完全可以访问该域的浏览器存储。如果攻击者能够在用户浏览器上执行特定网页文档的JavaScript代码,则可以窃取用户浏览器存储(包括本地和会话)中与该文档相关联的网域的内容。

为了教学目的,你会有意地让你的应用程序容易受到跨站脚本攻击,通过创建一个名为XSSHelper的组件,可以通过URL查询参数注入代码进入其中。然后,你将通过构建一个恶意的URL来利用这个漏洞。当用户在浏览器中导航到该URL并点击被注入到网页中的可疑链接时,该恶意的URL将访问并暴露已登录用户本地存储中的内容。

在前端应用程序的组件目录中打开一个名为XSSHelper.js的新组件。

  1. nano /app/jwt-storage-tutorial/front-end/src/components/XSSHelper.js

将以下代码添加到新文件中。

/src/components/XSSHelper.js 的中文原生轉述如下:
/src/components/XSSHelper.js 组件
import React from 'react';
import { useLocation } from 'react-router-dom';

export default (props) => {
  const search = useLocation().search;
  const code = new URLSearchParams(search).get('code');


  return(
    <h2>XSS Helper Active</h2>
  );
}

您创建了一个新的功能组件,该组件导入了useLocation钩子,并通过useLocation钩子的search属性访问了代码查询参数。您返回一个包含消息的<h2>标签,以说明XSSHelper组件是活动的。

URLSearchParams这个JavaScript函数提供了一些辅助方法,比如用于与搜索字符串进行交互的获取器。

现在将着重标记的行添加到代码中,以引入并使用 useEffect 钩子来记录查询参数的值。

/src/components/XSSHelper.js 组件
import React, { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

export default (props) => {
  const search = useLocation().search;
  const code = new URLSearchParams(search).get('code');

  useEffect(() => {
    console.log(code)
  })

  return(
    <h2>XSS Helper Active</h2>
  );
}

保存并关闭文件。

接下来,当用户导航到你的应用程序的 xss-helper 路由时,你将修改你的 App.js 文件以返回组件。

打开App.js文件。

  1. nano /app/jwt-storage-tutorial/front-end/src/App.js

将下面的代码添加到import部分,将XSSHelper组件作为一个路由添加进去:

jwt-storage-tutorial/front-end/src/App.js的含义是什么?
import logo from './logo.svg';
import './App.css';

import { useState } from 'react'

import { BrowserRouter, Route, Switch } from 'react-router-dom';
import SubscriberFeed from "./components/SubscriberFeed";
import Login from './components/Login';
import XSSHelper from './components/XSSHelper'

function setToken(userToken) {
  localStorage.setItem('token', JSON.stringify(userToken));
  window.location.reload(false)
}

function getToken() {
  const tokenString = localStorage.getItem('token');
  const userToken = JSON.parse(tokenString);
  return userToken?.token
}

function App() {
  let token = getToken()

  if (!token) {
    return <Login setToken={setToken} />
  }

  return(
    <div className="App wrapper">
      <h1 className="App-header">
        JWT-Storage-Tutorial Application
      </h1>
      <BrowserRouter>
        <Switch>
          <Route path="/subscriber-feed">
            <SubscriberFeed />
          </Route>
          <Route path="/xss-helper">
            <XSSHelper />
          </Route>
        </Switch>
      </BrowserRouter>
    </div>
  );
}

export default App;  

保存并关闭文件。

请在浏览器中导航至localhost:3000/xss-helper?code=’在此处插入代码’。确保您已登录该应用程序,否则您将无法访问XSSHelper组件。

点击左键并按下“检查”按钮。然后导航到控制台部分。在控制台日志中,你会看到“在此处插入代码”。

Screencapture of the XSS Helper page with the URL query parameter passing to show the  message

你现在知道了你可以将URL查询参数传递给你的组件。

接下来,您将使用dangerouslySetInnerHTML属性在网页文档上设置传递到组件中的查询参数的值。该组件会将代码URL查询参数的值注入到网页上的一个div组件中。

Warning

警告:在生产环境中使用dangerouslySetInnerHTML属性可能使您的应用程序容易受到XSS攻击的威胁。

再次打开XSSHelper文件。

  1. nano XSSHelper.js

添加以下线条:

源码/components/XSSHelper.js
import React, {useEffect} from 'react';
import { useLocation } from 'react-router-dom';

export default (props) => {
  const search = useLocation().search;
  const code = new URLSearchParams(search).get('code');

  useEffect(() => {
    console.log(code)
  })

  return(
    <>
      <h2>XSS Helper Active</h2>
      <div dangerouslySetInnerHTML={{__html: code}} />
    </>
  );
}

为了避免在使用React片段时产生多个碎片化的JSX返回结果(从语法上来说是非法的),你可以把要返回的元素用空的JSX标签(<> … </>)包裹起来。

保存并关闭文件。

现在你可以将恶意构建的代码注入到你的组件中,在网页上执行代码。

你知道将发送到xss-helper路由的代码查询参数的值将直接嵌入到你的应用程序文档中。你可以将代码查询参数的值设置为一个带有<a>标签的链接,使用href属性将自定义的JavaScript代码直接传递到浏览器。

在浏览器中输入以下URL进行导航:

localhost:3000/xss-helper?code=<a href="javascript:alert(`You have been pwned`);">Click Me!</a>

在上述URL中,您可以创建一个查询参数的XSS载荷,以一个名为“点击我!” 的链接形式显示在网页上。当用户点击链接时,链接会告诉浏览器执行您编写的JavaScript代码。该代码使用alert函数创建一个弹出窗口,显示消息“您已被黑客入侵”。

Screencapture of a successful XSS attack that displays the

接下来,在您的浏览器中输入以下网址:

localhost:3000/xss-helper?code=<a href="javascript:alert(`Your token object is ${localStorage.getItem('token')}. It has been sent to a malicious server >:)`);">Click Me!</a>

对于这个页面来说,通过URL查询参数脚本注入,攻击者可以访问浏览器存储内容,并使用JavaScript代码读取localStorage中的令牌值。

只有在登录应用程序后,令牌才会存在,这样您恶意构建的URL才能显示本地存储中存储的令牌。当您点击网页上的”点我!”链接时,您将收到一个弹出消息,表示您的令牌已被窃取。

Screencapture of a successful XSS attack for stealing the contents of local storage with a pop-up message informing the user of a stolen token

在这一步中,您使用了众多攻击向量之一来实现代码执行。恶意攻击者利用一个毫不知情的用户的认证令牌,可以冒充用户进入您的网络应用程序,以访问特权站点资源。通过这些测试,您现在知道将诸如认证令牌之类的秘密信息存储在浏览器存储中是一种不安全的做法。

接下来,您将使用一种替代方法来存储机密信息,该方法将无法被运行在文档上的脚本访问,并且对于这种类型的跨站脚本攻击是免疫的。

第七步 – 使用仅限HTTP的Cookie来减轻浏览器存储XSS漏洞的风险。

在这个步骤中,您将使用仅限HTTP的Cookie来减轻在之前的步骤中发现和利用的XSS漏洞。

HTTP cookies是存储在浏览器中的键值对形式的信息片段。它们经常用于跟踪、个性化或会话管理。

JavaScript无法通过Document.cookie属性访问HTTP-only Cookie。 这样可以防止通过恶意代码注入进行的XSS攻击,以窃取用户信息。 您可以使用Set-Cookie头在服务器端为经过身份验证的客户端设置Cookie,在客户端对服务器的每个请求中都可用,然后服务器可以使用它来检查用户的身份验证状态。 您将使用Express中的cookie-parser中间件来处理此操作,而不是设置头文件。

为了实现基于安全的HTTP-only cookie的令牌存储,您需要更新以下文件:

  • The back-end index.js file will be modified to implement the login route so that it sets a cookie upon successful authentication. The back-end will also need two new routes: one for checking the authentication status of a user and one for logging out a user.
  • The front-end Login.js and App.js files will be modified to use the new routes from the back-end.

这些修改将在你的客户端和服务器代码中实现登录、注销和身份验证状态功能。

移动到后端目录并安装cookie-parser软件包,该软件包允许您在Express应用程序中设置和读取cookie。

  1. cd /app/jwt-storage-tutorial/back-end
  2. npm install cookie-parser

您将看到以下输出的一种变体:

Output
... added 2 packages, and audited 62 packages in 1s 7 packages are looking for funding run `npm fund` for details found 0 vulnerabilities...

接下来,在你的后端应用程序中打开index.js文件。

  1. nano /app/jwt-storage-tutorial/back-end/index.js

将以下突出显示的代码添加到app中,使用require方法导入新安装的cookie-parser包,并将其作为中间件使用:

后端/index.js
const express = require('express');
const cors = require('cors');

const cookieParser = require('cookie-parser')

const app = express();

app.use(cors());
app.use(cookieParser())

app.post('/login', (req, res) => {
    res.send({
      token: "This is a secret token"
    });
});

app.listen(8080, () => console.log('API active on http://localhost:8080'));

你还需要配置CORS中间件以绕过开发目的的CORS限制。在同一个文件中,添加如下标明的代码行:

后端/index.js
const express = require('express');
const cors = require('cors');

const cookieParser = require('cookie-parser')

const app = express();

let corsOptions = {
  origin: 'http://localhost:3000',
  credentials: true,
}

app.use(cors(corsOptions));
app.use(cookieParser())

app.post('/login', (req, res) => {
    res.send({
      token: "This is a secret token"
    });
});

app.listen(8080, () => console.log('API active on http://localhost:8080'));

你可以在corsOptions对象的origin选项下设置Access-Control-Allow-Origin CORS头部。这个origin选项应该设置为你的前端发送API请求的域名。你还需要将credentials参数设置为true,告诉前端在每个API请求中发送授权令牌的cookie。origin选项的值指定了接受来自哪些域的访问控制数据,比如cookie,用于后端处理。

最后,将corsOptions的配置对象传递给cors中间件对象。

接下来,您将使用cookie-parser中间件在路由处理程序的响应对象上提供的cookie()方法设置用户的cookie令牌。将app.use(‘/login’, (req, res)部分中的行替换为突出显示的行:

后端/index.js
const express = require('express');
const cors = require('cors');

const cookieParser = require('cookie-parser')

const app = express();

let corsOptions = {
  origin: 'http://localhost:3000',
  credentials: true,
}

app.use(cors(corsOptions));
app.use(cookieParser())

app.use('/login', (req, res) => {
    res.cookie("token", "this is a secret token", {
      httpOnly: true,
      maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age,
      domain: "localhost",
      sameSite: 'Lax',
    }).send({
      authenticated: true,
      message: "Authentication Successful."});
});

app.listen(8080, () => console.log('API active on http://localhost:8080'));

在上面的代码块中,您使用键为token,值为this is a secret token来设置了一个cookie。httpOnly配置选项设置了httpOnly属性,以便该cookie对文档上运行的JavaScript不可访问。

您设置了maxAge属性,使得cookie在14天后过期。14天后,cookie将会过期,浏览器将需要一个新的身份验证cookie。因此,用户需要使用他们的用户名和密码再次登录。

为了确保客户端浏览器不因跨域资源共享(CORS)或其他安全协议问题而拒绝接受您的Cookie,需要设置sameSite和domain属性。

既然你已经有了登录的路径,你现在需要一个退出的路径。请添加以下突出显示的代码来设置退出的方法:

后端/索引.js (Hòuduān/suǒyǐn.js)
const express = require('express');
const cors = require('cors');

const cookieParser = require('cookie-parser')

const app = express();

let corsOptions = {
  origin: 'http://localhost:3000',
  credentials: true,
}

app.use(cors(corsOptions));
app.use(cookieParser())

app.use('/login', (req, res) => {
    res.cookie("token", "this is a secret token", {
      httpOnly: true,
      maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age,
      domain: "localhost",
      sameSite: 'Lax',
    }).send({
      authenticated: true,
      message: "Authentication Successful."});
});

app.use('/logout', (req, res) => {
  res.cookie("token", null, {
    httpOnly: true,
    maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age,
    domain: "localhost",
    sameSite: 'Lax',
  }).send({
    authenticated: false,
    message: "Logout Successful."
  });
});

app.listen(8080, () => console.log('API active on http://localhost:8080'));

退出方法类似于登录路由。退出方法将通过将令牌 cookie 设置为 null 来删除用户存储的令牌。然后,它会通知用户已成功退出登录。

最后,添加上面标出的代码行,实现一个认证状态路由,让用户客户端可以检查用户是否已登录并允许访问私有资源。

后端/索引.js
const express = require('express');
const cors = require('cors');

const cookieParser = require('cookie-parser')

const app = express();

let corsOptions = {
  origin: 'http://localhost:3000',
  credentials: true,
}

app.use(cors(corsOptions));
app.use(cookieParser())

app.use('/login', (req, res) => {
    res.cookie("token", "this is a secret token", {
      httpOnly: true,
      maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age,
      domain: "localhost",
      sameSite: 'Lax',
    }).send({
      authenticated: true,
      message: "Authentication Successful."});
});

app.use('/logout', (req, res) => {
  res.cookie("token", null, {
    httpOnly: true,
    maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age,
    domain: "localhost",
    sameSite: 'Lax',
  }).send({
    authenticated: false,
    message: "Logout Successful."
  });
});

app.use('/auth-status', (req, res) => {
  console.log(req.cookies)

  if (req.cookies?.token === "this is a secret token") {
    res.send({isAuthenticated: true})
  } else {
    res.send({isAuthenticated: false})
  }
})

app.listen(8080, () => console.log('API active on http://localhost:8080'));

您的 auth-status 路由会检查是否存在与用户身份验证令牌的预期值匹配的令牌 cookie。然后,它会回应一个布尔值来指示用户是否已经通过身份验证。

当完成时,请保存并关闭文件。您已在后端进行了必要的更改,以使您的前端能够通过后端API跟踪用户的认证状态。

接下来,您将进行必要的前端更改,以实现基于HTTP的仅Cookie存储的令牌功能。

前往前端目录并打开Login.js文件。

  1. cd ..
  2. cd front-end/src/components/
  3. nano Login.js

请将高亮的行添加到您的登录组件中的loginUser函数中进行修改:

jwt-storage-tutorial/front-end/src/components/Login.js 的中文同义表述如下:登录组件。
...

async function loginUser(credentials) {
  return fetch('http://localhost:8080/login', {
    method: 'POST',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(credentials)
  }).then(data => data.json())
}

...            

你可以将你的fetch请求的credentials header设置为“include”,这样就会告诉loginUser函数在发送到已在后端修改过的登录路径的API调用时,发送任何可能设置为cookie的凭据。

接下来,您将从登录组件中移除setToken输入属性以及将其在handleSubmit回调末尾的使用,因为您不再将令牌保留在内存中。

在handlesubmit函数结束时,您还需要触发一次刷新,以便在单击登录按钮后刷新应用程序,并通过客户端应用程序识别新设置的令牌cookie。添加以下突出显示的行:

教程应存储在JWT-STORAGE前端组件的Login.js文件中。
...
  const handleSubmit = async (e) => {
    e.preventDefault();
    const token = await loginUser({
        username: emailRef.current.value,
        password: passwordRef.current.value
    })
    window.location.reload(false);
  }
...

你的Login.js文件现在应该看起来像这样:

请用中文将以下内容进行重新表达,只需要一个选项:
jwt-storage-tutorial/front-end/src/components/Login.js

JWT存储教程/前端/源代码/组件/Login.js

import React, { useRef } from 'react';

async function loginUser(credentials) {
  return fetch('http://localhost:8080/login', {
    method: 'POST',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(credentials)
  }).then(data => data.json())
}

export default () => {
  const emailRef = useRef();
  const passwordRef = useRef();

  const handleSubmit = async (e) => {
    e.preventDefault();
    const token = await loginUser({
        username: emailRef.current.value,
        password: passwordRef.current.value
    })
    window.location.reload(false);
  }

  return(
    <div className='login-wrapper'>
      <h1>Login</h1>
      <form onSubmit={handleSubmit}>
        <label>
          <p>Username</p>
          <input type="text" ref={emailRef} />
        </label>
        <label>
          <p>Password</p>
          <input type="password" ref={passwordRef} />
        </label>
        <div>
          <button type="submit">Submit</button>
        </div>
      </form>
    </div>
  );
}

保存并关闭文件。

由于您不再将身份验证令牌保存在内存中,因此当您需要确定用户是否应该登录或访问私有资产时,无法检查是否存在身份验证令牌。

为了进行这些更改,请打开你的前端App.js文件。

  1. cd ..
  2. nano App.js

从React包中导入useState hook,并初始化一个新的经过身份验证的状态变量,以及用于反映用户认证状态的setter:

jwt-storage-tutorial/front-end/src/App.js 的中文本土化版本。
import logo from './logo.svg';
import './App.css';

import { useState } from 'react'

...

function App() {
  let [authenticated, setAuthenticated] = useState(false);

  if (!token) {
    return <Login setToken={setToken} />
  }

  return(
    <div className="App wrapper">
      <h1 className="App-header">
        JWT-Storage-Tutorial Application
      </h1>
      <BrowserRouter>
        <Switch>
          <Route path="/subscriber-feed">
            <SubscriberFeed />
          </Route>
        </Switch>
      </BrowserRouter>
    </div>
  );
}

export default App;

useState钩子将通过向后端API发送请求来检查用户的身份验证状态,该API能够判断前端客户端是否持有有效的身份验证令牌作为cookie。

接下来,删除setToken和getToken函数,token变量以及登录组件的条件渲染部分。然后,使用下面的突出显示的代码行创建两个新函数,分别命名为getAuthStatus和isAuthenticated。

请把以下内容用中文表达,只提供一种选项:
jwt-storage-tutorial/front-end/src/App.js
import logo from './logo.svg';
import './App.css';

import { useState } from 'react'

import { BrowserRouter, Route, Switch } from 'react-router-dom';
import SubscriberFeed from "./components/SubscriberFeed";
import Login from './components/Login';

function App() {
  let [authenticated, setAuthenticated] = useState(false);

  async function getAuthStatus() {
    return fetch('http://localhost:8080/auth-status', {
      method: 'GET',
      credentials: 'include',
      headers: {
        'Content-Type': 'application/json'
      },
    }).then(data => data.json())
  }

  async function isAuthenticated() {
    const authStatus = await getAuthStatus();
    setAuthenticated(authStatus.isAuthenticated);
  }

  return(
    <div className="App wrapper">
      <h1 className="App-header">
        JWT-Storage-Tutorial Application
      </h1>
      <BrowserRouter>
        <Switch>
          <Route path="/subscriber-feed">
            <SubscriberFeed />
          </Route>
        </Switch>
      </BrowserRouter>
    </div>
  );
}

export default App;    

getAuthStatus函数将向后端应用程序的auth-status路由发出GET请求,以获取用户的身份验证状态,具体取决于用户是否使用有效的身份验证令牌cookie发送请求。

通过将凭据选项的值设置为包括,fetch函数将发送浏览器为用户客户端可能存储的任何凭据,如cookies。isAuthenticated函数将调用getAuthStatus函数,并将您的应用程序的认证状态设置为反映用户认证状态的布尔值。

接下来,您将使用下面标记的行导入useEffect钩子。

jwt-storage-tutorial/front-end/src/App.js的中文释义如下:

在jwt-storage-tutorial/front-end/src/App.js中的解释如下:

import logo from './logo.svg';
import './App.css';

import { useState, useEffect } from 'react'

import { BrowserRouter, Route, Switch } from 'react-router-dom';
import SubscriberFeed from "./components/SubscriberFeed";
import Login from './components/Login';

function App() {
  let [authenticated, setAuthenticated] = useState(false);

  async function getAuthStatus() {
    return fetch('http://localhost:8080/auth-status', {
      method: 'GET',
      credentials: 'include',
      headers: {
        'Content-Type': 'application/json'
      },
    }).then(data => data.json())
  }

  async function isAuthenticated() {
    const authStatus = await getAuthStatus();
    setAuthenticated(authStatus.isAuthenticated)
  }

  useEffect(() => {
    isAuthenticated();
  }, [])

...

这个修改将在useEffect hook中调用登录路由来检查身份验证状态。在useEffect hook中包含一个空的依赖数组可以帮助避免应用程序中的内存泄漏。

在应用主页上有一个条件渲染登录组件的方式,只需要添加下面突出的代码行:

在jwt-storage-tutorial/front-end/src/App.js文件中
...

function App() {
  let [authenticated, setAuthenticated] = useState(false);
  let [loading, setLoading] = useState(true)

  async function getAuthStatus() {
    await setLoading(true);
    return fetch('http://localhost:8080/auth-status', {
      method: 'GET',
      credentials: 'include',
      headers: {
        'Content-Type': 'application/json'
      },
    }).then(data => data.json())
  }

  async function isAuthenticated() {
    const authStatus = await getAuthStatus();
    await setAuthenticated(authStatus.isAuthenticated);
    await setLoading(false)
  }

  useEffect(() => {
    isAuthenticated();
  }, [])

  return (
    <>
      {!loading && (
        <>
          {!authenticated && <Login />}

          {authenticated && (
            <div className="App wrapper">
              <h1 className="App-header">
                JWT-Storage-Tutorial Application
              </h1>
              <BrowserRouter>
                <Switch>
                  <Route path="/subscriber-feed">
                    <SubscriberFeed />
                  </Route>
                  <Route path="/xss-helper">
                    <XSSHelper />
                  </Route>
                </Switch>
              </BrowserRouter>
            </div>
          )}
        </>
      )}
    </>
  );
}

export default App;

如果认证的变量设置为false,你的应用将渲染登录组件。否则,应用的主页和其所有的路由,包括私有页面,将会渲染。

你添加了一个新的加载状态变量,以避免在完成到你的后端应用程序的认证状态路由的调用之前渲染任何内容。因为认证状态变量最初为false,客户端会假定用户尚未登录,直到认证状态路由的API调用完成并更新认证状态变量。

接下来,你将创建一个logoutUser函数,在后端API上调用你的登出路由。将以下行添加到文件中:

将以下的原文以中国大陆的母语进行释义(只提供一个选项):jwt-storage-tutorial/front-end/src/App.js
import logo from './logo.svg';
import './App.css';

import { useState, useEffect } from 'react';

import { BrowserRouter, Route, Switch } from 'react-router-dom';
import SubscriberFeed from "./components/SubscriberFeed";
import Login from './components/Login';
import XSSHelper from './components/XSSHelper'

function App() {
  let [authenticated, setAuthenticated] = useState(false);
  let [loading, setLoading] = useState(true)

  async function getAuthStatus() {
    await setLoading(true);
    return fetch('http://localhost:8080/auth-status', {
      method: 'GET',
      credentials: 'include',
      headers: {
        'Content-Type': 'application/json'
      },
    }).then(data => data.json())
  }

  async function isAuthenticated() {
    const authStatus = await getAuthStatus();
    await setAuthenticated(authStatus.isAuthenticated);
    await setLoading(false);
  }

  async function logoutUser() {
    await fetch('http://localhost:8080/logout', {
      method: 'POST',
      credentials: 'include',
      headers: {
        'Content-Type': 'application/json'
      },
    })
    isAuthenticated();
  }

  useEffect(() => {
    isAuthenticated();
  }, [])

  return (
    <>
      {!loading && (
        <>
          {!authenticated && <Login />}

          {authenticated && (
            <div className="App wrapper">
              <h1 className="App-header">
                JWT-Storage-Tutorial Application
              </h1>
              <button onClick={logoutUser}>Logout</button>
              <BrowserRouter>
                <Switch>
                  <Route path="/subscriber-feed">
                    <SubscriberFeed />
                  </Route>
                  <Route path="/xss-helper">
                    <XSSHelper />
                  </Route>
                </Switch>
              </BrowserRouter>
            </div>
          )}
        </>
      )}
    </>
  );
}

export default App;

你将会创建一个“登出”按钮来登出用户,将其onClick属性设置为一个回调函数,该函数会调用后端API上的登出路线。该路线将会通过设置一个set-cookie头来将客户端的令牌cookie设置为null,从而有效地将您的前端应用程序的身份验证状态设为虚假值。

在退出回调函数结束时,你还需要调用isAuthenticated函数,通过将authenticated状态变量设置为false来更新应用程序的状态,以反映用户的未认证状态。

完成后保存并关闭文件。

现在你可以测试基于 HTTP-only Cookie 的令牌存储系统。刷新网页应用程序以实施刚刚所做的修改。

然后,清除浏览器存储中的内容,以删除任何残留的令牌。接下来,请导航到与第四步中恶意制作的相同的 URL,以查看攻击者是否仍然可以通过注入的 JavaScript 来窃取您的令牌。

localhost:3000/xss-helper?code=<a href="javascript:alert(`Your token object is ${localStorage.getItem('token')}. It has been sent to a malicious server >:)`);">Click Me!</a>

您可能需要重新登录您的网站以查看XSS Helper激活线。在点击名为“点击我!”的链接后,您应该看到以下弹窗,提示“您的令牌对象为null”。

Screencapture of the failed XSS attack that results in

注入的JavaScript无法找到令牌对象,因此弹出窗口显示空值。关闭弹出消息。

你现在可以通过点击“注销”按钮来退出应用。

在这个步骤中,你通过从使用浏览器存储进行身份验证令牌持久化切换为使用仅限HTTP的Cookie来提高了应用程序的安全性。

结论

在本教程中,您使用Docker容器创建了一个具有用户登录功能的React和Node网页应用程序。您实施了一个使用易受攻击的令牌存储方法的身份验证系统,以测试您网站的安全性。然后,您使用反射式XSS攻击有效载荷利用了这种方法,从而使您能够评估使用浏览器存储身份验证Cookie时存在的漏洞。最后,您通过设置一个使用仅HTTP的Cookie而不是浏览器存储来存储身份验证令牌的身份验证系统,来减轻初始实现中的XSS漏洞。现在,您拥有一个基于HTTP-only Cookie的前端和后端应用程序,具有身份验证令牌系统。

为了提升应用程序认证过程的安全性和易用性,你可以整合第三方认证工具,比如PassportJS或者像DigitalOcean的OAuth API这样的OAuth API。关于OAuth框架的更多信息,请参考《OAuth 2简介》。