From c325c6d8e9d4c52974fe2f28700446cb1fbfa662 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 2 May 2026 17:07:57 +0300 Subject: [PATCH] Add account version tags to cookies (#5563) * feat: add user account version to session cookie Co-authored-by: Copilot * feat: include user handle in account version hash calculation * feat: refactor recovery code generation to use a dedicated function * fix: don't overwrite current session version if updating another user Co-authored-by: Copilot * fix: reset session version instead of nullifying the entire session * fix: short circuit and clear cookie on request invalidation Co-authored-by: Copilot * fix: update account version on recovery --------- Co-authored-by: Copilot --- index.d.ts | 4 ++++ src/endpoints/users-private.js | 9 ++++++++- src/endpoints/users-public.js | 11 +++++++++-- src/users.js | 28 ++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/index.d.ts b/index.d.ts index 2e55437c1..5bffcad3f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -41,6 +41,10 @@ declare global { * Authenticated user handle. */ handle: string | null; + /** + * Account version tag: shake256 derivative of password hash and salt. + */ + version: string | null; /** * Last time the session was extended. */ diff --git a/src/endpoints/users-private.js b/src/endpoints/users-private.js index 6c2ea9510..90734b319 100644 --- a/src/endpoints/users-private.js +++ b/src/endpoints/users-private.js @@ -5,7 +5,7 @@ import crypto from 'node:crypto'; import storage from 'node-persist'; import express from 'express'; -import { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist, toAvatarKey } from '../users.js'; +import { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist, toAvatarKey, getAccountVersion } from '../users.js'; import { SETTINGS_FILE } from '../constants.js'; import { checkForNewContent, CONTENT_TYPES } from './content-manager.js'; import { color, Cache, getConfigValue } from '../util.js'; @@ -23,6 +23,7 @@ router.post('/logout', async (request, response) => { request.session.handle = null; request.session.csrfToken = null; + request.session.version = null; request.session = null; return response.sendStatus(204); } catch (error) { @@ -129,6 +130,12 @@ router.post('/change-password', async (request, response) => { } await storage.setItem(toKey(request.body.handle), user); + + // Update session version to keep the current session valid after password change + if (request.session && request.session.handle === user.handle) { + request.session.version = getAccountVersion(user); + } + return response.sendStatus(204); } catch (error) { console.error(error); diff --git a/src/endpoints/users-public.js b/src/endpoints/users-public.js index 205a3aafc..1645f4c52 100644 --- a/src/endpoints/users-public.js +++ b/src/endpoints/users-public.js @@ -5,7 +5,7 @@ import express from 'express'; import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible'; import { getIpAddress, retryAfter } from '../express-common.js'; import { color, Cache, getConfigValue } from '../util.js'; -import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js'; +import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt, getAccountVersion } from '../users.js'; const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false, 'boolean'); const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false, 'boolean'); @@ -13,6 +13,8 @@ const LOGIN_POINTS = getConfigValue('rateLimiting.accountsLoginMaxAttempts', 5, const RECOVER_POINTS = getConfigValue('rateLimiting.accountsRecoverMaxAttempts', 5, 'number'); const MFA_CACHE = new Cache(5 * 60 * 1000); +const generateRecoveryCode = () => Array.from({ length: 6 }, () => crypto.randomInt(0, 10)).join(''); + export const router = express.Router(); const loginLimiter = new RateLimiterMemory({ points: LOGIN_POINTS > 0 ? LOGIN_POINTS : Number.MAX_SAFE_INTEGER, @@ -91,6 +93,7 @@ router.post('/login', async (request, response) => { await loginLimiter.delete(ip); request.session.handle = user.handle; + request.session.version = getAccountVersion(user); console.info('Login successful:', user.handle, 'from', ip, 'at', new Date().toLocaleString()); return response.json({ handle: user.handle }); } catch (error) { @@ -127,7 +130,7 @@ router.post('/recover-step1', async (request, response) => { return response.status(403).json({ error: 'User is disabled' }); } - const mfaCode = String(crypto.randomInt(1000, 9999)); + const mfaCode = generateRecoveryCode(); console.log(); console.log(color.blue(`${user.name}, your password recovery code is: `) + color.magenta(mfaCode)); console.log(); @@ -189,6 +192,10 @@ router.post('/recover-step2', async (request, response) => { await storage.setItem(toKey(user.handle), user); } + if (request.session && request.session.handle === user.handle) { + request.session.version = getAccountVersion(user); + } + await recoverLimiter.delete(ip); MFA_CACHE.remove(user.handle); return response.sendStatus(204); diff --git a/src/users.js b/src/users.js index 45d5144eb..cac44f724 100644 --- a/src/users.js +++ b/src/users.js @@ -788,6 +788,7 @@ async function singleUserLogin(request) { const user = await storage.getItem(toKey(userHandles[0])); if (user && !user.password) { request.session.handle = userHandles[0]; + request.session.version = getAccountVersion(user); return true; } } @@ -882,6 +883,7 @@ async function headerUserLogin(request, header = 'Remote-User') { const user = await storage.getItem(toKey(userHandle)); if (user && user.enabled) { request.session.handle = userHandle; + request.session.version = getAccountVersion(user); return true; } } @@ -923,6 +925,7 @@ async function basicUserLogin(request) { // Verify pass again here just to be sure if (user && user.enabled && user.password && user.password === getPasswordHash(password, user.salt)) { request.session.handle = userHandle; + request.session.version = getAccountVersion(user); return true; } } @@ -931,6 +934,17 @@ async function basicUserLogin(request) { return false; } +/** + * Gets the account version tag for the provided user. + * @param {User} user User account object + * @returns {string} Account version tag + */ +export function getAccountVersion(user) { + return crypto.createHash('shake256', { outputLength: 8 }) + .update(JSON.stringify([user.handle, user.password, user.salt])) + .digest('hex'); +} + /** * Middleware to add user data to the request object. * @param {import('express').Request} request Request object @@ -975,6 +989,20 @@ export async function setUserDataMiddleware(request, response, next) { return next(); } + if (Object.hasOwn(request.session, 'version')) { + if (request.session.version !== getAccountVersion(user)) { + console.warn('User data has changed since the session was created. Invalidating session for user:', handle); + request.session.handle = null; + request.session.csrfToken = null; + request.session.version = null; + request.session = null; + return response.sendStatus(403); + } + } else { + // If there is no version in the session, it means it's an old session. Upgrade it by adding the version. + request.session.version = getAccountVersion(user); + } + const directories = getUserDirectories(handle); request.user = { profile: user,