diff --git a/meshmail.js b/meshmail.js
index f4dfdb03..05d0ae72 100644
--- a/meshmail.js
+++ b/meshmail.js
@@ -43,11 +43,13 @@ module.exports.CreateMeshMail = function (parent) {
// Set default mail templates
// You can override these by placing a file with the same name in "meshcentral-data/mail"
// If the server hash many domains, just add the domainid to the file like this: 'account-check-customer1.html', 'mesh-invite-customer1.txt'.
+ obj.mailTemplates['account-login.html'] = '
[[[SERVERNAME]]] - Account Login\r\n
[[[SERVERNAME]]] - Account Login
Your login token is: [[[TOKEN]]]
This token can only be used once and is valid for 5 minutes.
To install the software, click here and follow the instructions.
[[[/AREA-LINK]]]
If you did not initiate this request, please ignore this mail.
Best regards, [[[USERNAME]]]
';
+ obj.mailTemplates['account-login.txt'] = '[[[SERVERNAME]]] - Account Login\r\nYour login token is: [[[TOKEN]]]\r\n\r\nThis token can only be used once and is valid for 5 minutes.';
obj.mailTemplates['account-invite.txt'] = '[[[SERVERNAME]]] - Account Invitation\r\nAn account was created for you on server [[[SERVERNAME]]] ([[[SERVERURL]]]/), you can access it now with username \"[[[ACCOUNTNAME]]]\" and password \"[[[PASSWORD]]]\".\r\n\r\nBest regards,\r\n[[[USERNAME]]]';
obj.mailTemplates['account-check.txt'] = '[[[SERVERNAME]]] - Email Verification\r\nHi [[[USERNAME]]], [[[SERVERNAME]]] ([[[SERVERURL]]]) is performing an e-mail verification. Nagivate to the following link to complete the process:\r\n\r\n[[[SERVERURL]]]/checkmail?c=[[[COOKIE]]]\r\n\r\nIf you did not initiate this request, please ignore this mail.\r\n';
obj.mailTemplates['account-reset.txt'] = '[[[SERVERNAME]]] - Account Reset\r\nHi [[[USERNAME]]], [[[SERVERNAME]]] ([[[SERVERURL]]]) is requesting an account password reset. Nagivate to the following link to complete the process:\r\n\r\n[[[SERVERURL]]]/checkmail?c=[[[COOKIE]]]\r\n\r\nIf you did not initiate this request, please ignore this mail.';
@@ -133,6 +135,20 @@ module.exports.CreateMeshMail = function (parent) {
sendNextMail();
};
+ // Send account login mail / 2 factor token
+ obj.sendAccountLoginMail = function (domain, email, token) {
+ var template = getTemplateEx('account-login', domain);
+ if ((template == null) || (template.htmlSubject == null) || (template.txtSubject == null) || (parent.certificates == null) || (parent.certificates.CommonName == null) || (parent.certificates.CommonName.indexOf('.') == -1)) return; // If the server name is not set, invitation not possible.
+
+ // Set all the options.
+ var options = { email: email, servername: domain.title ? domain.title : 'MeshCentral', token: token };
+
+ // Send the email
+ console.log(options);
+ obj.pendingMails.push({ to: email, from: parent.config.smtp.from, subject: mailReplacements(template.htmlSubject, domain, options), text: mailReplacements(template.txt, domain, options), html: mailReplacements(template.html, domain, options) });
+ sendNextMail();
+ };
+
// Send account invitation mail
obj.sendAccountInviteMail = function (domain, username, accountname, email, password) {
var template = getTemplateEx('account-invite', domain);
diff --git a/package.json b/package.json
index f369e8fc..14d8965f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "meshcentral",
- "version": "0.5.0-e",
+ "version": "0.5.0-f",
"keywords": [
"Remote Management",
"Intel AMT",
diff --git a/pluginHandler.js b/pluginHandler.js
index 4f871d9b..3d79c421 100644
--- a/pluginHandler.js
+++ b/pluginHandler.js
@@ -507,10 +507,9 @@ module.exports.pluginHandler = function (parent) {
obj.removePlugin = function (id, func) {
parent.db.getPlugin(id, function (err, docs) {
var plugin = docs[0];
- var rimraf = null;
- try { rimraf = require('rimraf'); } catch (ex) { }
+ var rimraf = require('rimraf');
let pluginPath = obj.parent.path.join(obj.pluginPath, plugin.shortName);
- if (rimraf) rimraf.sync(pluginPath);
+ rimraf.sync(pluginPath);
parent.db.deletePlugin(id, func);
delete obj.plugins[plugin.shortName];
});
diff --git a/views/login-mobile.handlebars b/views/login-mobile.handlebars
index bd47fcdc..fa900c5a 100644
--- a/views/login-mobile.handlebars
+++ b/views/login-mobile.handlebars
@@ -164,7 +164,10 @@
-
+
+
+
+
@@ -238,7 +241,7 @@
-
+
X
@@ -271,10 +274,11 @@
var hardwareKeyChallenge = decodeURIComponent('{{{hkey}}}');
var publicKeyCredentialRequestOptions = null;
var currentpanel = 0;
+ var otpemail = ('{{{otpemail}}}' === 'true');
// Display the right server message
var messageid = parseInt('{{{messageid}}}');
- var okmessages = ['', "Hold on, reset mail sent."];
+ var okmessages = ['', "Hold on, reset mail sent.", "Email sent."];
var failmessages = ["Unable to create account.", "Account limit reached.", "Existing account with this email address.", "Invalid account creation token.", "Username already exists.", "Password rejected, use a different one.", "Invalid email.", "Account not found.", "Invalid token, try again.", "Unable to sent email.", "Account locked.", "Access denied.", "Login failed, check username and password.", "Password change requested.", "IP address blocked, try again later."];
if (messageid > 0) {
var msg = '';
@@ -328,6 +332,7 @@
if (loginMode == '4') {
try { if (hardwareKeyChallenge.length > 0) { hardwareKeyChallenge = JSON.parse(hardwareKeyChallenge); } else { hardwareKeyChallenge = null; } } catch (ex) { hardwareKeyChallenge = null }
QV('securityKeyButton', (hardwareKeyChallenge != null) && (hardwareKeyChallenge.type == 'webAuthn'));
+ QV('emailKeyButton', otpemail && (messageid != 2)); // TODO
}
if (loginMode == '5') {
@@ -364,6 +369,7 @@
// Use a hardware security key
function useSecurityKey() {
+ if (xxdialogMode) return;
if ((hardwareKeyChallenge != null) && (hardwareKeyChallenge.type == 'webAuthn')) {
if (typeof hardwareKeyChallenge.challenge == 'string') { hardwareKeyChallenge.challenge = Uint8Array.from(atob(hardwareKeyChallenge.challenge), function (c) { return c.charCodeAt(0) }).buffer; }
@@ -393,6 +399,18 @@
}
}
+ function useEmailToken() {
+ if (xxdialogMode) return;
+ if (otpemail != true) return;
+ setDialogMode(1, "Secure Login", 3, useEmailKeyEx, "Send token to registed email address?");
+ }
+
+ function useEmailKeyEx() {
+ Q('hwtokenInput').value = '**email**';
+ QE('tokenOkButton', true);
+ Q('tokenOkButton').click();
+ }
+
function showPassHint() {
if (passRequirements.hint === true) { messagebox("Password Hint", passhint); }
}
@@ -633,7 +651,7 @@
if (((b & 8) || x) && f) f(x, t);
}
- function center() { QS('dialog').left = ((((getDocWidth() - 400) / 2)) + 'px'); }
+ function center() { QS('dialog').left = ((((getDocWidth() - 300) / 2)) + 'px'); }
function messagebox(t, m) { QH('id_dialogMessage', m); setDialogMode(1, t, 1); }
function statusbox(t, m) { QH('id_dialogMessage', m); setDialogMode(1, t); }
function getDocWidth() { if (window.innerWidth) return window.innerWidth; if (document.documentElement && document.documentElement.clientWidth && document.documentElement.clientWidth != 0) return document.documentElement.clientWidth; return document.getElementsByTagName('body')[0].clientWidth; }
diff --git a/views/login.handlebars b/views/login.handlebars
index 96459d0b..5e9cddc1 100644
--- a/views/login.handlebars
+++ b/views/login.handlebars
@@ -160,7 +160,10 @@
-
+
+
+
+
@@ -270,10 +273,11 @@
var webPageFullScreen = true;
var nightMode = (getstore('_nightMode', '0') == '1');
var publicKeyCredentialRequestOptions = null;
+ var otpemail = ('{{{otpemail}}}' === 'true');
// Display the right server message
var messageid = parseInt('{{{messageid}}}');
- var okmessages = ['', "Hold on, reset mail sent."];
+ var okmessages = ['', "Hold on, reset mail sent.", "Email sent."];
var failmessages = ["Unable to create account.", "Account limit reached.", "Existing account with this email address.", "Invalid account creation token.", "Username already exists.", "Password rejected, use a different one.", "Invalid email.", "Account not found.", "Invalid token, try again.", "Unable to sent email.", "Account locked.", "Access denied.", "Login failed, check username and password.", "Password change requested.", "IP address blocked, try again later."];
if (messageid > 0) {
var msg = '';
@@ -349,6 +353,7 @@
if (loginMode == '4') {
try { if (hardwareKeyChallenge.length > 0) { hardwareKeyChallenge = JSON.parse(hardwareKeyChallenge); } else { hardwareKeyChallenge = null; } } catch (ex) { hardwareKeyChallenge = null }
QV('securityKeyButton', (hardwareKeyChallenge != null) && (hardwareKeyChallenge.type == 'webAuthn'));
+ QV('emailKeyButton', otpemail && (messageid != 2));
}
if (loginMode == '5') {
@@ -417,6 +422,17 @@
}
}
+ function useEmailToken() {
+ if (otpemail != true) return;
+ setDialogMode(1, "Secure Login", 3, useEmailKeyEx, "Send token to registed email address?");
+ }
+
+ function useEmailKeyEx() {
+ Q('hwtokenInput').value = '**email**';
+ QE('tokenOkButton', true);
+ Q('tokenOkButton').click();
+ }
+
function showPassHint(e) {
messagebox("Password Hint", passhint);
haltEvent(e);
diff --git a/webserver.js b/webserver.js
index 53184c4e..5db03780 100644
--- a/webserver.js
+++ b/webserver.js
@@ -541,7 +541,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
}
// Check if a 2nd factor is present
- return ((parent.config.settings.no2factorauth !== true) && ((user.otpsecret != null) || ((user.otphkeys != null) && (user.otphkeys.length > 0))));
+ return ((parent.config.settings.no2factorauth !== true) && ((user.otpsecret != null) || (user.otpekey != null) || ((user.otphkeys != null) && (user.otphkeys.length > 0))));
}
// Check the 2-step auth token
@@ -550,6 +550,22 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
const twoStepLoginSupported = ((domain.auth != 'sspi') && (obj.parent.certificates.CommonName.indexOf('.') != -1) && (obj.args.nousers !== true) && (parent.config.settings.no2factorauth !== true));
if (twoStepLoginSupported == false) { parent.debug('web', 'checkUserOneTimePassword: not supported.'); func(true); return; };
+ // Check if we can use OTP tokens with email
+ var otpemail = (parent.mailserver != null);
+ if ((typeof domain.passwordrequirements == 'object') && (typeof domain.passwordrequirements.email2factor == false)) { otpemail = false; }
+
+ // Check email key
+ if ((otpemail) && (user.otpekey != null) && (user.otpekey.d != null) && (user.otpekey.k === token)) {
+ var deltaTime = (Date.now() - user.otpekey.d);
+ if ((deltaTime > 0) && (deltaTime < 300000)) { // Allow 5 minutes to use the email token (10000 * 60 * 5).
+ user.otpekey = {};
+ obj.db.SetUser(user);
+ parent.debug('web', 'checkUserOneTimePassword: success (email).');
+ func(true);
+ return;
+ }
+ }
+
// Check hardware key
if (user.otphkeys && (user.otphkeys.length > 0) && (typeof (hwtoken) == 'string') && (hwtoken.length > 0)) {
var authResponse = null;
@@ -595,10 +611,10 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
// Update the hardware key counter and accept the 2nd factor
webAuthnKey.counter = webauthnResponse.counter;
obj.db.SetUser(user);
- parent.debug('web', 'checkUserOneTimePassword: success.');
+ parent.debug('web', 'checkUserOneTimePassword: success (hardware).');
func(true);
} else {
- parent.debug('web', 'checkUserOneTimePassword: fail.');
+ parent.debug('web', 'checkUserOneTimePassword: fail (hardware).');
func(false);
}
return;
@@ -610,12 +626,21 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
// Check Google Authenticator
const otplib = require('otplib')
otplib.authenticator.options = { window: 2 }; // Set +/- 1 minute window
- if (user.otpsecret && (typeof (token) == 'string') && (token.length == 6) && (otplib.authenticator.check(token, user.otpsecret) == true)) { func(true); return; };
+ if (user.otpsecret && (typeof (token) == 'string') && (token.length == 6) && (otplib.authenticator.check(token, user.otpsecret) == true)) {
+ parent.debug('web', 'checkUserOneTimePassword: success (authenticator).');
+ func(true);
+ return;
+ };
// Check written down keys
if ((user.otpkeys != null) && (user.otpkeys.keys != null) && (typeof (token) == 'string') && (token.length == 8)) {
var tokenNumber = parseInt(token);
- for (var i = 0; i < user.otpkeys.keys.length; i++) { if ((tokenNumber === user.otpkeys.keys[i].p) && (user.otpkeys.keys[i].u === true)) { user.otpkeys.keys[i].u = false; func(true); return; } }
+ for (var i = 0; i < user.otpkeys.keys.length; i++) {
+ if ((tokenNumber === user.otpkeys.keys[i].p) && (user.otpkeys.keys[i].u === true)) {
+ parent.debug('web', 'checkUserOneTimePassword: success (one-time).');
+ user.otpkeys.keys[i].u = false; func(true); return;
+ }
+ }
}
// Check OTP hardware key
@@ -631,7 +656,15 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
var yubikeyotp = require('yubikeyotp');
var request = { otp: token, id: domain.yubikey.id, key: domain.yubikey.secret, timestamp: true }
if (domain.yubikey.proxy) { request.requestParams = { proxy: domain.yubikey.proxy }; }
- yubikeyotp.verifyOTP(request, function (err, results) { func((results != null) && (results.status == 'OK')); });
+ yubikeyotp.verifyOTP(request, function (err, results) {
+ if ((results != null) && (results.status == 'OK')) {
+ parent.debug('web', 'checkUserOneTimePassword: success (Yubikey).');
+ func(true);
+ } else {
+ parent.debug('web', 'checkUserOneTimePassword: fail (Yubikey).');
+ func(false);
+ }
+ });
return;
}
}
@@ -687,6 +720,17 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
// Check if this user has 2-step login active
if ((req.session.loginmode != '6') && checkUserOneTimePasswordRequired(domain, user, req)) {
+ if ((req.body.hwtoken == '**email**') && (user.email != null) && (user.emailVerified == true) && (parent.mailserver != null) && (user.otpekey != null)) {
+ user.otpekey = { k: obj.common.zeroPad(getRandomEightDigitInteger(), 8), d: Date.now() };
+ obj.db.SetUser(user);
+ parent.debug('web', 'Sending 2FA email to: ' + user.email);
+ parent.mailserver.sendAccountLoginMail(domain, user.email, user.otpekey.k);
+ req.session.messageid = 2; // "Email sent" message
+ req.session.loginmode = '4';
+ if (direct === true) { handleRootRequestEx(req, res, domain); } else { res.redirect(domain.url + getQueryPortion(req)); }
+ return;
+ }
+
checkUserOneTimePassword(req, domain, user, req.body.token, req.body.hwtoken, function (result) {
if (result == false) {
var randomWaitTime = 0;
@@ -706,6 +750,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
// Wait and redirect the user
setTimeout(function () {
req.session.loginmode = '4';
+ req.session.tokenemail = ((user.email != null) && (user.emailVerified == true) && (parent.mailserver != null) && (user.otpekey != null));
req.session.tokenusername = xusername;
req.session.tokenpassword = xpassword;
if (direct === true) { handleRootRequestEx(req, res, domain); } else { res.redirect(domain.url + getQueryPortion(req)); }
@@ -793,6 +838,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
//req.session.regenerate(function () {
// Store the user's primary key in the session store to be retrieved, or in this case the entire user object
delete req.session.loginmode;
+ delete req.session.tokenemail;
delete req.session.tokenusername;
delete req.session.tokenpassword;
delete req.session.tokenemail;
@@ -1008,6 +1054,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
// Failed, error out.
parent.debug('web', 'handleResetPasswordRequest: failed authenticate()');
delete req.session.loginmode;
+ delete req.session.tokenemail;
delete req.session.tokenusername;
delete req.session.tokenpassword;
delete req.session.resettokenusername;
@@ -1672,8 +1719,12 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
var hwstate = null;
if (hardwareKeyChallenge) { hwstate = obj.parent.encodeCookie({ u: req.session.tokenusername, p: req.session.tokenpassword, c: req.session.u2fchallenge }, obj.parent.loginCookieEncryptionKey) }
+ // Check if we can use OTP tokens with email
+ var otpemail = (parent.mailserver != null) && (req.session.tokenemail);
+ if ((typeof domain.passwordrequirements == 'object') && (typeof domain.passwordrequirements.email2factor == false)) { otpemail = false; }
+
// Render the login page
- render(req, res, getRenderPage('login', req), getRenderArgs({ loginmode: loginmode, rootCertLink: getRootCertLink(), newAccount: newAccountsAllowed, newAccountPass: (((domain.newaccountspass == null) || (domain.newaccountspass == '')) ? 0 : 1), serverDnsName: obj.getWebServerName(domain), serverPublicPort: httpsPort, emailcheck: emailcheck, features: features, sessiontime: args.sessiontime, passRequirements: passRequirements, footer: (domain.footer == null) ? '' : domain.footer, hkey: encodeURIComponent(hardwareKeyChallenge), messageid: msgid, passhint: passhint, welcometext: domain.welcometext ? encodeURIComponent(domain.welcometext).split('\'').join('\\\'') : null, hwstate: hwstate }, domain));
+ render(req, res, getRenderPage('login', req), getRenderArgs({ loginmode: loginmode, rootCertLink: getRootCertLink(), newAccount: newAccountsAllowed, newAccountPass: (((domain.newaccountspass == null) || (domain.newaccountspass == '')) ? 0 : 1), serverDnsName: obj.getWebServerName(domain), serverPublicPort: httpsPort, emailcheck: emailcheck, features: features, sessiontime: args.sessiontime, passRequirements: passRequirements, footer: (domain.footer == null) ? '' : domain.footer, hkey: encodeURIComponent(hardwareKeyChallenge), messageid: msgid, passhint: passhint, welcometext: domain.welcometext ? encodeURIComponent(domain.welcometext).split('\'').join('\\\'') : null, hwstate: hwstate, otpemail: otpemail }, domain));
}
// Handle a post request on the root
@@ -4223,6 +4274,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
delete user2.domain;
delete user2.subscriptions;
delete user2.passtype;
+ if ((typeof user2.otpkeys == 'object') && (user2.otpkeys != null)) { user2.otpekey = 1; } // Indicates that email 2FA is enabled.
if ((typeof user2.otpsecret == 'string') && (user2.otpsecret != null)) { user2.otpsecret = 1; } // Indicates a time secret is present.
if ((typeof user2.otpkeys == 'object') && (user2.otpkeys != null)) { user2.otpkeys = 0; if (user.otpkeys != null) { for (var i = 0; i < user.otpkeys.keys.length; i++) { if (user.otpkeys.keys[i].u == true) { user2.otpkeys = 1; } } } } // Indicates the number of one time backup codes that are active.
if ((typeof user2.otphkeys == 'object') && (user2.otphkeys != null)) { user2.otphkeys = user2.otphkeys.length; } // Indicates the number of hardware keys setup
@@ -4510,6 +4562,9 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
function getRandomPassword() { return Buffer.from(obj.crypto.randomBytes(9), 'binary').toString('base64').split('/').join('@'); }
function getRandomLowerCase(len) { var r = '', random = obj.crypto.randomBytes(len); for (var i = 0; i < len; i++) { r += String.fromCharCode(97 + (random[i] % 26)); } return r; }
+ // Generate a 8 digit integer with even random probability for each value.
+ function getRandomEightDigitInteger() { var bigInt; do { bigInt = parent.crypto.randomBytes(4).readUInt32BE(0); } while (bigInt >= 4200000000); return bigInt % 100000000; }
+
// Clean a IPv6 address that encodes a IPv4 address
function cleanRemoteAddr(addr) { if (typeof addr != 'string') { return null; } if (addr.indexOf('::ffff:') == 0) { return addr.substring(7); } else { return addr; } }