Files
cloudron-box/src/users.js

797 lines
30 KiB
JavaScript
Raw Normal View History

'use strict';
exports = module.exports = {
2021-04-14 15:54:09 -07:00
removePrivateFields,
removeRestrictedFields,
2021-07-15 09:50:11 -07:00
add,
createOwner,
isActivated,
2021-08-20 11:30:35 -07:00
list,
listPaged,
2021-04-14 15:54:09 -07:00
get,
getByResetToken,
getByUsername,
2021-07-19 12:43:30 -07:00
getByEmail,
2021-07-15 09:50:11 -07:00
getOwner,
2021-04-14 15:54:09 -07:00
getAdmins,
getSuperadmins,
2021-07-15 09:50:11 -07:00
verify,
verifyWithUsername,
verifyWithEmail,
2021-04-14 15:54:09 -07:00
setPassword,
update,
2021-07-15 09:50:11 -07:00
del,
2021-04-14 15:54:09 -07:00
sendInvite,
2021-07-15 09:50:11 -07:00
2021-04-14 15:54:09 -07:00
setTwoFactorAuthenticationSecret,
enableTwoFactorAuthentication,
disableTwoFactorAuthentication,
sendPasswordResetByIdentifier,
2021-07-15 09:50:11 -07:00
notifyLoginLocation,
setupAccount,
2021-07-15 09:50:11 -07:00
2021-04-29 12:49:48 -07:00
getAvatarUrl,
setAvatar,
2021-04-29 12:49:48 -07:00
getAvatar,
2020-01-31 15:28:42 -08:00
AP_MAIL: 'mail',
AP_WEBADMIN: 'webadmin',
ROLE_ADMIN: 'admin',
ROLE_USER: 'user',
ROLE_USER_MANAGER: 'usermanager',
ROLE_OWNER: 'owner',
2021-04-14 15:54:09 -07:00
compareRoles,
};
const ORDERED_ROLES = [ exports.ROLE_USER, exports.ROLE_USER_MANAGER, exports.ROLE_ADMIN, exports.ROLE_OWNER ];
2021-07-15 09:50:11 -07:00
// the avatar field is special and not added here to reduce response sizes
const USERS_FIELDS = [ 'id', 'username', 'email', 'fallbackEmail', 'password', 'salt', 'creationTime', 'resetToken', 'displayName',
'twoFactorAuthenticationEnabled', 'twoFactorAuthenticationSecret', 'active', 'source', 'role', 'resetTokenCreationTime', 'loginLocationsJson' ].join(',');
const GHOST_LIFETIME = 6 * 60 * 60 * 1000; // 6 hours
2021-06-25 22:11:17 -07:00
const appPasswords = require('./apppasswords.js'),
assert = require('assert'),
2019-10-22 16:34:17 -07:00
BoxError = require('./boxerror.js'),
crypto = require('crypto'),
constants = require('./constants.js'),
2021-06-25 22:11:17 -07:00
database = require('./database.js'),
2016-05-29 23:15:55 -07:00
debug = require('debug')('box:user'),
2016-05-01 20:01:34 -07:00
eventlog = require('./eventlog.js'),
2019-10-25 15:58:11 -07:00
externalLdap = require('./externalldap.js'),
hat = require('./hat.js'),
2016-02-08 15:15:42 -08:00
mailer = require('./mailer.js'),
2021-07-15 09:50:11 -07:00
mysql = require('mysql'),
2020-03-15 11:41:39 -07:00
paths = require('./paths.js'),
2018-04-25 19:08:15 +02:00
qrcode = require('qrcode'),
safe = require('safetydance'),
settings = require('./settings.js'),
2018-04-25 19:08:15 +02:00
speakeasy = require('speakeasy'),
tokens = require('./tokens.js'),
uuid = require('uuid'),
uaParser = require('ua-parser-js'),
superagent = require('superagent'),
2021-07-15 09:50:11 -07:00
util = require('util'),
validator = require('validator'),
_ = require('underscore');
2021-06-25 22:11:17 -07:00
const CRYPTO_SALT_SIZE = 64; // 512-bit salt
const CRYPTO_ITERATIONS = 10000; // iterations
const CRYPTO_KEY_LENGTH = 512; // bits
const CRYPTO_DIGEST = 'sha1'; // used to be the default in node 4.1.1 cannot change since it will affect existing db records
2021-07-15 09:50:11 -07:00
const pbkdf2Async = util.promisify(crypto.pbkdf2);
2021-08-13 14:43:08 -07:00
const randomBytesAsync = util.promisify(crypto.randomBytes);
2021-07-15 09:50:11 -07:00
function postProcess(result) {
assert.strictEqual(typeof result, 'object');
result.twoFactorAuthenticationEnabled = !!result.twoFactorAuthenticationEnabled;
result.active = !!result.active;
result.loginLocations = safe.JSON.parse(result.loginLocationsJson) || [];
if (!Array.isArray(result.loginLocations)) result.loginLocations = [];
delete result.loginLocationsJson;
return result;
}
// keep this in sync with validateGroupname and validateAlias
function validateUsername(username) {
assert.strictEqual(typeof username, 'string');
2019-10-24 14:40:26 -07:00
if (username.length < 1) return new BoxError(BoxError.BAD_FIELD, 'Username must be atleast 1 char');
if (username.length >= 200) return new BoxError(BoxError.BAD_FIELD, 'Username too long');
2019-10-24 14:40:26 -07:00
if (constants.RESERVED_NAMES.indexOf(username) !== -1) return new BoxError(BoxError.BAD_FIELD, 'Username is reserved');
2016-04-13 16:50:20 -07:00
2020-07-23 16:29:54 -07:00
// also need to consider valid LDAP characters here (e.g '+' is reserved). apps like openvpn require _ to not be used
2019-10-24 14:40:26 -07:00
if (/[^a-zA-Z0-9.-]/.test(username)) return new BoxError(BoxError.BAD_FIELD, 'Username can only contain alphanumerals, dot and -');
2016-05-25 21:36:20 -07:00
// app emails are sent using the .app suffix
2019-10-24 14:40:26 -07:00
if (username.indexOf('.app') !== -1) return new BoxError(BoxError.BAD_FIELD, 'Username pattern is reserved for apps');
2016-05-18 21:45:02 -07:00
return null;
}
function validateEmail(email) {
assert.strictEqual(typeof email, 'string');
2019-10-24 14:40:26 -07:00
if (!validator.isEmail(email)) return new BoxError(BoxError.BAD_FIELD, 'Invalid email');
return null;
}
2021-07-15 09:50:11 -07:00
function validateResetToken(token) {
assert.strictEqual(typeof token, 'string');
2019-10-24 14:40:26 -07:00
if (token.length !== 64) return new BoxError(BoxError.BAD_FIELD, 'Invalid token'); // 256-bit hex coded token
return null;
}
function validateDisplayName(name) {
assert.strictEqual(typeof name, 'string');
return null;
}
function validatePassword(password) {
assert.strictEqual(typeof password, 'string');
2019-10-24 14:40:26 -07:00
if (password.length < 8) return new BoxError(BoxError.BAD_FIELD, 'Password must be atleast 8 characters');
if (password.length > 256) return new BoxError(BoxError.BAD_FIELD, 'Password cannot be more than 256 characters');
return null;
}
// remove all fields that should never be sent out via REST API
function removePrivateFields(user) {
2021-04-14 21:46:35 -07:00
return _.pick(user, 'id', 'username', 'email', 'fallbackEmail', 'displayName', 'groupIds', 'active', 'source', 'role', 'createdAt', 'twoFactorAuthenticationEnabled');
}
// remove all fields that Non-privileged users must not see
function removeRestrictedFields(user) {
2019-08-08 07:19:50 -07:00
return _.pick(user, 'id', 'username', 'email', 'displayName', 'active');
}
2021-07-15 09:50:11 -07:00
async function add(email, data, auditSource) {
assert.strictEqual(typeof email, 'string');
2021-07-15 09:50:11 -07:00
assert(data && typeof data === 'object');
assert(auditSource && typeof auditSource === 'object');
2016-02-08 21:05:02 -08:00
2021-07-15 09:50:11 -07:00
assert(data.username === null || typeof data.username === 'string');
assert(data.password === null || typeof data.password === 'string');
assert.strictEqual(typeof data.displayName, 'string');
let { username, password, displayName } = data;
const source = data.source || ''; // empty is local user
const role = data.role || exports.ROLE_USER;
let error;
if (username !== null) {
username = username.toLowerCase();
error = validateUsername(username);
2021-07-15 09:50:11 -07:00
if (error) throw error;
}
if (password !== null) {
error = validatePassword(password);
2021-07-15 09:50:11 -07:00
if (error) throw error;
} else {
password = hat(8 * 8);
}
email = email.toLowerCase();
error = validateEmail(email);
2021-07-15 09:50:11 -07:00
if (error) throw error;
error = validateDisplayName(displayName);
2021-07-15 09:50:11 -07:00
if (error) throw error;
error = validateRole(role);
2021-07-15 09:50:11 -07:00
if (error) throw error;
let salt, derivedKey;
2021-08-13 14:43:08 -07:00
[error, salt] = await safe(randomBytesAsync(CRYPTO_SALT_SIZE));
2021-07-15 09:50:11 -07:00
if (error) throw new BoxError(BoxError.CRYPTO_ERROR, error);
[error, derivedKey] = await safe(pbkdf2Async(password, salt, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST));
if (error) throw new BoxError(BoxError.CRYPTO_ERROR, error);
const user = {
id: 'uid-' + uuid.v4(),
username: username,
email: email,
fallbackEmail: email,
password: Buffer.from(derivedKey, 'binary').toString('hex'),
salt: salt.toString('hex'),
resetToken: '',
displayName: displayName,
source: source,
role: role,
avatar: constants.AVATAR_NONE
};
const query = 'INSERT INTO users (id, username, password, email, fallbackEmail, salt, resetToken, displayName, source, role, avatar) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
const args = [ user.id, user.username, user.password, user.email, user.fallbackEmail, user.salt, user.resetToken, user.displayName, user.source, user.role, user.avatar ];
[error] = await safe(database.query(query, args));
if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('users_email') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'email already exists');
if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('users_username') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'username already exists');
if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('PRIMARY') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'id already exists');
if (error) throw error;
// when this is used to create the owner, then we have to patch the auditSource to contain himself
if (!auditSource.userId) auditSource.userId = user.id;
if (!auditSource.username) auditSource.username= user.username;
eventlog.add(eventlog.ACTION_USER_ADD, auditSource, { userId: user.id, email: user.email, user: removePrivateFields(user) });
2021-07-19 12:43:30 -07:00
return user.id;
}
// returns true if ghost user was matched
function verifyGhost(username, password) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
2020-03-15 11:41:39 -07:00
var ghostData = safe.JSON.parse(safe.fs.readFileSync(paths.GHOST_USER_FILE, 'utf8'));
if (!ghostData) return false;
// 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];
safe.fs.writeFileSync(paths.GHOST_USER_FILE, JSON.stringify(ghostData, null, 4), 'utf8');
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];
safe.fs.writeFileSync(paths.GHOST_USER_FILE, JSON.stringify(ghostData, null, 4), 'utf8');
return true;
}
}
return false;
}
2021-07-15 09:50:11 -07:00
async function verifyAppPassword(userId, password, identifier) {
2020-01-31 15:28:42 -08:00
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof identifier, 'string');
2021-07-15 09:50:11 -07:00
const results = await appPasswords.list(userId);
2020-01-31 15:28:42 -08:00
2021-06-25 22:11:17 -07:00
const hashedPasswords = results.filter(r => r.identifier === identifier).map(r => r.hashedPassword);
let hash = crypto.createHash('sha256').update(password).digest('base64');
2020-01-31 15:28:42 -08:00
2021-07-15 09:50:11 -07:00
if (hashedPasswords.includes(hash)) return;
2020-01-31 15:28:42 -08:00
2021-07-15 09:50:11 -07:00
throw new BoxError(BoxError.INVALID_CREDENTIALS);
2020-01-31 15:28:42 -08:00
}
2021-07-15 09:50:11 -07:00
async function verify(userId, password, identifier) {
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof password, 'string');
2020-01-31 15:28:42 -08:00
assert.strictEqual(typeof identifier, 'string');
2021-07-15 09:50:11 -07:00
const user = await get(userId);
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
if (!user.active) throw new BoxError(BoxError.NOT_FOUND, 'User not active');
2021-07-15 09:50:11 -07:00
// for just invited users the username may be still null
if (user.username && verifyGhost(user.username, password)) {
user.ghost = true;
return user;
}
2019-08-08 05:45:56 -07:00
2021-07-15 09:50:11 -07:00
const [error] = await safe(verifyAppPassword(user.id, password, identifier));
if (!error) { // matched app password
user.appPassword = true;
return user;
}
2021-07-15 09:50:11 -07:00
if (user.source === 'ldap') {
await externalLdap.verifyPassword(user, password);
} 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) throw new BoxError(BoxError.INVALID_CREDENTIALS);
}
return user;
}
2021-07-15 09:50:11 -07:00
async function verifyWithUsername(username, password, identifier) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
2020-01-31 15:28:42 -08:00
assert.strictEqual(typeof identifier, 'string');
2021-07-15 09:50:11 -07:00
const user = await getByUsername(username.toLowerCase());
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
2021-07-15 09:50:11 -07:00
return await verify(user.id, password, identifier);
}
2021-07-15 09:50:11 -07:00
async function verifyWithEmail(email, password, identifier) {
assert.strictEqual(typeof email, 'string');
assert.strictEqual(typeof password, 'string');
2020-01-31 15:28:42 -08:00
assert.strictEqual(typeof identifier, 'string');
2021-07-15 09:50:11 -07:00
const user = await getByEmail(email.toLowerCase());
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
2021-07-15 09:50:11 -07:00
return await verify(user.id, password, identifier);
}
2021-06-26 09:57:07 -07:00
async function del(user, auditSource) {
assert.strictEqual(typeof user, 'object');
assert(auditSource && typeof auditSource === 'object');
2021-06-26 09:57:07 -07:00
if (settings.isDemo() && user.username === constants.DEMO_USERNAME) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
2021-06-26 09:57:07 -07:00
const queries = [];
queries.push({ query: 'DELETE FROM groupMembers WHERE userId = ?', args: [ user.id ] });
queries.push({ query: 'DELETE FROM tokens WHERE identifier = ?', args: [ user.id ] });
queries.push({ query: 'DELETE FROM appPasswords WHERE userId = ?', args: [ user.id ] });
queries.push({ query: 'DELETE FROM users WHERE id = ?', args: [ user.id ] });
2021-06-26 09:57:07 -07:00
const [error, result] = await safe(database.transaction(queries));
if (error && error.code === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, error);
if (error) throw error;
if (result[3].affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
2021-06-03 11:42:32 -07:00
2021-06-26 09:57:07 -07:00
await safe(eventlog.add(eventlog.ACTION_USER_REMOVE, auditSource, { userId: user.id, user: removePrivateFields(user) }));
}
2021-08-20 11:30:35 -07:00
async function list() {
2021-07-15 09:50:11 -07:00
const results = await database.query(`SELECT ${USERS_FIELDS},GROUP_CONCAT(groupMembers.groupId) AS groupIds ` +
' FROM users LEFT OUTER JOIN groupMembers ON users.id = groupMembers.userId ' +
' GROUP BY users.id ORDER BY users.username');
2016-02-09 09:25:17 -08:00
2021-07-15 09:50:11 -07:00
results.forEach(function (result) {
result.groupIds = result.groupIds ? result.groupIds.split(',') : [ ];
2016-02-09 09:25:17 -08:00
});
2021-07-15 09:50:11 -07:00
results.forEach(postProcess);
return results;
2016-02-09 09:25:17 -08:00
}
2021-08-20 11:30:35 -07:00
async function listPaged(search, page, perPage) {
assert(typeof search === 'string' || search === null);
2019-01-14 16:39:20 +01:00
assert.strictEqual(typeof page, 'number');
assert.strictEqual(typeof perPage, 'number');
2021-07-15 09:50:11 -07:00
let query = `SELECT ${USERS_FIELDS},GROUP_CONCAT(groupMembers.groupId) AS groupIds FROM users LEFT OUTER JOIN groupMembers ON users.id = groupMembers.userId `;
2019-01-14 16:39:20 +01:00
2021-07-15 09:50:11 -07:00
if (search) {
query += ' WHERE ';
query += '(LOWER(users.username) LIKE ' + mysql.escape(`%${search.toLowerCase()}%`) + ')';
query += ' OR ';
query += '(LOWER(users.email) LIKE ' + mysql.escape(`%${search.toLowerCase()}%`) + ')';
query += ' OR ';
query += '(LOWER(users.displayName) LIKE ' + mysql.escape(`%${search.toLowerCase()}%`) + ')';
}
2019-01-14 16:39:20 +01:00
2021-07-15 09:50:11 -07:00
query += ` GROUP BY users.id ORDER BY users.username ASC LIMIT ${(page-1)*perPage},${perPage} `;
2016-06-07 09:59:29 -07:00
2021-07-15 09:50:11 -07:00
const results = await database.query(query);
2016-06-07 09:59:29 -07:00
2021-07-15 09:50:11 -07:00
results.forEach(function (result) {
result.groupIds = result.groupIds ? result.groupIds.split(',') : [ ];
2016-06-07 09:59:29 -07:00
});
2021-07-15 09:50:11 -07:00
results.forEach(postProcess);
2021-07-15 09:50:11 -07:00
return results;
}
2021-07-15 09:50:11 -07:00
async function isActivated() {
const result = await database.query('SELECT COUNT(*) AS total FROM users');
return result[0].total !== 0;
}
2021-07-15 09:50:11 -07:00
async function get(userId) {
assert.strictEqual(typeof userId, 'string');
2021-07-15 09:50:11 -07:00
const results = await database.query(`SELECT ${USERS_FIELDS},GROUP_CONCAT(groupMembers.groupId) AS groupIds ` +
' FROM users LEFT OUTER JOIN groupMembers ON users.id = groupMembers.userId ' +
' GROUP BY users.id HAVING users.id = ?', [ userId ]);
2021-07-15 09:50:11 -07:00
if (results.length === 0) return null;
2016-02-08 20:38:50 -08:00
2021-07-15 09:50:11 -07:00
results[0].groupIds = results[0].groupIds ? results[0].groupIds.split(',') : [ ];
2016-02-08 20:38:50 -08:00
2021-07-15 09:50:11 -07:00
return postProcess(results[0]);
}
async function getByEmail(email) {
assert.strictEqual(typeof email, 'string');
const result = await database.query(`SELECT ${USERS_FIELDS} FROM users WHERE email = ?`, [ email ]);
if (result.length === 0) return null;
return postProcess(result[0]);
}
2021-07-15 09:50:11 -07:00
async function getByRole(role) {
assert.strictEqual(typeof role, 'string');
// the mailer code relies on the first object being the 'owner' (thus the ORDER)
const results = await database.query(`SELECT ${USERS_FIELDS} FROM users WHERE role=? ORDER BY creationTime`, [ role ]);
results.forEach(postProcess);
return results;
}
async function getByResetToken(resetToken) {
assert.strictEqual(typeof resetToken, 'string');
2021-07-15 09:50:11 -07:00
let error = validateResetToken(resetToken);
if (error) throw error;
2021-07-15 09:50:11 -07:00
const result = await database.query(`SELECT ${USERS_FIELDS} FROM users WHERE resetToken=?`, [ resetToken ]);
if (result.length === 0) return null;
2021-07-15 09:50:11 -07:00
return postProcess(result[0]);
}
2021-07-15 09:50:11 -07:00
async function getByUsername(username) {
2019-03-18 21:15:50 -07:00
assert.strictEqual(typeof username, 'string');
2021-07-15 09:50:11 -07:00
const result = await database.query(`SELECT ${USERS_FIELDS} FROM users WHERE username = ?`, [ username ]);
if (result.length === 0) return null;
2019-03-18 21:15:50 -07:00
2021-07-15 09:50:11 -07:00
return postProcess(result[0]);
2019-03-18 21:15:50 -07:00
}
2021-07-15 09:50:11 -07:00
async function update(user, data, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof data, 'object');
assert(auditSource && typeof auditSource === 'object');
2021-07-15 09:50:11 -07:00
assert(!('twoFactorAuthenticationEnabled' in data) || (typeof data.twoFactorAuthenticationEnabled === 'boolean'));
assert(!('active' in data) || (typeof data.active === 'boolean'));
assert(!('loginLocations' in data) || (Array.isArray(data.loginLocations)));
2021-07-15 09:50:11 -07:00
if (settings.isDemo() && user.username === constants.DEMO_USERNAME) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
let error, result;
2021-07-15 09:50:11 -07:00
if (_.isEmpty(data)) return;
if (data.username) {
data.username = data.username.toLowerCase();
error = validateUsername(data.username);
2021-07-15 09:50:11 -07:00
if (error) throw error;
}
if (data.email) {
data.email = data.email.toLowerCase();
error = validateEmail(data.email);
2021-07-15 09:50:11 -07:00
if (error) throw error;
}
2018-01-21 14:25:39 +01:00
if (data.fallbackEmail) {
data.fallbackEmail = data.fallbackEmail.toLowerCase();
error = validateEmail(data.fallbackEmail);
2021-07-15 09:50:11 -07:00
if (error) throw error;
2018-01-21 14:25:39 +01:00
}
if (data.role) {
error = validateRole(data.role);
2021-07-15 09:50:11 -07:00
if (error) throw error;
2020-02-13 22:06:54 -08:00
}
2021-07-15 09:50:11 -07:00
let args = [ ];
let fields = [ ];
for (const k in data) {
if (k === 'twoFactorAuthenticationEnabled' || k === 'active') {
fields.push(k + ' = ?');
args.push(data[k] ? 1 : 0);
} else if (k === 'loginLocations') {
fields.push('loginLocationsJson = ?');
args.push(JSON.stringify(data[k]));
} else {
fields.push(k + ' = ?');
args.push(data[k]);
}
}
args.push(user.id);
2021-07-15 09:50:11 -07:00
[error, result] = await safe(database.query('UPDATE users SET ' + fields.join(', ') + ' WHERE id = ?', args));
if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('users_email') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'email already exists');
if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('users_username') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'username already exists');
if (error) throw new BoxError(BoxError.DATABASE_ERROR, error);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
2021-07-15 09:50:11 -07:00
const newUser = _.extend({}, user, data);
2020-02-14 13:01:51 -08:00
2021-07-15 09:50:11 -07:00
eventlog.add(eventlog.ACTION_USER_UPDATE, auditSource, {
userId: user.id,
user: removePrivateFields(newUser),
roleChanged: newUser.role !== user.role,
activeStatusChanged: ((newUser.active && !user.active) || (!newUser.active && user.active))
});
}
2021-07-15 09:50:11 -07:00
async function getOwner() {
const owners = await getByRole(exports.ROLE_OWNER);
if (owners.length === 0) return null;
return owners[0];
2016-02-09 15:47:02 -08:00
}
2021-07-15 09:50:11 -07:00
async function getAdmins() {
const owners = await getByRole(exports.ROLE_OWNER);
const admins = await getByRole(exports.ROLE_ADMIN);
2021-07-15 09:50:11 -07:00
return owners.concat(admins);
2016-01-15 16:04:33 +01:00
}
2021-07-15 09:50:11 -07:00
async function getSuperadmins() {
return await getByRole(exports.ROLE_OWNER);
}
2021-07-15 09:50:11 -07:00
async function sendPasswordResetByIdentifier(identifier, auditSource) {
assert.strictEqual(typeof identifier, 'string');
2021-07-15 09:50:11 -07:00
assert.strictEqual(typeof auditSource, 'object');
2021-07-15 09:50:11 -07:00
const user = identifier.indexOf('@') === -1 ? await getByUsername(identifier.toLowerCase()) : await getByEmail(identifier.toLowerCase());
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
const resetToken = hat(256);
const resetTokenCreationTime = new Date();
2021-07-15 09:50:11 -07:00
user.resetToken = resetToken;
user.resetTokenCreationTime = resetTokenCreationTime;
await update(user, { resetToken,resetTokenCreationTime }, auditSource);
const resetLink = `${settings.dashboardOrigin()}/login.html?resetToken=${user.resetToken}`;
await mailer.passwordReset(user, resetLink);
return resetLink;
}
2021-07-15 09:50:11 -07:00
async function notifyLoginLocation(user, ip, userAgent, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof ip, 'string');
assert.strictEqual(typeof userAgent, 'string');
2021-07-15 09:50:11 -07:00
assert.strictEqual(typeof auditSource, 'object');
2021-07-15 09:50:11 -07:00
debug(`notifyLoginLocation: ${user.id} ${ip} ${userAgent}`);
2021-07-15 09:50:11 -07:00
if (constants.TEST && ip === '127.0.0.1') return;
const response = await superagent.get('https://geolocation.cloudron.io/json').query({ ip }).ok(() => true);
2021-07-15 09:50:11 -07:00
if (response.statusCode !== 200) return console.error(`Failed to get geoip info. statusCode: ${response.statusCode}`);
2021-04-30 09:44:25 -07:00
2021-07-15 09:50:11 -07:00
const country = safe.query(response.body, 'country.names.en', '');
const city = safe.query(response.body, 'city.names.en', '');
2021-07-15 09:50:11 -07:00
if (!city || !country) return;
2021-07-15 09:50:11 -07:00
const ua = uaParser(userAgent);
const simplifiedUserAgent = ua.browser.name ? `${ua.browser.name} - ${ua.os.name}` : userAgent;
2021-07-15 09:50:11 -07:00
const knownLogin = user.loginLocations.find(function (l) {
return l.userAgent === simplifiedUserAgent && l.country === country && l.city === city;
});
2021-07-15 09:50:11 -07:00
if (knownLogin) return;
2021-07-15 09:50:11 -07:00
// purge potentially old locations where ts > now() - 6 months
const sixMonthsBack = Date.now() - 6 * 30 * 24 * 60 * 60 * 1000;
const newLoginLocation = { ts: Date.now(), ip, userAgent: simplifiedUserAgent, country, city };
let loginLocations = user.loginLocations.filter(function (l) { return l.ts > sixMonthsBack; });
2021-07-15 09:50:11 -07:00
// only stash if we have a real useragent, otherwise warn the user every time
if (simplifiedUserAgent) loginLocations.push(newLoginLocation);
2021-07-15 09:50:11 -07:00
await update(user, { loginLocations }, auditSource);
await mailer.sendNewLoginLocation(user, newLoginLocation);
}
2021-07-15 09:50:11 -07:00
async function setPassword(user, newPassword, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof newPassword, 'string');
2021-07-15 09:50:11 -07:00
assert.strictEqual(typeof auditSource, 'object');
2021-07-15 09:50:11 -07:00
let error = validatePassword(newPassword);
if (error) throw error;
2021-07-15 09:50:11 -07:00
if (settings.isDemo() && user.username === constants.DEMO_USERNAME) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
if (user.source) throw new BoxError(BoxError.CONFLICT, 'User is from an external directory');
2021-08-13 14:43:08 -07:00
let salt, derivedKey;
[error, salt] = await safe(randomBytesAsync(CRYPTO_SALT_SIZE));
2021-08-13 14:43:08 -07:00
[error, derivedKey] = await safe(pbkdf2Async(newPassword, salt, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST));
2021-07-15 09:50:11 -07:00
if (error) throw new BoxError(BoxError.CRYPTO_ERROR, error);
2021-07-15 09:50:11 -07:00
const data = {
2021-08-13 14:43:08 -07:00
salt: salt.toString('hex'),
2021-07-15 09:50:11 -07:00
password: Buffer.from(derivedKey, 'binary').toString('hex'),
resetToken: ''
};
2021-07-15 09:50:11 -07:00
await update(user, data, auditSource);
}
2021-07-15 09:50:11 -07:00
async function createOwner(email, username, password, displayName, auditSource) {
assert.strictEqual(typeof email, 'string');
2016-04-04 15:14:00 +02:00
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof displayName, 'string');
assert(auditSource && typeof auditSource === 'object');
2016-04-04 15:14:00 +02:00
2021-07-15 09:50:11 -07:00
// This is only not allowed for the owner. reset of username validation happens in add()
if (username === '') throw new BoxError(BoxError.BAD_FIELD, 'Username cannot be empty');
2021-07-15 09:50:11 -07:00
const activated = await isActivated();
if (activated) throw new BoxError(BoxError.ALREADY_EXISTS, 'Cloudron already activated');
2021-07-15 09:50:11 -07:00
return await add(email, { username, password, displayName, role: exports.ROLE_OWNER }, auditSource);
2016-01-13 12:28:38 -08:00
}
2016-01-18 15:16:18 +01:00
async function sendInvite(user, options, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof options, 'object');
2016-01-18 15:16:18 +01:00
2021-07-15 09:50:11 -07:00
if (user.source) throw new BoxError(BoxError.CONFLICT, 'User is from an external directory');
// if (!user.resetToken) throw new BoxError(BoxError.CONFLICT, 'Must generate resetToken to send invitation');
const resetToken = hat(256);
const resetTokenCreationTime = new Date();
2021-07-15 09:50:11 -07:00
await update(user, { resetToken, resetTokenCreationTime }, auditSource);
const directoryConfig = await settings.getDirectoryConfig();
let inviteLink = `${settings.dashboardOrigin()}/setupaccount.html?resetToken=${user.resetToken}&email=${encodeURIComponent(user.email)}`;
if (user.username) inviteLink += `&username=${encodeURIComponent(user.username)}`;
if (user.displayName) inviteLink += `&displayName=${encodeURIComponent(user.displayName)}`;
if (directoryConfig.lockUserProfiles) inviteLink += '&profileLocked=true';
2021-09-16 15:38:06 +02:00
await mailer.sendInvite(user, options.invitor || null, inviteLink);
return inviteLink;
}
2021-07-15 09:50:11 -07:00
async function setupAccount(user, data, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof data, 'object');
assert(auditSource && typeof auditSource === 'object');
const directoryConfig = await settings.getDirectoryConfig();
2021-07-15 09:50:11 -07:00
if (directoryConfig.lockUserProfiles) return;
2018-08-17 09:49:58 -07:00
2021-07-15 09:50:11 -07:00
await update(user, _.pick(data, 'username', 'displayName'), auditSource);
2021-07-31 04:33:09 -07:00
await setPassword(user, data.password, auditSource); // setPassword clears the resetToken
2021-07-15 09:50:11 -07:00
const token = { clientId: tokens.ID_WEBADMIN, identifier: user.id, expires: Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS };
const result = await tokens.add(token);
return result.accessToken;
2018-08-17 09:49:58 -07:00
}
2021-07-15 09:50:11 -07:00
async function setTwoFactorAuthenticationSecret(userId, auditSource) {
2018-04-25 19:08:15 +02:00
assert.strictEqual(typeof userId, 'string');
2021-07-15 09:50:11 -07:00
assert(auditSource && typeof auditSource === 'object');
2018-04-25 19:08:15 +02:00
2021-07-15 09:50:11 -07:00
const user = await get(userId);
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
2018-04-25 19:08:15 +02:00
2021-07-15 09:50:11 -07:00
if (settings.isDemo() && user.username === constants.DEMO_USERNAME) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
2021-07-15 09:50:11 -07:00
if (user.twoFactorAuthenticationEnabled) throw new BoxError(BoxError.ALREADY_EXISTS);
2018-04-25 19:08:15 +02:00
2021-07-15 09:50:11 -07:00
const secret = speakeasy.generateSecret({ name: `Cloudron ${settings.dashboardFqdn()} (${user.username})` });
2018-04-25 19:08:15 +02:00
2021-07-15 09:50:11 -07:00
await update(user, { twoFactorAuthenticationSecret: secret.base32, twoFactorAuthenticationEnabled: false }, auditSource);
2018-04-25 19:08:15 +02:00
2021-07-15 09:50:11 -07:00
const [error, dataUrl] = await safe(qrcode.toDataURL(secret.otpauth_url));
if (error) throw new BoxError(BoxError.INTERNAL_ERROR, error);
2018-04-25 19:08:15 +02:00
2021-07-15 09:50:11 -07:00
return { secret: secret.base32, qrcode: dataUrl };
2018-04-25 19:08:15 +02:00
}
2021-07-15 09:50:11 -07:00
async function enableTwoFactorAuthentication(userId, totpToken, auditSource) {
2018-04-25 19:08:15 +02:00
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof totpToken, 'string');
2021-07-15 09:50:11 -07:00
assert(auditSource && typeof auditSource === 'object');
2018-04-25 19:08:15 +02:00
2021-07-15 09:50:11 -07:00
const user = await get(userId);
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
2018-04-25 19:08:15 +02:00
2021-07-15 09:50:11 -07:00
const verified = speakeasy.totp.verify({ secret: user.twoFactorAuthenticationSecret, encoding: 'base32', token: totpToken, window: 2 });
if (!verified) throw new BoxError(BoxError.INVALID_CREDENTIALS);
2018-04-25 19:08:15 +02:00
2021-07-15 09:50:11 -07:00
if (user.twoFactorAuthenticationEnabled) throw new BoxError(BoxError.ALREADY_EXISTS);
2018-04-25 19:08:15 +02:00
2021-07-15 09:50:11 -07:00
await update(user, { twoFactorAuthenticationEnabled: true }, auditSource);
2018-04-25 19:08:15 +02:00
}
2021-07-15 09:50:11 -07:00
async function disableTwoFactorAuthentication(userId, auditSource) {
2018-04-25 19:08:15 +02:00
assert.strictEqual(typeof userId, 'string');
2021-07-15 09:50:11 -07:00
assert(auditSource && typeof auditSource === 'object');
2019-08-08 05:45:56 -07:00
2021-07-15 09:50:11 -07:00
const user = await get(userId);
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
await update(user, { twoFactorAuthenticationEnabled: false, twoFactorAuthenticationSecret: '' }, auditSource);
2019-08-08 05:45:56 -07:00
}
2020-01-31 15:28:42 -08:00
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');
let roleInt1 = ORDERED_ROLES.indexOf(role1);
let roleInt2 = ORDERED_ROLES.indexOf(role2);
return roleInt1 - roleInt2;
}
2021-06-25 22:11:17 -07:00
async function getAvatarUrl(user) {
assert.strictEqual(typeof user, 'object');
2021-07-07 14:31:39 +02:00
const fallbackUrl = `${settings.dashboardOrigin()}/img/avatar-default-symbolic.svg`;
2021-07-08 08:52:51 -07:00
const result = await getAvatar(user.id);
2021-07-08 10:40:10 +02:00
if (result.equals(constants.AVATAR_NONE)) return fallbackUrl;
else if (result.equals(constants.AVATAR_GRAVATAR)) return `https://www.gravatar.com/avatar/${require('crypto').createHash('md5').update(user.email).digest('hex')}.jpg`;
2021-07-07 14:31:39 +02:00
else if (result) return `${settings.dashboardOrigin()}/api/v1/profile/avatar/${user.id}`;
else return fallbackUrl;
}
2021-06-25 22:11:17 -07:00
async function getAvatar(id) {
assert.strictEqual(typeof id, 'string');
2021-06-25 22:11:17 -07:00
const result = await database.query('SELECT avatar FROM users WHERE id = ?', [ id ]);
2021-07-07 14:31:39 +02:00
if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
2021-06-25 22:11:17 -07:00
return result[0].avatar;
}
2021-06-25 22:11:17 -07:00
async function setAvatar(id, avatar) {
assert.strictEqual(typeof id, 'string');
2021-07-08 10:40:10 +02:00
assert(Buffer.isBuffer(avatar));
2021-06-25 22:11:17 -07:00
const result = await database.query('UPDATE users SET avatar=? WHERE id = ?', [ avatar, id ]);
if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
}