HTTP-Onlyクッキーを使用してXSS攻撃からReactアプリケーションを保護する方法
筆者は、Write for Donationsプログラムの一環として寄付の受け手としてFree and Open Source Fundを選択しました。
以下は、日本語での自然な言い回しの一つです:
イントロダクション
トークンベースの認証は、公開と非公開のアセットを持つウェブアプリケーションのセキュリティを確保することができます。非公開のアセットへのアクセスには、ユーザーが正常に認証を行う必要があります。通常、ユーザーは自分だけが知っているユーザー名と秘密のパスワードを提供します。正常な認証により、ユーザーが認証状態を続ける期間のトークンが返されるため、特権アセットへのアクセスごとに再認証をする必要はありません。トークンの使用は、トークンを安全に保持するための重要な問題を提起します。トークンは、Window.localStorageやWindow.sessionStorageプロパティを使用してブラウザストレージに保存することができますが、この方法はクロスサイトスクリプティング(XSS)攻撃に対して脆弱です。なぜなら、ローカルおよびセッションストレージの内容は、同じドキュメント上で実行されるJavaScriptによってアクセス可能だからです。
このチュートリアルでは、ReactアプリケーションとモックAPIを作成し、ローカルのDockerコンテナで設定されたトークンベースの認証システムを実装して、プラットフォーム間での一貫したテストを行います。最初に、Window.localStorageプロパティを使用してブラウザストレージを使ったトークンベースの認証を実装します。次に、ブラウザストレージを使用して秘密の情報を永続化する際に存在するセキュリティの脆弱性を理解するために、この設定を反映型のクロスサイトスクリプティング攻撃で利用します。その後、認証トークンを保存するためにHTTP Onlyクッキーを使用するようにこのアプリケーションを改善します。これにより、ドキュメント上に存在する悪意のあるJavaScriptコードからアクセスできなくなります。
このチュートリアルの終わりまでには、ReactとNodeのWebアプリケーションと共に機能するトークンベースの認証システムを実装するために必要なセキュリティの考慮事項が理解できるようになります。このチュートリアルのコードはSilicon Cloud Communityの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.
ステップ1 – 開発用のDockerコンテナの準備
このステップでは、開発目的のためにDockerコンテナをセットアップします。最初に、コンテナを作成するためのイメージのビルド手順を含むDockerfileを作成します。
nanoや好みのエディタを使用して、ホームディレクトリ内にDockerfileというファイルを作成し、開いてください。
- 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" ]
以下の内容を日本語で言い換えます(1つのオプションのみ):
FROMの行は、プリビルドされたnode:18.7.0-bullseyeイメージを使用して、イメージのベースを作成します。このイメージには、必要なNodeJSの依存関係がインストールされており、セットアッププロセスがスムーズに進行します。
RUN行はパッケージを更新・アップグレードし、他の必要なパッケージもインストールします。WORKDIR行は作業ディレクトリを設定します。
CMD行はコンテナ内で実行される主要なプロセスを定義し、コンテナが稼働し続けるようにします。これにより、開発のためにコンテナに接続して使用できる状態が保持されます。
ファイルを保存して閉じる。 (Fairu o hozon shite tojiru.)
Dockerビルドコマンドを使用して、Dockerイメージを作成し、path_to_your_dockerfileを自身のDockerfileのパスに置き換えてください。
- docker build -f /path_to_your_dockerfile –tag jwt-tutorial-image .
Dockerfileのパスは、「-f」オプションに渡され、イメージのビルド元のファイルパスが示されます。ビルドしたイメージには、「–tag」オプションを使用してタグを付けることができ、後でわかりやすい名前(この場合、「jwt-tutorial-image」)で参照することができます。
ビルドコマンドを実行すると、以下のような出力が表示されます。
… => => 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フラグを使用すると、コンテナはデタッチモードで実行され、別のターミナルセッションでそれに接続することができます。
Note
「-p」フラグは、コンテナのポート3000と8080を転送します。これらのポートは、フロントエンドとバックエンドのアプリケーションをホストマシンのローカルネットワークのlocalhostに提供し、ローカルのブラウザを使用してアプリケーションをテストすることができます。
Note
注意: もしホストマシンが現在ポート3000と8080を使用している場合、それらのポートを使用しているアプリケーションを停止する必要があります。そうしないと、Dockerがポートを転送しようとする際にエラーが発生します。
また、-Pフラグを使用することで、コンテナのポートをホストマシンの使用されていないポートに転送することもできます。特定のポートのマッピングではなく、-Pフラグを使用する場合、どの開発コンテナのポートがどのローカルポートにマッピングされているかを知るために、docker network inspect your_container_nameを実行する必要があります。
VSCodeのリモートコンテナプラグインを使用して接続することもできます。
別のターミナルセッションで、コンテナに接続するためにこのコマンドを実行してください。
- docker exec -it jwt-tutorial-container /bin/bash
あなたは、コンテナのラベルとこのように接続されていることを示す接続を見ることができます。
root@d7e051c96368:/app#
この手順では、事前に準備されたDockerイメージをセットアップして、開発に使用するコンテナに接続します。次に、create-react-appを使用して、コンテナ内にアプリケーションの骨組みをセットアップします。
ステップ2-フロントエンドアプリケーションの基盤を設定する
このステップでは、Reactアプリケーションを初期化し、ecosystem.config.jsファイルを使用してアプリの管理を設定します。
コンテナに接続した後、mkdirコマンドを使用してアプリケーション用のディレクトリを作成し、cdコマンドを使用して新しく作成されたディレクトリに移動してください。
- mkdir /app/jwt-storage-tutorial
- cd /app/jwt-storage-tutorial
その後、npxコマンドを使用してcreate-react-appバイナリを実行し、Webアプリケーションのフロントエンドとして機能する新しいReactプロジェクトを初期化します。
- npx create-react-app front-end
create-react-appというバイナリは、READMEファイルとともに、開発やテストのためのベアボーンのReactアプリケーションを初期化します。また、react-scripts、react-dom、jestなど、よく使われる依存関係も含まれています。
インストールを続ける場合は、指示されたときに「y」と入力してください。
create-react-appの呼び出しの結果、この出力を確認することができます。
… 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: Production-Ready Nodejs Applications in Minutes」をご覧ください。
インストールの結果は次のようになります。
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という名前のファイルを作成してください。
- cd front-end
- nano ecosystem.config.js
「ecosystem.config.js」ファイルは、PM2プロセスマネージャーがアプリケーションを実行する方法に関する設定を保持します。
新しく作成したecosystem.config.jsファイルに以下のコードを追加してください。
jwt-storage-tutorial/front-end/ecosystem.config.jsの内容を日本語で言い換えると、以下のようになります:
jwt-storage-tutorial/front-end/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上で実行中のプロセスはありませんので、以下の出力が表示されます。 (Kono baai, anata wa genzai PM2-jou de jikkou chuu no purosesu wa arimasen node, ika no shutsuryoku ga hyouji sa remasu.)
┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐ │ id │ name │ mode │ ↺ │ status │ cpu │ memory │ └────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘
コマンドを実行していて、プロセスマネージャーを初期状態にリセットする必要がある場合は、以下のコマンドを実行してください。
- pm2 delete all
えいご:あなたのecosystem.config.jsファイル内で指定された設定を使用して、PM2プロセスマネージャーを使ってアプリケーションを開始してください。
にほんご:ecosystem.config.jsファイルに指定されている設定を使用して、PM2プロセスマネージャーを使ってアプリケーションを開始してください。
- pm2 start ecosystem.config.js
ターミナル上で、このような出力が表示されます。
┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐ │ 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](https://cdn.silicloud.com/blog-img/blog/img/65646d6cdaa94e2bdf7b66e1/74-0.png)
クライアントサイドルーティングのために、最終的にはReact-Routerのバージョン5.2.0をインストールしてください。
- npm install react-router-dom@5.2.0
インストールが完了すると、以下のメッセージのバリエーションが受け取れます。
… 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コンポーネントを作成します。
まず、アプリケーションのコンポーネントを保持するために、コンポーネントディレクトリを作成してください。
- mkdir src/components
その後、コンポーネントディレクトリ内にSubscriberFeed.jsという新しいファイルを作成して開きます。
- nano src/components/SubscriberFeed.js
SubscriberFeed.jsファイルの内部に、この行を追加してください。タグ内にコンポーネントのタイトルを記載した
タグを使用します。
以下の文を日本語で表現する(必要な1つのオプションのみ):
jwt-storage-tutorial/front-end/src/components/SubscriberFeed.js
import React from 'react';
export default () => {
return(
<h2>Subscriber Feed</h2>
);
}
ファイルを保存して閉じる。
次に、App.jsファイル内でSubscriberFeedコンポーネントをインポートし、コンポーネントをユーザーにアクセス可能にするためのルートを作成します。プロジェクトのsrcディレクトリにあるApp.jsファイルを開いてください。
- 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ストレージチュートリアルのフロントエンド側にある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キーワードの後の括弧内に含まれているすべてのもの)そして、それを以下の行で置き換えます。
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属性を持つdivタグがあり、その中にアプリケーションの名前を表示する
タグが含まれています。
タグの下に、Switchコンポーネントを使用してBrowserRouterコンポーネントを追加し、それに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を使用してアプリケーションに余白を追加し、タイトルやコンポーネントが中央に配置され、見栄えの良いものにします。最も外側の
タグの className 属性にラッパーを追加してください。
以下は日本語での原文です。一つのオプションのみ提供させていただきます。
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 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 {
padding: 20px;
text-align: center;
}
アプリケーション内のテキストを中央に配置するために、ラッパークラスのtext-alignプロパティを中央に設定しました。また、paddingプロパティを20pxに設定することで、ラッパークラスに20ピクセルの余白を追加しました。
App.cssファイルを保存して閉じる。
新しいスタイルが適用されたReactのホームページが表示されるかもしれません。http://localhost:3000/subscriber-feed に移動して、新しく表示される「Subscriber Feed」を確認してください。
![Screencapture of the React application with a visible subscriber feed page](https://cdn.silicloud.com/blog-img/blog/img/65646d6cdaa94e2bdf7b66e1/122-0.png)
ルートは予想通り機能しますが、全ての訪問者がサブスクリバーのフィードにアクセスできます。サブスクライバーのフィードが認証ユーザーのみに表示されるようにするためには、ユーザーが自分のユーザー名とパスワードで自身を確認するためのログインページを作成する必要があります。
あなたのコンポーネントディレクトリに新しいLogin.jsファイルを開いてください。
- 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>
);
}
タグのヘッダー、2つの入力(ユーザー名とパスワード)、そして送信ボタンを持つフォームを作成します。そのフォームを
タグで囲み、クラス名がlogin-wrapperのところに配置することで、App.cssファイルでスタイルを設定できます。
ファイルを保存して閉じる。
プロジェクトのルートディレクトリにあるApp.cssファイルを開き、ログインコンポーネントのスタイルを設定してください。
- nano src/App.css
ログインラッパークラスに次のCSS行を追加して、スタイルを適用してください。
...
.login-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
ページ上のコンポーネントをflex属性で中央に配置し、align-items属性をcenterに設定します。そして、flex-directionをcolumnに設定し、要素を垂直に列に整列させます。
ファイルを保存して閉じてください。
最初に、useStateフックを使用してトークンをメモリに保存するために、LoginコンポーネントをApp.js内にレンダリングします。App.jsファイルを開いてください。
- 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専用のクッキーを使用して、認証状態をより安全に保存する永続化方法をさらに強化します。
tokenの値が偽である場合、Loginページを表示するLoginコンポーネントもインポートします。もしトークンが偽である場合、if文は未認証の場合にユーザーにログインを要求します。setToken関数をプロップとしてLoginコンポーネントに渡します。
ファイルを保存して閉じる。 (Fairu o hozon shite tojiru)
次に、新しく作成したログインページを読み込むために、アプリケーションのページをリフレッシュしてください。現在、トークンの設定機能は実装されていないため、アプリケーションはログインページのみを表示します。
![Screencapture of the React application login form showing username and password input boxes](https://cdn.silicloud.com/blog-img/blog/img/65646d6cdaa94e2bdf7b66e1/148-0.png)
このステップでは、ログインページと未認証ユーザーから保護されるプライベートコンポーネントが追加され、アプリケーションがアップデートされます。ログインするまで、未認証ユーザーから保護されます。
次のステップでは、NodeJSを使用して新しいバックエンドアプリケーションを作成し、フロントエンドアプリケーションで認証トークンを呼び出すための新しいログインルートを作成します。
ステップ4 — トークンAPIの作成
このステップでは、前のステップでセットアップしたフロントエンドのReactアプリケーションのバックエンドとして、Nodeサーバーを作成します。Nodeサーバーを使用してAPIを作成し、フロントエンドのユーザー認証が成功した場合に認証トークンを返すことができます。このステップの終了時には、アプリケーションには動作するログインページ、認証に成功した後にのみ利用可能なプライベートリソース、およびAPI呼び出しを通じた認証を可能にするバックエンドのサーバーアプリケーションが含まれています。
Expressフレームワークを使用してサーバーを構築します。corsパッケージを使用して、すべてのルートでクロスオリジンリソース共有を可能にします。これにより、CORSエラーなしでアプリケーションをテストや開発することができます。
Warning
新しいディレクトリを作成し、バックエンドと呼ばれるディレクトリに移動し、Nodeプロジェクトをそこに配置してください。
- mkdir /app/jwt-storage-tutorial/back-end
- cd /app/jwt-storage-tutorial/back-end
新しいディレクトリで、Nodeプロジェクトを初期化してください。
- npm init -y
以下は、-yフラグを使用して実行されたinitコマンドの出力です。このコマンドは、npmコマンドラインユーティリティに現在のディレクトリに新しいNodeプロジェクトを作成するように指示します。-yフラグは、新しいプロジェクトを作成する際に対話型コマンドラインツールが尋ねるすべての初期化の設問にデフォルト値を使用します。
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モジュールをインストールしてください。
- npm install express cors
以下のような出力のバリエーションが、ターミナルに表示されます。
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ファイルを作成してください。
- nano index.js
以下の行を追加して、expressモジュールをインポートし、express()を呼び出して新しいExpressアプリケーションを初期化し、その結果を変数appに格納します。
「jwt-storage-tutorial/back-end/index.js」
const express = require('express');
const app = express();
次に、アプリにミドルウェアとしてcorsを追加し、ハイライトされた行を使用します。
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());
corsモジュールをインポートし、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メソッドの最初の引数は、アプリケーションがリクエストを受け付けるルートです。2番目の引数は、アプリケーションが受信したリクエストの処理方法を詳細に示すコールバック関数です。このコールバック関数には、リクエストデータを含むreq引数と、レスポンスデータを含むres引数の2つの引数があります。
Note
最後に、app.listen関数を使用してサーバーをポート8080で実行するために、下線部を追加してください。
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
新しく作成されたバックエンド/ecosystem.config.jsファイルに、以下の設定コードを追加してください。
jwt-storage-tutorial/back-end/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は、フロントエンドアプリケーションと似た設定パラメータを使用して、バックエンドアプリケーションを管理します。
設定ファイルのウォッチパラメータを設定して、ファイルが変更される度にアプリケーションが自動的にリロードされるようにします。ウォッチパラメータは開発に便利な機能であり、コードに変更が加えられるたびにブラウザ内で結果が更新されます。フロントエンドアプリケーションにはウォッチパラメータは必要ありませんでした。なぜなら、react-scriptsを使用して実行したため、自動リロード機能がデフォルトで備わっているからです。しかし、バックエンドアプリケーションはノードランタイムで実行されるため、そのデフォルト機能はありません。
ファイルを保存して閉じてください。
pm2を使用してバックエンドアプリケーションを実行できます。
- pm2 start ecosystem.config.js
「あなたの出力は以下のようなバリエーションのいずれかになります」
[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
以下の出力をご覧ください。
{“token”:”This is a secret token”}
あなたは今、サーバーのログインルートが期待通りにトークンを返すことを知っています。
次に、APIを使用するためにフロントエンドのログインコンポーネントを変更します。適切なフロントエンドのフォルダに移動してください。
- 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>
);
}
emailとpasswordの入力フィールドの値を追跡するために、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ヘルパー関数を呼び出して、以前に作成したAPIのログインルートへのフェッチリクエストを行います。handleSubmit関数に渡されたイベントに対してpreventDefault関数を呼び出すことで、送信ボタンのデフォルトの更新機能が実行されないようにし、代わりにアプリがログインエンドポイントを呼び出し、ユーザーログインに必要な手順を処理できるようにします。また、Loginコンポーネントに渡されたセッターを使用して、tokenステート変数の値も設定します。
作業が終了したら、ファイルを保存して閉じてください。
ウェブアプリケーションをブラウザで確認する際、任意のユーザー名とパスワードでログインできるようになりました。サブミットボタンを押すと、ログインした状態でリダイレクトされるページに移動します。ページをリフレッシュすると、Reactアプリはトークンを失い、ログアウトされます。
次のステップでは、フロントエンドアプリケーションで受け取ったトークンをブラウザのストレージに永続化します。
ステップ5 — ブラウザストレージを使用してトークンを保存します。
ユーザーがブラウザのセッションやページのリフレッシュを維持できる場合、ユーザーエクスペリエンスが向上します。この手順では、Window.localStorageプロパティを使用して認証トークンを保存し、ユーザーがブラウザを閉じたりWebページをリフレッシュしたりしても失われない永続的なユーザーセッションを実現します。モダンなWebアプリの継続的なユーザーセッションにより、同じウェブサイトに対してユーザーが繰り返しログイン認証する必要がなくなるため、アプリが処理するネットワークトラフィックが減少します。
ブラウザのストレージには、2つの異なるが似たようなタイプのストレージがあります。ローカルストレージとセッションストレージです。簡単に言えば、セッションストレージはタブセッションをまたいでデータを保持し、ローカルストレージはタブおよびブラウザセッションをまたいでデータを保持します。ブラウザのストレージでトークンを保存するためには、ローカルストレージを使用します。
フロントエンドアプリケーションのために、App.jsファイルを開いてください。
- nano /app/jwt-storage-tutorial/front-end/src/App.js
ブラウザストレージの統合を開始するために、ハイライトされた行を追加しましょう。これにより、2つのヘルパー関数(setTokenとgetToken)が定義され、新しく実装された関数を使用してトークン変数を取得するようにトークン変数を変更します。
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 } 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;
あなたは2つのヘルパー関数を作成します:setTokenとgetToken。setToken内では、ヘルパー関数のuserToken入力パラメーターをトークンというキーにマップするためにlocalStorageのsetItem関数を使用します。また、アプリケーションがブラウザのストレージに新しく設定されたトークンを見つけてアプリケーションを再描画できるように、window.locationプロパティのreload関数も使用します。
getToken内では、localStorageのgetItem関数を使用して、トークンキーの値が存在するかどうかをチェックし、その値を返します。App()関数内の定義済み変数をgetToken関数を使用して置き換えます。
ユーザーがウェブサイトを訪れるたびに、フロントエンドはブラウザのストレージに認証トークンがあるかどうかを確認し、ユーザーにログインを求める代わりに、既存のトークンを使用してユーザーの妥当性を検証しようとします。
ファイルを保存して閉じ、アプリケーションをリフレッシュしてください。それによって、アプリケーションへのログインが可能になり、ウェブページもリフレッシュして再度ログインする必要がなくなるはずです。
このステップでは、ブラウザのストレージを使ってトークンの永続化を実装しました。次のセクションでは、ブラウザのストレージを利用してトークンベースの認証システムを利用します。
ステップ6 – XSS 攻撃を利用してブラウザのストレージを突破
このステップでは、現在のアプリケーションに対してステージングされたクロスサイトスクリプティング攻撃(またはXSS攻撃)を実行します。これにより、ブラウザストレージを使用して秘密情報を永続化する際に存在するセキュリティ脆弱性が示されます。攻撃はURLリンクの形で行われ、クリックすると被害者があなたのウェブアプリケーションにリダイレクトされ、アプリケーションにクラフトされたコードが挿入されます。この挿入はユーザーを騙してそれと対話させ、悪意のあるエージェントが被害者のブラウザのローカルストレージの内容を盗み取ることが可能です。
XSS攻撃は現代のサイバー攻撃の中でも最も一般的なものの一つです。攻撃者は通常、悪意のあるスクリプトをブラウザに注入して、信頼できる環境でのコード実行を達成します。攻撃者はしばしばフィッシングの技術を利用して、スパムメールなどによって提供されるような悪意のあるリンクとのやり取りにより、ユーザーを騙してブラウザの内容を危険にさらすことがあります。
XSS攻撃は、特に、ドメインに関連するドキュメントで実行されるJavaScriptコードによって、ドメインのブラウザストレージが完全にアクセス可能であるため、無防備な被害者のブラウザストレージの内容を盗むことを狙う攻撃者に特に興味があります。攻撃者が特定のWebドキュメントのユーザーのブラウザでJavaScriptコードを実行できれば、そのドキュメントに関連するWebドメインのブラウザストレージ(ローカルとセッションの両方)の内容を盗むことができます。
教育目的のため、URLのクエリパラメータを通じてコードが注入される可能性のあるコンポーネント「XSSHelper」を作成し、故意にアプリケーションをXSS攻撃に脆弱にします。そして、この脆弱性を利用して、悪意のあるURLを作り出します。ユーザーがブラウザでURLに移動し、ウェブページに埋め込まれた怪しいリンクをクリックすると、悪意のあるURLはログインしたユーザーのローカルストレージの内容を取得し、公開します。
フロントエンドアプリケーションのcomponentsディレクトリにXSSHelper.jsという新しいコンポーネントを作成してください。
- nano /app/jwt-storage-tutorial/front-end/src/components/XSSHelper.js
新しいファイルに以下のコードを追加してください。
/src/components/XSSHelper.jsの以下の部分を日本語で言い換えてください。1つのオプションで十分です:
Paraphrase: /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プロパティを通じてコードクエリパラメータにアクセスする新しい機能コンポーネントを作成します。XSSHelperコンポーネントがアクティブであることを示すメッセージを含んだ
タグを返します。
URLSearchParamsというJavaScriptの関数は、検索文字列とのやり取りに役立つゲッターなどのヘルパーメソッドを提供しています。
ハイライトされた行を追加して、useEffectフックをインポートして使用して、クエリパラメーターの値をログに出力してください。
/src/components/XSSHelper.jsを日本語で言い換えると以下の通りです:
/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ヘルパールートに移動した場合に、App.jsファイルを修正してコンポーネントを返すようにします。
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=’ここにコードをインジェクト’に移動してください。アプリケーションにログインしていることを確認してください。ログインしていない場合、XSSHelperコンポーネントにアクセスできません。
Note: The provided translation is a paraphrase in Japanese and may vary depending on the context.
「左クリックしてインスペクト(検査)を選択します。次に、コンソールセクションに移動します。コンソールログには、「ここにコードを挿入」と表示されます。」
![Screencapture of the XSS Helper page with the URL query parameter passing to show the message](https://cdn.silicloud.com/blog-img/blog/img/65646d6cdaa94e2bdf7b66e1/254-0.png)
あなたは今、URLクエリパラメータをコンポーネントに渡すことができることを知っています。
次に、webページのドキュメント上で、コンポーネントに渡されたクエリパラメータの値をdangerouslySetInnerHTML属性を使用して設定します。コンポーネントは、コードURLのクエリパラメータの値を取得し、webページ上のdivコンポーネントに挿入します。
Warning
再び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}} />
</>
);
}
React fragmentsと一緒に使用する場合、複数のフラグメントを返すことは構文的に認められていないため、返される要素を空のJSXタグ(<> … </>)で包む必要があります。
ファイルを保存して閉じる。
ウェブページ上でコードの実行を達成するために、あなたは今、悪意のあるコードを独自に作成した部品に注入することができます。
xss-helperルートに送信されるコードクエリパラメータの値が、アプリケーションの文書に直接埋め込まれることを知っていますか?コードクエリパラメータの値を、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](https://cdn.silicloud.com/blog-img/blog/img/65646d6cdaa94e2bdf7b66e1/270-0.png)
次に、ブラウザで以下の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クエリパラメータスクリプトインジェクションによって、ブラウザストレージの内容が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](https://cdn.silicloud.com/blog-img/blog/img/65646d6cdaa94e2bdf7b66e1/275-0.png)
この手順では、多くのサンプル攻撃ベクトルのうちの一つを使用して、コードの実行を達成しました。認証トークンを持たない無知なユーザーの名前で、悪意のある攻撃者は、ウェブアプリケーション上でユーザーをなりすまし、特権を持つサイトの資産にアクセスすることができます。これらのテストから、認証トークンなどの秘密情報をブラウザの保存領域に保存することは安全ではないとわかりました。
次に、ドキュメント上で実行されるスクリプトからアクセスされず、この種のXSS攻撃への影響を受けない、秘密情報を保存するための代替手法を使用します。
ステップ7 – HTTP-Onlyクッキーを使用して、ブラウザストレージXSSの脆弱性を緩和する。
このステップでは、前のステップで発見されかつ悪用されたXSS脆弱性を軽減するために、HTTP専用クッキーを使用します。
HTTPクッキーは、ブラウザ内のキーと値のペアとして保存される情報のスニペットです。これらは、追跡、パーソナライズ、またはセッション管理のためによく使用されます。
JavaScriptでは、Document.cookieプロパティを通じてHTTP専用のクッキーにアクセスすることはできません。これにより、悪意のあるコードインジェクションを通じてユーザー情報を盗むXSS攻撃が防がれます。認証されたクライアント向けにサーバーサイドで「Set-Cookie」ヘッダーを使用することで、クライアントがサーバーに対して行うすべてのリクエストで利用できるようになり、サーバーはユーザーの認証状態をチェックするためにそれを使用できます。ヘッダーの代わりに、これを処理するためにExpressと「cookie-parser」ミドルウェアを使用します。
セキュアなHTTPオンリーのクッキーベースのトークンストレージを実装するために、以下のファイルを更新します。
- 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アプリ内でクッキーの設定と読み取りが可能になります。
- cd /app/jwt-storage-tutorial/back-end
- npm install cookie-parser
以下の出力のバリエーションが表示されます。
… 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ミドルウェアも設定します。同じファイルに、ハイライトされた行を追加してください。
バックエンドのインデックスファイル “index.js” について、日本語で言い換えると、以下のようになります。
「バックエンドのメインファイルである “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オプションを使用して、フロントエンドがAPIリクエストを送信するドメインに対してAccess-Control-Allow-Origin CORSヘッダーを設定します。また、クレデンシャルパラメーターをtrueに設定し、フロントエンドに対して認証トークンをクッキーに含めてAPIリクエストを送信するように指示します。originオプションの値は、バックエンド処理のためにクッキーなどのアクセス制御データを受け入れるドメインを指定します。
最終的に、corsOptionsの設定オブジェクトをcorsミドルウェアオブジェクトに渡します。
次に、cookie-parserミドルウェアによってルートハンドラーのresponseオブジェクトで利用可能になっているcookie()メソッドを使用して、ユーザーのクッキートークンを設定します。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'));
上記のコードブロックでは、トークンというキーと”これは秘密のトークンです”という値を持つクッキーを設定しています。httpOnlyの設定オプションは、クッキーをドキュメント上で実行されているJavaScriptからアクセスできないように、httpOnly属性を設定します。
maxAge属性を設定して、クッキーの有効期限を14日に設定しました。14日後にクッキーは無効になり、ブラウザは新しい認証クッキーを必要とします。そのため、ユーザーは再びユーザー名とパスワードでログインする必要があります。
同じサイトとドメインの属性は、クライアントのブラウザが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'));
ログアウトの方法は、ログインルートと似ています。ログアウトの方法では、トークンクッキーをnullに設定することで、ユーザーが保存しているトークンを削除します。その後、ユーザーにログアウトが成功したことを通知します。
最後に、ユーザークライアントがログインしているかどうかを確認し、プライベートアセットにアクセスすることができるかどうかを確認するための認証ステータスのルートに、下線部を追加してください。
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'));
ユーザーの認証トークンの予想される値と一致するトークンクッキーを認証状態の経路はチェックします。その後、ユーザーが認証されたかどうかを示すブール値で応答します。
作業が終了したら、ファイルを保存して閉じてください。バックエンドで必要な変更を行い、フロントエンドがユーザーの認証状態をバックエンドAPIを通じて追跡できるようにしました。
次に、HTTP専用のクッキーベースのトークンストレージを実装するために必要なフロントエンドの変更を行います。
フロントエンドディレクトリに移動し、Login.jsファイルを開いてください。
- cd ..
- cd front-end/src/components/
- nano Login.js
ログインコンポーネントのloginUser関数を修正するために、ハイライトされた行を追加してください。
jwt-storage-tutorial/front-end/src/components/Login.jsを言い換えると、
「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())
}
...
フェッチリクエストの資格情報ヘッダーに含めるように設定することで、loginUser関数に対してAPI呼び出し時に設定されているクッキーを送信するように指示します。これは、バックエンドで変更したログインパスへ向けられます。
次に、トークンをもはやメモリに保持しないため、LoginコンポーネントのsetToken入力プロパティとhandleSubmitコールバックの最後での使用を削除します。
handlesubmit関数の最後でリフレッシュをトリガーする必要があります。これにより、ログインボタンをクリックしたときにアプリケーションが更新され、クライアントアプリケーションが新しく設定されたトークンクッキーを認識できるようになります。ハイライトされた行を追加してください。
jwt-storage-tutorial/front-end/src/components/Login.jsのコードを日本語で言い換えると、以下のようになります。
「jwt-storage-tutorial/front-end/src/components/Login.js」コンポーネントのコードを日本語で説明すると、「ログイン.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ファイルは、このようになるべきです。 (Anata no Login.js fairu wa, kono you ni narubekidesu.)
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
ReactパッケージからuseStateフックをインポートし、ユーザーの認証状態を反映する新しい認証済みステート変数とそのセッターを初期化します。
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へのリクエストを通じて、フロントエンドクライアントに有効な認証トークンがクッキーとしてアクティブに保持されているかを確認します。
次に、setToken関数とgetToken関数、token変数、およびログインコンポーネントの条件付きレンダリングを削除してください。そして、getAuthStatusとisAuthenticatedという2つの新しい関数を作成してください。
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 } 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リクエストを行い、ユーザーの認証ステータスを取得します。
credentialsオプションの値をincludeに設定することで、fetchはブラウザがユーザークライアントのために保存しているクッキーとして、任意の資格情報を送信します。isAuthenticated関数はgetAuthStatus関数を呼び出し、ユーザーの認証状態を反映した真偽値でアプリの認証状態を設定します。
次に、以下の行でuseEffectフックをインポートします:
「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フック内で認証状態をチェックするためにログインルートを呼び出します。useEffectフックの依存配列に空の配列を含めることで、アプリケーション内のメモリーリークを防ぐのに役立ちます。
アプリケーションのホームページにログインコンポーネントを条件付きで表示するために、以下の行を追加してください。
...
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呼び出しが認証ステータスルートで完了し、認証状態変数が更新されるまでです。
次に、バックエンドのAPIでログアウトルートを呼び出すlogoutUser関数を作成します。ファイルに以下のハイライトされた行を追加してください。
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';
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のログアウトルートを呼び出します。ルートはセットクッキーヘッダーを含んで応答し、クライアントのトークンクッキーをnullに設定します。これにより、フロントエンドアプリケーションの認証状態が偽の値になります。
ログアウトのコールバック関数の最後に、isAuthenticated関数を呼び出すことも忘れずに行いましょう。これにより、authenticated状態の変数をfalseに設定し、ユーザーの未認証状態をアプリケーションのステータスに反映させることができます。
作業が終了したら、ファイルを保存して閉じてください。
今、HTTPオンリーのクッキーベースのトークンストレージシステムをテストすることができます。さきほど行った変更を反映するために、ウェブアプリケーションをリフレッシュしてください。
その後、ブラウザのストレージの内容を削除して、残っているトークンを完全に取り除きます。次に、ステップ4と同じように、意図的に作られた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 Activeラインを確認するために、再度ログインする必要があるかもしれません。[Click Me!]というリンクをクリックした後、次のポップアップが表示されるはずです。”トークンオブジェクトはnullです”と記載されています。
![Screencapture of the failed XSS attack that results in](https://cdn.silicloud.com/blog-img/blog/img/65646d6cdaa94e2bdf7b66e1/360-0.png)
挿入されたJavaScriptはトークンオブジェクトを見つけることができませんので、ポップアップにはnullの値が表示されます。ポップアップメッセージを閉じてください。
今は、ログアウトボタンを押すことでアプリからログアウトできるはずです。
このステップでは、ブラウザストレージから認証トークンの永続化にHTTPOnlyクッキーを使用することで、アプリケーションのセキュリティを向上させました。
結論
このチュートリアルでは、Dockerコンテナ内でユーザーログイン機能を持つReactとNodeのWebアプリケーションを作成しました。ウェブサイトのセキュリティをテストするため、脆弱なトークン保存方法を使用した認証システムを実装しました。そして、この方法を反射型XSS攻撃のペイロードで悪用し、ブラウザストレージを使用して認証クッキーを保存する場合の脆弱性を評価しました。最後に、初期実装のXSSの脆弱性を軽減するため、ブラウザストレージではなくHTTP-Onlyクッキーを使用した認証システムを設定しました。これにより、HTTP-Onlyクッキーベースの認証トークンシステムを備えたフロントエンドおよびバックエンドのアプリケーションが完成しました。
アプリケーションの認証プロセスのセキュリティと使いやすさを向上させるために、PassportJSやSilicon CloudのOAuth APIなど、サードパーティの認証ツールを統合することができます。OAuthフレームワークについて詳しく知りたい場合は、「OAuth 2の概要」を参照してください。