Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b69c5f62c0 | |||
| 63f6f065ba | |||
| 92f0f56fae | |||
| cb8aa15e62 | |||
| 4356d673bc | |||
| 5ece159fba | |||
| b59776bf9b | |||
| 475795a107 | |||
| 9a80049d36 | |||
| daf212468f | |||
| 2f510c2625 | |||
| 7a977fa76b | |||
| f5e025c213 | |||
| 971b73f853 | |||
| 0103b21724 | |||
| cef5c1e78c | |||
| 50ff6b99e0 | |||
| 26dbd50cf2 | |||
| 84884b969e | |||
| 62174c5328 | |||
| 716951a3f1 | |||
| fbf6fe22af | |||
| b18c4d3426 | |||
| 26a993abe7 |
@@ -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
@@ -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
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user