Add account version tags to cookies (#5563) * feat: add user account version to session cookie Co-authored-by: Copilot <copilot@github.com> * 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 <copilot@github.com> * fix: reset session version instead of nullifying the entire session * fix: short circuit and clear cookie on request invalidation Co-authored-by: Copilot <copilot@github.com> * fix: update account version on recovery --------- Co-authored-by: Copilot <copilot@github.com>

c325c6d8e9d4c52974fe2f28700446cb1fbfa662

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
4 files changed, +49 -3Showing whitespace changes
index.d.ts+4 -0
@@ -42,6 +42,10 @@ declare global {
4242 */
4343 handle: string | null;
4444 /**
45+ * Account version tag: shake256 derivative of password hash and salt.
46+ */
47+ version: string | null;
48+ /**
4549 * Last time the session was extended.
4650 */
4751 touch: number;
src/endpoints/users-private.js+8 -1
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
55import storage from 'node-persist';
66import express from 'express';
77
88import { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist, toAvatarKey, getAccountVersion } from '../users.js';
99import { SETTINGS_FILE } from '../constants.js';
1010import { checkForNewContent, CONTENT_TYPES } from './content-manager.js';
1111import { color, Cache, getConfigValue } from '../util.js';
@@ -23,6 +23,7 @@ router.post('/logout', async (request, response) => {
2323
2424 request.session.handle = null;
2525 request.session.csrfToken = null;
26+ request.session.version = null;
2627 request.session = null;
2728 return response.sendStatus(204);
2829 } catch (error) {
@@ -129,6 +130,12 @@ router.post('/change-password', async (request, response) => {
129130 }
130131
131132 await storage.setItem(toKey(request.body.handle), user);
133+
134+ // Update session version to keep the current session valid after password change
135+ if (request.session && request.session.handle === user.handle) {
136+ request.session.version = getAccountVersion(user);
137+ }
138+
132139 return response.sendStatus(204);
133140 } catch (error) {
134141 console.error(error);
src/endpoints/users-public.js+9 -2
@@ -5,7 +5,7 @@ import express from 'express';
55import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
66import { getIpAddress, retryAfter } from '../express-common.js';
77import { color, Cache, getConfigValue } from '../util.js';
88import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt, getAccountVersion } from '../users.js';
99
1010const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false, 'boolean');
1111const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false, 'boolean');
@@ -13,6 +13,8 @@ const LOGIN_POINTS = getConfigValue('rateLimiting.accountsLoginMaxAttempts', 5,
1313const RECOVER_POINTS = getConfigValue('rateLimiting.accountsRecoverMaxAttempts', 5, 'number');
1414const MFA_CACHE = new Cache(5 * 60 * 1000);
1515
16+const generateRecoveryCode = () => Array.from({ length: 6 }, () => crypto.randomInt(0, 10)).join('');
17+
1618export const router = express.Router();
1719const loginLimiter = new RateLimiterMemory({
1820 points: LOGIN_POINTS > 0 ? LOGIN_POINTS : Number.MAX_SAFE_INTEGER,
@@ -91,6 +93,7 @@ router.post('/login', async (request, response) => {
9193
9294 await loginLimiter.delete(ip);
9395 request.session.handle = user.handle;
96+ request.session.version = getAccountVersion(user);
9497 console.info('Login successful:', user.handle, 'from', ip, 'at', new Date().toLocaleString());
9598 return response.json({ handle: user.handle });
9699 } catch (error) {
@@ -127,7 +130,7 @@ router.post('/recover-step1', async (request, response) => {
127130 return response.status(403).json({ error: 'User is disabled' });
128131 }
129132
130133 const mfaCode = String(crypto.randomIntgenerateRecoveryCode(1000, 9999));
131134 console.log();
132135 console.log(color.blue(`${user.name}, your password recovery code is: `) + color.magenta(mfaCode));
133136 console.log();
@@ -189,6 +192,10 @@ router.post('/recover-step2', async (request, response) => {
189192 await storage.setItem(toKey(user.handle), user);
190193 }
191194
195+ if (request.session && request.session.handle === user.handle) {
196+ request.session.version = getAccountVersion(user);
197+ }
198+
192199 await recoverLimiter.delete(ip);
193200 MFA_CACHE.remove(user.handle);
194201 return response.sendStatus(204);
src/users.js+28 -0
@@ -788,6 +788,7 @@ async function singleUserLogin(request) {
788788 const user = await storage.getItem(toKey(userHandles[0]));
789789 if (user && !user.password) {
790790 request.session.handle = userHandles[0];
791+ request.session.version = getAccountVersion(user);
791792 return true;
792793 }
793794 }
@@ -882,6 +883,7 @@ async function headerUserLogin(request, header = 'Remote-User') {
882883 const user = await storage.getItem(toKey(userHandle));
883884 if (user && user.enabled) {
884885 request.session.handle = userHandle;
886+ request.session.version = getAccountVersion(user);
885887 return true;
886888 }
887889 }
@@ -923,6 +925,7 @@ async function basicUserLogin(request) {
923925 // Verify pass again here just to be sure
924926 if (user && user.enabled && user.password && user.password === getPasswordHash(password, user.salt)) {
925927 request.session.handle = userHandle;
928+ request.session.version = getAccountVersion(user);
926929 return true;
927930 }
928931 }
@@ -932,6 +935,17 @@ async function basicUserLogin(request) {
932935}
933936
934937/**
938+ * Gets the account version tag for the provided user.
939+ * @param {User} user User account object
940+ * @returns {string} Account version tag
941+ */
942+export function getAccountVersion(user) {
943+ return crypto.createHash('shake256', { outputLength: 8 })
944+ .update(JSON.stringify([user.handle, user.password, user.salt]))
945+ .digest('hex');
946+}
947+
948+/**
935949 * Middleware to add user data to the request object.
936950 * @param {import('express').Request} request Request object
937951 * @param {import('express').Response} response Response object
@@ -975,6 +989,20 @@ export async function setUserDataMiddleware(request, response, next) {
975989 return next();
976990 }
977991
992+ if (Object.hasOwn(request.session, 'version')) {
993+ if (request.session.version !== getAccountVersion(user)) {
994+ console.warn('User data has changed since the session was created. Invalidating session for user:', handle);
995+ request.session.handle = null;
996+ request.session.csrfToken = null;
997+ request.session.version = null;
998+ request.session = null;
999+ return response.sendStatus(403);
1000+ }
1001+ } else {
1002+ // If there is no version in the session, it means it's an old session. Upgrade it by adding the version.
1003+ request.session.version = getAccountVersion(user);
1004+ }
1005+
9781006 const directories = getUserDirectories(handle);
9791007 request.user = {
9801008 profile: user,