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