diff --git a/src/controllers/authentication.js b/src/controllers/authentication.js index b3949905d4..6591459cf2 100644 --- a/src/controllers/authentication.js +++ b/src/controllers/authentication.js @@ -342,7 +342,7 @@ authenticationController.doLogin = async function (req, uid) { await authenticationController.onSuccessfulLogin(req, uid); }; -authenticationController.onSuccessfulLogin = async function (req, uid) { +authenticationController.onSuccessfulLogin = async function (req, uid, trackSession = true) { /* * Older code required that this method be called from within the SSO plugin. * That behaviour is no longer required, onSuccessfulLogin is now automatically @@ -380,7 +380,7 @@ authenticationController.onSuccessfulLogin = async function (req, uid) { new Promise((resolve) => { req.session.save(resolve); }), - user.auth.addSession(uid, req.sessionID, uuid), + trackSession ? user.auth.addSession(uid, req.sessionID) : undefined, user.updateLastOnlineTime(uid), user.onUserOnline(uid, Date.now()), analytics.increment('logins'), diff --git a/src/meta/tags.js b/src/meta/tags.js index 121c1c74c2..b59760b167 100644 --- a/src/meta/tags.js +++ b/src/meta/tags.js @@ -14,8 +14,10 @@ const relative_path = nconf.get('relative_path'); const upload_url = nconf.get('upload_url'); Tags.parse = async (req, data, meta, link) => { + const isAPI = req.res && req.res.locals && req.res.locals.isAPI; + // Meta tags - const defaultTags = [{ + const defaultTags = isAPI ? [] : [{ name: 'viewport', content: 'width=device-width, initial-scale=1.0', }, { @@ -40,14 +42,14 @@ Tags.parse = async (req, data, meta, link) => { content: Meta.config.themeColor || '#ffffff', }]; - if (Meta.config.keywords) { + if (Meta.config.keywords && !isAPI) { defaultTags.push({ name: 'keywords', content: Meta.config.keywords, }); } - if (Meta.config['brand:logo']) { + if (Meta.config['brand:logo'] && !isAPI) { defaultTags.push({ name: 'msapplication-square150x150logo', content: Meta.config['brand:logo'], @@ -59,7 +61,7 @@ Tags.parse = async (req, data, meta, link) => { const cacheBuster = `${Meta.config['cache-buster'] ? `?${Meta.config['cache-buster']}` : ''}`; // Link Tags - const defaultLinks = [{ + const defaultLinks = isAPI ? [] : [{ rel: 'icon', type: 'image/x-icon', href: `${faviconPath}${cacheBuster}`, @@ -69,7 +71,7 @@ Tags.parse = async (req, data, meta, link) => { crossorigin: `use-credentials`, }]; - if (plugins.hooks.hasListeners('filter:search.query')) { + if (plugins.hooks.hasListeners('filter:search.query') && !isAPI) { defaultLinks.push({ rel: 'search', type: 'application/opensearchdescription+xml', @@ -78,7 +80,59 @@ Tags.parse = async (req, data, meta, link) => { }); } - // Touch icons for mobile-devices + if (!isAPI) { + addTouchIcons(defaultLinks); + } + + const results = await utils.promiseParallel({ + tags: plugins.hooks.fire('filter:meta.getMetaTags', { req: req, data: data, tags: defaultTags }), + links: plugins.hooks.fire('filter:meta.getLinkTags', { req: req, data: data, links: defaultLinks }), + }); + + meta = results.tags.tags.concat(meta || []).map((tag) => { + if (!tag || typeof tag.content !== 'string') { + winston.warn('Invalid meta tag. ', tag); + return tag; + } + + if (!tag.noEscape) { + const attributes = Object.keys(tag); + attributes.forEach((attr) => { + tag[attr] = utils.escapeHTML(String(tag[attr])); + }); + } + + return tag; + }); + + await addSiteOGImage(meta); + + addIfNotExists(meta, 'property', 'og:title', Meta.config.title || 'NodeBB'); + const ogUrl = url + (req.originalUrl !== '/' ? stripRelativePath(req.originalUrl) : ''); + addIfNotExists(meta, 'property', 'og:url', ogUrl); + addIfNotExists(meta, 'name', 'description', Meta.config.description); + addIfNotExists(meta, 'property', 'og:description', Meta.config.description); + + link = results.links.links.concat(link || []); + if (isAPI) { + const whitelist = ['canonical', 'alternate', 'up']; + link = link.filter(link => whitelist.some(val => val === link.rel)); + } + link = link.map((tag) => { + if (!tag.noEscape) { + const attributes = Object.keys(tag); + attributes.forEach((attr) => { + tag[attr] = utils.escapeHTML(String(tag[attr])); + }); + } + + return tag; + }); + + return { meta, link }; +}; + +function addTouchIcons(defaultLinks) { if (Meta.config['brand:touchIcon']) { defaultLinks.push({ rel: 'apple-touch-icon', @@ -142,64 +196,16 @@ Tags.parse = async (req, data, meta, link) => { href: `${relative_path}/assets/images/touch/512.png`, }); } - - const results = await utils.promiseParallel({ - tags: plugins.hooks.fire('filter:meta.getMetaTags', { req: req, data: data, tags: defaultTags }), - links: plugins.hooks.fire('filter:meta.getLinkTags', { req: req, data: data, links: defaultLinks }), - }); - - meta = results.tags.tags.concat(meta || []).map((tag) => { - if (!tag || typeof tag.content !== 'string') { - winston.warn('Invalid meta tag. ', tag); - return tag; - } - - if (!tag.noEscape) { - const attributes = Object.keys(tag); - attributes.forEach((attr) => { - tag[attr] = utils.escapeHTML(String(tag[attr])); - }); - } - - return tag; - }); - - await addSiteOGImage(meta); - - addIfNotExists(meta, 'property', 'og:title', Meta.config.title || 'NodeBB'); - const ogUrl = url + (req.originalUrl !== '/' ? stripRelativePath(req.originalUrl) : ''); - addIfNotExists(meta, 'property', 'og:url', ogUrl); - addIfNotExists(meta, 'name', 'description', Meta.config.description); - addIfNotExists(meta, 'property', 'og:description', Meta.config.description); - - link = results.links.links.concat(link || []).map((tag) => { - if (!tag.noEscape) { - const attributes = Object.keys(tag); - attributes.forEach((attr) => { - tag[attr] = utils.escapeHTML(String(tag[attr])); - }); - } - - return tag; - }); - - return { meta, link }; -}; +} function addIfNotExists(meta, keyName, tagName, value) { - let exists = false; - meta.forEach((tag) => { - if (tag[keyName] === tagName) { - exists = true; - } - }); + const exists = meta.some(tag => tag[keyName] === tagName); if (!exists && value) { - const data = { + meta.push({ content: utils.escapeHTML(String(value)), - }; - data[keyName] = tagName; - meta.push(data); + [keyName]: tagName, + }); } } diff --git a/src/middleware/user.js b/src/middleware/user.js index 3b7c1168db..ca6afcaf9b 100644 --- a/src/middleware/user.js +++ b/src/middleware/user.js @@ -41,7 +41,7 @@ module.exports = function (middleware) { async function finishLogin(req, user) { const loginAsync = util.promisify(req.login).bind(req); await loginAsync(user, { keepSessionInfo: true }); - await controllers.authentication.onSuccessfulLogin(req, user.uid); + await controllers.authentication.onSuccessfulLogin(req, user.uid, false); req.uid = parseInt(user.uid, 10); req.loggedIn = req.uid > 0; return true; diff --git a/src/upgrades/3.8.3/remove-session-uuid.js b/src/upgrades/3.8.3/remove-session-uuid.js new file mode 100644 index 0000000000..59a975fce2 --- /dev/null +++ b/src/upgrades/3.8.3/remove-session-uuid.js @@ -0,0 +1,21 @@ +'use strict'; + + +const db = require('../../database'); +const batch = require('../../batch'); + +module.exports = { + name: 'Remove uid::sessionUUID:sessionId object', + timestamp: Date.UTC(2024, 5, 26), + method: async function () { + const { progress } = this; + + await batch.processSortedSet('users:joindate', async (uids) => { + progress.incr(uids.length); + await db.deleteAll(uids.map(uid => `uid:${uid}:sessionUUID:sessionId`)); + }, { + batch: 500, + progress: progress, + }); + }, +}; diff --git a/src/user/auth.js b/src/user/auth.js index 954d00a0c5..0adf589967 100644 --- a/src/user/auth.js +++ b/src/user/auth.js @@ -1,6 +1,5 @@ 'use strict'; -const winston = require('winston'); const validator = require('validator'); const _ = require('lodash'); const db = require('../database'); @@ -77,56 +76,53 @@ module.exports = function (User) { }; async function cleanExpiredSessions(uid) { - const uuidMapping = await db.getObject(`uid:${uid}:sessionUUID:sessionId`); - if (!uuidMapping) { - return; + const sids = await db.getSortedSetRange(`uid:${uid}:sessions`, 0, -1); + if (!sids.length) { + return []; } - const expiredUUIDs = []; + const expiredSids = []; - await Promise.all(Object.keys(uuidMapping).map(async (uuid) => { - const sid = uuidMapping[uuid]; + const activeSids = []; + await Promise.all(sids.map(async (sid) => { const sessionObj = await db.sessionStoreGet(sid); const expired = !sessionObj || !sessionObj.hasOwnProperty('passport') || !sessionObj.passport.hasOwnProperty('user') || parseInt(sessionObj.passport.user, 10) !== parseInt(uid, 10); if (expired) { - expiredUUIDs.push(uuid); expiredSids.push(sid); + } else { + activeSids.push(sid); } })); - await db.deleteObjectFields(`uid:${uid}:sessionUUID:sessionId`, expiredUUIDs); + await db.sortedSetRemove(`uid:${uid}:sessions`, expiredSids); + return activeSids; } - User.auth.addSession = async function (uid, sessionId, uuid) { + User.auth.addSession = async function (uid, sessionId) { if (!(parseInt(uid, 10) > 0)) { return; } - await cleanExpiredSessions(uid); - await Promise.all([ - db.sortedSetAdd(`uid:${uid}:sessions`, Date.now(), sessionId), - db.setObjectField(`uid:${uid}:sessionUUID:sessionId`, uuid, sessionId), - ]); - await revokeSessionsAboveThreshold(uid, meta.config.maxUserSessions); + + const activeSids = await cleanExpiredSessions(uid); + await db.sortedSetAdd(`uid:${uid}:sessions`, Date.now(), sessionId); + await revokeSessionsAboveThreshold(activeSids.push(sessionId), uid); }; - async function revokeSessionsAboveThreshold(uid, maxUserSessions) { - const activeSessions = await db.getSortedSetRange(`uid:${uid}:sessions`, 0, -1); - if (activeSessions.length > maxUserSessions) { - const sessionsToRevoke = activeSessions.slice(0, activeSessions.length - maxUserSessions); - await Promise.all(sessionsToRevoke.map(sessionId => User.auth.revokeSession(sessionId, uid))); + async function revokeSessionsAboveThreshold(activeSids, uid) { + if (meta.config.maxUserSessions > 0 && activeSids.length > meta.config.maxUserSessions) { + const sessionsToRevoke = activeSids.slice(0, activeSids.length - meta.config.maxUserSessions); + await User.auth.revokeSession(sessionsToRevoke, uid); } } - User.auth.revokeSession = async function (sessionId, uid) { - winston.verbose(`[user.auth] Revoking session ${sessionId} for user ${uid}`); - const sessionObj = await db.sessionStoreGet(sessionId); - if (sessionObj && sessionObj.meta && sessionObj.meta.uuid) { - await db.deleteObjectField(`uid:${uid}:sessionUUID:sessionId`, sessionObj.meta.uuid); - } + User.auth.revokeSession = async function (sessionIds, uid) { + sessionIds = Array.isArray(sessionIds) ? sessionIds : [sessionIds]; + const destroySids = sids => Promise.all(sids.map(db.sessionStoreDestroy)); + await Promise.all([ - db.sortedSetRemove(`uid:${uid}:sessions`, sessionId), - db.sessionStoreDestroy(sessionId), + db.sortedSetRemove(`uid:${uid}:sessions`, sessionIds), + destroySids(sessionIds), ]); }; @@ -137,7 +133,7 @@ module.exports = function (User) { uids.forEach((uid, index) => { const ids = sids[index].filter(id => id !== except); if (ids.length) { - promises.push(ids.map(s => User.auth.revokeSession(s, uid))); + promises.push(User.auth.revokeSession(ids, uid)); } }); await Promise.all(promises); @@ -146,11 +142,10 @@ module.exports = function (User) { User.auth.deleteAllSessions = async function () { await batch.processSortedSet('users:joindate', async (uids) => { const sessionKeys = uids.map(uid => `uid:${uid}:sessions`); - const sessionUUIDKeys = uids.map(uid => `uid:${uid}:sessionUUID:sessionId`); const sids = _.flatten(await db.getSortedSetRange(sessionKeys, 0, -1)); await Promise.all([ - db.deleteAll(sessionKeys.concat(sessionUUIDKeys)), + db.deleteAll(sessionKeys), ...sids.map(sid => db.sessionStoreDestroy(sid)), ]); }, { batch: 1000 }); diff --git a/src/user/delete.js b/src/user/delete.js index b84b4ef1d8..8f99117c59 100644 --- a/src/user/delete.js +++ b/src/user/delete.js @@ -119,7 +119,7 @@ module.exports = function (User) { `uid:${uid}:chat:rooms:read`, `uid:${uid}:upvote`, `uid:${uid}:downvote`, `uid:${uid}:flag:pids`, - `uid:${uid}:sessions`, `uid:${uid}:sessionUUID:sessionId`, + `uid:${uid}:sessions`, `invitation:uid:${uid}`, ]; diff --git a/test/api.js b/test/api.js index 47961742ff..0ea9918953 100644 --- a/test/api.js +++ b/test/api.js @@ -562,8 +562,10 @@ describe('API', async () => { const reloginPaths = ['GET /api/user/{userslug}/edit/email', 'PUT /users/{uid}/password', 'DELETE /users/{uid}/sessions/{uuid}']; if (reloginPaths.includes(`${method.toUpperCase()} ${path}`)) { ({ jar } = await helpers.loginUser('admin', '123456')); - const sessionUUIDs = await db.getObject('uid:1:sessionUUID:sessionId'); - mocks.delete['/users/{uid}/sessions/{uuid}'][1].example = Object.keys(sessionUUIDs).pop(); + const sessionIds = await db.getSortedSetRange('uid:1:sessions', 0, -1); + const sessObj = await db.sessionStoreGet(sessionIds[0]); + const { uuid } = sessObj.meta; + mocks.delete['/users/{uid}/sessions/{uuid}'][1].example = uuid; // Retrieve CSRF token using cookie, to test Write API csrfToken = await helpers.getCsrfToken(jar); diff --git a/test/authentication.js b/test/authentication.js index 1dcbe176a8..193d617435 100644 --- a/test/authentication.js +++ b/test/authentication.js @@ -195,7 +195,7 @@ describe('authentication', () => { }); assert(body); assert.equal(body.username, username); - const sessions = await db.getObject(`uid:${uid}:sessionUUID:sessionId`); + const sessions = await db.getSortedSetRange(`uid:${uid}:sessions`, 0, -1); assert(sessions); assert(Object.keys(sessions).length > 0); });