wildduck/lib/user-handler.js

637 lines
21 KiB
JavaScript
Raw Normal View History

2017-04-21 01:10:03 +08:00
'use strict';
2017-07-16 19:37:33 +08:00
const config = require('wild-config');
2017-04-21 01:10:03 +08:00
const log = require('npmlog');
const bcrypt = require('bcryptjs');
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
const tools = require('./tools');
2017-07-17 21:32:31 +08:00
const consts = require('./consts');
const ObjectID = require('mongodb').ObjectID;
const generatePassword = require('generate-password');
2017-04-21 21:38:03 +08:00
const base32 = require('base32.js');
2017-07-12 02:38:23 +08:00
const os = require('os');
2017-04-21 01:10:03 +08:00
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(options) {
this.database = options.database;
this.users = options.users || options.database;
this.redis = options.redis;
2017-04-21 01:10:03 +08:00
}
/**
* Authenticate user
*
* @param {String} username Either username or email address
*/
2017-04-22 03:16:01 +08:00
authenticate(username, password, meta, callback) {
if (!callback && typeof meta === 'function') {
callback = meta;
meta = {};
}
if (!password) {
// do not allow signing in without a password
return callback(null, false);
}
2017-04-21 01:10:03 +08:00
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.users.collection('addresses').findOne({
2017-04-21 01:10:03 +08:00
address
}, {
fields: {
user: true
}
}, (err, addressData) => {
if (err) {
return callback(err);
}
2017-04-21 17:07:38 +08:00
2017-04-21 01:10:03 +08:00
if (!addressData) {
return callback(null, false);
}
2017-04-21 17:07:38 +08:00
2017-04-21 01:10:03 +08:00
return next(null, {
_id: addressData.user
});
});
};
2017-04-21 17:07:38 +08:00
checkAddress((err, query) => {
if (err) {
return callback(err);
}
this.users.collection('users').findOne(query, {
2017-04-21 01:10:03 +08:00
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 || '')) {
2017-04-22 03:16:01 +08:00
meta.scope = 'master';
2017-06-03 14:51:58 +08:00
this.redis
.multi()
.zadd('wl:' + userData._id.toString(), Date.now(), JSON.stringify(meta))
.zremrangebyscore('wl:' + userData._id.toString(), '-INF', Date.now() - 10 * 24 * 3600 * 1000)
.expire('wl:' + userData._id.toString(), 10 * 24 * 3600)
.exec(() => false);
2017-04-22 03:16:01 +08:00
2017-04-21 01:10:03 +08:00
return callback(null, {
user: userData._id,
username: userData.username,
scope: 'master',
enabled2fa: userData.enabled2fa
});
}
// 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 || '')) {
2017-04-22 03:16:01 +08:00
meta.scope = asp.id.toString();
2017-06-03 14:51:58 +08:00
this.redis
.multi()
.zadd('wl:' + userData._id.toString(), Date.now(), JSON.stringify(meta))
.zremrangebyscore('wl:' + userData._id.toString(), '-INF', Date.now() - 10 * 24 * 3600 * 1000)
.expire('wl:' + userData._id.toString(), 10 * 24 * 3600)
.exec(() => false);
2017-04-22 03:16:01 +08:00
2017-04-21 01:10:03 +08:00
return callback(null, {
user: userData._id,
username: userData.username,
scope: 'application',
2017-04-21 17:07:38 +08:00
enabled2fa: false // application scope never requires 2FA
2017-04-21 01:10:03 +08:00
});
}
}
return callback(null, false);
});
});
}
generateASP(data, callback) {
let password = generatePassword.generate({
length: 16,
uppercase: false,
numbers: false,
symbols: false
});
let passwordEntry = {
id: new ObjectID(),
description: data.description,
created: new Date(),
password: bcrypt.hashSync(password, 11)
};
// register this address as the default address for that user
return this.users.collection('users').findOneAndUpdate({
username: data.username
}, {
$push: {
asp: passwordEntry
}
}, {}, (err, result) => {
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 (!result || !result.value) {
return callback(new Error('User not found'));
}
2017-04-22 02:06:29 +08:00
passwordEntry.password = password;
return callback(null, passwordEntry);
});
}
2017-04-21 01:10:03 +08:00
create(data, callback) {
this.users.collection('users').findOne({
2017-04-21 01:10:03 +08:00
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'));
}
2017-07-17 21:32:31 +08:00
2017-04-21 01:10:03 +08:00
if (userData) {
let err = new Error('This username already exists');
err.fields = {
username: err.message
};
return callback(err);
}
2017-07-17 21:32:31 +08:00
let junkRetention = consts.JUNK_RETENTION;
2017-04-21 01:10:03 +08:00
// Insert
let hash = data.password ? bcrypt.hashSync(data.password, 11) : '';
2017-07-17 21:32:31 +08:00
let id = new ObjectID();
this.users.collection('users').insertOne({
2017-07-17 21:32:31 +08:00
_id: id,
2017-04-21 01:10:03 +08:00
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,
2017-07-17 21:32:31 +08:00
quota: data.quota || 0,
recipients: data.recipients || 0,
forwards: data.forwards || 0,
2017-04-21 01:10:03 +08:00
filters: [],
// default retention for user mailboxes
2017-07-17 21:32:31 +08:00
retention: data.retention || 0,
2017-04-21 01:10:03 +08:00
created: new Date(),
// until setup value is not true, this account is not usable
setup: false
2017-07-17 21:32:31 +08:00
}, err => {
2017-04-21 01:10:03 +08:00
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 mailboxes = this.getMailboxes(data.language).map(mailbox => {
2017-07-17 21:32:31 +08:00
mailbox.user = id;
if (['\\Trash', '\\Junk'].includes(mailbox.specialUse)) {
2017-07-17 21:32:31 +08:00
mailbox.retention = data.retention ? Math.min(data.retention, junkRetention) : junkRetention;
} else {
2017-07-17 21:32:31 +08:00
mailbox.retention = data.retention;
}
2017-04-21 01:10:03 +08:00
return mailbox;
});
this.database.collection('mailboxes').insertMany(mailboxes, {
w: 1,
ordered: false
}, err => {
if (err) {
2017-07-17 21:32:31 +08:00
// try to rollback
this.users.collection('users').deleteOne({ _id: id }, () => false);
2017-04-21 01:10:03 +08:00
log.error('DB', 'CREATEFAIL username=%s error=%s', data.username, err.message);
return callback(new Error('Database Error, failed to create user'));
}
2017-07-12 02:38:23 +08:00
let address = data.address ? data.address : data.username + '@' + (config.emailDomain || os.hostname()).toLowerCase();
2017-05-17 20:58:42 +08:00
2017-04-21 01:10:03 +08:00
// insert alias address to email address registry
this.users.collection('addresses').insertOne({
2017-07-17 21:32:31 +08:00
user: id,
2017-05-17 20:58:42 +08:00
address,
2017-04-21 01:10:03 +08:00
created: new Date()
}, err => {
if (err) {
2017-07-17 21:32:31 +08:00
// try to rollback
this.users.collection('users').deleteOne({ _id: id }, () => false);
this.database.collection('mailboxes').deleteMany({ user: id }, () => false);
2017-04-21 01:10:03 +08:00
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.users.collection('users').findOneAndUpdate({
2017-07-17 21:32:31 +08:00
_id: id,
setup: false
2017-04-21 01:10:03 +08:00
}, {
$set: {
password: hash,
2017-05-17 20:58:42 +08:00
address,
2017-04-21 01:10:03 +08:00
setup: true
}
}, {}, err => {
if (err) {
2017-07-17 21:32:31 +08:00
// try to rollback
this.users.collection('users').deleteOne({ _id: id }, () => false);
this.database.collection('mailboxes').deleteMany({ user: id }, () => false);
2017-04-21 01:10:03 +08:00
log.error('DB', 'CREATEFAIL username=%s error=%s', data.username, err.message);
return callback(new Error('Database Error, failed to create user'));
}
2017-07-17 21:32:31 +08:00
return callback(null, id);
2017-04-21 01:10:03 +08:00
});
});
});
});
});
}
2017-06-12 17:51:44 +08:00
reset(username, callback) {
let password = generatePassword.generate({
length: 12,
uppercase: true,
numbers: true,
symbols: false
});
return this.users.collection('users').findOneAndUpdate({
2017-06-12 17:51:44 +08:00
username
}, {
$set: {
enabled2fa: false,
seed: '',
requirePasswordChange: true,
password: bcrypt.hashSync(password, 11)
}
}, {}, (err, result) => {
if (err) {
log.error('DB', 'UPDATEFAIL username=%s error=%s', username, err.message);
return callback(new Error('Database Error, failed to reset user credentials'));
}
if (!result || !result.value) {
return callback(new Error('Could not update user'));
}
return callback(null, password);
});
}
2017-06-12 16:30:28 +08:00
setup2fa(username, issuer, callback) {
return this.users.collection('users').findOne({
2017-04-21 21:38:03 +08:00
username
2017-04-21 01:10:03 +08:00
}, {
2017-04-21 21:38:03 +08:00
fields: {
enabled2fa: true,
seed: true
2017-04-21 01:10:03 +08:00
}
2017-04-21 21:38:03 +08:00
}, (err, entry) => {
2017-04-21 01:10:03 +08:00
if (err) {
log.error('DB', 'UPDATEFAIL username=%s error=%s', username, err.message);
2017-04-21 21:38:03 +08:00
return callback(new Error('Database Error, failed to check user'));
2017-04-21 01:10:03 +08:00
}
2017-04-21 21:38:03 +08:00
if (!entry) {
return callback(new Error('Could not find user data'));
2017-04-21 01:10:03 +08:00
}
2017-04-21 21:38:03 +08:00
if (entry.enabled2fa) {
return callback(new Error('2FA is already enabled for this user'));
2017-04-21 01:10:03 +08:00
}
2017-04-21 21:38:03 +08:00
if (entry.seed) {
let otpauth_url = speakeasy.otpauthURL({
secret: base32.decode(entry.seed),
label: username,
2017-06-12 16:30:28 +08:00
issuer
2017-04-21 21:38:03 +08:00
});
return QRCode.toDataURL(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);
});
2017-04-21 01:10:03 +08:00
}
2017-04-21 21:38:03 +08:00
let secret = speakeasy.generateSecret({
length: 20,
2017-06-12 16:30:28 +08:00
name: username
2017-04-21 21:38:03 +08:00
});
return this.users.collection('users').findOneAndUpdate({
2017-04-21 01:10:03 +08:00
username,
enabled2fa: false
}, {
$set: {
2017-04-21 21:38:03 +08:00
seed: secret.base32
2017-04-21 01:10:03 +08:00
}
}, {}, (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'));
}
2017-06-12 16:30:28 +08:00
let otpauth_url = speakeasy.otpauthURL({
secret: secret.ascii,
label: username,
issuer
});
QRCode.toDataURL(otpauth_url, (err, data_url) => {
2017-04-21 21:38:03 +08:00
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);
});
2017-04-21 01:10:03 +08:00
});
});
}
2017-04-21 21:38:03 +08:00
enable2fa(username, userToken, callback) {
2017-04-21 01:10:03 +08:00
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.users.collection('users').findOneAndUpdate({
2017-04-21 21:38:03 +08:00
username
2017-04-21 01:10:03 +08:00
}, {
$set: {
2017-04-21 21:38:03 +08:00
enabled2fa: true
2017-04-21 01:10:03 +08:00
}
}, {}, (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) {
2017-04-21 21:38:03 +08:00
return callback(new Error('Could not update user, check if 2FA is not already enabled'));
2017-04-21 01:10:03 +08:00
}
return callback(null, true);
});
});
}
2017-04-21 21:38:03 +08:00
disable2fa(username, callback) {
return this.users.collection('users').findOneAndUpdate({
2017-04-21 21:38:03 +08:00
username
}, {
$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);
});
}
2017-04-21 01:10:03 +08:00
check2fa(username, userToken, callback) {
this.users.collection('users').findOne({
2017-04-21 01:10:03 +08:00
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',
2017-04-24 02:00:49 +08:00
token: userToken,
window: 6
2017-04-21 01:10:03 +08:00
});
return callback(null, verified);
});
}
update(data, callback) {
2017-06-12 17:10:29 +08:00
let query = {
2017-04-21 01:10:03 +08:00
username: data.username
2017-06-12 17:10:29 +08:00
};
if (data.resetPassword) {
query.requirePasswordChange = true;
}
this.users.collection('users').findOne(query, {
2017-04-21 01:10:03 +08:00
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);
}
2017-06-12 17:10:29 +08:00
if (!data.resetPassword && data.oldpassword && !bcrypt.compareSync(data.oldpassword, userData.password || '')) {
2017-04-21 01:10:03 +08:00
let err = new Error('Password does not match');
err.fields = {
oldpassword: err.message
};
return callback(err);
}
2017-05-07 22:21:44 +08:00
let update = {};
2017-04-21 01:10:03 +08:00
2017-05-07 22:21:44 +08:00
if (data.hasOwnProperty('name')) {
update.name = data.name || '';
2017-05-07 20:09:14 +08:00
}
2017-05-07 22:21:44 +08:00
if (data.hasOwnProperty('forward')) {
update.forward = data.forward || '';
}
if (data.hasOwnProperty('targetUrl')) {
update.targetUrl = data.targetUrl || '';
}
if (data.hasOwnProperty('autoreply')) {
update.autoreply = data.autoreply || '';
2017-05-07 20:09:14 +08:00
}
2017-04-21 01:10:03 +08:00
if (data.password) {
update.password = bcrypt.hashSync(data.password, 11);
}
2017-06-12 17:10:29 +08:00
if (data.resetPassword) {
update.requirePasswordChange = false;
}
return this.users.collection('users').findOneAndUpdate({
2017-04-21 01:10:03 +08:00
_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;
2017-06-03 14:51:58 +08:00
let defaultMailboxes = [
{
path: 'INBOX'
},
{
specialUse: '\\Sent'
},
{
specialUse: '\\Trash'
},
{
specialUse: '\\Drafts'
},
{
specialUse: '\\Junk'
},
{
specialUse: '\\Archive'
}
];
2017-04-21 01:10:03 +08:00
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;