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:
Vendored
+4
@@ -41,6 +41,10 @@ declare global {
|
|||||||
* Authenticated user handle.
|
* Authenticated user handle.
|
||||||
*/
|
*/
|
||||||
handle: string | null;
|
handle: string | null;
|
||||||
|
/**
|
||||||
|
* Account version tag: shake256 derivative of password hash and salt.
|
||||||
|
*/
|
||||||
|
version: string | null;
|
||||||
/**
|
/**
|
||||||
* Last time the session was extended.
|
* Last time the session was extended.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
|
|||||||
import storage from 'node-persist';
|
import storage from 'node-persist';
|
||||||
import express from 'express';
|
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 { SETTINGS_FILE } from '../constants.js';
|
||||||
import { checkForNewContent, CONTENT_TYPES } from './content-manager.js';
|
import { checkForNewContent, CONTENT_TYPES } from './content-manager.js';
|
||||||
import { color, Cache, getConfigValue } from '../util.js';
|
import { color, Cache, getConfigValue } from '../util.js';
|
||||||
@@ -23,6 +23,7 @@ router.post('/logout', async (request, response) => {
|
|||||||
|
|
||||||
request.session.handle = null;
|
request.session.handle = null;
|
||||||
request.session.csrfToken = null;
|
request.session.csrfToken = null;
|
||||||
|
request.session.version = null;
|
||||||
request.session = null;
|
request.session = null;
|
||||||
return response.sendStatus(204);
|
return response.sendStatus(204);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -129,6 +130,12 @@ router.post('/change-password', async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await storage.setItem(toKey(request.body.handle), user);
|
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);
|
return response.sendStatus(204);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import express from 'express';
|
|||||||
import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
|
import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
|
||||||
import { getIpAddress, retryAfter } from '../express-common.js';
|
import { getIpAddress, retryAfter } from '../express-common.js';
|
||||||
import { color, Cache, getConfigValue } from '../util.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 DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false, 'boolean');
|
||||||
const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', 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 RECOVER_POINTS = getConfigValue('rateLimiting.accountsRecoverMaxAttempts', 5, 'number');
|
||||||
const MFA_CACHE = new Cache(5 * 60 * 1000);
|
const MFA_CACHE = new Cache(5 * 60 * 1000);
|
||||||
|
|
||||||
|
const generateRecoveryCode = () => Array.from({ length: 6 }, () => crypto.randomInt(0, 10)).join('');
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
const loginLimiter = new RateLimiterMemory({
|
const loginLimiter = new RateLimiterMemory({
|
||||||
points: LOGIN_POINTS > 0 ? LOGIN_POINTS : Number.MAX_SAFE_INTEGER,
|
points: LOGIN_POINTS > 0 ? LOGIN_POINTS : Number.MAX_SAFE_INTEGER,
|
||||||
@@ -91,6 +93,7 @@ router.post('/login', async (request, response) => {
|
|||||||
|
|
||||||
await loginLimiter.delete(ip);
|
await loginLimiter.delete(ip);
|
||||||
request.session.handle = user.handle;
|
request.session.handle = user.handle;
|
||||||
|
request.session.version = getAccountVersion(user);
|
||||||
console.info('Login successful:', user.handle, 'from', ip, 'at', new Date().toLocaleString());
|
console.info('Login successful:', user.handle, 'from', ip, 'at', new Date().toLocaleString());
|
||||||
return response.json({ handle: user.handle });
|
return response.json({ handle: user.handle });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -127,7 +130,7 @@ router.post('/recover-step1', async (request, response) => {
|
|||||||
return response.status(403).json({ error: 'User is disabled' });
|
return response.status(403).json({ error: 'User is disabled' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const mfaCode = String(crypto.randomInt(1000, 9999));
|
const mfaCode = generateRecoveryCode();
|
||||||
console.log();
|
console.log();
|
||||||
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));
|
||||||
console.log();
|
console.log();
|
||||||
@@ -189,6 +192,10 @@ router.post('/recover-step2', async (request, response) => {
|
|||||||
await storage.setItem(toKey(user.handle), user);
|
await storage.setItem(toKey(user.handle), user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.session && request.session.handle === user.handle) {
|
||||||
|
request.session.version = getAccountVersion(user);
|
||||||
|
}
|
||||||
|
|
||||||
await recoverLimiter.delete(ip);
|
await recoverLimiter.delete(ip);
|
||||||
MFA_CACHE.remove(user.handle);
|
MFA_CACHE.remove(user.handle);
|
||||||
return response.sendStatus(204);
|
return response.sendStatus(204);
|
||||||
|
|||||||
@@ -788,6 +788,7 @@ async function singleUserLogin(request) {
|
|||||||
const user = await storage.getItem(toKey(userHandles[0]));
|
const user = await storage.getItem(toKey(userHandles[0]));
|
||||||
if (user && !user.password) {
|
if (user && !user.password) {
|
||||||
request.session.handle = userHandles[0];
|
request.session.handle = userHandles[0];
|
||||||
|
request.session.version = getAccountVersion(user);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -882,6 +883,7 @@ async function headerUserLogin(request, header = 'Remote-User') {
|
|||||||
const user = await storage.getItem(toKey(userHandle));
|
const user = await storage.getItem(toKey(userHandle));
|
||||||
if (user && user.enabled) {
|
if (user && user.enabled) {
|
||||||
request.session.handle = userHandle;
|
request.session.handle = userHandle;
|
||||||
|
request.session.version = getAccountVersion(user);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -923,6 +925,7 @@ async function basicUserLogin(request) {
|
|||||||
// Verify pass again here just to be sure
|
// Verify pass again here just to be sure
|
||||||
if (user && user.enabled && user.password && user.password === getPasswordHash(password, user.salt)) {
|
if (user && user.enabled && user.password && user.password === getPasswordHash(password, user.salt)) {
|
||||||
request.session.handle = userHandle;
|
request.session.handle = userHandle;
|
||||||
|
request.session.version = getAccountVersion(user);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -931,6 +934,17 @@ async function basicUserLogin(request) {
|
|||||||
return false;
|
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.
|
* Middleware to add user data to the request object.
|
||||||
* @param {import('express').Request} request Request object
|
* @param {import('express').Request} request Request object
|
||||||
@@ -975,6 +989,20 @@ export async function setUserDataMiddleware(request, response, next) {
|
|||||||
return 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);
|
const directories = getUserDirectories(handle);
|
||||||
request.user = {
|
request.user = {
|
||||||
profile: user,
|
profile: user,
|
||||||
|
|||||||
Reference in New Issue
Block a user