Compare commits

..

24 Commits

Author SHA1 Message Date
Girish Ramakrishnan b69c5f62c0 Add to changes 2021-10-28 10:27:32 -07:00
Johannes Zellner 63f6f065ba Add and fixup invite link related tests 2021-10-28 11:18:31 +02:00
Johannes Zellner 92f0f56fae do not strictly require fallbackEmail on user creation but provide a fallback 2021-10-28 10:29:02 +02:00
Johannes Zellner cb8aa15e62 Do not allow setting ghost password for user without username 2021-10-27 23:36:44 +02:00
Johannes Zellner 4356d673bc Fix wrong assert and minor typos 2021-10-27 22:31:54 +02:00
Girish Ramakrishnan 5ece159fba sftp: fix crash when creating directory 2021-10-27 13:17:23 -07:00
Johannes Zellner b59776bf9b fail getting invite link or sending invite if invate was already used 2021-10-27 21:25:43 +02:00
Johannes Zellner 475795a107 Invite is now also separate 2021-10-27 19:58:06 +02:00
Johannes Zellner 9a80049d36 Add two distinct password reset routes 2021-10-27 19:12:18 +02:00
Johannes Zellner daf212468f fallbackEmail is now independent from email 2021-10-26 22:50:02 +02:00
Girish Ramakrishnan 2f510c2625 capitalize sql keywords 2021-10-26 11:19:30 -07:00
Girish Ramakrishnan 7a977fa76b 7.0.2 changes 2021-10-26 11:17:57 -07:00
Girish Ramakrishnan f5e025c213 mail: mailbox listing does not return pop3 status 2021-10-26 11:11:07 -07:00
Girish Ramakrishnan 971b73f853 move the bind inside 2021-10-26 11:03:54 -07:00
Girish Ramakrishnan 0103b21724 bump default backup memory limit to 800 2021-10-26 11:03:54 -07:00
Johannes Zellner cef5c1e78c Use normal bind() 2021-10-26 18:47:51 +02:00
Johannes Zellner 50ff6b99e0 More external ldap fixes after the test tests the correct thing 2021-10-26 18:04:25 +02:00
Johannes Zellner 26dbd50cf2 Ensure we don't crash if mount status does not include some strings 2021-10-26 14:54:56 +02:00
Johannes Zellner 84884b969e Fix external ldap bind
See "Losing context" https://masteringjs.io/tutorials/node/promisify
2021-10-26 11:55:58 +02:00
Girish Ramakrishnan 62174c5328 proxyauth: only log failed requests by default 2021-10-25 09:41:12 -07:00
Girish Ramakrishnan 716951a3f1 dkim: ignore any spurious errors
in one of our cloudrons, we had a random dangling symlink in that directory
2021-10-22 17:26:12 -07:00
Girish Ramakrishnan fbf6fe22af 7.0.1 changes 2021-10-22 16:39:42 -07:00
Girish Ramakrishnan b18c4d3426 migration: wellKnown is {} or NULL 2021-10-22 16:29:32 -07:00
Girish Ramakrishnan 26a993abe7 Ubuntu 16 is unsupported 2021-10-22 16:09:43 -07:00
18 changed files with 245 additions and 66 deletions
+11
View File
@@ -2324,6 +2324,7 @@
* password reset: check 2fa when enabled
[7.0.0]
* Ubuntu 16 is not supported anymore
* Do not use Gravatar as the default but only an option
* redis: suppress password warning
* setup UI: fix dark mode
@@ -2363,3 +2364,13 @@
* mail: add duplication detection for lists
* mail: add SRS for Sieve Forwarding
[7.0.1]
* Fix matrix wellKnown client migration
[7.0.2]
* mail: POP3 flag was not returned correctly
* external ldap: fix crash preventing users from logging in
* volumes: ensure we don't crash if mount status is unexpected
* backups: set default backup memory limit to 800
* users: allow admins to specify password recovery email
@@ -11,7 +11,7 @@ exports.up = function(db, callback) {
if (!r.wellKnownJson) return iteratorDone();
const wellKnown = safe.JSON.parse(r.wellKnownJson);
if (!wellKnown) return iteratorDone();
if (!wellKnown || !wellKnown['matrix/server']) return iteratorDone();
const matrixHostname = JSON.parse(wellKnown['matrix/server'])['m.server'];
wellKnown['matrix/client'] = JSON.stringify({
@@ -2,7 +2,8 @@
const async = require('async'),
fs = require('fs'),
path = require('path');
path = require('path'),
safe = require('safetydance');
const MAIL_DATA_DIR = '/home/yellowtent/boxdata/mail';
const DKIM_DIR = `${MAIL_DATA_DIR}/dkim`;
@@ -17,8 +18,9 @@ exports.up = function(db, callback) {
async.eachSeries(filenames, function (filename, iteratorCallback) {
const domain = filename;
const publicKey = fs.readFileSync(path.join(DKIM_DIR, domain, 'public'), 'utf8');
const privateKey = fs.readFileSync(path.join(DKIM_DIR, domain, 'private'), 'utf8');
const publicKey = safe.fs.readFileSync(path.join(DKIM_DIR, domain, 'public'), 'utf8');
const privateKey = safe.fs.readFileSync(path.join(DKIM_DIR, domain, 'private'), 'utf8');
if (!publicKey || !privateKey) return iteratorCallback();
const dkimKey = {
publicKey,
+1 -1
View File
@@ -193,7 +193,7 @@ async function startBackupTask(auditSource) {
const backupConfig = await settings.getBackupConfig();
const memoryLimit = 'memoryLimit' in backupConfig ? Math.max(backupConfig.memoryLimit/1024/1024, 400) : 400;
const memoryLimit = 'memoryLimit' in backupConfig ? Math.max(backupConfig.memoryLimit/1024/1024, 800) : 800;
const taskId = await tasks.add(tasks.TASK_BACKUP, [ { /* options */ } ]);
+6 -6
View File
@@ -254,9 +254,8 @@ async function search(identifier) {
return users;
}
async function maybeCreateUser(identifier, password) {
async function maybeCreateUser(identifier) {
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof password, 'string');
const externalLdapConfig = await settings.getExternalLdapConfig();
if (externalLdapConfig.provider === 'noop') throw new BoxError(BoxError.BAD_STATE, 'not enabled');
@@ -269,13 +268,14 @@ async function maybeCreateUser(identifier, password) {
const user = translateUser(externalLdapConfig, ldapUsers[0]);
if (!validUserRequirements(user)) throw new BoxError(BoxError.BAD_FIELD);
const [error] = await safe(users.add(user.email, { username: user.username, password: null, displayName: user.displayName, source: 'ldap' }, AuditSource.EXTERNAL_LDAP_AUTO_CREATE));
const [error, userId] = await safe(users.add(user.email, { username: user.username, password: null, displayName: user.displayName, source: 'ldap' }, AuditSource.EXTERNAL_LDAP_AUTO_CREATE));
if (error) {
debug(`maybeCreateUser: failed to auto create user ${user.username}`, error);
throw error;
}
return user;
// fetch the full record
return await users.get(userId);
}
async function verifyPassword(user, password) {
@@ -291,7 +291,7 @@ async function verifyPassword(user, password) {
const client = await getClient(externalLdapConfig, { bind: false });
const [error] = await safe(util.promisify(client.bind)(ldapUsers[0].dn, password));
const [error] = await safe(util.promisify(client.bind.bind(client))(ldapUsers[0].dn, password));
client.unbind();
if (error instanceof ldap.InvalidCredentialsError) throw new BoxError(BoxError.INVALID_CREDENTIALS);
if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, error);
@@ -337,7 +337,7 @@ async function syncUsers(externalLdapConfig, progressCallback) {
debug(`syncUsers: [adding user] username=${ldapUser.username} email=${ldapUser.email} displayName=${ldapUser.displayName}`);
const [userAddError] = await safe(users.add(ldapUser.email, { username: ldapUser.username, password: null, displayName: ldapUser.displayName, source: 'ldap' }, AuditSource.EXTERNAL_LDAP_TASK));
if (userAddError) debug('syncUsers: Failed to create user', ldapUser, userAddError.message);
if (userAddError) debug('syncUsers: Failed to create user', ldapUser, userAddError);
} else if (user.source !== 'ldap') {
debug(`syncUsers: [mapping user] username=${ldapUser.username} email=${ldapUser.email} displayName=${ldapUser.displayName}`);
+1 -1
View File
@@ -22,6 +22,6 @@ exports = module.exports = {
'redis': { repo: 'cloudron/redis', tag: 'cloudron/redis:3.0.4@sha256:5c60de75d078ae609da5565f32dcd91030f45907e945756cc976ff207b8c6199' },
'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:3.4.0@sha256:5f9795cad3634c177f789019c12f8d53a6481de3cc627fb5a4866ce085006507' },
'graphite': { repo: 'cloudron/graphite', tag: 'cloudron/graphite:3.0.1@sha256:bed9f6b5d06fe2c5289e895e806cfa5b74ad62993d705be55d4554a67d128029' },
'sftp': { repo: 'cloudron/sftp', tag: 'cloudron/sftp:3.4.1@sha256:13e066fcd52230f23244c16fdd2f7aa447a91e98ff703269f48b1afe3b393e31' }
'sftp': { repo: 'cloudron/sftp', tag: 'cloudron/sftp:3.4.2@sha256:810306478c3dac7caa7497e5f6381cc7ce2f68aafda849a4945d39a67cc04bc1' }
}
};
+3 -2
View File
@@ -106,6 +106,7 @@ const assert = require('assert'),
const DNS_OPTIONS = { timeout: 5000 };
const REMOVE_MAILBOX_CMD = path.join(__dirname, 'scripts/rmmailbox.sh');
// if you add a field here, listMailboxes has to be updated
const MAILBOX_FIELDS = [ 'name', 'type', 'ownerId', 'ownerType', 'aliasName', 'aliasDomain', 'creationTime', 'membersJson', 'membersOnly', 'domain', 'active', 'enablePop3' ].join(',');
const MAILDB_FIELDS = [ 'domain', 'enabled', 'mailFromValidation', 'catchAllJson', 'relayJson', 'dkimKeyJson', 'dkimSelector', 'bannerJson' ].join(',');
@@ -1080,7 +1081,7 @@ async function listMailboxes(domain, search, page, perPage) {
const escapedSearch = mysql.escape('%' + search + '%'); // this also quotes the string
const searchQuery = search ? ` HAVING (name LIKE ${escapedSearch} OR aliasNames LIKE ${escapedSearch} OR aliasDomains LIKE ${escapedSearch})` : ''; // having instead of where because of aggregated columns use
const query = 'SELECT m1.name AS name, m1.domain AS domain, m1.ownerId AS ownerId, m1.ownerType as ownerType, m1.active as active, JSON_ARRAYAGG(m2.name) AS aliasNames, JSON_ARRAYAGG(m2.domain) AS aliasDomains '
const query = 'SELECT m1.name AS name, m1.domain AS domain, m1.ownerId AS ownerId, m1.ownerType as ownerType, m1.active as active, JSON_ARRAYAGG(m2.name) AS aliasNames, JSON_ARRAYAGG(m2.domain) AS aliasDomains, m1.enablePop3 AS enablePop3 '
+ ` FROM (SELECT * FROM mailboxes WHERE type='${exports.TYPE_MAILBOX}') AS m1`
+ ` LEFT JOIN (SELECT * FROM mailboxes WHERE type='${exports.TYPE_ALIAS}') AS m2`
+ ' ON m1.name=m2.aliasName AND m1.domain=m2.aliasDomain AND m1.ownerId=m2.ownerId'
@@ -1101,7 +1102,7 @@ async function listAllMailboxes(page, perPage) {
assert.strictEqual(typeof page, 'number');
assert.strictEqual(typeof perPage, 'number');
const query = 'SELECT m1.name AS name, m1.domain AS domain, m1.ownerId AS ownerId, m1.ownerType as ownerType, m1.active as active, JSON_ARRAYAGG(m2.name) AS aliasNames, JSON_ARRAYAGG(m2.domain) AS aliasDomains '
const query = 'SELECT m1.name AS name, m1.domain AS domain, m1.ownerId AS ownerId, m1.ownerType as ownerType, m1.active as active, JSON_ARRAYAGG(m2.name) AS aliasNames, JSON_ARRAYAGG(m2.domain) AS aliasDomains, m1.enablePop3 AS enablePop3 '
+ ` FROM (SELECT * FROM mailboxes WHERE type='${exports.TYPE_MAILBOX}') AS m1`
+ ` LEFT JOIN (SELECT * FROM mailboxes WHERE type='${exports.TYPE_ALIAS}') AS m2`
+ ' ON m1.name=m2.aliasName AND m1.domain=m2.aliasDomain AND m1.ownerId=m2.ownerId'
+8 -5
View File
@@ -61,7 +61,7 @@ async function sendMail(mailOptions) {
}
}));
const transportSendMail = util.promisify(transport.sendMail).bind(transport);
const transportSendMail = util.promisify(transport.sendMail.bind(transport));
const [error] = await safe(transportSendMail(mailOptions));
if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, error);
debug(`Email "${mailOptions.subject}" sent to ${mailOptions.to}`);
@@ -89,9 +89,10 @@ function render(templateFile, params, translationAssets) {
return content;
}
async function sendInvite(user, invitor, inviteLink) {
async function sendInvite(user, invitor, email, inviteLink) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof invitor, 'object');
assert.strictEqual(typeof email, 'string');
assert.strictEqual(typeof inviteLink, 'string');
const mailConfig = await getMailConfig();
@@ -108,7 +109,7 @@ async function sendInvite(user, invitor, inviteLink) {
const mailOptions = {
from: mailConfig.notificationFrom,
to: user.fallbackEmail,
to: email,
subject: ejs.render(translation.translate('{{ welcomeEmail.subject }}', translationAssets.translations || {}, translationAssets.fallback || {}), { cloudron: mailConfig.cloudronName }),
text: render('welcome_user-text.ejs', templateData, translationAssets),
html: render('welcome_user-html.ejs', templateData, translationAssets)
@@ -152,8 +153,10 @@ async function sendNewLoginLocation(user, loginLocation) {
await sendMail(mailOptions);
}
async function passwordReset(user, resetLink) {
async function passwordReset(user, email, resetLink) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof email, 'string');
assert.strictEqual(typeof resetLink, 'string');
const mailConfig = await getMailConfig();
const translationAssets = await translation.getTranslations();
@@ -167,7 +170,7 @@ async function passwordReset(user, resetLink) {
const mailOptions = {
from: mailConfig.notificationFrom,
to: user.fallbackEmail,
to: email,
subject: ejs.render(translation.translate('{{ passwordResetEmail.subject }}', translationAssets.translations || {}, translationAssets.fallback || {}), { cloudron: mailConfig.cloudronName }),
text: render('password_reset-text.ejs', templateData, translationAssets),
html: render('password_reset-html.ejs', templateData, translationAssets)
+1 -1
View File
@@ -150,7 +150,7 @@ async function getStatus(mountType, hostPath) {
let start = -1, end = -1; // start and end of error message block
for (let idx = lines.length - 1; idx >= 0; idx--) { // reverse
const line = lines[idx];
const match = line['SYSLOG_IDENTIFIER'] === 'mount' || line['_EXE'].includes('mount') || line['_COMM'].includes('mount');
const match = line['SYSLOG_IDENTIFIER'] === 'mount' || (line['_EXE'] && line['_EXE'].includes('mount')) || (line['_COMM'] && line['_COMM'].includes('mount'));
if (match) {
if (end === -1) end = idx;
start = idx;
+18 -1
View File
@@ -209,7 +209,24 @@ function initializeAuthwallExpressSync() {
const json = middleware.json({ strict: true, limit: QUERY_LIMIT }); // application/json
if (process.env.BOX_ENV !== 'test') app.use(middleware.morgan('proxyauth :method :url :status :response-time ms - :res[content-length]', { immediate: false }));
if (process.env.BOX_ENV !== 'test') {
app.use(middleware.morgan(function (tokens, req, res) {
return [
'proxyauth',
tokens.method(req, res),
tokens.url(req, res),
tokens.status(req, res),
res.errorBody ? res.errorBody.status : '', // attached by connect-lastmile. can be missing when router errors like 404
res.errorBody ? res.errorBody.message : '', // attached by connect-lastmile. can be missing when router errors like 404
tokens['response-time'](req, res), 'ms', '-',
tokens.res(req, res, 'content-length')
].join(' ');
}, {
immediate: false,
// only log failed requests by default
skip: function (req, res) { return res.statusCode < 400; }
}));
}
const router = new express.Router();
router.del = router.delete; // amend router.del for readability further on
+2 -2
View File
@@ -31,9 +31,9 @@ async function passwordAuth(req, res, next) {
let [error, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN));
if (error && error.reason === BoxError.NOT_FOUND) {
[error, user] = await safe(externalLdap.maybeCreateUser(username.toLowerCase(), password));
[error, user] = await safe(externalLdap.maybeCreateUser(username.toLowerCase()));
if (error) return next(new HttpError(401, 'Unauthorized'));
[error] = await safe(externalLdap.verifyPassword(user));
[error] = await safe(externalLdap.verifyPassword(user, password));
if (error) return next(new HttpError(401, 'Unauthorized'));
}
if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new HttpError(401, 'Unauthorized'));
+18
View File
@@ -76,6 +76,15 @@ describe('Users API', function () {
expect(response.statusCode).to.equal(400);
});
it('cannot create user with non email fallbackEmail', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/users`)
.query({ access_token: owner.token })
.send({ username: user2.username, email: user2.email, fallbackEmail: 'notanemail' })
.ok(() => true);
expect(response.statusCode).to.equal(400);
});
it('create second user succeeds', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/users`)
.query({ access_token: owner.token })
@@ -299,6 +308,15 @@ describe('Users API', function () {
expect(response.statusCode).to.equal(400);
});
it('change fallbackEmail fails due to invalid email', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/users/${user2.id}`)
.query({ access_token: owner.token })
.send({ fallbackEmail: 'newemail@cloudron' })
.ok(() => true);
expect(response.statusCode).to.equal(400);
});
it('change user succeeds without email nor displayName', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/users/${user2.id}`)
.query({ access_token: owner.token })
+56 -13
View File
@@ -8,11 +8,16 @@ exports = module.exports = {
del,
setPassword,
verifyPassword,
sendInvite,
setGroups,
setGhost,
makeOwner,
getPasswordResetLink,
sendPasswordResetEmail,
getInviteLink,
sendInviteEmail,
disableTwoFactorAuthentication,
load
@@ -43,6 +48,7 @@ async function add(req, res, next) {
if (typeof req.body.email !== 'string') return next(new HttpError(400, 'email must be string'));
if ('username' in req.body && typeof req.body.username !== 'string') return next(new HttpError(400, 'username must be string'));
if ('fallbackEmail' in req.body && typeof req.body.fallbackEmail !== 'string') return next(new HttpError(400, 'fallbackEmail must be string'));
if ('displayName' in req.body && typeof req.body.displayName !== 'string') return next(new HttpError(400, 'displayName must be string'));
if ('password' in req.body && typeof req.body.password !== 'string') return next(new HttpError(400, 'password must be string'));
if ('role' in req.body) {
@@ -54,8 +60,9 @@ async function add(req, res, next) {
const email = req.body.email;
const username = 'username' in req.body ? req.body.username : null;
const displayName = req.body.displayName || '';
const fallbackEmail = req.body.fallbackEmail || '';
const [error, id] = await safe(users.add(email, { username, password, displayName, invitor: req.user, role: req.body.role || users.ROLE_USER }, AuditSource.fromRequest(req)));
const [error, id] = await safe(users.add(email, { username, password, displayName, fallbackEmail, invitor: req.user, role: req.body.role || users.ROLE_USER }, AuditSource.fromRequest(req)));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(201, { id }));
@@ -151,17 +158,6 @@ async function disableTwoFactorAuthentication(req, res, next) {
next(new HttpSuccess(200, {}));
}
async function sendInvite(req, res, next) {
assert.strictEqual(typeof req.resource, 'object');
if (users.compareRoles(req.user.role, req.resource.role) < 0) return next(new HttpError(403, `role '${req.resource.role}' is required but user has only '${req.user.role}'`));
const [error, inviteLink ] = await safe(users.sendInvite(req.resource, { invitor: req.user }, AuditSource.fromRequest(req)));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { inviteLink }));
}
async function setGroups(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
assert.strictEqual(typeof req.resource, 'object');
@@ -214,3 +210,50 @@ async function makeOwner(req, res, next) {
next(new HttpSuccess(204));
}
// This will always return a reset link, if none is set or expired a new one will be created
async function getPasswordResetLink(req, res, next) {
assert.strictEqual(typeof req.resource, 'object');
if (users.compareRoles(req.user.role, req.resource.role) < 0) return next(new HttpError(403, `role '${req.resource.role}' is required but user has only '${req.user.role}'`));
let [error, passwordResetLink] = await safe(users.getPasswordResetLink(req.resource, AuditSource.fromRequest(req)));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { passwordResetLink }));
}
async function sendPasswordResetEmail(req, res, next) {
assert.strictEqual(typeof req.resource, 'object');
if (!req.body.email || typeof req.body.email !== 'string') return next(new HttpError(400, 'email must be a non-empty string'));
if (users.compareRoles(req.user.role, req.resource.role) < 0) return next(new HttpError(403, `role '${req.resource.role}' is required but user has only '${req.user.role}'`));
let [error] = await safe(users.sendPasswordResetEmail(req.resource, req.body.email, AuditSource.fromRequest(req)));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, {}));
}
async function getInviteLink(req, res, next) {
assert.strictEqual(typeof req.resource, 'object');
if (users.compareRoles(req.user.role, req.resource.role) < 0) return next(new HttpError(403, `role '${req.resource.role}' is required but user has only '${req.user.role}'`));
let [error, inviteLink] = await safe(users.getInviteLink(req.resource, AuditSource.fromRequest(req)));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { inviteLink }));
}
async function sendInviteEmail(req, res, next) {
assert.strictEqual(typeof req.resource, 'object');
if (!req.body.email || typeof req.body.email !== 'string') return next(new HttpError(400, 'email must be a non-empty string'));
if (users.compareRoles(req.user.role, req.resource.role) < 0) return next(new HttpError(403, `role '${req.resource.role}' is required but user has only '${req.user.role}'`));
let [error] = await safe(users.sendInviteEmail(req.resource, req.body.email, AuditSource.fromRequest(req)));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, {}));
}
+4 -1
View File
@@ -179,8 +179,11 @@ function initializeExpressSync() {
router.post('/api/v1/users/:userId/ghost', json, token, authorizeAdmin, routes.users.load, routes.users.setGhost);
router.put ('/api/v1/users/:userId/groups', json, token, authorizeUserManager, routes.users.load, routes.users.setGroups);
router.post('/api/v1/users/:userId/make_owner', json, token, authorizeOwner, routes.users.load, routes.users.makeOwner);
router.post('/api/v1/users/:userId/send_invite', json, token, authorizeUserManager, routes.users.load, routes.users.sendInvite);
router.post('/api/v1/users/:userId/twofactorauthentication_disable', json, token, authorizeUserManager, routes.users.load, routes.users.disableTwoFactorAuthentication);
router.get ('/api/v1/users/:userId/password_reset_link', json, token, authorizeUserManager, routes.users.load, routes.users.getPasswordResetLink);
router.post('/api/v1/users/:userId/send_password_reset_email', json, token, authorizeUserManager, routes.users.load, routes.users.sendPasswordResetEmail);
router.get ('/api/v1/users/:userId/invite_link', json, token, authorizeUserManager, routes.users.load, routes.users.getInviteLink);
router.post('/api/v1/users/:userId/send_invite_email', json, token, authorizeUserManager, routes.users.load, routes.users.sendInviteEmail);
// Group management
router.get ('/api/v1/groups', token, authorizeUserManager, routes.groups.list);
+6 -2
View File
@@ -63,7 +63,7 @@ const admin = {
username: 'testadmin',
password: 'secret123',
email: 'admin@me.com',
fallbackEmail: 'admin@me.com',
fallbackEmail: 'admin@external.com',
salt: 'morton',
createdAt: 'sometime back',
resetToken: '',
@@ -80,7 +80,7 @@ const user = {
username: 'user',
password: '123secret',
email: 'user@me.com',
fallbackEmail: 'user@me.com',
fallbackEmail: 'user@external.com',
role: 'user',
salt: 'morton',
createdAt: 'sometime back',
@@ -208,5 +208,9 @@ function clearMailQueue() {
async function checkMails(number) {
await delay(1000);
expect(mailer._mailQueue.length).to.equal(number);
const emails = mailer._mailQueue;
clearMailQueue();
// return for further investigation
return emails;
}
+2 -6
View File
@@ -535,16 +535,12 @@ describe('External LDAP', function () {
});
it('succeeds for known user with correct password', async function () {
const newUser = {
gLdapUsers.push({
username: 'autologinuser2',
displayName: 'Auto Login2',
email: 'auto2@login.com',
password: LDAP_SHARED_PASSWORD
};
gLdapUsers.push(newUser);
await users.add(newUser.email, newUser, auditSource);
});
const response = await superagent.post(`${serverUrl}/api/v1/cloudron/login`)
.send({ username: 'autologinuser2', password: LDAP_SHARED_PASSWORD })
+37 -5
View File
@@ -83,6 +83,12 @@ describe('User', function () {
expect(error.reason).to.equal(BoxError.BAD_FIELD);
});
it('fails because fallbackEmail is not an email', async function () {
const user = Object.assign({}, admin, { fallbackEmail: 'notanemail' });
const [error] = await safe(users.add(user.email, user, auditSource));
expect(error.reason).to.equal(BoxError.BAD_FIELD);
});
it('can add user', async function () {
const id = await users.add(admin.email, admin, auditSource);
admin.id = id;
@@ -403,7 +409,7 @@ describe('User', function () {
const result = await users.get(admin.id);
expect(result.id).to.equal(admin.id);
expect(result.email).to.equal(admin.email.toLowerCase());
expect(result.fallbackEmail).to.equal(admin.email.toLowerCase());
expect(result.fallbackEmail).to.equal(admin.fallbackEmail.toLowerCase());
expect(result.username).to.equal(admin.username.toLowerCase());
expect(result.displayName).to.equal(admin.displayName);
});
@@ -487,11 +493,37 @@ describe('User', function () {
describe('invite', function () {
before(createOwner);
it('send invite', async function () {
let user;
it('get link fails as alreayd been used', async function () {
const [error] = await safe(users.getInviteLink(admin, auditSource));
expect(error.reason).to.be(BoxError.BAD_STATE);
});
it('can get link', async function () {
const userId = await users.add('some@mail.com', { username: 'someoneinvited', displayName: 'some one', password: 'unsafe1234' }, auditSource);
user = await users.get(userId);
const inviteLink = await users.getInviteLink(user, auditSource);
expect(inviteLink).to.be.a('string');
expect(inviteLink).to.contain(user.inviteToken);
});
it('cannot send mail for already active user', async function () {
const [error] = await safe(users.sendInviteEmail(admin, 'admin@mail.com', auditSource));
expect(error.reason).to.be(BoxError.BAD_STATE);
});
it('cannot send mail with empty receipient', async function () {
const [error] = await safe(users.sendInviteEmail(user, '', auditSource));
expect(error.reason).to.be(BoxError.BAD_FIELD);
});
it('can send mail', async function () {
await clearMailQueue();
const inviteLink = await users.sendInvite(admin, {}, auditSource);
expect(inviteLink).to.be.ok();
await checkMails(1);
await users.sendInviteEmail(user, 'custom@mail.com', auditSource);
const emails = await checkMails(1);
expect(emails[0].to).to.equal('custom@mail.com');
});
});
+65 -16
View File
@@ -29,14 +29,18 @@ exports = module.exports = {
del,
sendInvite,
setTwoFactorAuthenticationSecret,
enableTwoFactorAuthentication,
disableTwoFactorAuthentication,
sendPasswordResetByIdentifier,
getPasswordResetLink,
sendPasswordResetEmail,
getInviteLink,
sendInviteEmail,
notifyLoginLocation,
setupAccount,
@@ -180,8 +184,10 @@ async function add(email, data, auditSource) {
assert(data.username === null || typeof data.username === 'string');
assert(data.password === null || typeof data.password === 'string');
assert.strictEqual(typeof data.displayName, 'string');
if ('fallbackEmail' in data) assert.strictEqual(typeof data.fallbackEmail, 'string');
let { username, password, displayName } = data;
let fallbackEmail = data.fallbackEmail || '';
const source = data.source || ''; // empty is local user
const role = data.role || exports.ROLE_USER;
@@ -204,6 +210,12 @@ async function add(email, data, auditSource) {
error = validateEmail(email);
if (error) throw error;
fallbackEmail = fallbackEmail.toLowerCase();
if (fallbackEmail) {
let error = validateEmail(fallbackEmail);
if (error) throw error;
}
error = validateDisplayName(displayName);
if (error) throw error;
@@ -222,7 +234,7 @@ async function add(email, data, auditSource) {
id: 'uid-' + uuid.v4(),
username: username,
email: email,
fallbackEmail: email,
fallbackEmail: fallbackEmail,
password: Buffer.from(derivedKey, 'binary').toString('hex'),
salt: salt.toString('hex'),
resetToken: '',
@@ -256,6 +268,8 @@ async function setGhost(user, password, expiresAt) {
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);
debug(`setGhost: ${user.username} expiresAt ${expiresAt}`);
@@ -616,11 +630,42 @@ async function sendPasswordResetByIdentifier(identifier, auditSource) {
await update(user, { resetToken,resetTokenCreationTime }, auditSource);
const resetLink = `${settings.dashboardOrigin()}/login.html?resetToken=${user.resetToken}`;
await mailer.passwordReset(user, resetLink);
await mailer.passwordReset(user, user.fallbackEmail || user.email, resetLink);
return resetLink;
}
async function getPasswordResetLink(user, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof auditSource, 'object');
let resetToken = user.resetToken;
let resetTokenCreationTime = user.resetTokenCreationTime || 0;
if (!resetToken || (Date.now() - resetTokenCreationTime > 7 * 24 * 60 * 60 * 1000)) {
resetToken = hat(256);
resetTokenCreationTime = new Date();
await update(user, { resetToken, resetTokenCreationTime }, auditSource);
}
const resetLink = `${settings.dashboardOrigin()}/login.html?resetToken=${resetToken}`;
return resetLink;
}
async function sendPasswordResetEmail(user, email, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof email, 'string');
assert.strictEqual(typeof auditSource, 'object');
const error = validateEmail(email);
if (error) throw error;
const resetLink = await getPasswordResetLink(user, auditSource);
await mailer.passwordReset(user, email, resetLink);
}
async function notifyLoginLocation(user, ip, userAgent, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof ip, 'string');
@@ -701,21 +746,15 @@ async function createOwner(email, username, password, displayName, auditSource)
const activated = await isActivated();
if (activated) throw new BoxError(BoxError.ALREADY_EXISTS, 'Cloudron already activated');
return await add(email, { username, password, displayName, role: exports.ROLE_OWNER }, auditSource);
return await add(email, { username, password, fallbackEmail: '', displayName, role: exports.ROLE_OWNER }, auditSource);
}
async function sendInvite(user, options, auditSource) {
async function getInviteLink(user, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof auditSource, 'object');
if (user.source) throw new BoxError(BoxError.CONFLICT, 'User is from an external directory');
// not sure if this can ever be the case
if (!user.inviteToken) {
const inviteToken = hat(256);
user.inviteToken = inviteToken;
await update(user, { inviteToken }, auditSource);
}
if (!user.inviteToken) throw new BoxError(BoxError.BAD_STATE, 'User already used invite link');
const directoryConfig = await settings.getDirectoryConfig();
let inviteLink = `${settings.dashboardOrigin()}/setupaccount.html?inviteToken=${user.inviteToken}&email=${encodeURIComponent(user.email)}`;
@@ -724,11 +763,21 @@ async function sendInvite(user, options, auditSource) {
if (user.displayName) inviteLink += `&displayName=${encodeURIComponent(user.displayName)}`;
if (directoryConfig.lockUserProfiles) inviteLink += '&profileLocked=true';
await mailer.sendInvite(user, options.invitor || null, inviteLink);
return inviteLink;
}
async function sendInviteEmail(user, email, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof email, 'string');
assert.strictEqual(typeof auditSource, 'object');
const error = validateEmail(email);
if (error) throw error;
const inviteLink = await getInviteLink(user, auditSource);
await mailer.sendInvite(user, null /* invitor */, email, inviteLink);
}
async function setupAccount(user, data, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof data, 'object');