mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-12-29 11:50:36 +01:00
Merge branch 'master' into develop
This commit is contained in:
@@ -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'),
|
||||
|
||||
124
src/meta/tags.js
124
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
21
src/upgrades/3.8.3/remove-session-uuid.js
Normal file
21
src/upgrades/3.8.3/remove-session-uuid.js
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}`,
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user