진화하는 웹 개발 환경에서 서버리스 애플리케이션은 꾸준히 그 존재감을 드러내고 있습니다. 비할 데 없는 확장성과 강력한 성능 같은 부인할 수 없는 장점들이 이를 돋보이게 합니다. 서버리스의 아름다움은 그 약속에 있습니다: 손쉽게 확장될 뿐만 아니라 비용 효율적인 강력한 솔루션을 제공하는 것입니다.

사업 아이디어를 시작하려는 꿈을 꾸었지만, 사용자 등록 및 로그인 시스템 구축의 기술적 복잡성에 주저한 적이 있습니까? 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)는 전 세계에 분산된 최종 일관성(eventually consistent) 키-값 저장 시스템으로, 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개를 동적으로 생성합니다.

가능성은 거의 무한합니다. 상상력만 발휘하면 됩니다.

마무리

이것으로 마무리합니다! 이 글이 여러분에게 통찰력 있고 흥미로웠기를 진심으로 바랍니다. 숙련된 개발자이든 확장 가능한 클라우드 웹사이트의 세계에 막 발을 들이는 분이든, 지식을 공유하는 것은 커뮤니티 성장에 필수적입니다.

이 글이 가치 있다고 느끼셨다면, 동료 개발자나 이 주제에 관심 있는 분들에게 전달해 주시면 감사하겠습니다. 공유할 때마다 우리의 집단적 이해가 넓어집니다.

여러분의 피드백과 질문을 매우 감사하게 생각합니다. 댓글을 남기거나 연락하는 것을 주저하지 마세요 - 대화를 계속 이어가겠습니다. 안전한 코딩하세요!