From 6fd638e78856d38f69b0d45bd3b15ab0de03856e Mon Sep 17 00:00:00 2001
From: Demi Dev Unit <131922119+jackandcarter@users.noreply.github.com>
Date: Sun, 23 Nov 2025 12:34:20 -0600
Subject: [PATCH] Add authentication flow and gate lobby access
---
database/dump.sql | 9 ++
webapp/.env.example | 9 +-
webapp/package-lock.json | 155 +++++++++++++++++++++++++++++++++-
webapp/package.json | 6 +-
webapp/public/auth.js | 55 ++++++++++++
webapp/public/login.html | 33 ++++++++
webapp/public/main.js | 33 +++++++-
webapp/public/register.html | 37 ++++++++
webapp/public/styles.css | 43 ++++++++++
webapp/src/app.ts | 23 ++++-
webapp/src/config/env.ts | 2 +
webapp/src/db/setup.ts | 34 ++++++--
webapp/src/middleware/auth.ts | 57 +++++++++++++
webapp/src/routes/auth.ts | 77 +++++++++++++++++
webapp/src/services/auth.ts | 90 ++++++++++++++++++++
15 files changed, 640 insertions(+), 23 deletions(-)
create mode 100644 webapp/public/auth.js
create mode 100644 webapp/public/login.html
create mode 100644 webapp/public/register.html
create mode 100644 webapp/src/middleware/auth.ts
create mode 100644 webapp/src/routes/auth.ts
create mode 100644 webapp/src/services/auth.ts
diff --git a/database/dump.sql b/database/dump.sql
index 36bddbd..c67bbce 100644
--- a/database/dump.sql
+++ b/database/dump.sql
@@ -39,6 +39,7 @@ DROP TABLE IF EXISTS `status_effects`;
DROP TABLE IF EXISTS `elements`;
DROP TABLE IF EXISTS `floor_room_rules`;
DROP TABLE IF EXISTS `difficulties`;
+DROP TABLE IF EXISTS `web_users`;
CREATE TABLE IF NOT EXISTS difficulties (
difficulty_id INT AUTO_INCREMENT PRIMARY KEY,
@@ -56,6 +57,14 @@ CREATE TABLE IF NOT EXISTS difficulties (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
+CREATE TABLE IF NOT EXISTS web_users (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ email VARCHAR(255) NOT NULL UNIQUE,
+ display_name VARCHAR(255) NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+
CREATE TABLE IF NOT EXISTS floor_room_rules (
rule_id INT AUTO_INCREMENT PRIMARY KEY,
difficulty_name VARCHAR(50) NOT NULL,
diff --git a/webapp/.env.example b/webapp/.env.example
index 699c4ea..2f17582 100644
--- a/webapp/.env.example
+++ b/webapp/.env.example
@@ -1,8 +1,9 @@
PORT=8080
NODE_ENV=development
LOG_LEVEL=info
-MYSQL_HOST=127.0.0.1
+MYSQL_HOST=localhost
MYSQL_PORT=3306
-MYSQL_USER=adventurebot
-MYSQL_PASSWORD=bot_password
-MYSQL_DATABASE=adventure
+MYSQL_USER=root
+MYSQL_PASSWORD=changeme
+MYSQL_DATABASE=adventurebot
+JWT_SECRET=change-me-to-a-long-random-string
diff --git a/webapp/package-lock.json b/webapp/package-lock.json
index cb57d0c..b629a73 100644
--- a/webapp/package-lock.json
+++ b/webapp/package-lock.json
@@ -8,8 +8,11 @@
"name": "adventurebot-webapp",
"version": "0.1.0",
"dependencies": {
+ "@types/jsonwebtoken": "^9.0.10",
+ "cookie-parser": "^1.4.7",
"dotenv": "^16.4.5",
"express": "^4.19.2",
+ "jsonwebtoken": "^9.0.2",
"mysql2": "^3.9.4",
"pino": "^9.3.2",
"pino-http": "^10.1.0",
@@ -17,6 +20,7 @@
"zod": "^3.23.8"
},
"devDependencies": {
+ "@types/cookie-parser": "^1.4.10",
"@types/express": "^4.17.21",
"@types/node": "^20.14.11",
"@typescript-eslint/eslint-plugin": "^7.16.1",
@@ -324,6 +328,16 @@
"@types/node": "*"
}
},
+ "node_modules/@types/cookie-parser": {
+ "version": "1.4.10",
+ "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
+ "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/express": "*"
+ }
+ },
"node_modules/@types/express": {
"version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
@@ -364,6 +378,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/jsonwebtoken": {
+ "version": "9.0.10",
+ "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
+ "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*",
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -371,11 +395,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "20.19.25",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -1082,6 +1111,12 @@
"ieee754": "^1.2.1"
}
},
+ "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/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -1274,6 +1309,28 @@
"node": ">= 0.6"
}
},
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-parser/node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@@ -1526,6 +1583,15 @@
"xtend": "^4.0.0"
}
},
+ "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/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -3507,6 +3573,49 @@
"json5": "lib/cli.js"
}
},
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
+ "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^3.2.2",
+ "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": "1.4.2",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
+ "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
+ "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": "3.2.2",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
+ "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^1.4.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3547,6 +3656,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "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.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -3554,6 +3699,12 @@
"dev": true,
"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/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -4590,7 +4741,6 @@
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
- "dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -5385,7 +5535,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
diff --git a/webapp/package.json b/webapp/package.json
index b531f37..79355be 100644
--- a/webapp/package.json
+++ b/webapp/package.json
@@ -16,8 +16,11 @@
"node": ">=18"
},
"dependencies": {
+ "@types/jsonwebtoken": "^9.0.10",
+ "cookie-parser": "^1.4.7",
"dotenv": "^16.4.5",
"express": "^4.19.2",
+ "jsonwebtoken": "^9.0.2",
"mysql2": "^3.9.4",
"pino": "^9.3.2",
"pino-http": "^10.1.0",
@@ -25,6 +28,7 @@
"zod": "^3.23.8"
},
"devDependencies": {
+ "@types/cookie-parser": "^1.4.10",
"@types/express": "^4.17.21",
"@types/node": "^20.14.11",
"@typescript-eslint/eslint-plugin": "^7.16.1",
@@ -38,4 +42,4 @@
"ts-node-dev": "^2.0.0",
"typescript": "5.5.4"
}
-}
\ No newline at end of file
+}
diff --git a/webapp/public/auth.js b/webapp/public/auth.js
new file mode 100644
index 0000000..eb5405a
--- /dev/null
+++ b/webapp/public/auth.js
@@ -0,0 +1,55 @@
+const form = document.querySelector('#auth-form');
+const errorEl = document.querySelector('#auth-error');
+if (!form || !errorEl) {
+ throw new Error('Auth form missing from page');
+}
+const mode = form.dataset.mode || 'login';
+
+const handleExistingSession = async () => {
+ try {
+ const response = await fetch('/api/auth/me', { credentials: 'include' });
+ if (response.ok) {
+ window.location.href = '/lobby';
+ }
+ } catch (_error) {
+ // Ignore and allow normal flow
+ }
+};
+
+const submitAuth = async (event) => {
+ event.preventDefault();
+ errorEl.textContent = '';
+
+ const formData = new FormData(form);
+ const payload = {
+ email: formData.get('email'),
+ password: formData.get('password'),
+ };
+
+ if (mode === 'register') {
+ payload.displayName = formData.get('displayName');
+ }
+
+ try {
+ const response = await fetch(`/api/auth/${mode}`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(payload),
+ });
+
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(data.message || 'Unable to complete request');
+ }
+
+ window.location.href = '/lobby';
+ } catch (error) {
+ errorEl.textContent = error.message;
+ }
+};
+
+form?.addEventListener('submit', submitAuth);
+handleExistingSession();
diff --git a/webapp/public/login.html b/webapp/public/login.html
new file mode 100644
index 0000000..0c65a6e
--- /dev/null
+++ b/webapp/public/login.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+ Sign in • AdventureBot Web
+
+
+
+
+ AdventureBot Web
+ Welcome back
+ Sign in to reach the lobby and manage your sessions.
+
+
+
+
+ Need an account? Create one here.
+
+
+
+
+
diff --git a/webapp/public/main.js b/webapp/public/main.js
index 483f684..174ddcc 100644
--- a/webapp/public/main.js
+++ b/webapp/public/main.js
@@ -13,13 +13,17 @@ const statWaitingEl = document.querySelector('#stat-waiting');
const onlineUsersEl = document.querySelector('#online-users');
let difficultyDefinitions = [];
+let currentUser = null;
const api = async (path, options = {}) => {
+ const { headers, ...rest } = options;
const response = await fetch(`/api${path}`, {
+ credentials: 'include',
+ ...rest,
headers: {
'Content-Type': 'application/json',
+ ...(headers || {}),
},
- ...options,
});
const payload = await response.json().catch(() => ({}));
@@ -31,6 +35,19 @@ const api = async (path, options = {}) => {
return payload;
};
+const ensureAuthenticated = async () => {
+ try {
+ const { user } = await api('/auth/me');
+ currentUser = user;
+ if (statusEl) {
+ statusEl.textContent = `Signed in as ${user.displayName}`;
+ }
+ } catch (error) {
+ window.location.href = '/login';
+ throw error;
+ }
+};
+
const roomLegend = [
{ type: 'entrance', label: 'Entrance', detail: 'Starting point for the party.' },
{ type: 'safe', label: 'Safe', detail: 'Quiet corridors with no threats.' },
@@ -488,6 +505,14 @@ document.querySelectorAll('[data-scroll]').forEach((button) => {
});
});
-loadDifficulties().then(() => refreshLobby());
-renderLegend();
-renderSession(null);
+const bootstrapLobby = async () => {
+ await ensureAuthenticated();
+ renderLegend();
+ renderSession(null);
+ await loadDifficulties();
+ await refreshLobby();
+};
+
+bootstrapLobby().catch(() => {
+ statusEl.textContent = 'Redirecting to sign-in…';
+});
diff --git a/webapp/public/register.html b/webapp/public/register.html
new file mode 100644
index 0000000..7078d94
--- /dev/null
+++ b/webapp/public/register.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+ Create account • AdventureBot Web
+
+
+
+
+ AdventureBot Web
+ Create your account
+ Register to host games, watch the lobby feed, and jump into sessions.
+
+
+
+
+ Already registered? Sign in instead.
+
+
+
+
+
diff --git a/webapp/public/styles.css b/webapp/public/styles.css
index d191c51..1696505 100644
--- a/webapp/public/styles.css
+++ b/webapp/public/styles.css
@@ -419,6 +419,49 @@ button.primary {
grid-column: 1 / -1;
}
+.auth-body {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ padding: 2rem;
+}
+
+.auth-card {
+ width: min(460px, 100%);
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02));
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 18px;
+ padding: 1.75rem;
+ box-shadow: 0 18px 60px rgba(0, 0, 0, 0.45), 0 0 24px rgba(139, 60, 245, 0.25);
+}
+
+.auth-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.9rem;
+ margin-top: 1rem;
+}
+
+.auth-form label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ font-weight: 600;
+}
+
+.auth-form input {
+ padding: 0.75rem;
+ border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ background: rgba(255, 255, 255, 0.05);
+ color: #f4f5ff;
+}
+
+.auth-card a {
+ color: #a2d2ff;
+}
+
@media (max-width: 720px) {
body {
padding: 1rem;
diff --git a/webapp/src/app.ts b/webapp/src/app.ts
index 6b17af6..b85dca1 100644
--- a/webapp/src/app.ts
+++ b/webapp/src/app.ts
@@ -1,13 +1,16 @@
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
+import cookieParser from 'cookie-parser';
import pinoHttp from 'pino-http';
import { logger } from './logger.js';
import { healthRouter } from './routes/health.js';
+import { authRouter } from './routes/auth.js';
import { hubRouter } from './routes/hub.js';
import { lobbyRouter } from './routes/lobby.js';
import { sessionsRouter } from './routes/sessions.js';
import { HttpError } from './errors/http-error.js';
+import { requireAuth } from './middleware/auth.js';
export const createApp = () => {
const app = express();
@@ -18,14 +21,28 @@ export const createApp = () => {
app.use(pinoHttp({ logger }));
app.use(express.json());
+ app.use(cookieParser());
app.use(express.static(publicDir));
app.use(healthRouter);
- app.use('/api', hubRouter);
- app.use('/api', lobbyRouter);
- app.use('/api', sessionsRouter);
+ app.use('/api', authRouter);
+ app.use('/api', requireAuth, hubRouter);
+ app.use('/api', requireAuth, lobbyRouter);
+ app.use('/api', requireAuth, sessionsRouter);
+
+ app.get('/login', (_req, res) => {
+ res.sendFile(path.join(publicDir, 'login.html'));
+ });
+
+ app.get('/register', (_req, res) => {
+ res.sendFile(path.join(publicDir, 'register.html'));
+ });
app.get('/', (_req, res) => {
+ res.redirect('/lobby');
+ });
+
+ app.get('/lobby', requireAuth, (_req, res) => {
res.sendFile(path.join(publicDir, 'index.html'));
});
diff --git a/webapp/src/config/env.ts b/webapp/src/config/env.ts
index 681582a..9c9a0a4 100644
--- a/webapp/src/config/env.ts
+++ b/webapp/src/config/env.ts
@@ -19,6 +19,7 @@ const EnvSchema = z.object({
MYSQL_USER: z.string().min(1),
MYSQL_PASSWORD: z.string().min(1),
MYSQL_DATABASE: z.string().min(1),
+ JWT_SECRET: z.string().min(10),
});
const parsed = EnvSchema.safeParse(process.env);
@@ -38,6 +39,7 @@ const env = {
password: parsed.data.MYSQL_PASSWORD,
database: parsed.data.MYSQL_DATABASE,
},
+ jwtSecret: parsed.data.JWT_SECRET,
};
export type Environment = typeof env;
diff --git a/webapp/src/db/setup.ts b/webapp/src/db/setup.ts
index 78308b6..50b09e6 100644
--- a/webapp/src/db/setup.ts
+++ b/webapp/src/db/setup.ts
@@ -36,6 +36,22 @@ const hasSchema = async (connection: mysql.Connection): Promise => {
return rows.length > 0;
};
+const ensureWebUsersTable = async (connection: mysql.Connection): Promise => {
+ const [rows] = await connection.query("SHOW TABLES LIKE 'web_users'");
+ if (rows.length > 0) return;
+
+ await connection.query(`
+ CREATE TABLE IF NOT EXISTS web_users (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ email VARCHAR(255) NOT NULL UNIQUE,
+ display_name VARCHAR(255) NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+ logger.info('Created missing web_users table');
+};
+
export const ensureDatabaseSetup = async (): Promise => {
const adminConnection = await mysql.createConnection({
host: env.mysql.host,
@@ -65,17 +81,19 @@ export const ensureDatabaseSetup = async (): Promise => {
try {
const schemaPresent = await hasSchema(connection);
- if (schemaPresent) {
- logger.info('Database schema already present; skipping setup');
- return;
- }
+ if (!schemaPresent) {
+ const statements = loadSetupStatements();
+ for (const statement of statements) {
+ await connection.query(statement);
+ }
- const statements = loadSetupStatements();
- for (const statement of statements) {
- await connection.query(statement);
+ logger.info('Database schema and seed data installed');
}
- logger.info('Database schema and seed data installed');
+ await ensureWebUsersTable(connection);
+ if (schemaPresent) {
+ logger.info('Database schema already present; ensured user table');
+ }
} catch (error) {
logger.error({ err: error }, 'Database setup failed');
throw error;
diff --git a/webapp/src/middleware/auth.ts b/webapp/src/middleware/auth.ts
new file mode 100644
index 0000000..0e68413
--- /dev/null
+++ b/webapp/src/middleware/auth.ts
@@ -0,0 +1,57 @@
+import { NextFunction, Request, Response } from 'express';
+import jwt from 'jsonwebtoken';
+import { getEnv } from '../config/env.js';
+import { findUserById, WebUser } from '../services/auth.js';
+
+type TokenPayload = {
+ sub: number;
+};
+
+export type AuthenticatedRequest = Request & { user?: WebUser };
+
+const env = getEnv();
+
+const extractToken = (req: Request): string | null => {
+ const cookieToken = (req as Request & { cookies?: Record }).cookies?.auth_token;
+ if (cookieToken) return cookieToken;
+
+ const header = req.header('authorization');
+ if (header?.startsWith('Bearer ')) {
+ return header.replace('Bearer ', '');
+ }
+
+ return null;
+};
+
+export const requireAuth = async (req: Request, res: Response, next: NextFunction) => {
+ const token = extractToken(req);
+ if (!token) {
+ res.status(401).json({ message: 'Authentication required' });
+ return;
+ }
+
+ try {
+ const decoded = jwt.verify(token, env.jwtSecret);
+ const userId =
+ typeof decoded === 'object' && decoded && 'sub' in decoded
+ ? Number((decoded as { sub?: string | number }).sub)
+ : NaN;
+
+ if (!Number.isFinite(userId)) {
+ res.status(401).json({ message: 'Invalid token' });
+ return;
+ }
+
+ const user = await findUserById(userId);
+
+ if (!user) {
+ res.status(401).json({ message: 'Invalid token' });
+ return;
+ }
+
+ (req as AuthenticatedRequest).user = user;
+ next();
+ } catch (_error) {
+ res.status(401).json({ message: 'Invalid or expired token' });
+ }
+};
diff --git a/webapp/src/routes/auth.ts b/webapp/src/routes/auth.ts
new file mode 100644
index 0000000..227de9c
--- /dev/null
+++ b/webapp/src/routes/auth.ts
@@ -0,0 +1,77 @@
+import { Response, Router } from 'express';
+import { z } from 'zod';
+import { getEnv } from '../config/env.js';
+import { HttpError } from '../errors/http-error.js';
+import { AuthenticatedRequest, requireAuth } from '../middleware/auth.js';
+import { createUser, findUserByEmail, issueTokenForUser, verifyUserPassword } from '../services/auth.js';
+
+export const authRouter = Router();
+
+const env = getEnv();
+const cookieOptions = {
+ httpOnly: true,
+ sameSite: 'lax' as const,
+ secure: env.nodeEnv === 'production',
+ maxAge: 7 * 24 * 60 * 60 * 1000,
+ path: '/',
+};
+
+const setSessionCookie = (res: Response, token: string) => {
+ res.cookie('auth_token', token, cookieOptions);
+};
+
+const registerSchema = z.object({
+ email: z.string().email(),
+ password: z.string().min(8),
+ displayName: z.string().min(2),
+});
+
+authRouter.post('/auth/register', async (req, res, next) => {
+ try {
+ const { email, password, displayName } = registerSchema.parse(req.body);
+ const normalizedEmail = email.toLowerCase();
+ const existing = await findUserByEmail(normalizedEmail);
+
+ if (existing) {
+ throw new HttpError(409, 'An account already exists for that email');
+ }
+
+ const user = await createUser(normalizedEmail, displayName, password);
+ const token = issueTokenForUser(user);
+ setSessionCookie(res, token);
+
+ res.json({ user, token });
+ } catch (error) {
+ next(error);
+ }
+});
+
+const loginSchema = z.object({
+ email: z.string().email(),
+ password: z.string().min(1),
+});
+
+authRouter.post('/auth/login', async (req, res, next) => {
+ try {
+ const { email, password } = loginSchema.parse(req.body);
+ const normalizedEmail = email.toLowerCase();
+ const user = await verifyUserPassword(normalizedEmail, password);
+
+ if (!user) {
+ throw new HttpError(401, 'Invalid email or password');
+ }
+
+ const token = issueTokenForUser(user);
+ setSessionCookie(res, token);
+
+ res.json({ user, token });
+ } catch (error) {
+ next(error);
+ }
+});
+
+authRouter.get('/auth/me', requireAuth, (req, res) => {
+ const { user } = req as AuthenticatedRequest & { cookies?: Record };
+ const token = (req as { cookies?: Record }).cookies?.auth_token;
+ res.json({ user, token });
+});
diff --git a/webapp/src/services/auth.ts b/webapp/src/services/auth.ts
new file mode 100644
index 0000000..747b687
--- /dev/null
+++ b/webapp/src/services/auth.ts
@@ -0,0 +1,90 @@
+import crypto from 'crypto';
+import jwt from 'jsonwebtoken';
+import { ResultSetHeader, RowDataPacket } from 'mysql2/promise';
+import { getEnv } from '../config/env.js';
+import { pool } from '../db/pool.js';
+
+export type WebUser = {
+ id: number;
+ email: string;
+ displayName: string;
+ createdAt: Date;
+};
+
+const env = getEnv();
+
+type UserRow = RowDataPacket & {
+ id: number;
+ email: string;
+ display_name: string;
+ password_hash: string;
+ created_at: string;
+};
+
+const mapUserRow = (row: UserRow): WebUser => ({
+ id: row.id,
+ email: row.email,
+ displayName: row.display_name,
+ createdAt: new Date(row.created_at),
+});
+
+const hashPassword = (password: string, salt?: string): string => {
+ const saltValue = salt ?? crypto.randomBytes(16).toString('hex');
+ const derivedKey = crypto.pbkdf2Sync(password, saltValue, 100_000, 64, 'sha512').toString('hex');
+ return `${saltValue}:${derivedKey}`;
+};
+
+const verifyPassword = (password: string, storedHash: string): boolean => {
+ const [salt, digest] = storedHash.split(':');
+ if (!salt || !digest) return false;
+ const hashed = hashPassword(password, salt).split(':')[1];
+ const digestBuffer = Buffer.from(digest, 'hex');
+ const hashedBuffer = Buffer.from(hashed, 'hex');
+ if (digestBuffer.length !== hashedBuffer.length) return false;
+ return crypto.timingSafeEqual(digestBuffer, hashedBuffer);
+};
+
+export const findUserByEmail = async (email: string): Promise => {
+ const [rows] = await pool.query('SELECT * FROM web_users WHERE email = ?', [email]);
+ if (!rows.length) return null;
+ return mapUserRow(rows[0]);
+};
+
+export const findUserWithPassword = async (email: string): Promise<{ user: WebUser; passwordHash: string } | null> => {
+ const [rows] = await pool.query('SELECT * FROM web_users WHERE email = ?', [email]);
+ if (!rows.length) return null;
+ const [row] = rows;
+ return { user: mapUserRow(row), passwordHash: row.password_hash };
+};
+
+export const findUserById = async (id: number): Promise => {
+ const [rows] = await pool.query('SELECT * FROM web_users WHERE id = ?', [id]);
+ if (!rows.length) return null;
+ return mapUserRow(rows[0]);
+};
+
+export const createUser = async (email: string, displayName: string, password: string): Promise => {
+ const passwordHash = hashPassword(password);
+ const [result] = await pool.query(
+ 'INSERT INTO web_users (email, display_name, password_hash) VALUES (?, ?, ?)',
+ [email, displayName, passwordHash]
+ );
+
+ const insertedId = result.insertId;
+ const createdUser = await findUserById(insertedId);
+ if (!createdUser) {
+ throw new Error('Failed to load created user');
+ }
+
+ return createdUser;
+};
+
+export const issueTokenForUser = (user: WebUser): string => {
+ return jwt.sign({ sub: user.id, email: user.email }, env.jwtSecret, { expiresIn: '7d' });
+};
+
+export const verifyUserPassword = async (email: string, password: string): Promise => {
+ const record = await findUserWithPassword(email);
+ if (!record) return null;
+ return verifyPassword(password, record.passwordHash) ? record.user : null;
+};