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 -3Ignore whitespace
index.d.ts+4 -0
@@ -42,6 +42,10 @@ declare global {
42 */42 */
43 handle: string | null;43 handle: string | null;
44 /**44 /**
45 * Account version tag: shake256 derivative of password hash and salt.
46 */
47 version: string | null;
48 /**
45 * Last time the session was extended.49 * Last time the session was extended.
46 */50 */
47 touch: number;51 touch: number;
src/endpoints/users-private.js+8 -1
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
5import storage from 'node-persist';5import storage from 'node-persist';
6import express from 'express';6import express from 'express';
77
8import { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist, toAvatarKey } from '../users.js';8import { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist, toAvatarKey, getAccountVersion } from '../users.js';
9import { SETTINGS_FILE } from '../constants.js';9import { SETTINGS_FILE } from '../constants.js';
10import { checkForNewContent, CONTENT_TYPES } from './content-manager.js';10import { checkForNewContent, CONTENT_TYPES } from './content-manager.js';
11import { color, Cache, getConfigValue } from '../util.js';11import { color, Cache, getConfigValue } from '../util.js';
@@ -23,6 +23,7 @@ router.post('/logout', async (request, response) => {
2323
24 request.session.handle = null;24 request.session.handle = null;
25 request.session.csrfToken = null;25 request.session.csrfToken = null;
26 request.session.version = null;
26 request.session = null;27 request.session = null;
27 return response.sendStatus(204);28 return response.sendStatus(204);
28 } catch (error) {29 } catch (error) {
@@ -129,6 +130,12 @@ router.post('/change-password', async (request, response) => {
129 }130 }
130131
131 await storage.setItem(toKey(request.body.handle), user);132 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
132 return response.sendStatus(204);139 return response.sendStatus(204);
133 } catch (error) {140 } catch (error) {
134 console.error(error);141 console.error(error);
src/endpoints/users-public.js+9 -2
@@ -5,7 +5,7 @@ import express from 'express';
5import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';5import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
6import { getIpAddress, retryAfter } from '../express-common.js';6import { getIpAddress, retryAfter } from '../express-common.js';
7import { color, Cache, getConfigValue } from '../util.js';7import { color, Cache, getConfigValue } from '../util.js';
8import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js';8import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt, getAccountVersion } from '../users.js';
99
10const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false, 'boolean');10const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false, 'boolean');
11const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false, 'boolean');11const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false, 'boolean');
@@ -13,6 +13,8 @@ const LOGIN_POINTS = getConfigValue('rateLimiting.accountsLoginMaxAttempts', 5,
13const RECOVER_POINTS = getConfigValue('rateLimiting.accountsRecoverMaxAttempts', 5, 'number');13const RECOVER_POINTS = getConfigValue('rateLimiting.accountsRecoverMaxAttempts', 5, 'number');
14const MFA_CACHE = new Cache(5 * 60 * 1000);14const MFA_CACHE = new Cache(5 * 60 * 1000);
1515
16const generateRecoveryCode = () => Array.from({ length: 6 }, () => crypto.randomInt(0, 10)).join('');
17
16export const router = express.Router();18export const router = express.Router();
17const loginLimiter = new RateLimiterMemory({19const loginLimiter = new RateLimiterMemory({
18 points: LOGIN_POINTS > 0 ? LOGIN_POINTS : Number.MAX_SAFE_INTEGER,20 points: LOGIN_POINTS > 0 ? LOGIN_POINTS : Number.MAX_SAFE_INTEGER,
@@ -91,6 +93,7 @@ router.post('/login', async (request, response) => {
9193
92 await loginLimiter.delete(ip);94 await loginLimiter.delete(ip);
93 request.session.handle = user.handle;95 request.session.handle = user.handle;
96 request.session.version = getAccountVersion(user);
94 console.info('Login successful:', user.handle, 'from', ip, 'at', new Date().toLocaleString());97 console.info('Login successful:', user.handle, 'from', ip, 'at', new Date().toLocaleString());
95 return response.json({ handle: user.handle });98 return response.json({ handle: user.handle });
96 } catch (error) {99 } catch (error) {
@@ -127,7 +130,7 @@ router.post('/recover-step1', async (request, response) => {
127 return response.status(403).json({ error: 'User is disabled' });130 return response.status(403).json({ error: 'User is disabled' });
128 }131 }
129132
130 const mfaCode = String(crypto.randomInt(1000, 9999));133 const mfaCode = generateRecoveryCode();
131 console.log();134 console.log();
132 console.log(color.blue(`${user.name}, your password recovery code is: `) + color.magenta(mfaCode));135 console.log(color.blue(`${user.name}, your password recovery code is: `) + color.magenta(mfaCode));
133 console.log();136 console.log();
@@ -189,6 +192,10 @@ router.post('/recover-step2', async (request, response) => {
189 await storage.setItem(toKey(user.handle), user);192 await storage.setItem(toKey(user.handle), user);
190 }193 }
191194
195 if (request.session && request.session.handle === user.handle) {
196 request.session.version = getAccountVersion(user);
197 }
198
192 await recoverLimiter.delete(ip);199 await recoverLimiter.delete(ip);
193 MFA_CACHE.remove(user.handle);200 MFA_CACHE.remove(user.handle);
194 return response.sendStatus(204);201 return response.sendStatus(204);
src/users.js+28 -0
@@ -788,6 +788,7 @@ async function singleUserLogin(request) {
788 const user = await storage.getItem(toKey(userHandles[0]));788 const user = await storage.getItem(toKey(userHandles[0]));
789 if (user && !user.password) {789 if (user && !user.password) {
790 request.session.handle = userHandles[0];790 request.session.handle = userHandles[0];
791 request.session.version = getAccountVersion(user);
791 return true;792 return true;
792 }793 }
793 }794 }
@@ -882,6 +883,7 @@ async function headerUserLogin(request, header = 'Remote-User') {
882 const user = await storage.getItem(toKey(userHandle));883 const user = await storage.getItem(toKey(userHandle));
883 if (user && user.enabled) {884 if (user && user.enabled) {
884 request.session.handle = userHandle;885 request.session.handle = userHandle;
886 request.session.version = getAccountVersion(user);
885 return true;887 return true;
886 }888 }
887 }889 }
@@ -923,6 +925,7 @@ async function basicUserLogin(request) {
923 // Verify pass again here just to be sure925 // Verify pass again here just to be sure
924 if (user && user.enabled && user.password && user.password === getPasswordHash(password, user.salt)) {926 if (user && user.enabled && user.password && user.password === getPasswordHash(password, user.salt)) {
925 request.session.handle = userHandle;927 request.session.handle = userHandle;
928 request.session.version = getAccountVersion(user);
926 return true;929 return true;
927 }930 }
928 }931 }
@@ -932,6 +935,17 @@ async function basicUserLogin(request) {
932}935}
933936
934/**937/**
938 * Gets the account version tag for the provided user.
939 * @param {User} user User account object
940 * @returns {string} Account version tag
941 */
942export 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/**
935 * Middleware to add user data to the request object.949 * Middleware to add user data to the request object.
936 * @param {import('express').Request} request Request object950 * @param {import('express').Request} request Request object
937 * @param {import('express').Response} response Response object951 * @param {import('express').Response} response Response object
@@ -975,6 +989,20 @@ export async function setUserDataMiddleware(request, response, next) {
975 return next();989 return next();
976 }990 }
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
978 const directories = getUserDirectories(handle);1006 const directories = getUserDirectories(handle);
979 request.user = {1007 request.user = {
980 profile: user,1008 profile: user,