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>
This commit is contained in:
Cohee
2026-05-02 17:07:57 +03:00
committed by GitHub
parent 91c40280ed
commit c325c6d8e9
4 changed files with 49 additions and 3 deletions
Vendored
+4
View File
@@ -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.
*/
+8 -1
View File
@@ -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);
+9 -2
View File
@@ -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);
+28
View File
@@ -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,