Как защитить приложения React от XSS-атак с помощью файлов cookie только для HTTP
Автор выбрал программу Write for DOnations.
Введение
Аутентификация на основе токенов может защитить веб-приложения, в которых есть как общедоступные, так и частные активы. Доступ к личным активам требует от пользователя успешной аутентификации, обычно путем предоставления имени пользователя и секретного пароля, которые знает только пользователь. Успешная проверка подлинности возвращает токен на время, пока пользователь решает оставаться аутентифицированным, поэтому пользователь может предоставить токен вместо необходимости повторной проверки подлинности при каждом доступе к привилегированным ресурсам. Использование токенов поднимает важный вопрос о том, где хранить токены, чтобы обеспечить их безопасность. Токены могут храниться в хранилище браузера с помощью XSS-атак, поскольку содержимое локального хранилища и хранилища сеанса доступно для любого JavaScript, запущенного в том же документе, в котором хранятся данные.
В этом руководстве вы создадите приложение React и фиктивный API, который реализует систему аутентификации на основе токенов, настроенную в локальном контейнере Docker для согласованного тестирования на разных платформах. Вы начнете с реализации аутентификации на основе токенов с использованием хранилища браузера со свойством Window.localStorage
. Затем вы будете использовать эту настройку с отраженной атакой межсайтовых сценариев, чтобы понять уязвимости безопасности, присутствующие при использовании хранилища браузера для сохранения секретной информации. Затем вы улучшите это приложение, изменив его на файлы cookie только для HTTP, в которых хранится токен аутентификации, который больше не будет доступен для потенциально вредоносного кода JavaScript, который может присутствовать в документе.
К концу этого руководства вы поймете соображения безопасности, необходимые для реализации функционирующей системы аутентификации на основе токенов вместе с веб-приложением React и Node. Код для этого руководства доступен на GitHub сообщества DigitalOcean.
Предпосылки
Для выполнения этого урока вам потребуется следующее:
- Локальная среда разработки внутри руководства по установке и использованию Docker в Ubuntu 22.04.
- Приложение в этом руководстве было создано на основе образа с запущенным
node:18.7.0-bulseye
. Вы также можете установить серию статей «Как установить Node.js» и «Создать локальную среду разработки».
Шаг 1 — Подготовка контейнера Docker к разработке
На этом этапе вы настроите контейнер Docker для целей разработки. Вы начнете с создания Dockerfile с инструкциями по сборке образа для создания вашего контейнера.
Создайте и откройте файл с именем
Dockerfile
в вашем домашнем каталоге, используяnano
или предпочитаемый вами редактор:- nano 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-bulseye
из Dockerhub. Этот образ поставляется с установленными необходимыми зависимостями NodeJS, что упростит процесс установки.Строка
RUN
обновляет и обновляет пакеты, и эта строка также устанавливает другие пакеты, которые могут вам понадобиться. СтрокаWORKDIR
устанавливает рабочий каталог.Строка
CMD
определяет основной процесс, который будет выполняться внутри контейнера, гарантируя, что контейнер будет продолжать работать, чтобы вы могли подключиться к нему и использовать его для разработки.Сохраните и закройте файл.
Создайте образ Docker с помощью команды
docker build
, заменивpath_to_your_dockerfile
на путь к вашему Dockerfile:- docker build -f /path_to_your_dockerfile --tag jwt-tutorial-image .
Путь к вашему Dockerfile будет передан в параметр
-f
, чтобы указать путь к файлу, из которого вы будете создавать образ. Вы помечаете эту сборку с помощью параметра--tag
, что позволяет позже ссылаться на нее с помощью удобного для чтения имени (в данном случаеjwt-tutorial-image
). .После запуска команды
build
вы увидите примерно такой результат:Output... => => writing image sha256:1cf8f3253e430cba962a1d205d5c919eb61ad106e2933e33644e0bc4e2cdc433 0.0s => => naming to docker.io/library/jwt-tutorial-imageЗапустите образ как контейнер с помощью следующей команды:
- docker run -d -p 3000:3000 -p 8080:8080 --name jwt-tutorial-container jwt-tutorial-image
Флаг
-d
запускает контейнер в режиме detached, поэтому вы можете подключиться к нему с помощью отдельного сеанса терминала.Примечание. Если вы предпочитаете разрабатывать с использованием того же терминала, который вы используете для запуска контейнера Docker, замените флаг
-d
на-it
, который немедленно предоставит вам интерактивный терминал, работающий внутри контейнера.Флаг
-p
будет перенаправлять порты3000
и8080
вашего контейнера. Эти порты обслуживают внешние и внутренние приложения соответственно в сетиlocalhost
вашего хост-компьютера, чтобы вы могли протестировать свое приложение с помощью локального браузера.Примечание. Если ваш хост-компьютер в настоящее время использует порты
3000
и8080
, вам потребуется остановить приложения, использующие эти порты, иначе Docker выдаст ошибку при попытке перенаправить порты.Вы также можете использовать флаг
-P
для переадресации портов ваших контейнеров на неиспользуемые порты в сетиlocalhost
вашего компьютера. Если вы используете флаг-P
вместо сопоставления определенных портов, вам нужно будет запуститьdocker network inspect your_container_name
, чтобы узнать, какие порты контейнера разработки сопоставлены с какими локальными портами.Вы также можете подключиться к VSCode с помощью плагина Remote Containers.
В отдельном сеансе терминала запустите эту команду для подключения к контейнеру:
- docker exec -it jwt-tutorial-container /bin/bash
Вы увидите такое соединение с этикеткой контейнера, чтобы указать, что вы подключились:
Outputroot@d7e051c96368:/app#На этом этапе вы настраиваете готовый образ Docker и подключаетесь к контейнеру, который будете использовать для разработки. Затем вы настроите скелет вашего приложения в контейнере с помощью
create-react-app
.Шаг 2 — Настройка основ вашего внешнего приложения
На этом шаге вы инициализируете приложение React и настроите управление приложением с помощью файла
ecosystem.config.js
.После подключения к контейнеру создайте каталог для своего приложения с помощью команды
mkdir
, а затем перейдите во вновь созданный каталог с помощью командыcd
:- mkdir /app/jwt-storage-tutorial
- cd /app/jwt-storage-tutorial
Затем запустите двоичный файл
create-react-app
с помощью командыnpx
, чтобы инициализировать новый проект React, который будет служить интерфейсом вашего веб-приложения:- 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
с помощью этой команды:- npm install pm2 -g
Флаг
-g
устанавливает пакет глобально. В зависимости от разрешений пользователя, под которым вы вошли, вам может потребоваться использовать командуsudo
для глобальной установки пакетов.PM2 предлагает несколько преимуществ на этапах разработки и производства приложения. Например, PM2 помогает поддерживать работу различных компонентов приложения в фоновом режиме во время разработки. Вы также можете использовать PM2 для оперативных нужд в производственной среде, например для реализации моделей развертывания для исправления рабочего приложения с минимальным временем простоя. Чтобы узнать больше, вы можете прочитать PM2: готовые к производству приложения Nodejs за считанные минуты.
Результат установки будет примерно таким:
Outputadded 183 packages, and audited 184 packages in 2m 12 packages are looking for funding run `npm fund` for details found 0 vulnerabilities -->Чтобы запустить приложение с помощью диспетчера процессов PM2, перейдите в каталог проекта React и создайте файл с именем
ecosystem.config.js
с помощьюnano
или предпочитаемого вами редактора:- cd front-end
- nano ecosystem.config.js
Файл
ecosystem.config.js
будет содержать настройки менеджера процессов PM2 для запуска вашего приложения.Добавьте следующий код во вновь созданный файл
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:
- pm2 list
В этом случае вы в настоящее время не запускаете какие-либо процессы на PM2, поэтому вы получаете следующий вывод:
Output┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐ │ id │ name │ mode │ ↺ │ status │ cpu │ memory │ └────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘Если вы запускаете команды и вам нужно сбросить диспетчер процессов для нового планшета, выполните эту команду:
- pm2 delete all
Теперь запустите приложение с помощью диспетчера процессов PM2 с конфигурациями, указанными в вашем файле
ecosystem.config.js
:- pm2 start ecosystem.config.js
Вы увидите примерно такой вывод на терминале:
Output┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐ │ id │ name │ mode │ ↺ │ status │ cpu │ memory │ ├────┼────────────────────┼──────────┼──────┼───────────┼──────────┼──────────┤ │ 0 │ front-end │ fork │ 0 │ online │ 0% │ 33.6mb │ └────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘Вы можете управлять активностью процессов PM2 с помощью команд
stop
иstart
, а также командrestart
иstartOrRestart
.Вы можете просмотреть приложение, перейдя по адресу
http://localhost:3000
в предпочитаемом вами браузере. Отобразится страница приветствия React по умолчанию:Наконец, установите версию 5.2.0
react-router
для маршрутизации на стороне клиента:- 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.На этом шаге вы настраиваете скелет вашего приложения React в контейнере Docker. Затем вы создадите страницы для своего приложения, которые позже будете использовать для тестирования против XSS-атак.
Шаг 3 — Создание страницы входа
На этом шаге вы создадите страницу входа для своего приложения. Вы будете использовать компоненты для представления приложения как с частными, так и с общедоступными активами. Затем вы создадите страницу входа, на которой пользователь будет верифицировать себя, чтобы получить разрешение на доступ к личным активам на веб-сайте. К концу этого шага у вас будет скелет стандартного приложения со смесью частных и общедоступных ресурсов и страницей входа.
Сначала вы создадите домашнюю страницу и страницу входа. Затем вы создадите компонент
SubscriberFeed
для представления частной страницы, которую смогут просматривать только пользователи, вошедшие в систему.Для начала создайте каталог
components
для хранения всех компонентов вашего приложения:- mkdir src/components
Затем создайте и откройте новый файл в каталоге
components
с именемSubscriberFeed.js
:- nano src/components/SubscriberFeed.js
Внутри файла
SubscriberFeed.js
добавьте эти строки с тегом<h2>
с названием компонента внутри:import React from 'react'; export default () => { return( <h2>Subscriber Feed</h2> ); }
Сохраните и закройте файл.
Затем вы импортируете компонент
SubscriberFeed
в файлApp.js
, создавая маршруты, чтобы сделать компонент доступным для пользователей. Откройте файлApp.js
, который находится в каталогеsrc
вашего проекта:- nano src/App.js
Добавьте следующую выделенную строку, чтобы импортировать компоненты
BrowserRouter
,Switch
иRoute
изreact-router-dom
. упаковка: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
, который вы только что создали: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
) и замените их выделенными строками: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;
Тег
div
имеет атрибутclassName
App
, который содержит тег<h1>
с именем вашего приложения. .Под тегом
<h1>
добавьте компонентBrowserRouter
, который использует компонентSwitch
для переноса компонентаRoute
, который содержит компонентSubscriberFeed
: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, чтобы заголовки и компоненты были отцентрованы и выглядели презентабельно. Добавьте
wrapper
в атрибутclassName
самого внешнего тега<div>
: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
:- nano src/App.css
Вы увидите существующий CSS в этом файле. Удалить все в файле.
Затем добавьте следующие строки, чтобы определить стиль
wrapper
:.wrapper { padding: 20px; text-align: center; }
Вы устанавливаете для свойства
text-align
классаwrapper
значениеcenter
, чтобы центрировать текст в приложении. Вы также добавили 20 пикселей отступа в класс-оболочку, установив для свойстваpadding
значение20px
.Сохраните и закройте файл
App.css
.Вы можете увидеть обновление домашней страницы React с новым стилем. Перейдите к
http://localhost:3000/subscriber-feed
, чтобы просмотреть фид подписчиков, который теперь виден.Маршруты работают как положено, но все посетители могут получить доступ к фиду подписчиков. Чтобы фид подписчиков был виден только пользователям, прошедшим проверку подлинности, вам необходимо создать страницу входа, чтобы пользователи могли подтвердить себя с помощью своего имени пользователя и пароля.
Откройте новый файл
Login.js
в каталоге компонентов:- nano 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>
сclassName
изlogin-wrapper
, чтобы вы могли стилизовать ее в своем приложении. css
файл.Сохраните и закройте файл.
Откройте файл
App.css
в корневом каталоге проекта, чтобы настроить стиль компонентаLogin
:- nano src/App.css
Добавьте следующие строки CSS для оформления класса
login-wrapper
:... .login-wrapper { display: flex; flex-direction: column; align-items: center; }
Вы центрируете компоненты на странице с помощью свойства
display
flex
и свойстваalign-items
center
. Затем вы устанавливаетеflex-direction
вcolumn
, что выровняет элементы по вертикали в столбце.Сохраните и закройте файл.
Наконец, вы визуализируете компонент
Login
внутриApp.js
, используя хукuseState
для сохранения токена в памяти. Откройте файлApp.js
:- nano 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;
Сначала вы импортируете хук
useState
из пакетаreact
.Вы также создаете новую переменную состояния
token
для хранения информации о токене, которая будет извлечена в процессе входа в систему. На шаге 5 вы улучшите эту настройку, используя хранилище браузера для сохранения статуса аутентификации. На шаге 7 вы еще больше укрепите свой метод сохраняемости, используя файлы cookie только для HTTP для безопасного хранения статуса аутентификации.Вы также импортируете компонент
Login
, который будет отображать страницу входа, если значение дляtoken
равноfalsy
. Операторif
объявляет, что если токенfalsy
, пользователь должен будет войти в систему, если он не аутентифицирован. Вы передаете функциюsetToken
компонентуLogin
в качестве реквизита.Сохраните и закройте файл.
Затем обновите страницу своего приложения, чтобы загрузить новую страницу входа. Поскольку в настоящее время не реализована функциональность для установки токена, приложение будет отображать только страницу входа:
На этом шаге вы обновили свое приложение страницей входа и частным компонентом, который будет защищен от неавторизованных пользователей до тех пор, пока они не войдут в систему.
На следующем шаге вы создадите новое серверное приложение с помощью NodeJS и новый маршрут
login
для вызова токена аутентификации в интерфейсном приложении.Шаг 4 — Создание API токена
На этом шаге вы создадите сервер Node в качестве серверной части внешнего приложения React, которое вы настроили на предыдущем шаге. Вы будете использовать сервер Node для создания и предоставления доступа к API, который возвращает токен аутентификации после успешной аутентификации пользователя переднего плана. К концу этого шага ваше приложение будет иметь работающую страницу входа, частные ресурсы, которые доступны только после успешной аутентификации, и серверное приложение, позволяющее выполнять аутентификацию через вызовы API.
Вы создадите сервер, используя совместное использование ресурсов между источниками для всех маршрутов. Затем вы можете тестировать и разрабатывать свое приложение без ошибок CORS.
Предупреждение: CORS включен в среде разработки в учебных целях. Однако включение CORS для всех маршрутов в рабочем приложении приведет к уязвимостям в системе безопасности.
Создайте и перейдите в новый каталог с именем
back-end
, в котором будет размещен ваш проект Node:- mkdir /app/jwt-storage-tutorial/back-end
- cd /app/jwt-storage-tutorial/back-end
В новом каталоге инициализируйте проект Node:
- npm init -y
Команда
init
указывает утилите командной строкиnpm
создать новый проект Node в каталоге, в котором выполняется команда. Флаг-y
использует значения по умолчанию для всех вопросов инициализации, которые интерактивный инструмент командной строки задает при создании нового проекта. Ниже приведен результат выполнения командыinit
с флагом-y
:OutputWrote 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
в каталог проектаback-end
:- npm install express cors
В терминале появится некоторая вариация следующего вывода:
Outputadded 59 packages, and audited 60 packages in 3s 7 packages are looking for funding run `npm fund` for details found 0 vulnerabilitiesСоздайте новый файл
index.js
:- nano index.js
Добавьте следующие строки, чтобы импортировать модуль
express
и инициализировать новое приложение Express, вызвавexpress()
и сохранив результат в переменной с именемapp
:const express = require('express'); const app = express();
Затем добавьте
cors
в приложение как промежуточное ПО с выделенными строками:const express = require('express'); const cors = require('cors'); const app = express(); app.use(cors());
Вы импортируете модуль
cors
, а затем добавляете его в объектapp
с помощью методаuse
.Затем добавьте выделенные строки, чтобы определить обработчик для пути
/login
, который возвращает токен пользователю, пытающемуся войти в систему: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
, содержащий данные ответа.Примечание. Вы не проверяете точность переданных учетных данных, когда пользователь запрашивает вход с помощью внутреннего API. Этот шаг не включен для краткости, но производственное приложение обычно запрашивает в базе данных информацию о пользователе, чтобы проверить, предоставили ли они правильное имя пользователя и пароль, прежде чем выдавать токен аутентификации.
Наконец, добавьте выделенные строки для запуска сервера на порту
8080
с помощью функцииapp.listen
: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
:- nano ecosystem.config.js
Добавьте следующий код конфигурации во вновь созданный файл
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
в файле конфигурации, чтобы включить автоматическую перезагрузку приложения каждый раз, когда в файл вносятся изменения. Параметрwatch
— полезная функция разработки, поскольку она обновляет результаты в браузере по мере внесения изменений в код. Вам не нужен параметр наблюдения для внешнего приложения, потому что вы запускали его с помощьюreact-scripts
, который по умолчанию имеет функцию автоматической перезагрузки. Однако ваше серверное приложение будет запускаться с использованием среды выполненияnode
, которая не имеет такой возможности по умолчанию.Сохраните и закройте файл.
Теперь вы можете запустить внутреннее приложение с помощью
pm2
:- 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 возвращает токен аутентификации:- curl localhost:8080/login
Вы должны увидеть следующий вывод:
Output{"token":"This is a secret token"}Теперь вы знаете, что ваш маршрут входа на сервер возвращает токен, как и ожидалось.
Затем вы измените внешний компонент
Login
для использования API. Перейдите к соответствующей папкеfront-end
:- cd ..
- cd front-end/src/components/
Откройте интерфейсный файл
Login.js
:- nano 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
для обработки при нажатии кнопки отправки в форме: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
, чтобы сделать запрос на выборку маршрутаlogin
созданного ранее API. Вызов функцииpreventDefault
для события, переданного в функциюhandleSubmit
, означает, что функция обновления по умолчанию для кнопки отправки не выполняется, поэтому вместо этого ваше приложение может вызывать конечную точку входа и обрабатывать шаги, необходимые для входа пользователя. Он также установит значение переменной состоянияtoken
, используя сеттер, переданный в компонентLogin
в качестве реквизита.Сохраните и закройте файл, когда закончите.
Когда вы проверяете веб-приложение в своем браузере, теперь вы можете войти в систему с произвольным именем пользователя и паролем. Нажмите кнопку «Отправить», чтобы перейти на страницу, на которой вы вошли в систему. Если вы обновите страницу, ваше приложение React потеряет токен, и вы выйдете из системы.
На следующем шаге вы будете использовать хранилище браузера для сохранения токена, полученного во внешнем приложении.
Шаг 5 — Хранение токенов в хранилище браузера
Это приносит пользу пользователю, если пользователи могут оставаться в системе во время сеансов браузера и обновлений страниц. На этом шаге вы будете использовать свойство
Window.localStorage
для хранения токенов проверки подлинности для постоянных пользовательских сеансов, которые не теряются, когда пользователь закрывает браузер или обновляет веб-страницу. Непрерывные пользовательские сеансы для современных веб-приложений сокращают сетевой трафик, обрабатываемый вашим приложением, поскольку пользователям не нужно постоянно использовать свои учетные данные для входа на один и тот же веб-сайт.Хранилище браузера включает в себя два разных, но похожих типа хранилища: хранилище сеансов. Короче говоря, хранилище сеансов сохраняет данные между сеансами вкладок, в то время как локальное хранилище сохраняет данные между сеансами вкладок и браузера. Чтобы сохранить свой токен в хранилище браузера, вы будете использовать локальное хранилище.
Откройте файл
App.js
для вашего внешнего приложения:- nano /app/jwt-storage-tutorial/front-end/src/App.js
Чтобы начать интеграцию хранилища браузера, добавьте выделенные строки, определяющие две вспомогательные функции (
setToken
иgetToken
), и измените переменнуюtoken
. чтобы получить токен с помощью недавно реализованных функций: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
вы используете функциюsetItem
изlocalStorage
для сопоставления с входным параметромuserToken
вспомогательной функции. на ключ с именемtoken
. Вы также будете использовать функциюreload
свойстваwindow.location
для обновления страницы, чтобы ваше приложение могло найти только что установленный токен в хранилище браузера и повторно отобразить приложение.Внутри
getToken
вы будете использовать функциюgetItem
изlocalStorage
, чтобы проверить, существует ли какое-либо значение для ключаtoken
. , который вы вернете. Вы заменяете определенную переменную в функцииApp()
, чтобы использовать функциюgetToken
.Каждый раз, когда пользователь посещает ваш веб-сайт, внешний интерфейс проверяет, есть ли токен аутентификации в хранилище браузера, и пытается проверить пользователя, используя уже существующий токен, вместо того, чтобы просить его войти в систему.
Сохраните и закройте файл, затем обновите приложение. Теперь вы сможете войти в приложение, обновить веб-страницу, и вам не нужно будет снова входить в систему.
На этом шаге вы реализовали сохранение токена с помощью хранилища браузера. В следующем разделе вы будете использовать систему аутентификации на основе токенов с использованием хранилища браузера.
Шаг 6 — Использование хранилища браузера с помощью XSS-атаки
На этом шаге вы проведете поэтапную атаку с использованием межсайтовых сценариев (также известную как XSS-атака) на свое текущее приложение, которая продемонстрирует уязвимости безопасности, присутствующие при использовании хранилища браузера для сохранения секретной информации. Атака будет осуществляться в виде URL-ссылки, которая при нажатии направляет жертву в ваше веб-приложение и внедряет в приложение созданный код. Внедрение может заставить пользователя взаимодействовать с ним, позволяя злоумышленнику украсть содержимое локального хранилища в браузере жертвы.
Атаки XSS являются одними из самых распространенных современных кибератак. Злоумышленники обычно внедряют вредоносные скрипты в браузеры, чтобы добиться выполнения кода в доверенной среде. Злоумышленники часто используют методы фишинга, чтобы обманом заставить пользователей скомпрометировать содержимое хранилища их браузера, взаимодействуя со злонамеренно созданными ссылками, такими как те, которые доставляются в спам-сообщениях.
Атаки XSS представляют особый интерес для злоумышленников, стремящихся украсть содержимое хранилища браузера ничего не подозревающей жертвы, поскольку хранилище браузера домена полностью доступно для кода JavaScript, который выполняется в любых документах, связанных с доменом. Если злоумышленник может выполнить код JavaScript в браузере пользователя для определенного веб-документа, он может украсть содержимое хранилища браузера пользователя (как локального, так и сеансового) для веб-домена, связанного с этим документом в браузере пользователя.
В учебных целях вы намеренно сделаете свое приложение уязвимым для XSS-атак, создав компонент с именем
XSSHelper
, в который можно будет вводить код через параметры запроса URL. Затем вы воспользуетесь этой уязвимостью, создав вредоносный URL-адрес. Вредоносный URL-адрес будет получать доступ и раскрывать содержимое локального хранилища вошедшего в систему пользователя, когда пользователь переходит по URL-адресу в своем браузере и щелкает подозрительную ссылку, внедренную на веб-страницу.Откройте новый компонент с именем
XSSHelper.js
в каталогеcomponents
клиентского приложения:- nano /app/jwt-storage-tutorial/front-end/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
и обращается к параметру запросаcode
через свойствоsearch
объектаuseLocation
ловушка. Вы возвращаете тег<h2>
с сообщением о том, что компонентXSSHelper
активен.Функция JavaScript
URLSearchParams
предоставляет вспомогательные методы, такие как геттеры, для взаимодействия со строками поиска.Теперь добавьте выделенные строки для импорта и используйте хук
useEffect
для регистрации значения параметра запроса: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> ); }
Сохраните и закройте файл.
Затем вы измените файл
App.js
, чтобы он возвращал компонент, когда пользователь переходит к маршруту вашего приложенияxss-helper
.Откройте файл
App.js
:- nano /app/jwt-storage-tutorial/front-end/src/App.js
Добавьте выделенные строки для импорта и добавьте компонент
XSSHelper
в качестве маршрута: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=inject code here
в своем браузере. Убедитесь, что вы вошли в приложение, иначе вы не сможете получить доступ к компонентуXSSHelper
.Щелкните левой кнопкой мыши и нажмите «Проверить». Затем перейдите в раздел «Консоль». В журнале консоли вы увидите
код для вставки сюда
.Теперь вы знаете, что можете передавать параметры URL-запроса в свой компонент.
Далее вы установите значение параметров запроса, которые передаются в ваш компонент в документе веб-страницы, используя атрибут
dangerouslySetInnerHTML
. Компонент принимает значение параметра URL-запросаcode
и вставляет его в компонентdiv
на веб-странице.Предупреждение. Использование атрибута
dangerouslySetInnerHTML
в рабочей среде может сделать ваше приложение уязвимым для XSS-атак.Снова откройте файл
XSSHelper
:- nano 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}} /> </> ); }
Вы заключаете возвращаемые элементы в пустой тег JSX (
<> ... >
), чтобы избежать многофрагментного возврата JSX, который синтаксически недопустим при работе с фрагментами React.Сохраните и закройте файл.
Теперь вы можете внедрять вредоносный код в свой компонент, чтобы обеспечить выполнение кода на веб-странице.
Вы знаете, что значение параметра запроса
code
, отправленное на маршрутxss-helper
, будет непосредственно встроено в документ вашего приложения. Вы можете установить значение параметра запросаcode
для ссылки с тегом<a>
, который использует атрибутhref
для передачи пользовательского кода JavaScript. прямо в браузер.Перейдите по следующему URL-адресу в браузере:
localhost:3000/xss-helper?code=<a href="javascript:alert(`You have been pwned`);">Click Me!</a>
В приведенном выше URL-адресе вы создаете полезную нагрузку XSS параметра запроса, чтобы она отображалась в виде ссылки с текстом
Click Me!
на веб-странице. Когда пользователь нажимает на ссылку, ссылка сообщает браузеру, что нужно выполнить созданный вами код JavaScript. Этот код использует функциюalert
для создания всплывающего окна с сообщением «Вас забанили».Затем перейдите по следующему URL-адресу в браузере:
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, который считывает значение
токена
, хранящегося вlocalStorage
, с помощью кода JavaScript.Вы должны войти в приложение, чтобы токен существовал, позволяя вашему злонамеренно созданному URL-адресу отображать токен, хранящийся в локальном хранилище. Когда вы нажимаете кнопку Click Me! ссылку на веб-странице, вы получите всплывающее сообщение о том, что ваш токен был украден.
На этом шаге вы использовали один из многих примеров векторов атаки для выполнения кода. С токеном аутентификации ничего не подозревающего пользователя злоумышленники могут выдавать себя за пользователей в вашем веб-приложении для доступа к привилегированным ресурсам сайта. Из этих тестов вы теперь знаете, что хранение секретной информации, такой как токены аутентификации, в хранилище браузера — небезопасная практика.
Далее вы будете использовать альтернативный метод хранения секретной информации, которая будет недоступна для скриптов, работающих с документом, и невосприимчива к этому типу XSS-атаки.
Шаг 7 — Использование файлов cookie только для HTTP для снижения XSS-уязвимости в хранилище браузера
На этом этапе вы будете использовать файлы cookie только для HTTP, чтобы смягчить уязвимость XSS, обнаруженную и использованную на предыдущем этапе.
Файлы cookie HTTP — это фрагменты информации, хранящиеся в парах ключ-значение в браузере. Они часто используются для отслеживания, персонализации или управления сеансами.
JavaScript не может получить доступ к файлу cookie только для HTTP через свойство
Document.cookie
, что помогает предотвратить атаки XSS, направленные на кражу информации о пользователе посредством внедрения вредоносного кода. Вы можете использовать заголовокSet-Cookie
, чтобы установить файлы cookie на стороне сервера для аутентифицированных клиентов, которые будут доступны в каждом запросе, который клиент отправляет на сервер, и затем могут использоваться сервером для проверки аутентификации. статус пользователя. Вы будете использовать промежуточное ПОcookie-parser
с Express, чтобы справиться с этим, а не устанавливать заголовок.Чтобы внедрить безопасное хранилище токенов на основе файлов cookie только для HTTP, вы обновите следующие файлы:
- Внутренний файл
index.js
будет изменен для реализации маршрутаlogin
, чтобы он устанавливал файл cookie после успешной аутентификации. Серверной части также потребуются два новых маршрута: один для проверки статуса аутентификации пользователя и один для выхода пользователя из системы. - Внешние файлы
Login.js
иApp.js
будут изменены для использования новых маршрутов из серверной части.
Эти модификации будут реализовывать функции входа в систему, выхода из системы и состояния аутентификации для вашего клиентского и серверного кода.
Перейдите во внутренний каталог и установите пакет
cookie-parser
, который позволит вам устанавливать и читать файлы cookie в вашем приложении Express:- cd /app/jwt-storage-tutorial/back-end
- 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
во внутреннем приложении:- nano /app/jwt-storage-tutorial/back-end/index.js
Добавьте выделенный код, чтобы импортировать недавно установленный пакет
cookie-parser
с помощью методаrequire
и использовать его в качестве промежуточного программного обеспечения в приложении: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 в целях разработки. В этот же файл добавьте выделенные строки: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'));
Вы устанавливаете заголовок CORS
Access-Control-Allow-Origin
с помощью параметраorigin
в объектеcorsOptions
для домена, из которого работает ваш внешний интерфейс. отправляет запросы API. Вы также устанавливаете для параметраcredentials
значениеtrue
, которое сообщает внешнему интерфейсу, что ожидается отправка маркера авторизации в файле cookie для каждого запроса API. Значение параметраorigin
указывает, с каких доменов принимать данные управления доступом, такие как файлы cookie, для внутренней обработки.Наконец, вы передаете объект конфигурации
corsOptions
в объект промежуточного программного обеспеченияcors
.Затем вы установите токен файла cookie пользователя с помощью метода
cookie()
, доступного в объекте ответа вашего обработчика маршрута промежуточным программным обеспечениемcookie-parser
. Замените строки в разделеapp.use(/login, (req, res)
выделенными строками: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'));
В приведенном выше блоке кода вы устанавливаете файл cookie с ключом
токен
и значениемэто секретный токен
. Параметр конфигурацииhttpOnly
задает атрибутhttpOnly
, чтобы файл cookie не был доступен для JavaScript, работающего в документе.Вы устанавливаете атрибут
maxAge
так, чтобы срок действия файла cookie истекал через 14 дней. Через 14 дней срок действия файла cookie истечет, и браузеру потребуется новый файл cookie для аутентификации. Таким образом, пользователю нужно будет снова войти в систему со своим именем пользователя и паролем.Атрибуты
sameSite
иdomain
устанавливаются таким образом, чтобы клиентский браузер не отклонял ваши файлы cookie из-за проблем с CORS или других протоколов безопасности.Теперь, когда у вас есть маршрут для входа, вам нужен маршрут для выхода. Добавьте выделенные строки, чтобы установить метод выхода из системы:
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'));
Метод
logout
аналогичен маршрутуlogin
. Методlogout
удалит токен, который пользователь сохранил в виде файла cookie, установив для файла cookietoken
значениеnull
. Затем он сообщит пользователю, что они успешно вышли из системы.Наконец, добавьте выделенные строки для реализации маршрута
auth-status
, который позволяет пользовательскому клиенту проверять, вошел ли пользователь в систему и ему разрешен доступ к личным активам: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
проверяет файл cookietoken
, который соответствует ожидаемому значению токена аутентификации пользователя. Затем он отвечает логическим значением, указывающим, аутентифицирован ли пользователь.Сохраните и закройте файл, когда закончите. Вы внесли необходимые изменения в свой бэкенд, чтобы ваш интерфейс мог отслеживать статус аутентификации пользователя через ваш внутренний API.
Далее вы внесете необходимые изменения во внешний интерфейс для реализации хранилища токенов на основе файлов cookie только для HTTP.
Перейдите в каталог
front-end
и откройте файлLogin.js
:- cd ..
- cd front-end/src/components/
- nano Login.js
Добавьте выделенную строку, чтобы изменить функцию
loginUser
в компонентеLogin
:... 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()) } ...
Вы устанавливаете для заголовка
credentials
свои запросы на выборку значениеinclude
, которое указывает функцииloginUser
отправлять любые учетные данные, которые могут быть установлены как файлы cookie в API. вызовы путиlogin
, который вы только что изменили в своей серверной части.Затем вы удалите входное свойство
setToken
для компонентаLogin
и его использование в конце обратного вызоваhandleSubmit
, поскольку вы не сохраните токен в памяти больше нет.Вам также потребуется инициировать обновление в конце функции
handlesubmit
, чтобы ваше приложение обновлялось при нажатии кнопки входа, а новый файл cookietoken
распознавался клиентом. приложение. Добавьте выделенную строку:... const handleSubmit = async (e) => { e.preventDefault(); const token = await loginUser({ username: emailRef.current.value, password: passwordRef.current.value }) window.location.reload(false); } ...
Ваш файл
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
для своего внешнего интерфейса:- cd ..
- nano App.js
Импортируйте хук
useState
из пакетаreact
и инициализируйте новую переменную состоянияauthenticated
и ее сеттер, чтобы отразить статус аутентификации пользователя: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, который может определить, активно ли хранится действительный токен аутентификации в виде файла cookie для внешнего клиента.Затем удалите функции
setToken
иgetToken
, переменную token и условное отображение компонентаlogin
. Затем создайте две новые функции с именамиgetAuthStatus
иisAuthenticated
с выделенными строками: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
отправит запросGET
к маршрутуauth-status
вашего внутреннего приложения, чтобы получить статус аутентификации пользователя, ожидая, или пользователь не отправил запрос с действительным файлом cookie токена аутентификации.Установив для параметра
credentials
значениеinclude
,fetch
отправит любые учетные данные, которые браузер может сохранить для клиента пользователя, в виде файлов cookie. ФункцияisAuthenticated
вызывает функциюgetAuthStatus
и устанавливает для состоянияauthenticated
вашего приложения логическое значение, отражающее статус аутентификации пользователя.Далее вы импортируете хук
useEffect
с выделенными строками: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(); }, []) ...
Эта модификация вызовет маршрут
login
для проверки статуса аутентификации в хукеuseEffect
. Включение пустого массива зависимостей для хукаuseEffect
может помочь избежать утечек памяти в вашем приложении.Чтобы условно отобразить компонент
login
на домашней странице приложения, добавьте выделенные строки:... 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;
Если для переменной
authenticated
установлено значениеfalse
, ваше приложение будет отображать компонентlogin
. В противном случае вместо этого будет отображаться домашняя страница приложения и все ее маршруты, включая частные страницы.Вы добавляете новую переменную состояния
loading
, чтобы избежать рендеринга чего-либо до завершения вызова маршрутаauth-status
вашего внутреннего приложения. Поскольку для переменной состоянияauthenticated
изначально задано значениеfalse
, клиент будет считать, что пользователь не вошел в систему до тех пор, пока API не вызоветauthentication-status
завершается, и переменная состоянияauthenticated
обновляется.Затем вы создадите функцию
logoutUser
, которая вызывает ваш маршрутlogout
во внутреннем API. Добавьте выделенные строки в файл: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
функцию обратного вызова, которая вызывает ваш маршрутlogout
во внутреннем API. Маршрут ответит заголовкомset-cookie
, который устанавливает для файла cookietoken
клиента значениеnull
, эффективно отображая статус аутентификации вашего фронта. -завершить приложение значениемfalsy
.Вы также вызовете функцию
isAuthenticated
в конце функции обратного вызоваlogout
, которая обновит статус вашего приложения, чтобы отразить статус неавторизованного пользователя, установивauthenticated
значение переменной состоянияfalse
.Сохраните и закройте файл, когда закончите.
Теперь вы можете протестировать систему хранения токенов на основе файлов cookie только для HTTP. Обновите веб-приложение, чтобы применить только что внесенные изменения.
Затем выполните шаг 4, чтобы узнать, сможет ли злоумышленник украсть ваш токен с помощью внедренного 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 Active
. Вы должны увидеть следующее всплывающее окно с сообщениемВаш токен имеет значение null
после нажатия на ссылку с текстомClick Me!
:Внедренный код JavaScript не может найти объект
token
, поэтому во всплывающем окне отображается значениеnull
. Закройте всплывающее сообщение.Теперь вы сможете выйти из приложения, нажав кнопку «Выход».
На этом шаге вы повысили безопасность своего приложения, переключившись с использования хранилища браузера для сохраняемости токена аутентификации на использование файлов cookie только для HTTP.
Заключение
В этом руководстве вы создали веб-приложение React и Node с функцией входа пользователя в контейнере Docker. Вы внедрили систему аутентификации с уязвимым методом хранения токенов для проверки безопасности вашего сайта. Затем вы использовали этот метод с полезной нагрузкой отраженной атаки XSS, что позволило вам оценить уязвимости при использовании хранилища браузера для хранения файлов cookie аутентификации. Наконец, вы смягчили уязвимость XSS в первоначальной реализации, настроив систему аутентификации, которая использует файлы cookie только для HTTP, а не хранилище браузера для хранения токенов аутентификации. Теперь у вас есть внешнее и внутреннее приложение с системой токенов аутентификации на основе файлов cookie только для HTTP.
Чтобы повысить безопасность и удобство использования процесса аутентификации вашего приложения, вы можете интегрировать сторонние инструменты аутентификации, такие как An Introduction to OAuth 2.
- Приложение в этом руководстве было создано на основе образа с запущенным