Revert "Add no-use-before-define linter rule"

This reverts commit fdcc5d68a2.

Unfortunately, this requires us to move exports to the bottom.
This in turn causes circular dep issues and also access of
exports.GLOBAL_VAR in the global context
This commit is contained in:
Girish Ramakrishnan
2025-10-08 20:11:55 +02:00
parent a5224258c3
commit 43e426ab9f
41 changed files with 718 additions and 681 deletions

View File

@@ -68,14 +68,6 @@ exports = module.exports = {
compareRoles,
};
const ORDERED_ROLES = [ exports.ROLE_USER, exports.ROLE_USER_MANAGER, exports.ROLE_MAIL_MANAGER, exports.ROLE_ADMIN, exports.ROLE_OWNER ];
// the avatar and backgroundImage fields are special and not added here to reduce response sizes
const USERS_FIELDS = [ 'id', 'username', 'email', 'fallbackEmail', 'password', 'salt', 'creationTime', 'inviteToken', 'resetToken', 'displayName', 'language',
'twoFactorAuthenticationEnabled', 'twoFactorAuthenticationSecret', 'active', 'source', 'role', 'resetTokenCreationTime', 'loginLocationsJson', 'notificationConfigJson' ].join(',');
const DEFAULT_GHOST_LIFETIME = 6 * 60 * 60 * 1000; // 6 hours
const appPasswords = require('./apppasswords.js'),
assert = require('node:assert'),
BoxError = require('./boxerror.js'),
@@ -105,6 +97,12 @@ const appPasswords = require('./apppasswords.js'),
validator = require('./validator.js'),
_ = require('./underscore.js');
// the avatar and backgroundImage fields are special and not added here to reduce response sizes
const USERS_FIELDS = [ 'id', 'username', 'email', 'fallbackEmail', 'password', 'salt', 'creationTime', 'inviteToken', 'resetToken', 'displayName', 'language',
'twoFactorAuthenticationEnabled', 'twoFactorAuthenticationSecret', 'active', 'source', 'role', 'resetTokenCreationTime', 'loginLocationsJson', 'notificationConfigJson' ].join(',');
const DEFAULT_GHOST_LIFETIME = 6 * 60 * 60 * 1000; // 6 hours
const CRYPTO_SALT_SIZE = 64; // 512-bit salt
const CRYPTO_ITERATIONS = 10000; // iterations
const CRYPTO_KEY_LENGTH = 512; // bits
@@ -201,6 +199,28 @@ function removePrivateFields(user) {
return result;
}
function validateRole(role) {
assert.strictEqual(typeof role, 'string');
const ORDERED_ROLES = [ exports.ROLE_USER, exports.ROLE_USER_MANAGER, exports.ROLE_MAIL_MANAGER, exports.ROLE_ADMIN, exports.ROLE_OWNER ];
if (ORDERED_ROLES.includes(role)) return null;
return new BoxError(BoxError.BAD_FIELD, `Invalid role '${role}'`);
}
function compareRoles(role1, role2) {
assert.strictEqual(typeof role1, 'string');
assert.strictEqual(typeof role2, 'string');
const ORDERED_ROLES = [ exports.ROLE_USER, exports.ROLE_USER_MANAGER, exports.ROLE_MAIL_MANAGER, exports.ROLE_ADMIN, exports.ROLE_OWNER ];
const roleInt1 = ORDERED_ROLES.indexOf(role1);
const roleInt2 = ORDERED_ROLES.indexOf(role2);
return roleInt1 - roleInt2;
}
async function add(email, data, auditSource) {
assert.strictEqual(typeof email, 'string');
assert(data && typeof data === 'object');
@@ -290,181 +310,6 @@ async function add(email, data, auditSource) {
return user.id;
}
async function setGhost(user, password, expiresAt) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof expiresAt, 'number');
if (!user.username) throw new BoxError(BoxError.BAD_STATE, 'user has no username yet');
expiresAt = expiresAt || (Date.now() + DEFAULT_GHOST_LIFETIME);
const ghostData = await settings.getJson(settings.GHOSTS_CONFIG_KEY) || {};
ghostData[user.username] = { password, expiresAt };
await settings.setJson(settings.GHOSTS_CONFIG_KEY, ghostData);
}
// returns true if ghost user was matched
async function verifyGhost(username, password) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
const ghostData = await settings.getJson(settings.GHOSTS_CONFIG_KEY) || {};
// either the username is an object with { password, expiresAt } or a string with the password which will expire on first match
if (username in ghostData) {
if (typeof ghostData[username] === 'object') {
if (ghostData[username].expiresAt < Date.now()) {
debug('verifyGhost: password expired');
delete ghostData[username];
await settings.setJson(settings.GHOSTS_CONFIG_KEY, ghostData);
return false;
} else if (ghostData[username].password === password) {
debug('verifyGhost: matched ghost user');
return true;
} else {
return false;
}
} else if(ghostData[username] === password) {
debug('verifyGhost: matched ghost user');
delete ghostData[username];
await settings.setJson(settings.GHOSTS_CONFIG_KEY, ghostData);
return true;
}
}
return false;
}
async function verifyAppPassword(userId, password, identifier) {
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof identifier, 'string');
const results = await appPasswords.list(userId);
const hashedPasswords = results.filter(r => r.identifier === identifier).map(r => r.hashedPassword);
const hash = crypto.createHash('sha256').update(password).digest('base64');
if (hashedPasswords.includes(hash)) return;
throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Password is not valid');
}
// identifier is only used to check if password is valid for a specific app
async function verify(user, password, identifier, options) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof options, 'object');
if (!user.active) {
debug(`verify: ${user.username} is not active`);
throw new BoxError(BoxError.NOT_FOUND, 'User not active');
}
// for just invited users the username may be still null
if (user.username) {
const valid = await verifyGhost(user.username, password);
if (valid) {
debug(`verify: ${user.username} authenticated via impersonation`);
user.ghost = true;
return user;
}
}
const [error] = await safe(verifyAppPassword(user.id, password, identifier));
if (!error) { // matched app password
debug(`verify: ${user.username || user.id} matched app password`);
user.appPassword = true;
return user;
}
let localTotpCheck = true; // does 2fa need to be verified with local database 2fa creds
if (user.source === 'ldap') {
await externalLdap.verifyPassword(user.username, password, options);
const externalLdapConfig = await externalLdap.getConfig();
localTotpCheck = user.twoFactorAuthenticationEnabled && !externalLdap.supports2FA(externalLdapConfig);
} else {
const saltBinary = Buffer.from(user.salt, 'hex');
const [error, derivedKey] = await safe(pbkdf2Async(password, saltBinary, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST));
if (error) throw new BoxError(BoxError.CRYPTO_ERROR, error);
const derivedKeyHex = Buffer.from(derivedKey, 'binary').toString('hex');
if (derivedKeyHex !== user.password) {
debug(`verify: ${user.username || user.id} provided incorrect password`);
throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Wrong password');
}
localTotpCheck = user.twoFactorAuthenticationEnabled;
}
if (localTotpCheck && !options.skipTotpCheck) {
if (!options.totpToken) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'A totpToken must be provided');
const verified = speakeasy.totp.verify({ secret: user.twoFactorAuthenticationSecret, encoding: 'base32', token: options.totpToken, window: 2 });
if (!verified) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Invalid totpToken');
}
return user;
}
async function verifyWithId(id, password, identifier, options) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof options, 'object');
const user = await get(id);
if (!user) {
debug(`verifyWithId: ${id} not found`);
throw new BoxError(BoxError.NOT_FOUND, 'User not found');
}
return await verify(user, password, identifier, options);
}
async function verifyWithUsername(username, password, identifier, options) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof options, 'object');
const user = await getByUsername(username.toLowerCase());
if (user) return await verify(user, password, identifier, options);
const [error, newUserId] = await safe(externalLdap.maybeCreateUser(username.toLowerCase()));
if (error && error.reason === BoxError.BAD_STATE) {
debug(`verifyWithUsername: ${username} not found`);
throw new BoxError(BoxError.NOT_FOUND, 'User not found'); // no external ldap or no auto create
}
if (error) {
debug(`verifyWithUsername: failed to auto create user ${username}. %o`, error);
throw new BoxError(BoxError.NOT_FOUND, 'User not found');
}
return await verifyWithId(newUserId, password, identifier, options);
}
async function verifyWithEmail(email, password, identifier, options) {
assert.strictEqual(typeof email, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof options, 'object');
const user = await getByEmail(email.toLowerCase());
if (!user) {
debug(`verifyWithEmail: ${email} no such user`);
throw new BoxError(BoxError.NOT_FOUND, 'User not found');
}
return await verify(user, password, identifier, options);
}
async function del(user, auditSource) {
assert.strictEqual(typeof user, 'object');
assert(auditSource && typeof auditSource === 'object');
@@ -712,6 +557,181 @@ async function getSuperadmins() {
return await listByRole(exports.ROLE_OWNER);
}
async function setGhost(user, password, expiresAt) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof expiresAt, 'number');
if (!user.username) throw new BoxError(BoxError.BAD_STATE, 'user has no username yet');
expiresAt = expiresAt || (Date.now() + DEFAULT_GHOST_LIFETIME);
const ghostData = await settings.getJson(settings.GHOSTS_CONFIG_KEY) || {};
ghostData[user.username] = { password, expiresAt };
await settings.setJson(settings.GHOSTS_CONFIG_KEY, ghostData);
}
// returns true if ghost user was matched
async function verifyGhost(username, password) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
const ghostData = await settings.getJson(settings.GHOSTS_CONFIG_KEY) || {};
// either the username is an object with { password, expiresAt } or a string with the password which will expire on first match
if (username in ghostData) {
if (typeof ghostData[username] === 'object') {
if (ghostData[username].expiresAt < Date.now()) {
debug('verifyGhost: password expired');
delete ghostData[username];
await settings.setJson(settings.GHOSTS_CONFIG_KEY, ghostData);
return false;
} else if (ghostData[username].password === password) {
debug('verifyGhost: matched ghost user');
return true;
} else {
return false;
}
} else if(ghostData[username] === password) {
debug('verifyGhost: matched ghost user');
delete ghostData[username];
await settings.setJson(settings.GHOSTS_CONFIG_KEY, ghostData);
return true;
}
}
return false;
}
async function verifyAppPassword(userId, password, identifier) {
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof identifier, 'string');
const results = await appPasswords.list(userId);
const hashedPasswords = results.filter(r => r.identifier === identifier).map(r => r.hashedPassword);
const hash = crypto.createHash('sha256').update(password).digest('base64');
if (hashedPasswords.includes(hash)) return;
throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Password is not valid');
}
// identifier is only used to check if password is valid for a specific app
async function verify(user, password, identifier, options) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof options, 'object');
if (!user.active) {
debug(`verify: ${user.username} is not active`);
throw new BoxError(BoxError.NOT_FOUND, 'User not active');
}
// for just invited users the username may be still null
if (user.username) {
const valid = await verifyGhost(user.username, password);
if (valid) {
debug(`verify: ${user.username} authenticated via impersonation`);
user.ghost = true;
return user;
}
}
const [error] = await safe(verifyAppPassword(user.id, password, identifier));
if (!error) { // matched app password
debug(`verify: ${user.username || user.id} matched app password`);
user.appPassword = true;
return user;
}
let localTotpCheck = true; // does 2fa need to be verified with local database 2fa creds
if (user.source === 'ldap') {
await externalLdap.verifyPassword(user.username, password, options);
const externalLdapConfig = await externalLdap.getConfig();
localTotpCheck = user.twoFactorAuthenticationEnabled && !externalLdap.supports2FA(externalLdapConfig);
} else {
const saltBinary = Buffer.from(user.salt, 'hex');
const [error, derivedKey] = await safe(pbkdf2Async(password, saltBinary, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST));
if (error) throw new BoxError(BoxError.CRYPTO_ERROR, error);
const derivedKeyHex = Buffer.from(derivedKey, 'binary').toString('hex');
if (derivedKeyHex !== user.password) {
debug(`verify: ${user.username || user.id} provided incorrect password`);
throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Wrong password');
}
localTotpCheck = user.twoFactorAuthenticationEnabled;
}
if (localTotpCheck && !options.skipTotpCheck) {
if (!options.totpToken) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'A totpToken must be provided');
const verified = speakeasy.totp.verify({ secret: user.twoFactorAuthenticationSecret, encoding: 'base32', token: options.totpToken, window: 2 });
if (!verified) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Invalid totpToken');
}
return user;
}
async function verifyWithId(id, password, identifier, options) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof options, 'object');
const user = await get(id);
if (!user) {
debug(`verifyWithId: ${id} not found`);
throw new BoxError(BoxError.NOT_FOUND, 'User not found');
}
return await verify(user, password, identifier, options);
}
async function verifyWithUsername(username, password, identifier, options) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof options, 'object');
const user = await getByUsername(username.toLowerCase());
if (user) return await verify(user, password, identifier, options);
const [error, newUserId] = await safe(externalLdap.maybeCreateUser(username.toLowerCase()));
if (error && error.reason === BoxError.BAD_STATE) {
debug(`verifyWithUsername: ${username} not found`);
throw new BoxError(BoxError.NOT_FOUND, 'User not found'); // no external ldap or no auto create
}
if (error) {
debug(`verifyWithUsername: failed to auto create user ${username}. %o`, error);
throw new BoxError(BoxError.NOT_FOUND, 'User not found');
}
return await verifyWithId(newUserId, password, identifier, options);
}
async function verifyWithEmail(email, password, identifier, options) {
assert.strictEqual(typeof email, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof options, 'object');
const user = await getByEmail(email.toLowerCase());
if (!user) {
debug(`verifyWithEmail: ${email} no such user`);
throw new BoxError(BoxError.NOT_FOUND, 'User not found');
}
return await verify(user, password, identifier, options);
}
async function getPasswordResetLink(user, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof auditSource, 'object');
@@ -969,24 +989,6 @@ async function disableTwoFactorAuthentication(user, auditSource) {
await update(user, { twoFactorAuthenticationEnabled: false, twoFactorAuthenticationSecret: '' }, auditSource);
}
function validateRole(role) {
assert.strictEqual(typeof role, 'string');
if (ORDERED_ROLES.indexOf(role) !== -1) return null;
return new BoxError(BoxError.BAD_FIELD, `Invalid role '${role}'`);
}
function compareRoles(role1, role2) {
assert.strictEqual(typeof role1, 'string');
assert.strictEqual(typeof role2, 'string');
const roleInt1 = ORDERED_ROLES.indexOf(role1);
const roleInt2 = ORDERED_ROLES.indexOf(role2);
return roleInt1 - roleInt2;
}
async function getAvatar(user) {
assert.strictEqual(typeof user, 'object');
@@ -1042,3 +1044,4 @@ function parseDisplayName(displayName) {
const lastName = displayName.substring(idx+1);
return { firstName, lastName, middleName };
}