فهرست منبع

Create back-end for user login

Lee Morgan 4 روز پیش
والد
کامیت
72df6de7d6

+ 1 - 0
.env.example

@@ -1,2 +1,3 @@
 MONGODB_URI=mongodb://127.0.0.1:27017
 MONGODB_URI=mongodb://127.0.0.1:27017
 MONGODB_DB=torus
 MONGODB_DB=torus
+JWT_SECRET=change-me-to-a-long-random-string

+ 139 - 0
package-lock.json

@@ -9,6 +9,7 @@
 			"version": "0.0.1",
 			"version": "0.0.1",
 			"dependencies": {
 			"dependencies": {
 				"bcrypt": "^6.0.0",
 				"bcrypt": "^6.0.0",
+				"jsonwebtoken": "^9.0.3",
 				"mongodb": "^7.6.0"
 				"mongodb": "^7.6.0"
 			},
 			},
 			"devDependencies": {
 			"devDependencies": {
@@ -580,6 +581,12 @@
 				"node": ">=20.19.0"
 				"node": ">=20.19.0"
 			}
 			}
 		},
 		},
+		"node_modules/buffer-equal-constant-time": {
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+			"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+			"license": "BSD-3-Clause"
+		},
 		"node_modules/clsx": {
 		"node_modules/clsx": {
 			"version": "2.1.1",
 			"version": "2.1.1",
 			"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
 			"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -627,6 +634,15 @@
 			"dev": true,
 			"dev": true,
 			"license": "MIT"
 			"license": "MIT"
 		},
 		},
+		"node_modules/ecdsa-sig-formatter": {
+			"version": "1.0.11",
+			"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+			"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+			"license": "Apache-2.0",
+			"dependencies": {
+				"safe-buffer": "^5.0.1"
+			}
+		},
 		"node_modules/esm-env": {
 		"node_modules/esm-env": {
 			"version": "1.2.2",
 			"version": "1.2.2",
 			"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
 			"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
@@ -695,6 +711,49 @@
 				"@types/estree": "^1.0.6"
 				"@types/estree": "^1.0.6"
 			}
 			}
 		},
 		},
+		"node_modules/jsonwebtoken": {
+			"version": "9.0.3",
+			"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+			"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+			"license": "MIT",
+			"dependencies": {
+				"jws": "^4.0.1",
+				"lodash.includes": "^4.3.0",
+				"lodash.isboolean": "^3.0.3",
+				"lodash.isinteger": "^4.0.4",
+				"lodash.isnumber": "^3.0.3",
+				"lodash.isplainobject": "^4.0.6",
+				"lodash.isstring": "^4.0.1",
+				"lodash.once": "^4.0.0",
+				"ms": "^2.1.1",
+				"semver": "^7.5.4"
+			},
+			"engines": {
+				"node": ">=12",
+				"npm": ">=6"
+			}
+		},
+		"node_modules/jwa": {
+			"version": "2.0.1",
+			"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+			"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+			"license": "MIT",
+			"dependencies": {
+				"buffer-equal-constant-time": "^1.0.1",
+				"ecdsa-sig-formatter": "1.0.11",
+				"safe-buffer": "^5.0.1"
+			}
+		},
+		"node_modules/jws": {
+			"version": "4.0.1",
+			"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+			"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+			"license": "MIT",
+			"dependencies": {
+				"jwa": "^2.0.1",
+				"safe-buffer": "^5.0.1"
+			}
+		},
 		"node_modules/kleur": {
 		"node_modules/kleur": {
 			"version": "4.1.5",
 			"version": "4.1.5",
 			"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
 			"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -985,6 +1044,48 @@
 			"dev": true,
 			"dev": true,
 			"license": "MIT"
 			"license": "MIT"
 		},
 		},
+		"node_modules/lodash.includes": {
+			"version": "4.3.0",
+			"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+			"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+			"license": "MIT"
+		},
+		"node_modules/lodash.isboolean": {
+			"version": "3.0.3",
+			"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+			"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+			"license": "MIT"
+		},
+		"node_modules/lodash.isinteger": {
+			"version": "4.0.4",
+			"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+			"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+			"license": "MIT"
+		},
+		"node_modules/lodash.isnumber": {
+			"version": "3.0.3",
+			"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+			"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+			"license": "MIT"
+		},
+		"node_modules/lodash.isplainobject": {
+			"version": "4.0.6",
+			"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+			"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+			"license": "MIT"
+		},
+		"node_modules/lodash.isstring": {
+			"version": "4.0.1",
+			"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+			"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+			"license": "MIT"
+		},
+		"node_modules/lodash.once": {
+			"version": "4.1.1",
+			"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+			"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+			"license": "MIT"
+		},
 		"node_modules/magic-string": {
 		"node_modules/magic-string": {
 			"version": "0.30.21",
 			"version": "0.30.21",
 			"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
 			"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1070,6 +1171,12 @@
 				"node": ">=10"
 				"node": ">=10"
 			}
 			}
 		},
 		},
+		"node_modules/ms": {
+			"version": "2.1.3",
+			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+			"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+			"license": "MIT"
+		},
 		"node_modules/nanoid": {
 		"node_modules/nanoid": {
 			"version": "3.3.19",
 			"version": "3.3.19",
 			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
 			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
@@ -1215,6 +1322,38 @@
 				"@rolldown/binding-win32-x64-msvc": "1.2.8"
 				"@rolldown/binding-win32-x64-msvc": "1.2.8"
 			}
 			}
 		},
 		},
+		"node_modules/safe-buffer": {
+			"version": "5.2.1",
+			"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+			"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+			"funding": [
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/feross"
+				},
+				{
+					"type": "patreon",
+					"url": "https://www.patreon.com/feross"
+				},
+				{
+					"type": "consulting",
+					"url": "https://feross.org/support"
+				}
+			],
+			"license": "MIT"
+		},
+		"node_modules/semver": {
+			"version": "7.8.5",
+			"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+			"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+			"license": "ISC",
+			"bin": {
+				"semver": "bin/semver.js"
+			},
+			"engines": {
+				"node": ">=10"
+			}
+		},
 		"node_modules/set-cookie-parser": {
 		"node_modules/set-cookie-parser": {
 			"version": "3.1.2",
 			"version": "3.1.2",
 			"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
 			"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",

+ 1 - 0
package.json

@@ -19,6 +19,7 @@
 	},
 	},
 	"dependencies": {
 	"dependencies": {
 		"bcrypt": "^6.0.0",
 		"bcrypt": "^6.0.0",
+		"jsonwebtoken": "^9.0.3",
 		"mongodb": "^7.6.0"
 		"mongodb": "^7.6.0"
 	}
 	}
 }
 }

+ 75 - 0
src/lib/server/auth.js

@@ -0,0 +1,75 @@
+import { dev } from '$app/environment';
+import { env } from '$env/dynamic/private';
+import jwt from 'jsonwebtoken';
+
+export const SESSION_COOKIE = 'torus_session';
+const TOKEN_TTL = '7d';
+const COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
+
+/**
+ * @returns {string}
+ */
+function getJwtSecret() {
+	const secret = env.JWT_SECRET;
+	if (!secret) {
+		throw new Error('JWT_SECRET is not set');
+	}
+	return secret;
+}
+
+/**
+ * @param {{ id: string, uuid: string }} payload
+ * @returns {string}
+ */
+export function signSessionToken(payload) {
+	return jwt.sign(
+		{
+			id: payload.id,
+			uuid: payload.uuid
+		},
+		getJwtSecret(),
+		{ expiresIn: TOKEN_TTL }
+	);
+}
+
+/**
+ * @param {string} token
+ * @returns {{ id: string, uuid: string } | null}
+ */
+export function verifySessionToken(token) {
+	try {
+		const decoded = jwt.verify(token, getJwtSecret());
+		if (
+			!decoded ||
+			typeof decoded !== 'object' ||
+			typeof decoded.id !== 'string' ||
+			typeof decoded.uuid !== 'string'
+		) {
+			return null;
+		}
+		return { id: decoded.id, uuid: decoded.uuid };
+	} catch {
+		return null;
+	}
+}
+
+/**
+ * @param {import('@sveltejs/kit').Cookies} cookies
+ * @param {string} token
+ */
+export function setSessionCookie(cookies, token) {
+	cookies.set(SESSION_COOKIE, token, {
+		path: '/',
+		httpOnly: true,
+		secure: !dev,
+		sameSite: 'lax',
+		maxAge: COOKIE_MAX_AGE
+	});
+}
+
+/**
+ * @param {import('@sveltejs/kit').Cookies} cookies
+ */
+export function clearSessionCookie(cookies) {
+	cookies.delete(SESSION_COOKIE, { path: '/' });
+}

+ 43 - 0
src/lib/server/users.js

@@ -109,3 +109,46 @@ export async function registerUser(db, input) {
 		password: validated.password
 		password: validated.password
 	});
 	});
 }
 }
+
+const LOGIN_FAIL = /** @type {const} */ ({ ok: false, message: 'Bad email/password' });
+
+/**
+ * Authenticate a user by email + password.
+ * Honeypot field (`website`) must be empty.
+ * @param {import('mongodb').Db} db
+ * @param {{ email?: string, password?: string, website?: string }} input
+ * @returns {Promise<{ ok: true, user: { id: string, uuid: string, name: string, email: string } } | { ok: false, message: string }>}
+ */
+export async function loginUser(db, input) {
+	const honeypot = String(input.website ?? '').trim();
+	if (honeypot) {
+		return LOGIN_FAIL;
+	}
+
+	const email = normalizeEmail(input.email);
+	const password = String(input.password ?? '');
+
+	if (!email || !isValidEmail(email) || !password) {
+		return LOGIN_FAIL;
+	}
+
+	const user = await db.collection(USERS).findOne({ email });
+	if (!user?.password || !user?.uuid) {
+		return LOGIN_FAIL;
+	}
+
+	const matches = await bcrypt.compare(password, user.password);
+	if (!matches) {
+		return LOGIN_FAIL;
+	}
+
+	return {
+		ok: true,
+		user: {
+			id: String(user._id),
+			uuid: String(user.uuid),
+			name: String(user.name ?? ''),
+			email: String(user.email)
+		}
+	};
+}

+ 28 - 0
src/routes/login/+page.server.js

@@ -0,0 +1,28 @@
+import { fail, redirect } from '@sveltejs/kit';
+import { setSessionCookie, signSessionToken } from '$lib/server/auth.js';
+import { loginUser } from '$lib/server/users.js';
+
+/** @type {import('./$types').Actions} */
+export const actions = {
+	default: async ({ request, locals, cookies }) => {
+		const form = await request.formData();
+
+		const result = await loginUser(locals.db, {
+			email: String(form.get('email') ?? ''),
+			password: String(form.get('password') ?? ''),
+			website: String(form.get('website') ?? '')
+		});
+
+		if (!result.ok) {
+			return fail(400, { message: result.message });
+		}
+
+		const token = signSessionToken({
+			id: result.user.id,
+			uuid: result.user.uuid
+		});
+		setSessionCookie(cookies, token);
+
+		redirect(303, '/workouts');
+	}
+};

+ 42 - 6
src/routes/login/+page.svelte

@@ -1,14 +1,20 @@
 <script>
 <script>
+	import { onMount } from 'svelte';
+	import { applyAction, enhance } from '$app/forms';
 	import logoMark from '$lib/images/logo_white.svg';
 	import logoMark from '$lib/images/logo_white.svg';
+	import { notifier } from '$lib/notifier.svelte.js';
 
 
 	let email = $state('');
 	let email = $state('');
 	let password = $state('');
 	let password = $state('');
 	/** Honeypot — leave empty. Bots often fill this. */
 	/** Honeypot — leave empty. Bots often fill this. */
 	let website = $state('');
 	let website = $state('');
+	let submitting = $state(false);
+	/** @type {HTMLInputElement | undefined} */
+	let emailInput = $state();
 
 
-	function handleSubmit(event) {
-		event.preventDefault();
-	}
+	onMount(() => {
+		emailInput?.focus();
+	});
 </script>
 </script>
 
 
 <svelte:head>
 <svelte:head>
@@ -32,7 +38,32 @@
 				<p class="lede">Email and password. Back to the floor.</p>
 				<p class="lede">Email and password. Back to the floor.</p>
 			</div>
 			</div>
 
 
-			<form class="form" method="POST" onsubmit={handleSubmit}>
+			<form
+				class="form"
+				method="POST"
+				use:enhance={() => {
+					submitting = true;
+					return async ({ result, update }) => {
+						submitting = false;
+
+						if (result.type === 'failure') {
+							const message =
+								result.data && typeof result.data === 'object' && 'message' in result.data
+									? String(result.data.message)
+									: 'Bad email/password';
+							notifier.fail(message);
+							await update({ reset: false });
+						} else if (result.type === 'redirect') {
+							await applyAction(result);
+						} else if (result.type === 'error') {
+							notifier.fail(result.error?.message ?? 'Login failed');
+							await update({ reset: false });
+						} else {
+							await update({ reset: false });
+						}
+					};
+				}}
+			>
 				<label class="field">
 				<label class="field">
 					<span class="label">Email</span>
 					<span class="label">Email</span>
 					<input
 					<input
@@ -41,7 +72,8 @@
 						name="email"
 						name="email"
 						autocomplete="email"
 						autocomplete="email"
 						required
 						required
-						autofocus
+						disabled={submitting}
+						bind:this={emailInput}
 						bind:value={email}
 						bind:value={email}
 					/>
 					/>
 				</label>
 				</label>
@@ -54,6 +86,7 @@
 						name="password"
 						name="password"
 						autocomplete="current-password"
 						autocomplete="current-password"
 						required
 						required
+						disabled={submitting}
 						bind:value={password}
 						bind:value={password}
 					/>
 					/>
 				</label>
 				</label>
@@ -66,12 +99,15 @@
 							name="website"
 							name="website"
 							tabindex="-1"
 							tabindex="-1"
 							autocomplete="off"
 							autocomplete="off"
+							disabled={submitting}
 							bind:value={website}
 							bind:value={website}
 						/>
 						/>
 					</label>
 					</label>
 				</div>
 				</div>
 
 
-				<button class="btn btn-primary btn-submit" type="submit">Log in</button>
+				<button class="btn btn-primary btn-submit" type="submit" disabled={submitting}>
+					{submitting ? 'Logging in…' : 'Log in'}
+				</button>
 			</form>
 			</form>
 
 
 			<p class="switch">
 			<p class="switch">

+ 8 - 1
src/routes/register/+page.svelte

@@ -1,4 +1,5 @@
 <script>
 <script>
+	import { onMount } from 'svelte';
 	import { applyAction, enhance } from '$app/forms';
 	import { applyAction, enhance } from '$app/forms';
 	import logoMark from '$lib/images/logo_white.svg';
 	import logoMark from '$lib/images/logo_white.svg';
 	import { notifier } from '$lib/notifier.svelte.js';
 	import { notifier } from '$lib/notifier.svelte.js';
@@ -8,6 +9,12 @@
 	let password = $state('');
 	let password = $state('');
 	let confirmPassword = $state('');
 	let confirmPassword = $state('');
 	let submitting = $state(false);
 	let submitting = $state(false);
+	/** @type {HTMLInputElement | undefined} */
+	let nameInput = $state();
+
+	onMount(() => {
+		nameInput?.focus();
+	});
 </script>
 </script>
 
 
 <svelte:head>
 <svelte:head>
@@ -66,8 +73,8 @@
 						name="name"
 						name="name"
 						autocomplete="name"
 						autocomplete="name"
 						required
 						required
-						autofocus
 						disabled={submitting}
 						disabled={submitting}
+						bind:this={nameInput}
 						bind:value={name}
 						bind:value={name}
 					/>
 					/>
 				</label>
 				</label>

+ 96 - 0
src/routes/workouts/+page.svelte

@@ -0,0 +1,96 @@
+<script>
+	import logoMark from '$lib/images/logo_white.svg';
+</script>
+
+<svelte:head>
+	<title>Workouts — Torus</title>
+</svelte:head>
+
+<div class="page">
+	<header class="top">
+		<a class="brand" href="/">
+			<img class="brand-mark" src={logoMark} alt="" width="28" height="28" />
+			<span class="brand-name">Torus</span>
+		</a>
+	</header>
+
+	<main>
+		<section class="panel">
+			<h1>Workouts</h1>
+			<p class="lede">Your sessions will show up here.</p>
+		</section>
+	</main>
+</div>
+
+<style>
+	.page {
+		min-height: 100vh;
+		display: flex;
+		flex-direction: column;
+	}
+
+	.top {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		gap: 16px;
+		padding: 16px 20px;
+		border-bottom: 1px solid var(--border);
+	}
+
+	.brand {
+		display: inline-flex;
+		align-items: center;
+		gap: 10px;
+		font-weight: 700;
+		letter-spacing: -0.02em;
+	}
+
+	.brand-mark {
+		display: block;
+		width: 28px;
+		height: 28px;
+		flex-shrink: 0;
+	}
+
+	.brand-name {
+		font-size: 15px;
+	}
+
+	main {
+		flex: 1;
+		width: min(1120px, 100%);
+		margin: 0 auto;
+		padding: 40px 20px 64px;
+	}
+
+	.panel {
+		background: var(--surface);
+		border: 1px solid var(--border);
+		border-radius: var(--radius-lg);
+		padding: 24px;
+	}
+
+	h1 {
+		margin: 0;
+		font-size: 1.6rem;
+		letter-spacing: -0.03em;
+		font-weight: 750;
+	}
+
+	.lede {
+		margin: 8px 0 0;
+		color: var(--text-muted);
+		line-height: 1.45;
+	}
+
+	@media (min-width: 720px) {
+		.top {
+			padding: 16px 28px;
+		}
+
+		main {
+			padding: 48px 28px 80px;
+		}
+	}
+</style>