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; +};