Merge branch 'master' into develop

This commit is contained in:
Barış Soner Uşaklı
2024-06-27 10:30:59 -04:00
8 changed files with 122 additions and 98 deletions

View File

@@ -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'),

View File

@@ -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,
});
}
}

View File

@@ -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;

View File

@@ -0,0 +1,21 @@
'use strict';
const db = require('../../database');
const batch = require('../../batch');
module.exports = {
name: 'Remove uid:<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,
});
},
};

View File

@@ -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 });

View File

@@ -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}`,
];

View File

@@ -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);

View File

@@ -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);
});