進化するウェブ開発の世界において、サーバーレスアプリケーションは着実にその存在感を示しています。比類のないスケーラビリティや堅牢なパフォーマンスといった否定できない利点により、際立った存在となっています。サーバーレスの美しさはその約束にあります:労力をかけずにスケールするだけでなく、コスト面でも優れた強力なソリューションを提供することです。

ビジネスアイデアを立ち上げようと夢見たことはありませんか?しかし、ユーザー登録やログインシステムの構築という技術的な課題に阻まれていませんか?Netlifyやさまざまなクラウドベースのデータベースなどのプラットフォームは存在しますが、Cloudflareが提供する機能には及びません。多くの代替サービスはスケーリング時にコストが膨らむ可能性がありますが、Cloudflare Pagesでは状況が異なります。

この数か月間、他の仕事と並行しながら断続的にこのプロジェクトとデモに取り組んできました。お待たせして申し訳ありません。特にこのシステムを心待ちにしてくださっていた方々 に対してお詫び申し上げます。

Cloudflare Pagesの力を解き放つ

  • シームレスなスケーラビリティ:無制限のユーザー登録をスムーズに管理。
  • コスト効率:予期しないオーバーヘッドとはお別れ;一貫した価格設定をお楽しみください。
  • 超高速パフォーマンス:これまでにないパフォーマンスを体験。
  • 堅固なセキュリティ:ユーザーのデータは保護され、安全に保たれます。

使用するもの

  • Cloudflare Pages
  • Cloudflare Pages Functions
  • Cloudflare Workers KV

Cloudflare Pagesとは?

Cloudflare Pages は、開発者がウェブサイトを構築、デプロイ、ホスティングするための最新のユーザーフレンドリーなプラットフォームです。GitHubとのシームレスな統合を提供しており、コードをGitHubにプッシュするだけで、Cloudflare Pagesがビルド、デプロイ、さらにはアップデートまですべてを処理します。

仕組みは以下の通りです:

  1. 統合されたワークフロー:Cloudflare Pagesはgitワークフローを中心に構築されています。GitHubリポジトリをCloudflare Pagesに接続すると、選択したブランチにプッシュするたびにサイトのビルドとデプロイが開始されます。
  2. JAMstack最適化:Cloudflare PagesはJAMstackの原則をサポートしており、Jekyll、Hugo、Next.js、Reactなどを含む、お好みの静的サイトジェネレーターやJavaScriptフレームワークでサイトを構築できます。
  3. 高速で安全な配信:グローバルに分散されたCloudflareネットワークにより、Pagesはユーザーがどこにいてもサイトが利用可能で高速であることを保証します。また、Cloudflareに組み込まれたセキュリティ機能がサイトを脅威から保護します。
  4. 継続的デプロイ:Cloudflare PagesはGitHubリポジトリに更新を行うたびに、自動的にサイトをビルドしてデプロイします。これにより迅速なイテレーションが可能になり、デプロイプロセスが非常に簡単になります。
  5. カスタムドメインとHTTPS:Pagesでは、カスタムドメインをサイトに接続でき、すべてのサイトに無料の自動HTTPSを提供して、接続が常に安全であることを保証します。
  6. プレビューデプロイ:リンクされたGitHubリポジトリで新しいプルリクエストを作成するたびに、Cloudflare Pagesは自動的にユニークなプレビューURLを生成し、公開前に変更を確認できます。

個人開発者であれ大規模チームの一員であれ、Cloudflare Pagesはウェブサイトをオンラインにするための簡単で高速かつ安全な方法を提供します。

上記を踏まえ、このユーザー登録システムでは、追加のフレームワークやビルドツールを使用せず、純粋でシンプルなHTMLページを選択しました。このアプローチにより、比類のないシンプルさが保証され、望む結果を達成するための柔軟性が確保されます。

Cloudflare Workersとは?

Cloudflare Workers は、開発者が世界中の200以上の都市に広がるCloudflareの広大なネットワークに直接コードをデプロイできる革新的なサーバーレスコンピューティングプラットフォームです。本質的に、アプリケーションをエンドユーザーにできるだけ近い場所で実行することを可能にし、レイテンシーを削減してユーザーエクスペリエンスを向上させます。

機能と利点の概要は以下の通りです:

  1. サーバーレス実行環境:Cloudflare Workersはサーバーレス環境で動作します。つまり、開発者はサーバーの管理やメンテナンスを行う必要がありません。代わりに、コードの記述に集中でき、プラットフォームが配布からスケーリングまですべてを処理します。
  2. エッジコンピューティング:アプリケーションが単一のサーバーやデータセンターで実行される従来のモデルとは異なり、Cloudflare WorkersはコードをCloudflareネットワークのエッジに配置します。これにより、アプリケーションがユーザーに近い場所で実行され、パフォーマンスと速度が向上します。
  3. 言語の柔軟性:WorkersはChromeと同じランタイムであるV8 JavaScriptエンジンを使用しており、開発者はJavaScriptでコードを書くことができます。さらに、WebAssemblyのサポートにより、Rust、C、C++などの他の言語も使用できます。
  4. セキュリティ:Cloudflareネットワークの本質的なセキュリティを活用することで、WorkersはDDoS攻撃などのさまざまな脅威からアプリケーションを保護します。

Cloudflare Workersは、アプリケーションのパフォーマンス、信頼性、セキュリティを向上させたい開発者のための革新的で高度にスケーラブルなソリューションを提供します。

Cloudflare Pages内では、Workersはfunctionsという名前のディレクトリに格納されています。すべてのJavaScript/TypeScriptコードをこのスペースに配置し、Workersが提供する包括的な機能を活用しています。

Cloudflare Workers KVとは?

Cloudflare Workers KV (Key-Value)は、グローバルに分散された結果整合性のあるキーバリューストレージシステムで、Cloudflare Workersスクリプト内のどこからでもデータを保存・アクセスできます。サーバーレス環境での状態管理のスケーリングと簡素化を支援するために設計されています。

主な機能と利点は以下の通りです:

  1. グローバル分散:Cloudflare Workers KVは、世界中の300以上の都市に広がるCloudflareネットワーク上に構築されています。これにより、データがユーザーの近くに保存・アクセスされ、レイテンシーが削減され、アプリケーションの全体的なパフォーマンスが向上します。
  2. 高速な読み書き:Workers KVは、さまざまなアプリケーションに適した低レイテンシーのデータアクセスを提供します。書き込みはグローバルに伝搬するのにやや時間がかかりますが(通常数秒以内)、読み取り操作は通常高速で、読み取り重視のワークロードに最適です。
  3. 大規模対応:単一のWorkers KV namespaceに数十億のキーを保存でき、各キーは最大25MBの値を保持できます。
  4. Namespace:KV namespaceはキーバリューペアのコンテナです。Workers KVストア内で異なるデータセットを分離でき、複数のアプリケーションや環境(ステージングやプロダクションなど)を管理する際に特に便利です。
  5. 結果整合性:Workers KVは結果整合性を使用します。つまり、データの更新(書き込み)はグローバルに伝搬し、時間の経過とともに整合性が保たれます。通常は数秒の問題です。

Cloudflare Workers KVは、サーバーレス環境での状態管理に独自のソリューションを提供し、開発者に信頼性が高く、高速で、グローバルに分散されたデータストレージシステムを提供します。

このユーザー登録システムの開発にあたり、以下のWorkers KV namespaceを戦略的に設計しました:

  • USERS:すべてのユーザーの主要ストレージとして機能します。本質的に無制限のレコード数を処理できるように設計されています。
  • USERS_LOGIN_HISTORY:ログイン活動を記録する専用スペースで、ユーザーがアカウントのセキュリティフットプリントを定期的に評価できるようにします。
  • USERS_SESSIONS:このnamespaceは、現在ログインしているユーザーの詳細(一意のID、デバイス、場所など)をキャプチャします。
  • USERS_SESSIONS_MAPPING:Workers KVの結果整合性モデルにより、USERS_SESSIONSへの書き込みとその確認の間に遅延が生じる可能性があります。これは、操作が異なるエッジロケーションで行われる場合に特に起こりやすいです。これを回避するため、バリデーション後に新しいセッションUIDをUSERS_SESSIONS_MAPPINGに直接追加し、USERS_SESSIONSへの書き込み前でも含まれるようにしています。
  • USERS_TEMP:このnamespaceを、あらかじめ決められた有効期限を持つ一時的なリンクやその他のコンテンツのリポジトリとして使用しました。

無制限の容量、自動スケーリング、高可用性を備えたデータベースを作成しました。これらは通常、より高価なデータベースに見られる機能です。

プロジェクトの設計

サードパーティライブラリに頼らず、シンプルで効果的なものを作ることを目指し、それを達成しました。プロジェクト構造の全体像は以下の通りです:

 1+---framework
 2| +---models
 3| | password.ts
 4| | user.ts
 5| +---templates
 6| | \---emails
 7| | password-reset.ts
 8| | password-updated.ts
 9| \---utils
10| encryptor.ts
11| index.ts
12| mailer.ts
13| validators.ts
14+---functions
15| | forgot-password.ts
16| | login.ts
17| | logout.ts
18| | password-reset.ts
19| | register.ts
20| | \_middleware.ts
21| \---user
22| dashboard.ts
23| \_middleware.ts
24\---public
25| forgot-password.html
26| index.html
27| login.html
28| logout.html
29| password-reset.html
30| register.html
31\---user
32dashboard.html

上記のアーキテクチャを明確に理解していただくために、詳細な内訳を見ていきましょう:

  • framework:このディレクトリには基盤となるTypeScriptコードが格納されています。データモデルからメールテンプレートまですべてがここにあり、システム全体で一貫したアプローチを保証します。
  • functions:ここには、Cloudflare Pages Functions専用に作られたTypeScriptコードがあり、サイトのバックエンド操作を効率化します。
  • public:公開アクセス可能なすべての静的HTMLファイルがこのフォルダに格納されており、ユーザーに表示されるインターフェースを形成しています。

簡単に言えば、login.htmlページに移動すると、Cloudflare Pagesが起動し、対応するlogin.tsコードを実行します。この動的な相互作用は、すべてのページとそれに関連する機能にわたって続きます。

このセットアップにより、さまざまなタスクをシームレスに処理できます。コンテンツの書き換え、データ処理、Cloudflare Workers KVを介したデータ取得など、すべてが効率的に管理されます。

ユーザー登録システムの構築

まず、ユーザー登録システムを確立します。これがコア機能です。

  1. 最初のステップは、シンプルなHTMLフォームを設計してregister.htmlに配置し、続いて提供されたデータを処理する関数を作成することです:
 1<form class="row g-3 needs-validation" id="register" method="POST" novalidate>
 2	<div class="col-md-6 mb-3">
 3		<label for="firstName" class="form-label">First Name</label>
 4		<input type="text" class="form-control" name="firstName" id="firstName" autocomplete="given-name" placeholder="Required" required>
 5		<div class="invalid-feedback">You must enter your First Name.</div>
 6	</div>
 7	<div class="col-md-6 mb-3">
 8		<label for="lastName" class="form-label">Last Name</label>
 9		<input type="text" class="form-control" name="lastName" id="lastName" autocomplete="family-name" placeholder="Required" required>
10		<div class="invalid-feedback">You must enter your Last Name.</div>
11	</div>
12	<div class="col-md-12 mb-3">
13		<label for="email" class="form-label">Email</label>
14		<input type="email" class="form-control" name="email" id="email" autocomplete="email" placeholder="Required" required>
15		<div class="invalid-feedback">You must enter your Email.</div>
16	</div>
17	<div class="col-md-12 mb-3">
18		<label for="company" class="form-label">Company</label>
19		<input type="text" class="form-control" name="company" id="company" autocomplete="organization" placeholder="Optional">
20		<div class="invalid-feedback">You must enter your Company.</div>
21	</div>
22	<div class="col-md-6 mb-3">
23		<label for="password" class="form-label">Password</label>
24		<input type="password" class="form-control" name="password" id="password" autocomplete="new-password" placeholder="Required" required>
25		<div class="invalid-feedback">You must enter a Password.</div>
26	</div>
27	<div class="col-md-6 mb-3">
28		<label for="confirm_password" class="form-label">Confirm Password</label>
29		<input type="password" class="form-control" name="confirm_password" id="confirm_password" autocomplete="new-password" placeholder="Required" required>
30		<div class="invalid-feedback">Your Password does not match.</div>
31	</div>
32	<div class="col-md-12 mb-3">
33		<div class="form-check">
34			<input class="form-check-input" type="checkbox" name="terms" id="terms" required>
35			<label class="form-check-label" for="terms">
36				I have read and agree to the <a href="#" class="link-primary">Terms of Service</a>, <a href="#" class="link-primary">Privacy Policy</a> and <a href="#" class="link-primary">Cookies Policy</a>.
37			</label>
38			<div class="invalid-feedback">You must agree before registering.</div>
39		</div>
40	</div>
41	<div class="col-md-6 mx-auto mb-3">
42		<div class="d-grid gap-2">
43			<button class="btn btn-primary" type="submit">Create Account</button>
44		</div>
45	</div>
46</form>

フォームの設定が完了したら、次のステップはJavaScriptを使用してデータ送信を強化することです。従来のフォーム送信も十分に機能しますが、私の例ではBootstrap 5のバリデーションを組み込んでおり、データ送信にはAJAXを使用しています。

デモから取った実際の動作例はこちらです:

  1(function () {
  2'use strict'
  3
  4    // Check if passwords match
  5    document.querySelectorAll("#password, #confirm_password").forEach(function (input) {
  6    	input.addEventListener("keyup", function () {
  7    		const password = document.getElementById("password");
  8    		const confirmPassword = document.getElementById("confirm_password");
  9    		if (
 10    			password.value !== "" &&
 11    			confirmPassword.value !== "" &&
 12    			confirmPassword.value === password.value
 13    		) {
 14    			// Do something when passwords match
 15    		}
 16    	});
 17    });
 18
 19    var form = document.querySelector("#register");
 20
 21    form.addEventListener('submit', function (event)
 22    {
 23    	const _alert = document.querySelector("div.alert.alert-danger");
 24
 25    	if(_alert)
 26    		_alert.remove();
 27
 28    	if (!form.checkValidity())
 29    	{
 30    	  // Prevent default form submission
 31    	  event.preventDefault();
 32    	  event.stopPropagation();
 33    	  form.classList.add('was-validated');
 34    	}
 35    	else
 36    	{
 37    		// Mark inputs as validated
 38    		form.classList.add('was-validated');
 39
 40    		// Prevent default form submission again
 41    		event.preventDefault();
 42    		event.stopPropagation();
 43
 44    		// Helper to get all inputs we want
 45    		const getAllFormElements = element => Array.from(element.elements).filter(tag => ["input"].includes(tag.tagName.toLowerCase()));
 46
 47    		// Grab desired inputs
 48    		const registerFormElements = getAllFormElements(event.currentTarget);
 49
 50    		// Loop over them and disable
 51    		Array.prototype.slice.call(registerFormElements).forEach(function (element) {
 52    			element.setAttribute("readonly", true);
 53    			element.style = 'background-color: #e9ecef; opacity: 1;';
 54    		});
 55
 56    		// Disable button and show loading spinner
 57    		let _button = event.currentTarget.querySelector(".btn");
 58    		_button.setAttribute("disabled", true);
 59    		_button.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Please wait...';
 60
 61    		// A modern replacement for XMLHttpRequest.
 62    		// https://caniuse.com/fetch
 63    		if (window.fetch)
 64    		{
 65    			(async() => {
 66    				await fetch(window.location.href, {
 67    					method: 'POST',
 68    					// NOTE: FormData will only grab inputs with name attribute
 69    					body: JSON.stringify(Object.fromEntries(new FormData(event.target))),
 70    					headers: {
 71    						'Accept': 'application/json',
 72    						'Content-Type': 'application/json'
 73    					}
 74    				}).then((response) => {
 75    					// Pass both the parsed JSON and the status code to the next .then()
 76    					return response.json().then((data) => ({status: response.status, body: data}));
 77    				}).then(({status, body}) => {
 78
 79    					if (body.success === true && status === 201) {
 80    						Array.prototype.slice.call(document.querySelectorAll('form *')).forEach(function (element) {
 81    							element.style.display = "none";
 82    						});
 83
 84    						let _alert = document.createElement("div");
 85    						_alert.classList.add('alert');
 86    						_alert.classList.add('alert-success');
 87    						_alert.setAttribute("role", 'alert');
 88    						_alert.innerHTML = '<p>Thank you for joining! A confirmation email has been sent out. You need to verify your email before trying to log in.</p>';
 89
 90    						form.prepend(_alert);
 91    					} else {
 92    						let _alert = document.createElement("div");
 93    						_alert.classList.add('alert');
 94    						_alert.classList.add('alert-danger');
 95    						_alert.setAttribute("role", 'alert');
 96    						_alert.innerText = `Error #${data.error.code}: ${data.error.message}`;
 97
 98    						form.prepend(_alert);
 99
100    						form.classList.remove('was-validated');
101
102    						Array.prototype.slice.call(registerFormElements).forEach(function (element) {
103    							element.removeAttribute("style");
104    							element.removeAttribute("readonly");
105    						});
106
107    						_button.removeAttribute("disabled");
108    						_button.innerHTML = 'Create Account';
109    					}
110    				});
111    			})();
112    		}
113    		else
114    		{
115    			alert("Your browser is too old!\nIt does not have the most recent features.\nPlease update your browser or use a different one.");
116    		}
117    	}
118    }, false);
119
120})();
Create Accountがクリックされると、データはAJAX経由で/registerに送信され、functions/register.tsコードがトリガーされます。このメカニズムにより、GETとPOSTの両方のデータ処理が可能になります。OPTIONSの管理は可能ですが、この議論では除外します。

  1. 標準的なページ訪問、つまりGET実行では、CSRF保護のような多くの高度な機能を導入する可能性があります。以下の例を見てみましょう:
 1/\*\*
 2
 3- GET /register
 4  \*/
 5  export const onRequestGet: PagesFunction = async ({ next }) =>
 6  {
 7  // Fetch the original page content
 8  const response = await next();
 9
10      // Prepare our CSRF data
11      const IP = request.headers.get('CF-Connecting-IP');
12      const Country = request.headers.get('CF-IPCountry') || '';
13      const UserAgent = request.headers.get('User-Agent');
14      const expiry = Date.now() + 60000;
15      const CSRF_TOKEN = JSON.stringify({i: IP, c: Country, u: UserAgent, e: expiry});
16      const encryptedData = await encryptData(new TextEncoder().encode(CSRF_TOKEN), env.CSRF_SECRET, 10000);
17      const hex = Utils.BytesToHex(new Uint8Array(encryptedData));
18
19      // Rewrite the content and stream it back to the user (async)
20      return new HTMLRewriter()
21      .on("form", {
22        element(form) {
23      	// The CSRF input
24      	form.append(
25      	  `<input type="hidden" name="csrf" value="${hex}" />`,
26      	  { html: true }
27      	);
28        },
29      })
30      .transform(response);
31
32  };
33  

このプロセスでは、Cloudflare WorkersのHTMLRewriter 機能を使用して、各ページリクエストに対して一意のCSRFトークンを生成しフォームに埋め込みます。

CSRF戦略については後続のセクションで詳しく説明します。現時点では、セキュリティを強化するために一意のランダムなCSRFコードを動的に追加する方法に注目してください。

  1. 次に、POST実行フェーズに移ります。ここでは、入力データを慎重にバリデートし、安全に保存し、確認または追加の手順のためにユーザーにメールを送信します。概念的な概要を提供するために、疑似コード表現を作成しました:
 1/\*\*
 2
 3- POST /register
 4  \*/
 5  export const onRequestPost: PagesFunction<{ USERS: KVNamespace; }> = async ({ request, env }) =>
 6  {
 7  // Validate content type
 8  // Validate our CSRF before doing anything
 9  // Check for existing user email
10  // Generate a new salt & hash the original password
11  // Store the user with some meta-data
12  // Etc
13  }
14  

私の設定では、登録成功後、ユーザーの詳細は以下のようにWorkers KVに保存されます:

 1{
 2"uid": "888e8801-3b35-4dd3-9ae2-7ff0f564bb3b",
 3"email": "[email protected]",
 4"firstname": "John",
 5"lastname": "Smith",
 6"company": "",
 7"role": "User",
 8"password": "....",
 9"salt": "...",
10"access_token": null,
11"created_at": 123456789,
12"confirmed_at": null,
13"last_updated": null,
14"last_sign_in_at": 123456789,
15"recovery_sent_at": null,
16"password_changed_at": null
17}

このフォーマットは、ニーズに応じてフィールドを追加または削除して調整できます。完全なコピー可能なコードは提供しないことを強調しておきます。理解と実践的な探求に投資することが不可欠です。それによってのみ、開発者として真に成長できるのです。

ユーザーログインシステムの構築

Cloudflare Pagesプロジェクトのログインメカニズムへの移行は簡単です。登録プロセスと同様のアプローチを採用します。

  1. 登録フォームを作成したのと同様に、ログインプロセス用の簡潔なフォームを作成し、login.htmlに配置します:
 1<form class="row g-3 needs-validation" id="login" method="POST" novalidate>
 2	<div class="col-sm-12">
 3		<label for="email" class="form-label">Email</label>
 4		<input type="email" class="form-control" name="email" id="email" autocomplete="email" placeholder="Required" required>
 5		<div class="invalid-feedback">You must enter your Email.</div>
 6	</div>
 7	<div class="col-sm-12">
 8		<label for="password" class="form-label">Password</label>
 9		<input type="password" class="form-control" name="password" id="password" autocomplete="password" placeholder="Required" required>
10		<div class="invalid-feedback">You must enter a Password.</div>
11	</div>
12	<div class="d-grid gap-2 col-sm-6 mx-auto">
13		<button class="btn btn-primary" type="submit">Login</button>
14	</div>
15</form>
  1. フォームの設定後、そのレンダリングを処理します。次のステップは必要なコードの作成です。これはlogin.tsから自動的にトリガーされます:
 1/\*\*
 2
 3- GET /login
 4  \*/
 5  export const onRequestGet: PagesFunction = async ({ next }) =>
 6  {
 7  // Fetch the original page content
 8  // Prepare our CSRF data
 9  // Rewrite the content and stream it back to the user (async)
10  };
11  
  1. 最後のステップは、login.ts内にあるPOST実行を管理するコードの策定です:
 1/\*\*
 2
 3- POST /login
 4  \*/
 5  export const onRequestPost: PagesFunction<{
 6  USERS: KVNamespace;
 7  USERS_SESSIONS: KVNamespace;
 8  USERS_SESSIONS_MAPPING: KVNamespace;
 9  USERS_LOGIN_HISTORY: KVNamespace; }> = async ({ request, env }) =>
10  {
11  // Validate content type
12  // Validate our CSRF before doing anything
13  // Check for existing user email
14  // Generate a new salt & hash the original password
15  // Compare the passwords
16  // Save session
17  // Update history
18  // Retrieve the current mapping for this user (if it exists)
19  // Add the new session UID to the mapping
20  // Store the updated mapping
21  // Etc
22  };
23  

これらの3つの合理化されたステップを通じて、シームレスにログインシステムを構築しました。間違いなく、ログインメカニズムは最も複雑なセグメントであり、注釈のとおりいくつかの操作が必要です。しかし、これを困難と見なすのではなく、克服すべきやりがいのある挑戦として捉えてください!

パスワードリセットシステムの構築

  1. これまでのコンポーネントと同様に、パスワードリセットを開始するための専用フォームの作成から始めます:
 1<form class="row g-3 needs-validation" id="forgot" novalidate>
 2	<div class="col-sm-12">
 3		<label for="email" class="form-label">Email</label>
 4		<input type="email" class="form-control" name="email" id="email" autocomplete="email" placeholder="Required" required />
 5		<div class="invalid-feedback">You must enter your Email.</div>
 6	</div>
 7
 8    <div class="d-grid gap-2 col-sm-6 mx-auto">
 9    	<button class="btn btn-primary" type="submit">Request</button>
10    </div>
11
12</form>

基本的なコンセプトはシンプルです:ユーザーのメールアドレスが入力され、システムで認識された場合、ワンタイムのパスワードリセットリンクが生成・送信されます。セキュリティを強化するため、このリンクが有効な期間を短く保ち、理想的には2時間以内にしてください。

  1. GET実行の管理用コードの作成を開始し、forgot-password.tsに埋め込みます:
 1/\*\*
 2
 3- GET /forgot-password
 4  \*/
 5  export const onRequestGet: PagesFunction = async ({ next }) =>
 6  {
 7  // Fetch the original page content
 8  // Prepare our CSRF data
 9  // Rewrite the content and stream it back to the user (async)
10  };
11  
  1. 続けて、POST実行を担当するコードを策定し、同じforgot-password.tsファイルに格納されていることを確認します:
 1/\*\*
 2
 3- POST /forgot-password
 4  \*/
 5  export const onRequestPost: PagesFunction<{
 6  USERS: KVNamespace;
 7  USERS_TEMP: KVNamespace;
 8  }> = async ({ request, env }) =>
 9  {
10  // Validate content type
11  // Validate our CSRF before doing anything
12  // Check for existing user email
13  // Generate a unique string to hash
14  // Hash the string
15  // Save to KV, expiration 2 hours
16  // Send email to user
17  // Etc
18  };
19  

コーディングパズルを完成させるのはあなたの番です。コメント内のガイダンスに従えば、プロセスは十分に管理可能です。完了すれば、完全に機能するパスワードリセットシステムを手に入れることができます。

注意事項:「そのようなメールアドレスは存在しません」のようなメッセージの表示は避けてください。そのような指標はハッカーにとって金鉱であり、ブルートフォース攻撃への道を開きます。代わりに、より曖昧だがユーザーフレンドリーなアプローチを採用してください:「メールアドレスがシステムに登録されている場合、リセットリンクが届きます。」

ユーザー登録デモ

私が構築したユーザー登録プロセスのライブデモをご覧ください:https://members.mecanik.dev/

機能には以下が含まれます:

現在他のプロジェクトも並行して進めていますが、このプラットフォームの改善を続けていきます。ぜひテストしてみてください - ただし、SendGridのレピュテーションに影響を与えないよう、本物のメールアドレスを使用してください。

フィードバックや問題の報告を歓迎します。何かあればお気軽にご連絡ください!

質問と回答

間違いなく、セキュリティが最優先事項です。潜在的な攻撃やデータ漏洩に対するシステムの堅牢性について疑問を持つかもしれません。

Cloudflare PagesはCloudflareプラットフォーム上で動作しており、高度なWeb Application Firewall(WAF)とセキュリティツール一式へのアクセスが付与されることを考慮してください。

CSRFの実際の仕組みは?

各ロード時にフォームにCSRFトークン が動的に生成・注入されるのを観察したかもしれません。このメカニズムがリクエストのセキュリティを確保する仕組みは以下の通りです:

CSRFトークンのコンポーネント

  • IP:ユーザーの現在のIPアドレスをキャプチャ。
  • Country:ユーザーの現在地を特定。
  • UserAgent:ユーザーのブラウザ情報を記録。
  • expiry:現在時刻に1分を加算してタイマーを設定。

これらのデータはJSON形式で組み立てられます:{i, c, u, e}。その後、暗号化されてHexに変換されます:

1const encryptedData = await encryptData(new TextEncoder().encode(CSRF_TOKEN), env.CSRF_SECRET, 10000);
2const hex = Utils.BytesToHex(new Uint8Array(encryptedData));

暗号化関数について

  1. データはユーザー定義のパスワードを使用して暗号化され、パスワードキーを生成し、そこからAESキーを導出します。
  2. このAESキーがデータを暗号化し、saltとIV(初期化ベクトル)はその場で生成されます。
  3. 暗号化された出力は、イテレーション回数、salt、IV、暗号化されたデータをカプセル化したArrayBufferです。

簡単に言えば、この暗号化プロセスは業界標準のプラクティスに従い、暗号化されたデータを解読不可能にし、改ざんから保護します。

CSRFトークンのバリデーション

 1const unhex = Utils.HexToBytes(formData.csrf);
 2const decrypted = new TextDecoder().decode(await decryptData(unhex, env.CSRF_SECRET));
 3const parsed = JSON.parse(decrypted);
 4
 5if (!Utils.isCRSFData(parsed))
 6{
 7return new Response(JSON.stringify({
 8result: null,
 9success: false,
10// An ambigous message; don't tell the hacker what's missing.
11error: { code: 1001, message: "Invalid CSRF Token. Please refresh the page and try again." }
12}),
13{
14status: 403,
15headers: { 'content-type': 'application/json;charset=UTF-8'
16}
17});
18}
19
20const IP = request.headers.get('CF-Connecting-IP');
21const Country = request.headers.get('CF-IPCountry') || '';
22const UserAgent = request.headers.get('User-Agent');
23
24if(IP !== parsed.i || Country !== parsed.c || UserAgent !== parsed.u || Date.now() > parsed.e)
25{
26return new Response(JSON.stringify({
27result: null,
28success: false,
29// An ambigous message; don't tell the hacker what's missing.
30error: { code: 1002, message: "Invalid CSRF Token. Please refresh the page and try again." }
31}),
32{
33status: 403,
34headers: { 'content-type': 'application/json;charset=UTF-8'
35}
36});
37}

バリデーションプロセスはCSRFトークンを厳密にチェックし、以下を確認します:

  • 同じIPアドレスから発信されていること。
  • 同じ国から送信されていること。
  • 同じブラウザから送信されていること。
  • 有効な時間枠内に送信されていること。
  • これらのチェックのいずれかが失敗した場合、システムはCSRFトークンを無効と識別し、潜在的な脅威に対する堅牢な保護を提供します。

これらのチェックのいずれかが失敗した場合、システムはCSRFトークンを無効と識別し、潜在的な脅威に対する堅牢な保護を提供します。

パスワードハッシュ化/暗号化

パスワードのハッシュ化/暗号化には、PBKDF2 (Password-Based Key Derivation Function 2)をSHA-256 ハッシュアルゴリズムと組み合わせて使用しました。

この方法は、各パスワードに対して一意の暗号学的に安全な疑似ランダム「salt」を使用し、強化されたセキュリティを保証します。このアプローチはレインボーテーブル攻撃やブルートフォース攻撃に対する保護を提供します。

平文ではなく、パスワードのハッシュ化されたバージョンが保存され、データの整合性がさらに強化されます。

なぜJWTトークンを使用しないのか?

JWT(JSON Web Tokens)は、ユーザー認証の処理やコンパクトでURL安全な方法で当事者間の情報を伝達するための人気のある方法です。

多くの利点がありますが、完全なユーザー登録システムなどの特定のコンテキストでは使用しない理由があります。詳しく見てみましょう:

  1. ステートレス性と取り消し:JWTの特徴の一つはステートレス性です。しかし、この性質はユーザー管理システムにとって問題になる可能性があります。例えば、ユーザーのJWTトークンが盗まれたり侵害されたりした場合、トークンのブラックリストを維持しない限り、そのトークンを取り消す簡単な方法がなく、ステートレス性の目的を損ないます。
  2. サイズ:JWTにデータを追加するほどサイズが大きくなります。より多くのユーザー関連データをトークンに保存する必要がある包括的なユーザー登録システムがある場合、これはHTTPヘッダーの増大と遅延の増加につながる可能性があります。
  3. ストレージのセキュリティ:JWTのクライアント側ストレージとして、一般的な保存場所にはlocal storageやcookieが含まれます。Local storageはXSS攻撃に脆弱であり、cookieはより安全にできますが、適切に処理されない場合、CSRF攻撃の可能性を開きます。
  4. 有効期限の管理:JWTの有効期限管理は複雑になる場合があります。短命トークンはリスクを減らしますが、リフレッシュメカニズムが必要になり、認証フローにより多くの複雑さをもたらします。
  5. 組み込みの取り消し機能なし:前述の通り、トークンが侵害された場合、有効期限が切れるまでそれを取り消したり無効にしたりする組み込みのメカニズムはありません。トークンの寿命が長い場合、これは重大なセキュリティリスクとなる可能性があります。
  6. 開発者にとっての複雑さ:JWTに馴染みのない人にとっては、適切に生成、検証、使用する方法を理解するための学習曲線があります。この複雑さは、十分に理解され実装されない場合、ミスや脆弱性を招く可能性があります。

さまざまなシステムとそのユーザーデータの扱い方を調査した結果、一部の企業がJWTトークンにユーザーロールなどの機密情報を含む膨大な情報を保存していることがわかりました。

明確にしておきますが、他のアプローチを批判したり中傷したりする意図はありません。しかし、セキュリティに対する私の専門的な立場から、そのような重要な情報をトークンに直接埋め込むことには懸念があります。必要以上のデータを公開することには固有のリスクがあり、特にそのデータがユーザー権限やその他の機能を推測するために利用される可能性がある場合は特にそうです。

これらの懸念から、この方法から離れる意識的な決定を下しました。代わりに、実績のあるサーバーサイド認証とセキュアなCookie処理の組み合わせに向かいました。このアプローチは、ユーザーエクスペリエンスと堅牢なセキュリティのバランスを取り、機密データが保護され、システムが潜在的な脆弱性に対して回復力を持つことを保証すると考えています。

信頼性の高いメール送信

その広範な採用と細部への深い知識から、動的テンプレートを備えたSendGridを選択しました。詳しくはAPIドキュメント をご参照ください。

その統合にはいくつかの複雑さがあるかもしれませんが、それはすべてやりがいのある挑戦の一部です。以下に例を示します:

 1const SENDGRID_API_URL = 'https://api.sendgrid.com/v3/mail/send';
 2const SENDGRID_API_KEY = 'YOUR_SENDGRID_API_KEY'; // Replace with your actual API key
 3
 4interface EmailDetails {
 5to: string;
 6from: string;
 7subject: string;
 8content: string;
 9}
10
11async function sendEmail(details: EmailDetails): Promise<void> {
12const data = {
13personalizations: [{
14to: [{
15email: details.to
16}]
17}],
18from: {
19email: details.from
20},
21subject: details.subject,
22content: [{
23type: 'text/plain',
24value: details.content
25}]
26};
27
28    try {
29        const response = await fetch(SENDGRID_API_URL, {
30            method: 'POST',
31            headers: {
32                'Authorization': `Bearer ${SENDGRID_API_KEY}`,
33                'Content-Type': 'application/json'
34            },
35            body: JSON.stringify(data)
36        });
37
38        if (response.status === 202) {
39            console.log('Email sent successfully!');
40        } else {
41            const responseBody = await response.json();
42            console.error('Failed to send email:', responseBody.errors);
43        }
44    } catch (error) {
45        console.error('Error sending email:', error);
46    }
47
48}
49
50// Usage example:
51sendEmail({
52to: '[email protected]',
53from: '[email protected]',
54subject: 'Hello from SendGrid!',
55content: 'This is a test email sent using the SendGrid API.'
56});

セキュリティをさらに強化するには?

Cloudflare Workers KVは保存されたデータを暗号化しますが、ユーザーデータに追加の暗号化レイヤーを加えることができます。

あるいは、潜在的な攻撃者にひねりを加えたいですか?ボットやハッカーを混乱させるダミーフォーム入力の実装を検討してください:

 1return new HTMLRewriter()
 2.on("form",
 3{
 4element(form)
 5{
 6// Dummy inputs... just to give the hacker more headache and confuse him :)
 7const randomNumber = Utils.RandomNumber(1, 5);
 8
 9    	for (let i = 0; i < randomNumber; i++)
10    	{
11    		form.append(
12    			`<input type="hidden" name="${Utils.RandomString(Utils.RandomNumber(i + 5, i + 25))}" value="${Utils.RandomString(Utils.RandomNumber(i + 25, i + 256))}" />`,
13    			{ html: true }
14    		);
15    	}
16      },
17    })
18
19.transform(response);

このスニペットは、ページロードごとにランダムに名前が付けられた1〜5個の入力を動的に生成します。

可能性はほぼ無限です。必要なのは想像力を働かせることだけです。

まとめ

以上です!この記事が皆さんにとって有益で興味深いものであったことを心から願っています。経験豊富な開発者であれ、スケーラブルなクラウドウェブサイトの世界に飛び込み始めたばかりであれ、知識の共有は私たちのコミュニティの成長にとって不可欠です。

この記事が価値あるものだと感じたら、仲間の開発者やこのトピックに興味がある方に共有することをご検討ください。共有するたびに、私たちの集合的な理解が広がります。

皆様のフィードバックやご質問を大変ありがたく思っています。コメントやご連絡をお気軽にどうぞ - 会話を続けましょう。安全なコーディングを!