diff --git a/index.d.ts b/index.d.ts index 417bf315c..8e30e75e6 100644 --- a/index.d.ts +++ b/index.d.ts @@ -10,3 +10,10 @@ declare global { } } } + +declare module 'express-session' { + export interface SessionData { + handle: string; + // other properties... + } + } diff --git a/package-lock.json b/package-lock.json index b4e5d82c1..4f9bfd49c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "command-exists": "^1.2.9", "compression": "^1", "cookie-parser": "^1.4.6", + "cookie-session": "^2.1.0", "cors": "^2.8.5", "csrf-csrf": "^2.2.3", "express": "^4.19.2", @@ -1291,10 +1292,68 @@ "node": ">= 0.8.0" } }, + "node_modules/cookie-session": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.0.tgz", + "integrity": "sha512-u73BDmR8QLGcs+Lprs0cfbcAPKl2HnPcjpwRXT41sEV4DRJ2+W0vJEEZkG31ofkx+HZflA70siRIjiTdIodmOQ==", + "dependencies": { + "cookies": "0.9.1", + "debug": "3.2.7", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cookie-session/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/cookie-session/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==" + }, + "node_modules/cookie-session/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" + } + ] + }, "node_modules/cookie-signature": { "version": "1.0.6", "license": "MIT" }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "license": "MIT" @@ -2511,6 +2570,17 @@ "dev": true, "license": "MIT" }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/keyv": { "version": "4.5.3", "license": "MIT", @@ -3643,6 +3713,14 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/type-check": { "version": "0.4.0", "dev": true, diff --git a/package.json b/package.json index d358ef5d4..313de3b7a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "command-exists": "^1.2.9", "compression": "^1", "cookie-parser": "^1.4.6", + "cookie-session": "^2.1.0", "cors": "^2.8.5", "csrf-csrf": "^2.2.3", "express": "^4.19.2", diff --git a/public/login.html b/public/login.html new file mode 100644 index 000000000..e69de29bb diff --git a/server.js b/server.js index c20bd30b0..bf35c9f1e 100644 --- a/server.js +++ b/server.js @@ -136,7 +136,7 @@ app.use(CORS); if (listen && basicAuthMode) app.use(basicAuthMiddleware); app.use(whitelistMiddleware(listen)); -app.use(userDataMiddleware()); +app.use(userDataMiddleware(app)); // CSRF Protection // if (!cliArguments.disableCsrf) { @@ -228,6 +228,10 @@ app.use(multer({ dest: UPLOADS_PATH, limits: { fieldSize: 10 * 1024 * 1024 } }). app.get('/', function (request, response) { response.sendFile(process.cwd() + '/public/index.html'); }); +// Host login page +app.get('/login', (_request, response) => { + return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') }); +}); app.get('/version', async function (_, response) { const data = await getVersion(); response.send(data); diff --git a/src/users.js b/src/users.js index 41f69c0f4..473833b41 100644 --- a/src/users.js +++ b/src/users.js @@ -1,19 +1,32 @@ +// Native Node Modules const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); +const os = require('os'); + +// Express and other dependencies const storage = require('node-persist'); +const express = require('express'); +const cookieSession = require('cookie-session'); const uuid = require('uuid'); const mime = require('mime-types'); const slugify = require('slugify').default; + +// Local imports +const { jsonParser } = require('./express-common'); const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, DEFAULT_AVATAR } = require('./constants'); const { getConfigValue, color, delay, setConfigValue, Cache } = require('./util'); -const express = require('express'); const { readSecret, writeSecret } = require('./endpoints/secrets'); -const { jsonParser } = require('./express-common'); const { checkForNewContent } = require('./endpoints/content-manager'); const DATA_ROOT = getConfigValue('dataRoot', './data'); +const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); const MFA_CACHE = new Cache(5 * 60 * 1000); +/** + * Cache for user directories. + * @type {Map} + */ +const DIRECTORIES_CACHE = new Map(); const STORAGE_KEYS = { users: 'users', @@ -298,6 +311,7 @@ async function migrateUserData() { async function initUserStorage() { await storage.init({ dir: path.join(DATA_ROOT, '_storage'), + ttl: true, }); const users = await storage.getItem('users'); @@ -323,10 +337,34 @@ function getCookieSecret() { return secret; } +/** + * Generates a random password salt. + * @returns {string} The password salt + */ function getPasswordSalt() { return crypto.randomBytes(16).toString('base64'); } +/** + * Get the session name for the current server. + * @returns {string} The session name + */ +function getCookieSessionName() { + // Get server hostname and hash it to generate a session suffix + const suffix = crypto.createHash('sha256').update(os.hostname()).digest('hex').slice(0, 8); + return `session-${suffix}`; +} + +/** + * Hashes a password using SHA256. + * @param {string} password Password to hash + * @param {string} salt Salt to use for hashing + * @returns {string} Hashed password + */ +function getPasswordHash(password, salt) { + return crypto.createHash('sha256').update(password + salt).digest('hex'); +} + /** * Get the CSRF secret from the storage. * @param {import('express').Request} [request] HTTP request object @@ -347,15 +385,6 @@ function getCsrfSecret(request) { return csrfSecret; } -/** - * Gets a user for the current request. Hard coded to return the default user. - * @param {import('express').Request} _req - The request object. Currently unused. - * @returns {Promise} - The user's handle - */ -async function getCurrentUserHandle(_req) { - return DEFAULT_USER.handle; -} - /** * Gets a list of all user handles. * @returns {Promise} - The list of user handles @@ -365,15 +394,6 @@ async function getAllUserHandles() { return users.map(user => user.handle); } -/** - * Gets the directories listing for the provided user. - * @param {import('express').Request} req - The request object - * @returns {Promise} - The user's directories like {worlds: 'data/user0/worlds/', ... - */ -async function getCurrentUserDirectories(req) { - const handle = await getCurrentUserHandle(req); - return getUserDirectories(handle); -} /** * Gets the directories listing for the provided user. @@ -381,18 +401,35 @@ async function getCurrentUserDirectories(req) { * @returns {UserDirectoryList} User directories */ function getUserDirectories(handle) { + if (DIRECTORIES_CACHE.has(handle)) { + const cache = DIRECTORIES_CACHE.get(handle); + if (cache) { + return cache; + } + } + const directories = structuredClone(USER_DIRECTORY_TEMPLATE); for (const key in directories) { directories[key] = path.join(DATA_ROOT, handle, USER_DIRECTORY_TEMPLATE[key]); } + DIRECTORIES_CACHE.set(handle, directories); return directories; } /** * Middleware to add user data to the request object. + * @param {import('express').Express} app Express app * @returns {import('express').RequestHandler} */ -function userDataMiddleware() { +function userDataMiddleware(app) { + app.use(cookieSession({ + name: getCookieSessionName(), + sameSite: 'strict', + httpOnly: true, + maxAge: 24 * 60 * 60 * 1000, // 24 hours + secret: getCookieSecret(), + })); + /** * Middleware to add user data to the request object. * @param {import('express').Request} req Request object @@ -400,12 +437,56 @@ function userDataMiddleware() { * @param {import('express').NextFunction} next Next function */ return async (req, res, next) => { - const directories = await getCurrentUserDirectories(req); + // Skip for login page + if (req.path === '/login') { + return next(); + } + + // If user accounts are disabled, use the default user + if (!ENABLE_ACCOUNTS) { + const handle = DEFAULT_USER.handle; + const directories = getUserDirectories(handle); + req.user = { + profile: DEFAULT_USER, + directories: directories, + }; + return next(); + } + // If user accounts are enabled, get the user from the session + /** + * @type {User[]} + */ + const users = await storage.getItem(STORAGE_KEYS.users); + let handle = req.session?.handle; + + // If we have the only user and it's not password protected, use it + if (!handle && users.length === 1 && !users[0].password) { + handle = users[0].handle; + req.session.handle = handle; + } + + if (!handle) { + return res.redirect('/login'); + } + + const user = users.find(user => user.handle === handle); + + if (!user) { + console.error('User not found:', handle); + return res.redirect('/login'); + } + + if (!user.enabled) { + console.error('User is disabled:', handle); + return res.redirect('/login'); + } + + const directories = getUserDirectories(handle); req.user = { - profile: DEFAULT_USER, + profile: user, directories: directories, }; - next(); + return next(); }; } @@ -461,16 +542,6 @@ router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.us const endpoints = express.Router(); -/** - * Hashes a password using SHA256. - * @param {string} password Password to hash - * @param {string} salt Salt to use for hashing - * @returns {string} Hashed password - */ -function getPasswordHash(password, salt) { - return crypto.createHash('sha256').update(password + salt).digest('hex'); -} - endpoints.get('/list', async (_request, response) => { /** @type {User[]} */ const users = await storage.getItem(STORAGE_KEYS.users); @@ -528,7 +599,7 @@ endpoints.post('/recover-step1', jsonParser, async (request, response) => { } const mfaCode = Math.floor(Math.random() * 1000000).toString().padStart(6, '0'); - console.log(color.blue(`${user.name} YOUR PASSWORD RECOVERY CODE IS: `) + color.magenta(mfaCode)); + console.log(color.blue(`${user.name} YOUR PASSWORD RECOVERY CODE IS: `) + color.magenta(mfaCode)); MFA_CACHE.set(user.handle, mfaCode); return response.sendStatus(204); }); @@ -587,12 +658,13 @@ endpoints.post('/login', jsonParser, async (request, response) => { return response.status(403).json({ error: 'User is disabled' }); } - if (user.password !== getPasswordHash(request.body.password, user.salt)) { + if (user.password && user.password !== getPasswordHash(request.body.password, user.salt)) { console.log('Login failed: Incorrect password'); return response.status(401).json({ error: 'Incorrect password' }); } - console.log('Login successful:', user.handle); + request.session.handle = user.handle; + console.log('Login successful:', user.handle, request.session); return response.json({ handle: user.handle }); }); @@ -640,8 +712,6 @@ router.use('/api/users', endpoints); module.exports = { initUserStorage, ensurePublicDirectoriesExist, - getCurrentUserDirectories, - getCurrentUserHandle, getAllUserHandles, getUserDirectories, userDataMiddleware,