diff --git a/CHANGES b/CHANGES index 8e9af4325..582416222 100644 --- a/CHANGES +++ b/CHANGES @@ -2135,4 +2135,5 @@ * logs: more descriptive log file names on download * collectd: remove collectd config when app stopped (and add it back when started) * Apps can optionally request an authwall to be installed in front of them +* mailbox can now owned by a group diff --git a/migrations/20201113071728-mailboxes-add-ownerType.js b/migrations/20201113071728-mailboxes-add-ownerType.js new file mode 100644 index 000000000..c078bb715 --- /dev/null +++ b/migrations/20201113071728-mailboxes-add-ownerType.js @@ -0,0 +1,18 @@ +'use strict'; + +var async = require('async'); + +exports.up = function(db, callback) { + async.series([ + db.runSql.bind(db, 'ALTER TABLE mailboxes ADD COLUMN ownerType VARCHAR(16)'), + db.runSql.bind(db, 'UPDATE mailboxes SET ownerType=?', [ 'user' ]), + db.runSql.bind(db, 'ALTER TABLE mailboxes MODIFY ownerType VARCHAR(16) NOT NULL'), + ], callback); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE mailboxes DROP COLUMN ownerType', function (error) { + if (error) console.error(error); + callback(error); + }); +}; diff --git a/migrations/schema.sql b/migrations/schema.sql index cd56350b6..145888913 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -180,6 +180,7 @@ CREATE TABLE IF NOT EXISTS mailboxes( name VARCHAR(128) NOT NULL, type VARCHAR(16) NOT NULL, /* 'mailbox', 'alias', 'list' */ ownerId VARCHAR(128) NOT NULL, /* user id */ + ownerType VARCHAR(16) NOT NULL, aliasName VARCHAR(128), /* the target name type is an alias */ aliasDomain VARCHAR(128), /* the target domain */ membersJson TEXT, /* members of a group. fully qualified */ diff --git a/src/ldap.js b/src/ldap.js index a1afd479b..efd8d3562 100644 --- a/src/ldap.js +++ b/src/ldap.js @@ -14,6 +14,7 @@ var addons = require('./addons.js'), constants = require('./constants.js'), debug = require('debug')('box:ldap'), eventlog = require('./eventlog.js'), + groups = require('./groups.js'), ldap = require('ldapjs'), mail = require('./mail.js'), mailboxdb = require('./mailboxdb.js'), @@ -133,8 +134,8 @@ function userSearch(req, res, next) { var dn = ldap.parseDN('cn=' + user.id + ',ou=users,dc=cloudron'); - var groups = [ GROUP_USERS_DN ]; - if (users.compareRoles(user.role, users.ROLE_ADMIN) >= 0) groups.push(GROUP_ADMINS_DN); + var memberof = [ GROUP_USERS_DN ]; + if (users.compareRoles(user.role, users.ROLE_ADMIN) >= 0) memberof.push(GROUP_ADMINS_DN); var displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null var nameParts = displayName.split(' '); @@ -155,7 +156,7 @@ function userSearch(req, res, next) { givenName: firstName, username: user.username, samaccountname: user.username, // to support ActiveDirectory clients - memberof: groups + memberof: memberof } }; @@ -328,7 +329,9 @@ function mailboxSearch(req, res, next) { async.eachSeries(mailboxes, function (mailbox, callback) { var dn = ldap.parseDN(`cn=${mailbox.name}@${mailbox.domain},ou=mailboxes,dc=cloudron`); - users.get(mailbox.ownerId, function (error, userObject) { + let getFunc = mailbox.ownerType === mail.OWNERTYPE_USER ? users.get : groups.get; + + getFunc(mailbox.ownerId, function (error, ownerObject) { if (error) return callback(); // skip mailboxes with unknown owner var obj = { @@ -336,7 +339,7 @@ function mailboxSearch(req, res, next) { attributes: { objectclass: ['mailbox'], objectcategory: 'mailbox', - displayname: userObject.displayName, + displayname: mailbox.ownerType === mail.OWNERTYPE_USER ? ownerObject.displayName : ownerObject.name, cn: `${mailbox.name}@${mailbox.domain}`, uid: `${mailbox.name}@${mailbox.domain}`, mail: `${mailbox.name}@${mailbox.domain}` @@ -495,6 +498,30 @@ function authorizeUserForApp(req, res, next) { }); } +function verifyMailboxPassword(mailbox, password, callback) { + assert.strictEqual(typeof mailbox, 'object'); + assert.strictEqual(typeof password, 'string'); + assert.strictEqual(typeof callback, 'function'); + + if (mailbox.ownerType === mail.OWNERTYPE_USER) return users.verify(mailbox.ownerId, password, users.AP_MAIL /* identifier */, callback); + + groups.getMembers(mailbox.ownerId, function (error, userIds) { + if (error) return callback(error); + + let verifiedUser = null; + async.someSeries(userIds, function iterator(userId, iteratorDone) { + users.verify(userId, password, users.AP_MAIL /* identifier */, function (error, result) { + if (error) return iteratorDone(null, false); + verifiedUser = result; + iteratorDone(null, true); + }); + }, function (error, result) { + if (!result) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); + callback(null, verifiedUser); + }); + }); +} + function authenticateUserMailbox(req, res, next) { debug('user mailbox auth: %s (from %s)', req.dn.toString(), req.connection.ldap.id); @@ -514,7 +541,7 @@ function authenticateUserMailbox(req, res, next) { if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString())); if (error) return next(new ldap.OperationsError(error.message)); - users.verify(mailbox.ownerId, req.credentials || '', users.AP_MAIL, function (error, result) { + verifyMailboxPassword(mailbox, req.credentials || '', function (error, result) { if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString())); if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(req.dn.toString())); if (error) return next(new ldap.OperationsError(error.message)); @@ -638,7 +665,7 @@ function authenticateMailAddon(req, res, next) { if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString())); if (error) return next(new ldap.OperationsError(error.message)); - users.verify(mailbox.ownerId, req.credentials || '', users.AP_MAIL, function (error, result) { + verifyMailboxPassword(mailbox, req.credentials || '', function (error, result) { if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString())); if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(req.dn.toString())); if (error) return next(new ldap.OperationsError(error.message)); @@ -674,7 +701,7 @@ function start(callback) { gServer.bind('ou=users,dc=cloudron', authenticateApp, authenticateUser, authorizeUserForApp); // http://www.ietf.org/proceedings/43/I-D/draft-srivastava-ldap-mail-00.txt - gServer.search('ou=mailboxes,dc=cloudron', mailboxSearch); // haraka (address translation), dovecot (LMTP) + gServer.search('ou=mailboxes,dc=cloudron', mailboxSearch); // haraka (address translation), dovecot (LMTP), sogo (mailbox search) gServer.bind('ou=mailboxes,dc=cloudron', authenticateUserMailbox); // apps like sogo can use domain=${domain} to authenticate a mailbox gServer.search('ou=mailaliases,dc=cloudron', mailAliasSearch); // haraka gServer.search('ou=mailinglists,dc=cloudron', mailingListSearch); // haraka diff --git a/src/mail.js b/src/mail.js index 1712003d1..095fadfe6 100644 --- a/src/mail.js +++ b/src/mail.js @@ -52,6 +52,9 @@ exports = module.exports = { removeList, resolveList, + OWNERTYPE_USER: 'user', + OWNERTYPE_GROUP: 'group', + _removeMailboxes: removeMailboxes, _readDkimPublicKeySync: readDkimPublicKeySync }; @@ -1162,10 +1165,11 @@ function getMailbox(name, domain, callback) { }); } -function addMailbox(name, domain, userId, auditSource, callback) { +function addMailbox(name, domain, ownerId, ownerType, auditSource, callback) { assert.strictEqual(typeof name, 'string'); assert.strictEqual(typeof domain, 'string'); - assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof ownerId, 'string'); + assert.strictEqual(typeof ownerType, 'string'); assert.strictEqual(typeof auditSource, 'object'); assert.strictEqual(typeof callback, 'function'); @@ -1174,31 +1178,36 @@ function addMailbox(name, domain, userId, auditSource, callback) { var error = validateName(name); if (error) return callback(error); - mailboxdb.addMailbox(name, domain, userId, function (error) { + if (ownerType !== exports.OWNERTYPE_USER && ownerType !== exports.OWNERTYPE_GROUP) return callback(new BoxError(BoxError.BAD_FIELD, 'bad owner type')); + + mailboxdb.addMailbox(name, domain, ownerId, ownerType, function (error) { if (error) return callback(error); - eventlog.add(eventlog.ACTION_MAIL_MAILBOX_ADD, auditSource, { name, domain, userId }); + eventlog.add(eventlog.ACTION_MAIL_MAILBOX_ADD, auditSource, { name, domain, ownerId, ownerType }); callback(null); }); } -function updateMailboxOwner(name, domain, userId, auditSource, callback) { +function updateMailboxOwner(name, domain, ownerId, ownerType, auditSource, callback) { assert.strictEqual(typeof name, 'string'); assert.strictEqual(typeof domain, 'string'); - assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof ownerId, 'string'); + assert.strictEqual(typeof ownerType, 'string'); assert.strictEqual(typeof auditSource, 'object'); assert.strictEqual(typeof callback, 'function'); name = name.toLowerCase(); + if (ownerType !== exports.OWNERTYPE_USER && ownerType !== exports.OWNERTYPE_GROUP) return callback(new BoxError(BoxError.BAD_FIELD, 'bad owner type')); + getMailbox(name, domain, function (error, result) { if (error) return callback(error); - mailboxdb.updateMailboxOwner(name, domain, userId, function (error) { + mailboxdb.updateMailboxOwner(name, domain, ownerId, ownerType, function (error) { if (error) return callback(error); - eventlog.add(eventlog.ACTION_MAIL_MAILBOX_UPDATE, auditSource, { name, domain, oldUserId: result.userId, userId }); + eventlog.add(eventlog.ACTION_MAIL_MAILBOX_UPDATE, auditSource, { name, domain, oldUserId: result.userId, ownerId, ownerType }); callback(null); }); diff --git a/src/mailboxdb.js b/src/mailboxdb.js index 12f9914bf..035144aad 100644 --- a/src/mailboxdb.js +++ b/src/mailboxdb.js @@ -42,7 +42,7 @@ var assert = require('assert'), safe = require('safetydance'), util = require('util'); -var MAILBOX_FIELDS = [ 'name', 'type', 'ownerId', 'aliasName', 'aliasDomain', 'creationTime', 'membersJson', 'membersOnly', 'domain' ].join(','); +var MAILBOX_FIELDS = [ 'name', 'type', 'ownerId', 'ownerType', 'aliasName', 'aliasDomain', 'creationTime', 'membersJson', 'membersOnly', 'domain' ].join(','); function postProcess(data) { data.members = safe.JSON.parse(data.membersJson) || [ ]; @@ -53,13 +53,14 @@ function postProcess(data) { return data; } -function addMailbox(name, domain, ownerId, callback) { +function addMailbox(name, domain, ownerId, ownerType, callback) { assert.strictEqual(typeof name, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof ownerId, 'string'); + assert.strictEqual(typeof ownerType, 'string'); assert.strictEqual(typeof callback, 'function'); - database.query('INSERT INTO mailboxes (name, type, domain, ownerId) VALUES (?, ?, ?, ?)', [ name, exports.TYPE_MAILBOX, domain, ownerId ], function (error) { + database.query('INSERT INTO mailboxes (name, type, domain, ownerId, ownerType) VALUES (?, ?, ?, ?, ?)', [ name, exports.TYPE_MAILBOX, domain, ownerId, ownerType ], function (error) { if (error && error.code === 'ER_DUP_ENTRY') return callback(new BoxError(BoxError.ALREADY_EXISTS, 'mailbox already exists')); if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error)); @@ -67,13 +68,14 @@ function addMailbox(name, domain, ownerId, callback) { }); } -function updateMailboxOwner(name, domain, ownerId, callback) { +function updateMailboxOwner(name, domain, ownerId, ownerType, callback) { assert.strictEqual(typeof name, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof ownerId, 'string'); + assert.strictEqual(typeof ownerType, 'string'); assert.strictEqual(typeof callback, 'function'); - database.query('UPDATE mailboxes SET ownerId = ? WHERE name = ? AND domain = ?', [ ownerId, name, domain ], function (error, result) { + database.query('UPDATE mailboxes SET ownerId = ?, ownerType = ? WHERE name = ? AND domain = ?', [ ownerId, ownerType, name, domain ], function (error, result) { if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error)); if (result.affectedRows === 0) return callback(new BoxError(BoxError.NOT_FOUND, 'Mailbox not found')); @@ -88,8 +90,8 @@ function addList(name, domain, members, membersOnly, callback) { assert.strictEqual(typeof membersOnly, 'boolean'); assert.strictEqual(typeof callback, 'function'); - database.query('INSERT INTO mailboxes (name, type, domain, ownerId, membersJson, membersOnly) VALUES (?, ?, ?, ?, ?, ?)', - [ name, exports.TYPE_LIST, domain, 'admin', JSON.stringify(members), membersOnly ], function (error) { + database.query('INSERT INTO mailboxes (name, type, domain, ownerId, ownerType, membersJson, membersOnly) VALUES (?, ?, ?, ?, ?, ?, ?)', + [ name, exports.TYPE_LIST, domain, 'admin', 'user', JSON.stringify(members), membersOnly ], function (error) { if (error && error.code === 'ER_DUP_ENTRY') return callback(new BoxError(BoxError.ALREADY_EXISTS, 'mailbox already exists')); if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error)); @@ -314,8 +316,8 @@ function setAliasesForName(name, domain, aliases, callback) { // clear existing aliases queries.push({ query: 'DELETE FROM mailboxes WHERE aliasName = ? AND aliasDomain = ? AND type = ?', args: [ name, domain, exports.TYPE_ALIAS ] }); aliases.forEach(function (alias) { - queries.push({ query: 'INSERT INTO mailboxes (name, domain, type, aliasName, aliasDomain, ownerId) VALUES (?, ?, ?, ?, ?, ?)', - args: [ alias.name, alias.domain, exports.TYPE_ALIAS, name, domain, results[0].ownerId ] }); + queries.push({ query: 'INSERT INTO mailboxes (name, domain, type, aliasName, aliasDomain, ownerId, ownerType) VALUES (?, ?, ?, ?, ?, ?, ?)', + args: [ alias.name, alias.domain, exports.TYPE_ALIAS, name, domain, results[0].ownerId, results[0].ownerType ] }); }); database.transaction(queries, function (error) { diff --git a/src/routes/mail.js b/src/routes/mail.js index 30d15d5df..8575a1f48 100644 --- a/src/routes/mail.js +++ b/src/routes/mail.js @@ -196,9 +196,10 @@ function addMailbox(req, res, next) { assert.strictEqual(typeof req.params.domain, 'string'); if (typeof req.body.name !== 'string') return next(new HttpError(400, 'name must be a string')); - if (typeof req.body.userId !== 'string') return next(new HttpError(400, 'userId must be a string')); + if (typeof req.body.ownerId !== 'string') return next(new HttpError(400, 'ownerId must be a string')); + if (typeof req.body.ownerType !== 'string') return next(new HttpError(400, 'ownerType must be a string')); - mail.addMailbox(req.body.name, req.params.domain, req.body.userId, auditSource.fromRequest(req), function (error) { + mail.addMailbox(req.body.name, req.params.domain, req.body.ownerId, req.body.ownerType, auditSource.fromRequest(req), function (error) { if (error) return next(BoxError.toHttpError(error)); next(new HttpSuccess(201, {})); @@ -209,9 +210,10 @@ function updateMailbox(req, res, next) { assert.strictEqual(typeof req.params.domain, 'string'); assert.strictEqual(typeof req.params.name, 'string'); - if (typeof req.body.userId !== 'string') return next(new HttpError(400, 'userId must be a string')); + if (typeof req.body.ownerId !== 'string') return next(new HttpError(400, 'ownerId must be a string')); + if (typeof req.body.ownerType !== 'string') return next(new HttpError(400, 'ownerType must be a string')); - mail.updateMailboxOwner(req.params.name, req.params.domain, req.body.userId, auditSource.fromRequest(req), function (error) { + mail.updateMailboxOwner(req.params.name, req.params.domain, req.body.ownerId, req.body.ownerType, auditSource.fromRequest(req), function (error) { if (error) return next(BoxError.toHttpError(error)); next(new HttpSuccess(204)); diff --git a/src/routes/test/mail-test.js b/src/routes/test/mail-test.js index c127e7d55..1cb023384 100644 --- a/src/routes/test/mail-test.js +++ b/src/routes/test/mail-test.js @@ -565,7 +565,7 @@ describe('Mail API', function () { describe('mailboxes', function () { it('add succeeds', function (done) { superagent.post(SERVER_URL + '/api/v1/mail/' + DOMAIN_0.domain + '/mailboxes') - .send({ name: MAILBOX_NAME, userId: userId }) + .send({ name: MAILBOX_NAME, ownerId: userId, ownerType: 'user' }) .query({ access_token: token }) .end(function (err, res) { expect(res.statusCode).to.equal(201); @@ -575,7 +575,7 @@ describe('Mail API', function () { it('cannot add again', function (done) { superagent.post(SERVER_URL + '/api/v1/mail/' + DOMAIN_0.domain + '/mailboxes') - .send({ name: MAILBOX_NAME, userId: userId }) + .send({ name: MAILBOX_NAME, ownerId: userId, ownerType: 'user' }) .query({ access_token: token }) .end(function (err, res) { expect(res.statusCode).to.equal(409); @@ -600,6 +600,7 @@ describe('Mail API', function () { expect(res.body.mailbox).to.be.an('object'); expect(res.body.mailbox.name).to.equal(MAILBOX_NAME); expect(res.body.mailbox.ownerId).to.equal(userId); + expect(res.body.mailbox.ownerType).to.equal('user'); expect(res.body.mailbox.aliasName).to.equal(null); expect(res.body.mailbox.aliasDomain).to.equal(null); expect(res.body.mailbox.domain).to.equal(DOMAIN_0.domain); @@ -616,6 +617,7 @@ describe('Mail API', function () { expect(res.body.mailboxes[0]).to.be.an('object'); expect(res.body.mailboxes[0].name).to.equal(MAILBOX_NAME); expect(res.body.mailboxes[0].ownerId).to.equal(userId); + expect(res.body.mailboxes[0].ownerType).to.equal('user'); expect(res.body.mailboxes[0].aliasName).to.equal(null); expect(res.body.mailboxes[0].aliasDomain).to.equal(null); expect(res.body.mailboxes[0].domain).to.equal(DOMAIN_0.domain); @@ -659,7 +661,7 @@ describe('Mail API', function () { it('add the mailbox', function (done) { superagent.post(SERVER_URL + '/api/v1/mail/' + DOMAIN_0.domain + '/mailboxes') - .send({ name: MAILBOX_NAME, userId: userId }) + .send({ name: MAILBOX_NAME, ownerId: userId, ownerType: 'user' }) .query({ access_token: token }) .end(function (err, res) { expect(res.statusCode).to.equal(201); diff --git a/src/test/database-test.js b/src/test/database-test.js index 2548366f8..9babd83cd 100644 --- a/src/test/database-test.js +++ b/src/test/database-test.js @@ -1820,21 +1820,21 @@ describe('database', function () { }); it('add user mailbox succeeds', function (done) { - mailboxdb.addMailbox('girish', DOMAIN_0.domain, 'uid-0', function (error) { + mailboxdb.addMailbox('girish', DOMAIN_0.domain, 'uid-0', 'user', function (error) { expect(error).to.be(null); done(); }); }); it('cannot add dup entry', function (done) { - mailboxdb.addMailbox('girish', DOMAIN_0.domain, 'uid-1', function (error) { + mailboxdb.addMailbox('girish', DOMAIN_0.domain, 'uid-1', 'group', function (error) { expect(error.reason).to.be(BoxError.ALREADY_EXISTS); done(); }); }); it('add app mailbox succeeds', function (done) { - mailboxdb.addMailbox('support', DOMAIN_0.domain, 'osticket', function (error) { + mailboxdb.addMailbox('support', DOMAIN_0.domain, 'osticket', 'user', function (error) { expect(error).to.be(null); done(); }); diff --git a/src/test/ldap-test.js b/src/test/ldap-test.js index ba2bb213e..e7437cf9a 100644 --- a/src/test/ldap-test.js +++ b/src/test/ldap-test.js @@ -19,6 +19,7 @@ var appdb = require('../appdb.js'), maildb = require('../maildb.js'), mailboxdb = require('../mailboxdb.js'), ldap = require('ldapjs'), + mail = require('../mail.js'), settings = require('../settings.js'), users = require('../users.js'); @@ -107,12 +108,12 @@ function setup(done) { callback(); }); }, - (done) => mailboxdb.addMailbox(USER_0.username.toLowerCase(), DOMAIN_0.domain, USER_0.id, done), + (done) => mailboxdb.addMailbox(USER_0.username.toLowerCase(), DOMAIN_0.domain, USER_0.id, mail.OWNERTYPE_USER, done), (done) => mailboxdb.setAliasesForName(USER_0.username.toLowerCase(), DOMAIN_0.domain, [ { name: USER_0_ALIAS.toLocaleLowerCase(), domain: DOMAIN_0.domain} ], done), appdb.update.bind(null, APP_0.id, { containerId: APP_0.containerId }), appdb.setAddonConfig.bind(null, APP_0.id, 'sendmail', [{ name: 'MAIL_SMTP_PASSWORD', value : 'sendmailpassword' }]), appdb.setAddonConfig.bind(null, APP_0.id, 'recvmail', [{ name: 'MAIL_IMAP_PASSWORD', value : 'recvmailpassword' }]), - mailboxdb.addMailbox.bind(null, APP_0.location + '.app', APP_0.domain, APP_0.id), + mailboxdb.addMailbox.bind(null, APP_0.location + '.app', APP_0.domain, APP_0.id, mail.OWNERTYPE_USER), function (callback) { users.create(USER_1.username, USER_1.password, USER_1.email, USER_0.displayName, { }, AUDIT_SOURCE, function (error, result) {