diff --git a/config/default.js b/config/default.js index b6821a1..af1723c 100644 --- a/config/default.js +++ b/config/default.js @@ -66,7 +66,7 @@ module.exports = { spamHeader: 'X-Rspamd-Spam', // default quota storage in MB (can be overriden per user) - maxStorage: 1000, + maxStorage: 1024, // default smtp recipients for 24h (can be overriden per user) maxRecipients: 2000 diff --git a/imap-core/lib/commands/authenticate-plain.js b/imap-core/lib/commands/authenticate-plain.js index 2bae062..16b16cb 100644 --- a/imap-core/lib/commands/authenticate-plain.js +++ b/imap-core/lib/commands/authenticate-plain.js @@ -71,7 +71,8 @@ function authenticate(connection, token, callback) { connection._server.logger.info('[%s] Authentication failed for %s using %s', connection.id, username, 'PLAIN'); return callback(null, { response: 'NO', - message: 'Authentication failure' + code: 'AUTHENTICATIONFAILED', + message: 'Invalid credentials' }); } diff --git a/imap-core/lib/commands/login.js b/imap-core/lib/commands/login.js index 490246d..11353d6 100644 --- a/imap-core/lib/commands/login.js +++ b/imap-core/lib/commands/login.js @@ -52,7 +52,8 @@ module.exports = { this._server.logger.info('[%s] Authentication failed for %s using %s', this.id, username, 'LOGIN'); return callback(null, { response: 'NO', - message: 'Authentication failure' + code: 'AUTHENTICATIONFAILED', + message: 'Invalid credentials' }); } diff --git a/imap.js b/imap.js index a2117d5..9f25b2c 100644 --- a/imap.js +++ b/imap.js @@ -7,13 +7,13 @@ const IMAPServerModule = require('./imap-core'); const IMAPServer = IMAPServerModule.IMAPServer; const ImapNotifier = require('./lib/imap-notifier'); const imapHandler = IMAPServerModule.imapHandler; -const bcrypt = require('bcryptjs'); const ObjectID = require('mongodb').ObjectID; const Indexer = require('./imap-core/lib/indexer/indexer'); const imapTools = require('./imap-core/lib/imap-tools'); const fs = require('fs'); const setupIndexes = require('./indexes.json'); const MessageHandler = require('./lib/message-handler'); +const UserHandler = require('./lib/user-handler'); const db = require('./lib/db'); const packageData = require('./package.json'); @@ -61,28 +61,28 @@ if (config.imap.cert) { const server = new IMAPServer(serverOptions); let messageHandler; +let userHandler; server.onAuth = function (login, session, callback) { let username = (login.username || '').toString().trim(); - db.database.collection('users').findOne({ - username - }, (err, user) => { + userHandler.authenticate(username, login.password, (err, result) => { if (err) { return callback(err); } - if (!user) { + if (!result) { return callback(); } - if (!bcrypt.compareSync(login.password, user.password)) { + if (result.scope === 'master' && result.enabled2fa) { + // master password not allowed if 2fa is enabled! return callback(); } callback(null, { user: { - id: user._id, - username + id: result.user, + username: result.username } }); }); @@ -1606,6 +1606,7 @@ module.exports = done => { let start = () => { messageHandler = new MessageHandler(db.database); + userHandler = new UserHandler(db.database); server.indexer = new Indexer({ database: db.database diff --git a/lib/user-handler.js b/lib/user-handler.js new file mode 100644 index 0000000..d57bec5 --- /dev/null +++ b/lib/user-handler.js @@ -0,0 +1,454 @@ +'use strict'; + +const config = require('config'); +const log = require('npmlog'); +const bcrypt = require('bcryptjs'); +const speakeasy = require('speakeasy'); +const QRCode = require('qrcode'); +const tools = require('./tools'); +// const generatePassword = require('generate-password'); + +const mailboxTranslations = { + en: { + '\\Sent': 'Sent Mail', + '\\Trash': 'Trash', + '\\Junk': 'Junk', + '\\Drafts': 'Drafts', + '\\Archive': 'Archive' + }, + et: { + '\\Sent': 'Saadetud kirjad', + '\\Trash': 'Prügikast', + '\\Junk': 'Rämpspost', + '\\Drafts': 'Mustandid', + '\\Archive': 'Arhiiv' + } +}; + +class UserHandler { + constructor(database) { + this.database = database; + } + + /** + * Authenticate user + * + * @param {String} username Either username or email address + */ + authenticate(username, password, callback) { + + let checkAddress = next => { + if (username.indexOf('@') < 0) { + // assume regular username + return next(null, { + username + }); + } + + // try to find existing email address + let address = tools.normalizeAddress(username); + this.database.collection('addresses').findOne({ + address + }, { + fields: { + user: true + } + }, (err, addressData) => { + + if (err) { + return callback(err); + } + if (!addressData) { + return callback(null, false); + } + return next(null, { + _id: addressData.user + }); + }); + }; + + checkAddress(query => { + this.database.collection('users').findOne(query, { + fields: { + username: true, + password: true, + enabled2fa: true, + asp: true + } + }, (err, userData) => { + + if (err) { + return callback(err); + } + + if (!userData) { + return callback(null, false); + } + + // try master password + if (bcrypt.compareSync(password, userData.password || '')) { + return callback(null, { + user: userData._id, + username: userData.username, + scope: 'master', + enabled2fa: userData.enabled2fa + }); + } + + /* + + var password = generatePassword.generate({ + length: 16, + uppercase: false, + numbers: false, + symbols: false + }); + + */ + + // try application specific passwords + password = password.replace(/\s+/g, '').toLowerCase(); + if (!userData.asp || !userData.asp.length || !/^[a-z]{16}$/.test(password)) { + // does not look like an application specific password + return callback(null, false); + } + + for (let i = 0; i < userData.asp.length; i++) { + let asp = userData.asp[i]; + if (bcrypt.compareSync(password, asp.password || '')) { + return callback(null, { + user: userData._id, + username: userData.username, + scope: 'application', + use2fa: false + }); + } + } + + return callback(null, false); + }); + }); + } + + create(data, callback) { + this.database.collection('users').findOne({ + username: data.username + }, { + fields: { + username: true + } + }, (err, userData) => { + if (err) { + log.error('DB', 'CREATEFAIL username=%s error=%s', data.username, err.message); + return callback(new Error('Database Error, failed to create user')); + } + if (userData) { + let err = new Error('This username already exists'); + err.fields = { + username: err.message + }; + return callback(err); + } + + // Insert + let hash = bcrypt.hashSync(data.password, 11); + this.database.collection('users').insertOne({ + username: data.username, + name: data.name, + + // security + password: '', // set this later. having no password prevents login + asp: [], // list of application specific passwords + + enabled2fa: false, + seed: '', // 2fa seed value + + // default email address + address: '', // set this later + + // quota + storageUsed: 0, + quota: config.maxStorage * (1024 * 1024), + recipients: config.maxRecipients, + + + filters: [], + + created: new Date(), + + // until setup value is not true, this account is not usable + setup: false + }, (err, result) => { + if (err) { + log.error('DB', 'CREATEFAIL username=%s error=%s', data.username, err.message); + return callback(new Error('Database Error, failed to create user')); + } + + let user = result.insertedId; + + let mailboxes = this.getMailboxes(data.language).map(mailbox => { + mailbox.user = user; + return mailbox; + }); + + this.database.collection('mailboxes').insertMany(mailboxes, { + w: 1, + ordered: false + }, err => { + if (err) { + log.error('DB', 'CREATEFAIL username=%s error=%s', data.username, err.message); + return callback(new Error('Database Error, failed to create user')); + } + + // insert alias address to email address registry + this.database.collection('addresses').insertOne({ + user, + address: data.username + '@' + config.emailDomain, + created: new Date() + }, err => { + if (err) { + log.error('DB', 'CREATEFAIL username=%s error=%s', data.username, err.message); + return callback(new Error('Database Error, failed to create user')); + } + + // register this address as the default address for that user + return this.database.collection('users').findOneAndUpdate({ + _id: user + }, { + $set: { + password: hash, + address: data.username + '@' + config.emailDomain, + setup: true + } + }, {}, err => { + if (err) { + log.error('DB', 'CREATEFAIL username=%s error=%s', data.username, err.message); + return callback(new Error('Database Error, failed to create user')); + } + + return callback(null, user); + }); + }); + }); + }); + }); + } + + setup2fa(username, callback) { + let secret = speakeasy.generateSecret({ + length: 20 + }); + + return this.database.collection('users').findOneAndUpdate({ + username, + enabled2fa: false + }, { + $set: { + seed: secret.base32 + } + }, {}, (err, result) => { + if (err) { + log.error('DB', 'UPDATEFAIL username=%s error=%s', username, err.message); + return callback(new Error('Database Error, failed to update user')); + } + + if (!result || !result.value) { + return callback(new Error('Could not update user, check if 2FA is not already enabled')); + } + + QRCode.toDataURL(secret.otpauth_url, (err, data_url) => { + if (err) { + log.error('DB', 'QRFAIL username=%s error=%s', username, err.message); + return callback(new Error('Failed to generate QR code')); + } + return callback(null, data_url); + }); + }); + } + + enable2fa(username, userToken, callback) { + this.check2fa(username, userToken, (err, verified) => { + if (err) { + return callback(err); + } + if (!verified) { + return callback(null, false); + } + + // token was valid, update user settings + return this.database.collection('users').findOneAndUpdate({ + username, + enabled2fa: false + }, { + $set: { + enabled2fa: true + } + }, {}, (err, result) => { + if (err) { + log.error('DB', 'UPDATEFAIL username=%s error=%s', username, err.message); + return callback(new Error('Database Error, failed to update user')); + } + + if (!result || !result.value) { + return callback(new Error('Could not update user, check if 2FA is not already enabled')); + } + + return callback(null, true); + }); + }); + } + + disable2fa(username, userToken, callback) { + this.check2fa(username, userToken, (err, verified) => { + if (err) { + return callback(err); + } + if (!verified) { + return callback(null, false); + } + + // token was valid, update user settings + return this.database.collection('users').findOneAndUpdate({ + username, + enabled2fa: true + }, { + $set: { + enabled2fa: false, + seed: '' + } + }, {}, (err, result) => { + if (err) { + log.error('DB', 'UPDATEFAIL username=%s error=%s', username, err.message); + return callback(new Error('Database Error, failed to update user')); + } + + if (!result || !result.value) { + return callback(new Error('Could not update user, check if 2FA is not already disabled')); + } + + return callback(null, true); + }); + }); + } + + check2fa(username, userToken, callback) { + this.database.collection('users').findOne({ + username + }, { + fields: { + username: true, + seed: true + } + }, (err, userData) => { + if (err) { + log.error('DB', 'LOADFAIL username=%s error=%s', username, err.message); + return callback(new Error('Database Error, failed to update user')); + } + if (!userData) { + let err = new Error('This username does not exist'); + err.fields = { + username: err.message + }; + return callback(err); + } + + if (!userData.seed) { + // 2fa not set up + return callback(null, true); + } + + let verified = speakeasy.totp.verify({ + secret: userData.seed, + encoding: 'base32', + token: userToken + }); + + return callback(null, verified); + }); + } + + update(data, callback) { + this.database.collection('users').findOne({ + username: data.username + }, { + fields: { + username: true, + password: true + } + }, (err, userData) => { + if (err) { + log.error('DB', 'UPDATEFAIL username=%s error=%s', data.username, err.message); + return callback(new Error('Database Error, failed to update user')); + } + if (!userData) { + let err = new Error('This username does not exist'); + err.fields = { + username: err.message + }; + return callback(err); + } + + if (data.oldpassword && !bcrypt.compareSync(data.oldpassword, userData.password || '')) { + let err = new Error('Password does not match'); + err.fields = { + oldpassword: err.message + }; + return callback(err); + } + + let update = { + name: data.name + }; + + if (data.password) { + update.password = bcrypt.hashSync(data.password, 11); + } + + return this.database.collection('users').findOneAndUpdate({ + _id: userData._id + }, { + $set: update + }, {}, err => { + if (err) { + log.error('DB', 'UPDATEFAIL username=%s error=%s', data.username, err.message); + return callback(new Error('Database Error, failed to update user')); + } + + return callback(null, userData._id); + }); + + }); + } + + getMailboxes(language) { + let translation = mailboxTranslations.hasOwnProperty(language) ? mailboxTranslations[language] : mailboxTranslations.en; + + let defaultMailboxes = [{ + path: 'INBOX' + }, { + specialUse: '\\Sent' + }, { + specialUse: '\\Trash' + }, { + specialUse: '\\Drafts' + }, { + specialUse: '\\Junk' + }, { + specialUse: '\\Archive' + }]; + + let uidValidity = Math.floor(Date.now() / 1000); + + return defaultMailboxes.map(mailbox => ({ + path: translation[mailbox.specialUse || mailbox.path] || mailbox.path, + specialUse: mailbox.specialUse, + uidValidity, + uidNext: 1, + modifyIndex: 0, + subscribed: true, + flags: [] + })); + } +} + +module.exports = UserHandler; diff --git a/package.json b/package.json index 61bc0ac..07c2bc6 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "bcryptjs": "^2.4.3", "config": "^1.25.1", + "generate-password": "^1.3.0", "grid-fs": "^1.0.1", "html-to-text": "^3.2.0", "iconv-lite": "^0.4.15", @@ -32,10 +33,12 @@ "mongodb": "^2.2.25", "nodemailer": "^4.0.1", "npmlog": "^4.0.2", + "qrcode": "^0.8.1", "redfour": "^1.0.0", "redis": "^2.7.1", "restify": "^4.3.0", "smtp-server": "^3.0.1", + "speakeasy": "^2.0.0", "toml": "^2.3.2", "utf7": "^1.0.2", "uuid": "^3.0.1"