在不断发展的 Web 开发领域,无服务器应用程序正在稳步留下自己的印记。 它们无可否认的优势,例如无与伦比的可扩展性和强大的性能,使它们脱颖而出。 无服务器的美妙之处在于它的承诺:提供强大的解决方案,不仅可以轻松扩展,而且预算友好。

您是否曾经梦想过推出一个商业创意,但却被创建用户注册和登录系统的技术细节所阻碍? 虽然 Netlify 等平台和各种基于云的数据库确实存在,但它们无法与 Cloudflare 提供的功能相比。 许多替代方案在扩展时可能会增加成本,但对于 Cloudflare Pages,情况有所不同。

在过去的几个月里,我一直断断续续地致力于这个项目和演示,同时兼顾我的其他承诺。 我对等待表示歉意,尤其是对那些热切期待 这个系统的人。

释放 Cloudflare 页面的力量

  • 无缝可扩展性:轻松管理无限的用户注册。
  • 成本效益:告别意外的管理费用; 享受一致的定价。
  • 极速:体验前所未有的性能。
  • 可靠的安全性:您的用户数据仍然受到保护且安全。

我们将使用什么

  • Cloudflare 页面
  • Cloudflare 页面功能
  • Cloudflare Workers KV

什么是 Cloudflare 页面?

Cloudflare Pages 是一个现代、用户友好的平台,供开发人员构建、部署和托管其网站。 它提供与 GitHub 的无缝集成,这意味着您只需将代码推送到 GitHub,Cloudflare Pages 将处理其余的工作 - 构建、部署,甚至更新。

它的工作原理如下:

  1. 集成工作流程:Cloudflare Pages 围绕 git 工作流程构建。 将 GitHub 存储库连接到 Cloudflare Pages 后,每次您推送到所选分支时,它都会开始构建和部署您的站点。
  2. JAMstack 优化:Cloudflare Pages 支持 JAMstack 原则,这意味着您可以使用您喜欢的静态站点生成器或 JavaScript 框架(包括但不限于 Jekyll、Hugo、Next.js 和 React)构建站点。
  3. 快速、安全的交付:Pages 由全球分布的 Cloudflare 网络提供支持,确保您的网站可用且快速,无论您的受众身在何处。 此外,Cloudflare 固有的安全功能可保护您的网站免受威胁。
  4. 持续部署:每次您在 GitHub 存储库上进行更新时,Cloudflare Pages 都会自动构建和部署您的网站。 这使您可以快速迭代并使部署过程变得轻而易举。
  5. 自定义域和 HTTPS:使用 Pages,您可以将自定义域连接到您的站点,并且它在所有站点上提供免费、自动的 HTTPS,以确保连接始终安全。
  6. 预览部署:每当您在链接的 GitHub 存储库中创建新的拉取请求时,Cloudflare Pages 都会自动生成一个唯一的预览 URL,以便您在上线之前查看更改。

无论您是独立开发者还是大型团队的一员,Cloudflare Pages 都提供了一种简单、快速且安全的方式让您的网站上线。

鉴于上述情况,对于这个用户注册系统,我选择了纯粹且直接的 HTML 页面,避开任何额外的框架或构建工具。 这种方法确保了无与伦比的简单性,并提供了实现任何期望结果的灵活性。

什么是 Cloudflare Workers?

Cloudflare Workers 是一个创新的无服务器计算平台,让开发人员可以将代码直接部署到 Cloudflare 的广泛网络,该网络遍布全球 200 多个城市。 从本质上讲,它使应用程序能够尽可能靠近最终用户运行,从而减少延迟并增强用户体验。

以下是其功能和优点的概述:

  1. 无服务器执行环境:Cloudflare Workers 在无服务器环境中运行,这意味着开发人员无需管理或维护任何服务器。 相反,他们可以专注于编写代码,而平台则负责从分发到扩展的其余部分。
  2. 边缘计算:与应用程序在单个服务器或数据中心上运行的传统模型不同,Cloudflare Workers 将您的代码带到 Cloudflare 网络的边缘。 这可确保您的应用程序运行时更贴近用户,从而提高性能和速度。
  3. 语言灵活性:Workers 使用 V8 JavaScript 引擎,与 Chrome 使用的运行时相同,允许开发人员使用 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 构建在 Cloudflare 网络之上,该网络覆盖全球 300 多个城市。 这可确保您的数据在用户附近存储和访问,从而减少延迟并提高应用程序的整体性能。
  2. 快速读写:Workers KV 提供适合各种应用的低延迟数据访问。 虽然写入需要更长的时间才能在全局范围内传播(通常在几秒钟内),但读取操作通常速度很快,因此非常适合读取密集型工作负载。
  3. 大规模:您可以在单个 Workers KV 命名空间中存储数十亿个密钥,每个密钥可以容纳高达 25MB 的值。
  4. 命名空间:KV 命名空间是键值对的容器。 它们允许您在 Workers KV 存储中隔离不同的数据集,这在管理多个应用程序或环境(例如登台和生产)时特别有用。
  5. 最终一致性:Workers KV 使用最终一致性。 这意味着数据更新(写入)将在全球范围内传播并随着时间的推移变得一致,这通常只需几秒钟。

Cloudflare Workers KV 提供了一种独特的解决方案,用于在无服务器环境中管理状态,为开发人员提供可靠、快速且全球分布式的数据存储系统。

在开发这个用户注册系统时,我战略性地设计了以下 Workers KV 命名空间:

  • USERS:这是所有用户的主存储。 它旨在处理本质上无限数量的记录。
  • USERS_LOGIN_HISTORY:记录登录活动的专用空间,使用户能够定期评估其帐户的安全足迹。
  • USERS_SESSIONS:此命名空间捕获有关当前登录用户的详细信息,包括唯一 ID、设备、位置等。
  • USERS_SESSIONS_MAPPING:由于 Workers KV 的最终一致性模型,写入“USERS_SESSIONS”和检查它之间可能存在延迟。 如果操作发生在不同的边缘位置,则这种情况尤其可能发生。 为了避免这种情况,在验证后,我直接将新的会话 UID 添加到 USERS_SESSIONS_MAPPING,确保在写入“USERS_SESSIONS”之前就将其包含在内。
  • USERS_TEMP:我使用此命名空间作为临时(临时)链接和其他具有预定到期时间的内容的存储库。

我们创建了具有无限容量、自动扩展和高可用性的数据库——这些功能通常存在于昂贵的数据库中。

设计项目

我的目标是在不依赖第三方库的情况下制作一些简单有效的东西,并且我成功了。 整个项目结构是这样展开的:

 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
32        dashboard.html

为了让您清楚地了解上面的架构,让我们深入研究一下详细的细分:

  • framework:该目录包含我们的基础 TypeScript 代码。 从数据模型到电子邮件模板的所有内容都位于此处,确保整个系统采用一致的方法。
  • functions:在这里,您将找到专为 Cloudflare Pages 函数定制的 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})();

单击“创建帐户”后,数据将通过 AJAX 发布到/register,从而触发functions/register.ts代码。 该机制允许 GET 和 POST 数据处理。 虽然管理 OPTIONS 是可能的,但我们将在本次讨论中将其放在一边。

  1. 对于标准页面访问或 GET 执行,有可能引入大量高级功能,例如 CSRF 保护。 让我们看一下下面的例子:
 1/**
 2 * GET /register
 3 */
 4export const onRequestGet: PagesFunction = async ({ next }) => 
 5{
 6	// Fetch the original page content
 7	const response = await next();
 8
 9	// Prepare our CSRF data
10	const IP = request.headers.get('CF-Connecting-IP');
11	const Country = request.headers.get('CF-IPCountry') || '';
12	const UserAgent = request.headers.get('User-Agent');
13	const expiry = Date.now() + 60000;
14	const CSRF_TOKEN = JSON.stringify({i: IP, c: Country, u: UserAgent, e: expiry});  
15	const encryptedData = await encryptData(new TextEncoder().encode(CSRF_TOKEN), env.CSRF_SECRET, 10000);	
16	const hex = Utils.BytesToHex(new Uint8Array(encryptedData));
17		
18	// Rewrite the content and stream it back to the user (async)
19	return new HTMLRewriter()
20    .on("form", {
21      element(form) {
22		// The CSRF input
23		form.append(
24		  `<input type="hidden" name="csrf" value="${hex}" />`,
25		  { html: true }
26		);
27      },
28    })
29    .transform(response);
30};

在此过程中,我们使用 Cloudflare Workers HTMLRewriter 功能为每个页面请求生成唯一的 CSRF 令牌并将其嵌入到表单中。

我将在后续部分中更深入地研究我的 CSRF 策略。 暂时观察我如何动态附加唯一的随机 CSRF 代码来增强安全性。

  1. 现在,让我们转向 POST 执行阶段。 在这里,我们仔细验证输入数据,安全存储,然后向用户发送电子邮件以进行确认或进一步说明。 为了向您提供概念性概述,我起草了一个伪代码表示:
 1/**
 2 * POST /register
 3 */
 4export const onRequestPost: PagesFunction<{ USERS: KVNamespace; }> = async ({ request, env }) => 
 5{
 6	// 验证内容类型
 7	// 在做任何事情之前验证我们的 CSRF
 8	// 检查现有用户电子邮件
 9	// 生成新的盐并对原始密码进行哈希处理
10	// 存储用户一些元数据
11	// ETC
12}

在我的配置中,成功注册后,用户的详细信息将存储在 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 * GET /login
3 */
4export const onRequestGet: PagesFunction = async ({ next }) => 
5{
6	// 获取原始页面内容
7	// 准备我们的CSRF数据
8	// 重写内容并将其流式传输回用户(异步)
9};
  1. 最后一步是制定代码来管理 POST 执行,位于login.ts中:
 1/**
 2 * POST /login
 3 */
 4export const onRequestPost: PagesFunction<{ 
 5	USERS: KVNamespace;
 6	USERS_SESSIONS: KVNamespace;
 7	USERS_SESSIONS_MAPPING: KVNamespace;
 8	USERS_LOGIN_HISTORY: KVNamespace; }> = async ({ request, env }) => 
 9{
10	// 验证内容类型
11	// 在做任何事情之前验证我们的 CSRF
12	// 检查现有用户电子邮件
13	// 生成新的盐并对原始密码进行哈希处理
14	// 比较密码
15	// 保存会议
16	// 更新历史记录
17	// 检索该用户的当前映射(如果存在)
18	// 将新的会话 UID 添加到映射中
19	// 存储更新后的映射
20	// ETC
21};

通过这 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</form>

基本概念很简单:输入用户的电子邮件后,如果我们的系统能够识别该电子邮件,则会生成并发送一个单一的密码重置链接。 为了增强安全性,请确保此链接在短时间内保持活动状态,最好不超过 2 小时。

  1. 开始编写专门用于管理 GET 执行的代码,您需要将其嵌入到 forgot-password.ts 中:
1/**
2 * GET /forgot-password
3 */
4export const onRequestGet: PagesFunction = async ({ next }) => 
5{
6	// 获取原始页面内容
7	// 准备我们的CSRF数据
8	// 重写内容并将其流式传输回用户(异步)
9};
  1. 继续制定负责 POST 执行的代码,确保其位于同一个 forgot-password.ts 文件中:
 1/**
 2 * POST /forgot-password
 3 */
 4export const onRequestPost: PagesFunction<{ 
 5	USERS: KVNamespace;
 6	USERS_TEMP: KVNamespace; 
 7	}> = async ({ request, env }) => 
 8{
 9	// 验证内容类型
10	// 在做任何事情之前验证我们的 CSRF
11	// 检查现有用户电子邮件
12	// 生成一个唯一的字符串进行哈希
13	// 散列字符串
14	// 保存到KV,过期2小时
15	// 发送邮件给用户
16	// ETC
17};

现在由您来完成编码难题。 通过遵循我的评论中的指导,您应该会发现该过程非常易于管理。 完成后,您将配备一个完全可操作的密码重置系统。

注意事项:不要显示诸如不存在此类电子邮件之类的消息。 这些指标对于黑客来说是金矿,为潜在的暴力攻击铺平了道路。 相反,采用一种更加模糊但用户友好的方法:如果电子邮件在我们的系统中注册,您将收到一个重置链接

用户注册演示

探索我建立的用户注册流程的现场演示:https://members.mecanik.dev/

特点包括:

  • 注册、登录和密码重置功能
  • 访问您的个人资料
  • 管理安全设置和活动会话 - 例如,如果您从手机登录,则可以断开连接。
  • 预览免费软件并留意即将推出的高级产品。

我目前正在处理其他项目,但我将继续增强这个平台。 请随意测试 - 只要确保您使用真实的电子邮件即可,以避免影响我的 SendGrid 声誉。

我欢迎任何反馈或报告的问题。 有的话请联系!

## 问题与解答

毫无疑问,安全是首要考虑因素。 您可能会质疑系统针对潜在攻击和数据泄露的稳健性。

考虑到 Cloudflare Pages 在 Cloudflare 平台上运行,允许您访问其高级 Web 应用程序防火墙 (WAF) 和一套安全工具。

CSRF 实际上是如何工作的?

您可能已经观察到每次加载时都会动态生成并将 CSRF 令牌 注入到表单中。 以下是该机制如何确保请求的安全性:

CSRF 令牌组件

  • IP: 捕获用户当前的 IP 地址。
  • Country: 识别用户的当前位置。
  • UserAgent: 记录用户的浏览器详细信息。
  • expiry: 通过在当前时间上添加一分钟来设置计时器。

该数据以 JSON 格式组装:{i, c, u, e},随后被加密并转换为十六进制:

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

关于加密功能

  1. 使用用户定义的密码对数据进行加密,生成密码密钥,然后从中导出 AES 密钥。
  2. 该 AES 密钥使用即时生成的盐和 IV(初始化向量)对数据进行加密。
  3. 加密输出是一个ArrayBuffer,封装了迭代次数、salt、IV 和加密数据。

简而言之,此加密过程遵循行业标准实践,使加密数据既不可破译又可防止篡改。

验证 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{
 7	return new Response(JSON.stringify({
 8			result: null,
 9			success: false,
10			// An ambigous message; don't tell the hacker what's missing.
11			error: { code: 1001, message: "CSRF 令牌无效。 请刷新页面并重试。" }
12		}),
13		{
14			status: 403,
15			headers: { '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{
26	return new Response(JSON.stringify({
27			result: null,
28			success: false,
29			// An ambigous message; don't tell the hacker what's missing.
30			error: { code: 1002, message: "CSRF 令牌无效。 请刷新页面并重试。" }
31		}),
32		{
33			status: 403,
34			headers: { 'content-type': 'application/json;charset=UTF-8'
35		}
36	}); 
37}

验证过程严格检查 CSRF 令牌,确保其:

  • 源自同一 IP 地址。
  • 从同一国家发货。
  • 从同一浏览器发送。
  • 在有效时间内提交。
  • 如果这些检查中的任何一个失败,系统会将 CSRF 令牌识别为无效,从而提供针对潜在威胁的强大保护。

如果这些检查中的任何一个失败,系统都会将 CSRF 令牌识别为无效,从而提供针对潜在威胁的强大保护。

密码散列/加密

对于密码散列/加密,我采用了 PBKDF2 (基于密码的密钥派生函数 2)与 [SHA-256](https://en.wikipedia.org/wiki/SHA-2 )哈希算法。

该方法为每个密码使用独特的、加密安全的伪随机“盐”,以确保增强的安全性。 这种方法可以防止彩虹表和暴力攻击。

存储密码的散列版本,而不是明文,进一步增强数据完整性。

为什么不使用 JWT 令牌?

JWT(JSON Web 令牌)是一种流行的方法,用于以紧凑、URL 安全的方式处理用户身份验证并在各方之间传输信息。

虽然它们有很多优点,但人们可能有理由选择不在特定环境中使用它们,例如完整的用户注册系统。 下面是更深入的了解:

  1. 无状态和撤销:JWT 的标志之一是其无状态。 然而,这种性质可能会给用户管理系统带来问题。 例如,如果用户的 JWT 令牌被盗或泄露,则没有直接的方法可以撤销该令牌,除非您维护令牌黑名单,这违背了无状态的目的。
  2. 大小:当您向 JWT 添加更多数据时,它的大小会增加。 如果您有一个全面的用户注册系统,您可能需要在令牌中存储更多与用户相关的数据,这可能会导致更大的 HTTP 标头和增加的延迟。
  3. 存储安全:对于JWT的客户端存储,常见的存储位置包括本地存储或cookie。 本地存储很容易受到 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'; // 替换为您的实际 API 密钥
 3
 4interface EmailDetails {
 5    to: string;
 6    from: string;
 7    subject: string;
 8    content: string;
 9}
10
11async function sendEmail(details: EmailDetails): Promise<void> {
12    const data = {
13        personalizations: [{
14            to: [{
15                email: details.to
16            }]
17        }],
18        from: {
19            email: details.from
20        },
21        subject: details.subject,
22        content: [{
23            type: 'text/plain',
24            value: 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('邮件发送成功!');
40        } else {
41            const responseBody = await response.json();
42            console.error('发送邮件失败:', responseBody.errors);
43        }
44    } catch (error) {
45        console.error('发送电子邮件时出错:', error);
46    }
47}
48
49// 使用示例:
50sendEmail({
51    to: '[email protected]',
52    from: '[email protected]',
53    subject: 'Hello from SendGrid!',
54    content: '这是使用 SendGrid API 发送的测试电子邮件。'
55});

如何进一步提高安全性?

尽管 Cloudflare Workers KV 会对存储的数据进行加密,但您可以为用户数据添加额外的加密层。

或者想为潜在的攻击者添加一些变化? 考虑实施虚拟表单输入来迷惑机器人和黑客:

 1return new HTMLRewriter()
 2	.on("form", 
 3	{
 4	  element(form) 
 5	  {
 6		// 虚拟输入...只是为了让黑客更加头疼并使他困惑:)
 7		const 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.transform(response);

此代码片段在每次页面加载时动态生成 1 到 5 个随机命名的输入。

功能几乎是无限的,你只需要发挥你的想象力。

包起来

我们终于得到它了! 我真诚地希望这篇文章对您来说既富有洞察力又有吸引力。 无论您是经验丰富的开发人员还是刚刚进入可扩展云网站的世界,共享知识对于我们社区的发展都至关重要。

如果您发现本文有价值,请考虑将其转发给其他开发人员或对该主题感兴趣的人。 每一次分享都会拓宽我们的集体理解。

非常感谢您的反馈和问题。 请随时发表评论或联系我们——让我们继续对话。 安全编码!