mirror of
https://github.com/nodemailer/wildduck.git
synced 2025-09-06 05:04:47 +08:00
updated authentication
This commit is contained in:
parent
eb47b871a8
commit
a66c6dcd12
6 changed files with 471 additions and 11 deletions
|
@ -66,7 +66,7 @@ module.exports = {
|
||||||
spamHeader: 'X-Rspamd-Spam',
|
spamHeader: 'X-Rspamd-Spam',
|
||||||
|
|
||||||
// default quota storage in MB (can be overriden per user)
|
// default quota storage in MB (can be overriden per user)
|
||||||
maxStorage: 1000,
|
maxStorage: 1024,
|
||||||
|
|
||||||
// default smtp recipients for 24h (can be overriden per user)
|
// default smtp recipients for 24h (can be overriden per user)
|
||||||
maxRecipients: 2000
|
maxRecipients: 2000
|
||||||
|
|
|
@ -71,7 +71,8 @@ function authenticate(connection, token, callback) {
|
||||||
connection._server.logger.info('[%s] Authentication failed for %s using %s', connection.id, username, 'PLAIN');
|
connection._server.logger.info('[%s] Authentication failed for %s using %s', connection.id, username, 'PLAIN');
|
||||||
return callback(null, {
|
return callback(null, {
|
||||||
response: 'NO',
|
response: 'NO',
|
||||||
message: 'Authentication failure'
|
code: 'AUTHENTICATIONFAILED',
|
||||||
|
message: 'Invalid credentials'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -52,7 +52,8 @@ module.exports = {
|
||||||
this._server.logger.info('[%s] Authentication failed for %s using %s', this.id, username, 'LOGIN');
|
this._server.logger.info('[%s] Authentication failed for %s using %s', this.id, username, 'LOGIN');
|
||||||
return callback(null, {
|
return callback(null, {
|
||||||
response: 'NO',
|
response: 'NO',
|
||||||
message: 'Authentication failure'
|
code: 'AUTHENTICATIONFAILED',
|
||||||
|
message: 'Invalid credentials'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
17
imap.js
17
imap.js
|
@ -7,13 +7,13 @@ const IMAPServerModule = require('./imap-core');
|
||||||
const IMAPServer = IMAPServerModule.IMAPServer;
|
const IMAPServer = IMAPServerModule.IMAPServer;
|
||||||
const ImapNotifier = require('./lib/imap-notifier');
|
const ImapNotifier = require('./lib/imap-notifier');
|
||||||
const imapHandler = IMAPServerModule.imapHandler;
|
const imapHandler = IMAPServerModule.imapHandler;
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
const ObjectID = require('mongodb').ObjectID;
|
const ObjectID = require('mongodb').ObjectID;
|
||||||
const Indexer = require('./imap-core/lib/indexer/indexer');
|
const Indexer = require('./imap-core/lib/indexer/indexer');
|
||||||
const imapTools = require('./imap-core/lib/imap-tools');
|
const imapTools = require('./imap-core/lib/imap-tools');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const setupIndexes = require('./indexes.json');
|
const setupIndexes = require('./indexes.json');
|
||||||
const MessageHandler = require('./lib/message-handler');
|
const MessageHandler = require('./lib/message-handler');
|
||||||
|
const UserHandler = require('./lib/user-handler');
|
||||||
const db = require('./lib/db');
|
const db = require('./lib/db');
|
||||||
const packageData = require('./package.json');
|
const packageData = require('./package.json');
|
||||||
|
|
||||||
|
@ -61,28 +61,28 @@ if (config.imap.cert) {
|
||||||
const server = new IMAPServer(serverOptions);
|
const server = new IMAPServer(serverOptions);
|
||||||
|
|
||||||
let messageHandler;
|
let messageHandler;
|
||||||
|
let userHandler;
|
||||||
|
|
||||||
server.onAuth = function (login, session, callback) {
|
server.onAuth = function (login, session, callback) {
|
||||||
let username = (login.username || '').toString().trim();
|
let username = (login.username || '').toString().trim();
|
||||||
|
|
||||||
db.database.collection('users').findOne({
|
userHandler.authenticate(username, login.password, (err, result) => {
|
||||||
username
|
|
||||||
}, (err, user) => {
|
|
||||||
if (err) {
|
if (err) {
|
||||||
return callback(err);
|
return callback(err);
|
||||||
}
|
}
|
||||||
if (!user) {
|
if (!result) {
|
||||||
return callback();
|
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();
|
return callback();
|
||||||
}
|
}
|
||||||
|
|
||||||
callback(null, {
|
callback(null, {
|
||||||
user: {
|
user: {
|
||||||
id: user._id,
|
id: result.user,
|
||||||
username
|
username: result.username
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1606,6 +1606,7 @@ module.exports = done => {
|
||||||
let start = () => {
|
let start = () => {
|
||||||
|
|
||||||
messageHandler = new MessageHandler(db.database);
|
messageHandler = new MessageHandler(db.database);
|
||||||
|
userHandler = new UserHandler(db.database);
|
||||||
|
|
||||||
server.indexer = new Indexer({
|
server.indexer = new Indexer({
|
||||||
database: db.database
|
database: db.database
|
||||||
|
|
454
lib/user-handler.js
Normal file
454
lib/user-handler.js
Normal file
|
@ -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;
|
|
@ -21,6 +21,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"config": "^1.25.1",
|
"config": "^1.25.1",
|
||||||
|
"generate-password": "^1.3.0",
|
||||||
"grid-fs": "^1.0.1",
|
"grid-fs": "^1.0.1",
|
||||||
"html-to-text": "^3.2.0",
|
"html-to-text": "^3.2.0",
|
||||||
"iconv-lite": "^0.4.15",
|
"iconv-lite": "^0.4.15",
|
||||||
|
@ -32,10 +33,12 @@
|
||||||
"mongodb": "^2.2.25",
|
"mongodb": "^2.2.25",
|
||||||
"nodemailer": "^4.0.1",
|
"nodemailer": "^4.0.1",
|
||||||
"npmlog": "^4.0.2",
|
"npmlog": "^4.0.2",
|
||||||
|
"qrcode": "^0.8.1",
|
||||||
"redfour": "^1.0.0",
|
"redfour": "^1.0.0",
|
||||||
"redis": "^2.7.1",
|
"redis": "^2.7.1",
|
||||||
"restify": "^4.3.0",
|
"restify": "^4.3.0",
|
||||||
"smtp-server": "^3.0.1",
|
"smtp-server": "^3.0.1",
|
||||||
|
"speakeasy": "^2.0.0",
|
||||||
"toml": "^2.3.2",
|
"toml": "^2.3.2",
|
||||||
"utf7": "^1.0.2",
|
"utf7": "^1.0.2",
|
||||||
"uuid": "^3.0.1"
|
"uuid": "^3.0.1"
|
||||||
|
|
Loading…
Add table
Reference in a new issue