From 5cdfd7e0b992cd938cf91ac7dc34ad7a250c03c0 Mon Sep 17 00:00:00 2001 From: Ylian Saint-Hilaire Date: Tue, 13 Apr 2021 19:59:10 -0700 Subject: [PATCH] Partial work on mobile device 2FA. --- meshagent.js | 44 +++++++++++++++++++++ meshuser.js | 85 ++++++++++++++++++++++++++++++---------- views/default.handlebars | 31 ++++++++++++++- webserver.js | 1 + 4 files changed, 139 insertions(+), 22 deletions(-) diff --git a/meshagent.js b/meshagent.js index b57cfbf7..e4886519 100644 --- a/meshagent.js +++ b/meshagent.js @@ -1516,6 +1516,50 @@ module.exports.CreateMeshAgent = function (parent, db, ws, req, args, domain) { } break; } + case '2faauth': { + // Validate input + if ((typeof command.url != 'string') || (typeof command.approved != 'boolean') || (command.url.startsWith('2fa://') == false)) return; + + // parse the URL + var url = null; + try { url = require('url').parse(command.url); } catch (ex) { } + if (url == null) return; + + // For now, do nothing if authentication is not approved. + if (command.approve == false) return; + + // Decode the cookie + var urlSplit = url.query.split('&c='); + if (urlSplit.length != 2) return; + const authCookie = parent.parent.decodeCookie(urlSplit[1], null, 1); + if ((authCookie == null) || (typeof authCookie.c != 'string') || (('code=' + authCookie.c) != urlSplit[0])) return; + if ((typeof authCookie.n != 'string') || (authCookie.n != obj.dbNodeKey) || (typeof authCookie.u != 'string')) return; + + // Fetch the user + const user = parent.users[authCookie.u]; + if (user == null) return; + + // Add this device as the authentication push notification device for this user + if (authCookie.a == 'addAuth') { + // Change the user + user.otpdev = obj.dbNodeKey; + parent.db.SetUser(user); + + // Notify change + var targets = ['*', 'server-users', user._id]; + if (user.groups) { for (var i in user.groups) { targets.push('server-users:' + i); } } + var event = { etype: 'user', userid: user._id, username: user.name, account: parent.CloneSafeUser(user), action: 'accountchange', msgid: 113, msg: "Added push notification authentication device", domain: domain.id }; + if (db.changeStream) { event.noact = 1; } // If DB change stream is active, don't use this event to change the user. Another event will come. + parent.parent.DispatchEvent(targets, obj, event); + } + + // Complete 2FA checking + if (authCookie.a == 'checkAuth') { + // TODO + } + + break; + } default: { parent.agentStats.unknownAgentActionCount++; parent.parent.debug('agent', 'Unknown agent action (' + obj.remoteaddrport + '): ' + JSON.stringify(command) + '.'); diff --git a/meshuser.js b/meshuser.js index 57127c9d..3f05b74b 100644 --- a/meshuser.js +++ b/meshuser.js @@ -1284,7 +1284,7 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use break; } case 'showpaths': { - r = 'Parent: ' + parent.parent.parentpath + '\r\n'; + r = 'Parent: ' + parent.parent.parentpath + '\r\n'; r += 'Data: ' + parent.parent.datapath + '\r\n'; r += 'Files: ' + parent.parent.filespath + '\r\n'; r += 'Backup: ' + parent.parent.backuppath + '\r\n'; @@ -1338,7 +1338,7 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use } case 'relays': { for (var i in parent.wsrelays) { - r += 'id: ' + i + ', ' + ((parent.wsrelays[i].state == 2)?'connected':'pending'); + r += 'id: ' + i + ', ' + ((parent.wsrelays[i].state == 2) ? 'connected' : 'pending'); if (parent.wsrelays[i].peer1 != null) { r += ', ' + cleanRemoteAddr(parent.wsrelays[i].peer1.req.clientIp); if (parent.wsrelays[i].peer1.user) { r += ' (User:' + parent.wsrelays[i].peer1.user.name + ')' } @@ -1864,7 +1864,7 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use case 'adduserbatch': { var err = null; - + // Add many new user accounts if ((user.siteadmin & 2) == 0) { err = 'Access denied'; } else if ((domain.auth == 'sspi') || (domain.auth == 'ldap')) { err = 'Unable to create users when in SSPI or LDAP mode'; } @@ -1881,13 +1881,13 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use userCount++; } } - + // Handle any errors if (err != null) { if (command.responseid != null) { try { ws.send(JSON.stringify({ action: 'adduserbatch', responseid: command.responseid, result: err })); } catch (ex) { } } break; } - + // Check if we exceed the maximum number of user accounts db.isMaxType(domain.limits.maxuseraccounts + userCount, 'user', domain.id, function (maxExceed) { if (maxExceed) { @@ -2249,7 +2249,7 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use // We are not user group administrator, return a list with limited data for our domain. var groups = {}, groupCount = 0; for (var i in parent.userGroups) { if (parent.userGroups[i].domain == domain.id) { groupCount++; groups[i] = { name: parent.userGroups[i].name }; } } - try { ws.send(JSON.stringify({ action: 'usergroups', ugroups: groupCount?groups:null, tag: command.tag })); } catch (ex) { } + try { ws.send(JSON.stringify({ action: 'usergroups', ugroups: groupCount ? groups : null, tag: command.tag })); } catch (ex) { } } else { // We are user group administrator, return a full user group list for our domain. var groups = {}, groupCount = 0; @@ -2750,14 +2750,14 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use chguser.passhint = hint; } if (command.resetNextLogin === true) { chguser.passchange = -1; } else { chguser.passchange = Math.floor(Date.now() / 1000); } - delete chguser.passtype; // Remove the password type if one was present. - if (command.removeMultiFactor == true) { - if (chguser.otpekey != null) { delete chguser.otpekey; } - if (chguser.otpsecret != null) { delete chguser.otpsecret; } - if (chguser.otphkeys != null) { delete chguser.otphkeys; } - if (chguser.otpkeys != null) { delete chguser.otpkeys; } - if ((chguser.otpekey != null) && (((typeof domain.passwordrequirements != 'object') || (domain.passwordrequirements.email2factor != false)) && (domain.mailserver != null))) { delete chguser.otpekey; } - if ((chguser.phone != null) && (parent.parent.smsserver != null)) { delete chguser.phone; } + delete chguser.passtype; // Remove the password type if one was present. + if (command.removeMultiFactor === true) { + delete chguser.otpkeys; // One time backup codes + delete chguser.otpsecret; // OTP Google Authenticator + delete chguser.otphkeys; // FIDO keys + delete chguser.otpekey; // Email 2FA + delete chguser.phone; // SMS 2FA + delete chguser.otpdev; // Push notification 2FA } db.SetUser(chguser); @@ -3096,7 +3096,7 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use // Handle any errors if (err != null) { if (command.responseid != null) { try { ws.send(JSON.stringify({ action: 'editmesh', responseid: command.responseid, result: err })); } catch (ex) { } } break; } - + change = ''; // Check if this user has rights to do this @@ -3126,7 +3126,7 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use } if (dup != null) { // A duplicate was found, don't allow this change. - displayNotificationMessage("Error, invite code \"" + dup + "\" already in use.", "Invite Codes", null, 6, 22, [ dup ]); + displayNotificationMessage("Error, invite code \"" + dup + "\" already in use.", "Invite Codes", null, 6, 22, [dup]); return; } mesh.invite = { codes: command.invite.codes, flags: command.invite.flags }; @@ -3366,7 +3366,7 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use node.links[newuserid] = { rights: command.rights } nodeChanged = true; } - + // Save the user to the database if (newuserid.startsWith('user/')) { db.SetUser(newuser); @@ -4003,13 +4003,13 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use command.nodeids = nodeids; } } catch (ex) { console.log(ex); err = "Validation exception: " + ex; } - + // Handle any errors if (err != null) { if (command.responseid != null) { try { ws.send(JSON.stringify({ action: 'toast', responseid: command.responseid, result: err })); } catch (ex) { } } break; } - + for (i in command.nodeids) { // Get the node and the rights for this node parent.GetNodeWithRights(domain, user, command.nodeids[i], function (node, rights, visible) { @@ -4361,7 +4361,7 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use // Check input if (typeof command.enabled != 'boolean') return; - + // See if we really need to change the state if ((command.enabled === true) && (user.otpekey != null)) return; if ((command.enabled === false) && (user.otpekey == null)) return; @@ -4374,7 +4374,7 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use // Notify change var targets = ['*', 'server-users', user._id]; if (user.groups) { for (var i in user.groups) { targets.push('server-users:' + i); } } - var event = { etype: 'user', userid: user._id, username: user.name, account: parent.CloneSafeUser(user), action: 'accountchange', msgid: command.enabled ? 88 : 89, msg: command.enabled ? "Enabled email two-factor authentication." :"Disabled email two-factor authentication.", domain: domain.id }; + var event = { etype: 'user', userid: user._id, username: user.name, account: parent.CloneSafeUser(user), action: 'accountchange', msgid: command.enabled ? 88 : 89, msg: command.enabled ? "Enabled email two-factor authentication." : "Disabled email two-factor authentication.", domain: domain.id }; if (db.changeStream) { event.noact = 1; } // If DB change stream is active, don't use this event to change the user. Another event will come. parent.parent.DispatchEvent(targets, obj, event); break; @@ -4588,6 +4588,49 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use } }); + break; + } + case 'otpdev-clear': + { + // Remove the authentication push notification device + if (user.otpdev != null) { + // Change the user + user.otpdev = obj.dbNodeKey; + parent.db.SetUser(user); + + // Notify change + var targets = ['*', 'server-users', user._id]; + if (user.groups) { for (var i in user.groups) { targets.push('server-users:' + i); } } + var event = { etype: 'user', userid: user._id, username: user.name, account: parent.CloneSafeUser(user), action: 'accountchange', msgid: 114, msg: "Removed push notification authentication device", domain: domain.id }; + if (db.changeStream) { event.noact = 1; } // If DB change stream is active, don't use this event to change the user. Another event will come. + parent.parent.DispatchEvent(targets, obj, event); + } + break; + } + case 'otpdev-set': + { + // Attempt to add a authentication push notification device + // This will only send a push notification to the device, the device needs to confirm for the auth device to be added. + if (common.validateString(command.nodeid, 1, 1024) == false) break; // Check nodeid + parent.GetNodeWithRights(domain, user, command.nodeid, function (node, rights, visible) { + // Only allow use of devices with full rights + if ((node == null) || (visible == false) || (rights != 0xFFFFFFFF) || (node.agent == null) || (node.agent.id != 14) || (node.pmt == null)) return; + + // Encode the cookie + const code = Buffer.from(user.name).toString('base64'); + const authCookie = parent.parent.encodeCookie({ a: 'addAuth', c: code, u: user._id, n: node._id }); + + // Send out a push message to the device + var payload = { notification: { title: "MeshCentral", body: user.name + " authentication" }, data: { url: '2fa://auth?code=' + code + '&c=' + authCookie } }; + var options = { priority: 'High', timeToLive: 60 }; // TTL: 1 minute + parent.parent.firebase.sendToDevice(node, payload, options, function (id, err, errdesc) { + if (err == null) { + parent.parent.debug('email', 'Successfully auth addition send push message to device ' + node.name); + } else { + parent.parent.debug('email', 'Failed auth addition push message to device ' + node.name + ', error: ' + errdesc); + } + }); + }); break; } case 'webauthn-startregister': diff --git a/views/default.handlebars b/views/default.handlebars index 031e7cd8..cf08702d 100644 --- a/views/default.handlebars +++ b/views/default.handlebars @@ -363,6 +363,7 @@
Manage email authentication
Manage authenticator app
Manage security keys
+
Manage push authentication
Manage backup codes
View previous logins
@@ -2029,6 +2030,7 @@ QV('authEmailSetupCheck', (userinfo.otpekey == 1) && (userinfo.email != null) && (userinfo.emailVerified == true)); QV('authAppSetupCheck', userinfo.otpsecret == 1); QV('authKeySetupCheck', userinfo.otphkeys > 0); + QV('authPushAuthDevCheck', (userinfo.otpdev > 0) && ((features2 & 2) != 0)); QV('authCodesSetupCheck', userinfo.otpkeys > 0); mainUpdate(4 + 128 + 4096); @@ -10052,6 +10054,31 @@ return false; } + function account_managePushAuthDev() { + if (xxdialogMode || ((features2 & 2) == 0)) return; + if (userinfo.otpdev == 1) { + // Remove the 2FA device + setDialogMode(2, "Authentication Device", 3, function () { meshserver.send({ action: 'otpdev-clear' }); }, "Confirm removal of push authentication device?"); + } else { + // Create a list of all mobile devices + var mobileDevices = []; + for (var i in nodes) { var node = nodes[i]; if ((node.agent != null) && (node.agent.id == 14) && (node.pmt == 1) && (GetNodeRights(node) == 0xFFFFFFFF)) { mobileDevices.push(node); } } + if (mobileDevices.length == 0) { + // No mobile devices found + setDialogMode(2, "Authentication Device", 1, null, "In order to use push notification authentication, a mobile device must be setup in your account with full rights."); + } else { + // Set a 2FA device + var x = "Select a device to register for push notification authentication. Once selected, the device will prompt for confirmation." + '

'; + var y = ''; + x += addHtmlValue("Device", y); + setDialogMode(2, "Authentication Device", 3, function () { meshserver.send({ action: 'otpdev-set', nodeid: Q('d2devselect').value }); }, x); + } + } + return false; + } + function account_manageHardwareOtp() { if ((xxdialogMode == 2) && (xxdialogTag == 'otpauth-hardware-manage')) { dialogclose(0); } if (xxdialogMode || ((features & 4096) == 0)) return false; @@ -11961,7 +11988,9 @@ 109: "User login attempt on locked account from {0}, {1}, {2}", 110: "Invalid user login attempt from {0}, {1}, {2}", 111: "Device requested Intel(R) AMT ACM TLS activation, FQDN: {0}", - 112: "Ended messenger session \"{0}\" from {1} to {2}, {3} second(s)" + 112: "Ended messenger session \"{0}\" from {1} to {2}, {3} second(s)", + 113: "Added push notification authentication device", + 114: "Removed push notification authentication device" }; // Highlights the device being hovered diff --git a/webserver.js b/webserver.js index 12694603..dbb7155c 100644 --- a/webserver.js +++ b/webserver.js @@ -6642,6 +6642,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { 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 + if ((typeof user2.otpdev == 'string') && (user2.otpdev != null)) { user2.otpdev = 1; } // Indicates device for 2FA push notification if ((typeof user2.webpush == 'object') && (user2.webpush != null)) { user2.webpush = user2.webpush.length; } // Indicates the number of web push sessions we have return user2; }