diff --git a/meshagent.js b/meshagent.js index ffd4b65b..df7e4f6f 100644 --- a/meshagent.js +++ b/meshagent.js @@ -1525,9 +1525,6 @@ module.exports.CreateMeshAgent = function (parent, db, ws, req, args, domain) { 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; @@ -1541,6 +1538,10 @@ module.exports.CreateMeshAgent = function (parent, db, ws, req, args, domain) { // Add this device as the authentication push notification device for this user if (authCookie.a == 'addAuth') { + // Do nothing if authentication is not approved. + // We do not want to indicate that the remote user responded to this. + if (command.approved !== true) return; + // Change the user user.otpdev = obj.dbNodeKey; parent.db.SetUser(user); @@ -1555,8 +1556,9 @@ module.exports.CreateMeshAgent = function (parent, db, ws, req, args, domain) { // Complete 2FA checking if (authCookie.a == 'checkAuth') { - // TODO - //console.log(authCookie); + if (typeof authCookie.s != 'string') return; + // Notify 2FA response + parent.parent.DispatchEvent(['2fadev-' + authCookie.s], obj, { etype: '2fadev', action: '2faresponse', domain: domain.id, nodeid: obj.dbNodeKey, code: authCookie.a, userid: user._id, approved: command.approved, sessionid: authCookie.s, nolog: 1 }); } break; diff --git a/public/images/login/push-150.png b/public/images/login/push-150.png index b1dfccb4..65f5c1b7 100644 Binary files a/public/images/login/push-150.png and b/public/images/login/push-150.png differ diff --git a/public/images/login/push-300.png b/public/images/login/push-300.png index 5bfd952f..8e58b814 100644 Binary files a/public/images/login/push-300.png and b/public/images/login/push-300.png differ diff --git a/views/default.handlebars b/views/default.handlebars index 8b7f6d38..7f67ab08 100644 --- a/views/default.handlebars +++ b/views/default.handlebars @@ -2032,8 +2032,7 @@ QV('authKeySetupCheck', userinfo.otphkeys > 0); QV('authPushAuthDevCheck', (userinfo.otpdev > 0) && ((features2 & 2) != 0)); QV('authCodesSetupCheck', userinfo.otpkeys > 0); - //QV('managePushAuthDev', (features2 & 2) && (count2factoraAuths() > 0)); - QV('managePushAuthDev', false); + QV('managePushAuthDev', (features2 & 2) && (count2factoraAuths() > 0)); mainUpdate(4 + 128 + 4096); // Check if none or at least 2 factors are enabled. diff --git a/views/login2.handlebars b/views/login2.handlebars index 75a24e62..460321e2 100644 --- a/views/login2.handlebars +++ b/views/login2.handlebars @@ -284,20 +284,26 @@ @@ -351,6 +357,7 @@ var otppush = (decodeURIComponent('{{{otppush}}}') === 'true'); var twoFactorCookieDays = parseInt('{{{twoFactorCookieDays}}}'); var authStrategies = '{{{authStrategies}}}'.split(','); + var websocket = null; function startup() { if (decodeURIComponent('{{{loginpicture}}}') == 'true') { Q('loginPicture').src = "loginlogo.png"; } @@ -361,13 +368,12 @@ // Display the right server message var i; var messageid = parseInt('{{{messageid}}}'); - var okmessages = ['', "If valid, reset mail sent.", "Email sent.", "Email verification required, check your mailbox and click the confirmation link.", "SMS sent.", "Notification sent, {0}."]; + var okmessages = ['', "If valid, reset mail sent.", "Email sent.", "Email verification required, check your mailbox and click the confirmation link.", "SMS sent.", "Sending notification..."]; 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.", "Server under maintenance.", "Unable to send device notification."]; if (messageid > 0) { var msg = ''; if ((messageid < 100) && (messageid < okmessages.length)) { msg = okmessages[messageid]; } else if ((messageid >= 100) && ((messageid - 100) < failmessages.length)) { msg = failmessages[messageid - 100]; } - if (messageid == 5) { msg = format(msg, passhint); } if (msg != '') { if (messageid >= 100) { msg = ('' + msg + '

'); } else { msg = ('' + msg + '

'); } for (i = 1; i < 9; i++) { QH('message' + i, msg); } @@ -403,16 +409,12 @@ Q('createformargs').value = xurlargs; Q('resetformargs').value = xurlargs; Q('tokenformargs').value = xurlargs; + Q('pushtokenformargs').value = xurlargs; Q('resettokenformargs').value = xurlargs; Q('resetpasswordformargs').value = xurlargs; Q('checkemailformargs').value = xurlargs; } - //var webPageFullScreen = getstore('webPageFullScreen', true); - //if (webPageFullScreen == 'false') { webPageFullScreen = false; } - //if (webPageFullScreen == 'true') { webPageFullScreen = true; } - //toggleFullScreen(); - if ((features & 32) == 0) { // Guard against other site's top frames (web bugs). var loc = null; @@ -442,16 +444,6 @@ if (authStrategies.indexOf('saml') >= 0) { QV('auth-saml', true); } } - // Display the welcome text - /* - if (welcomeText) { - QH('welcomeText', welcomeText); - } else { - QH('welcomeText', addTextLink('MeshCentral', Q('welcomeText').innerHTML, 'http://www.meshcommander.com/meshcentral2')); - } - QV('welcomeText', true); - */ - validateLogin(); validateCreate(); if (loginMode.length != 0) { go(parseInt(loginMode)); } else { go(1); } @@ -487,38 +479,40 @@ QV('2farow2', twofakey || emailkey || smskey || pushkey); } - /* - if (loginMode == '5') { - try { if (hardwareKeyChallenge.length > 0) { hardwareKeyChallenge = JSON.parse(hardwareKeyChallenge); } else { hardwareKeyChallenge = null; } } catch (ex) { hardwareKeyChallenge = null } - 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; } - - publicKeyCredentialRequestOptions = { challenge: hardwareKeyChallenge.challenge, allowCredentials: [], timeout: hardwareKeyChallenge.timeout } - for (var i = 0; i < hardwareKeyChallenge.keyIds.length; i++) { - publicKeyCredentialRequestOptions.allowCredentials.push( - { id: Uint8Array.from(atob(hardwareKeyChallenge.keyIds[i]), function (c) { return c.charCodeAt(0) }), type: 'public-key', transports: ['usb', 'ble', 'nfc', 'internal'] } - ); + if (loginMode == '8') { + // Perform websocket connection to server to wait for device authentication + websocket = new WebSocket(passhint); + websocket.onopen = function (e) { QS('waitpushpanelimage')['opacity'] = '1'; } + websocket.onmessage = function (e) { + if (typeof e.data != 'string') { this.close(); } + var r = null; + try { r = JSON.parse(e.data); } catch (ex) { } + if (r.sent === true) { + // Request was sent + QH('message8', '' + format("Request sent, {0}.", r.code) + '

'); + } else if (r.sent === false) { + // Request failed to send + QH('message8', '' + "Failed to send request." + '

'); + this.close(); + } else if (r.approved === true) { + // Request approved + this.close(); + QV('tokenInputRememberLabel2', false); + QH('message8', '' + "Request Accepted." + '

'); + Q('pushtokenInput').value = r.token; + Q('pushOkButton').click(); + } else { + // Request rejected + QH('message8', '' + "Access Rejected." + '

'); + this.close(); } - - // New WebAuthn hardware keys - navigator.credentials.get({ publicKey: publicKeyCredentialRequestOptions }).then( - function (rawAssertion) { - var assertion = { - id: btoa(String.fromCharCode.apply(null, new Uint8Array(rawAssertion.rawId))), - clientDataJSON: btoa(String.fromCharCode.apply(null, new Uint8Array(rawAssertion.response.clientDataJSON))), - userHandle: btoa(String.fromCharCode.apply(null, new Uint8Array(rawAssertion.response.userHandle))), - signature: btoa(String.fromCharCode.apply(null, new Uint8Array(rawAssertion.response.signature))), - authenticatorData: btoa(String.fromCharCode.apply(null, new Uint8Array(rawAssertion.response.authenticatorData))), - }; - Q('resetHwtokenInput').value = JSON.stringify(assertion); - QE('resetTokenOkButton', true); - Q('resetTokenOkButton').click(); - }, - function (error) { console.log('credentials-get error', error); } - ); + } + websocket.onclose = function (e) { QS('waitpushpanelimage')['opacity'] = '0.3'; } + websocket.onerror = function (e) { + QH('message8', '' + "Connection Error" + '

'); + QS('waitpushpanelimage')['opacity'] = '0.5'; } } - */ } // Use a hardware security key diff --git a/webserver.js b/webserver.js index de792582..3278d7ab 100644 --- a/webserver.js +++ b/webserver.js @@ -953,17 +953,59 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { } if ((req.body.hwtoken == '**push**') && push2fa) { - // Cause push notification to device - const logincode = obj.common.zeroPad(getRandomSixDigitInteger(), 6); - const code = Buffer.from(logincode).toString('base64'); - const authCookie = parent.encodeCookie({ a: 'checkAuth', c: code, u: user._id, n: user.otpdev }); - var payload = { notification: { title: "MeshCentral", body: user.name + " authentication" }, data: { url: '2fa://auth?code=' + code + '&c=' + authCookie } }; + const logincodeb64 = Buffer.from(obj.common.zeroPad(getRandomSixDigitInteger(), 6)).toString('base64'); + const sessioncode = obj.crypto.randomBytes(24).toString('base64'); + + // Create a browser cookie so the browser can connect using websocket and wait for device accept/reject. + const browserCookie = parent.encodeCookie({ a: 'waitAuth', c: logincodeb64, u: user._id, n: user.otpdev, s: sessioncode, d: domain.id }); + + // Get the HTTPS port + var httpsPort = ((obj.args.aliasport == null) ? obj.args.port : obj.args.aliasport); // Use HTTPS alias port if specified + if (obj.args.agentport != null) { httpsPort = obj.args.agentport; } // If an agent only port is enabled, use that. + if (obj.args.agentaliasport != null) { httpsPort = obj.args.agentaliasport; } // If an agent alias port is specified, use that. + + // Get the agent connection server name + var serverName = obj.getWebServerName(domain); + if (typeof obj.args.agentaliasdns == 'string') { serverName = obj.args.agentaliasdns; } + + // Build the connection URL. If we are using a sub-domain or one with a DNS, we need to craft the URL correctly. + var xdomain = (domain.dns == null) ? domain.id : ''; + if (xdomain != '') xdomain += '/'; + var url = 'wss://' + serverName + ':' + httpsPort + '/' + xdomain + '2fahold.ashx?c=' + browserCookie; + + // Request that the login page wait for device auth + req.session.messageid = 5; // "Sending notification..." message + req.session.passhint = url; + req.session.loginmode = '8'; + if (direct === true) { handleRootRequestEx(req, res, domain); } else { res.redirect(domain.url + getQueryPortion(req)); } + + /* + // Perform push notification to device + const deviceCookie = parent.encodeCookie({ a: 'checkAuth', c: logincodeb64, u: user._id, n: user.otpdev, s: sessioncode }); + var payload = { notification: { title: "MeshCentral", body: "Authentication - " + logincode }, data: { url: '2fa://auth?code=' + logincodeb64 + '&c=' + deviceCookie } }; var options = { priority: 'High', timeToLive: 60 }; // TTL: 1 minute parent.firebase.sendToDevice(user.otpdev, payload, options, function (id, err, errdesc) { if (err == null) { + // Create a browser cookie so the browser can connect using websocket and wait for device accept/reject. + const browserCookie = parent.encodeCookie({ a: 'waitAuth', c: logincodeb64, u: user._id, n: user.otpdev, s: sessioncode, d: domain.id }); + + // Get the HTTPS port + var httpsPort = ((obj.args.aliasport == null) ? obj.args.port : obj.args.aliasport); // Use HTTPS alias port if specified + if (obj.args.agentport != null) { httpsPort = obj.args.agentport; } // If an agent only port is enabled, use that. + if (obj.args.agentaliasport != null) { httpsPort = obj.args.agentaliasport; } // If an agent alias port is specified, use that. + + // Get the agent connection server name + var serverName = obj.getWebServerName(domain); + if (typeof obj.args.agentaliasdns == 'string') { serverName = obj.args.agentaliasdns; } + + // Build the connection URL. If we are using a sub-domain or one with a DNS, we need to craft the URL correctly. + var xdomain = (domain.dns == null) ? domain.id : ''; + if (xdomain != '') xdomain += '/'; + var url = 'wss://' + serverName + ':' + httpsPort + '/' + xdomain + '2fahold.ashx?c=' + browserCookie; + // Request that the login page wait for device auth req.session.messageid = 5; // "Notification sent." message - req.session.passhint = logincode; + req.session.passhint = logincode + '|' + url; req.session.loginmode = '8'; } else { // Indicate the push notification failed @@ -972,6 +1014,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { } if (direct === true) { handleRootRequestEx(req, res, domain); } else { res.redirect(domain.url + getQueryPortion(req)); } }); + */ return; } @@ -1116,6 +1159,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { // Regenerate session when signing in to prevent fixation //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.u2fchallenge; delete req.session.loginmode; delete req.session.tokenuserid; delete req.session.tokenusername; @@ -1318,6 +1362,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { // Check everything is ok if ((domain == null) || (domain.auth == 'sspi') || (domain.auth == 'ldap') || (typeof req.body.rpassword1 != 'string') || (typeof req.body.rpassword2 != 'string') || (req.body.rpassword1 != req.body.rpassword2) || (typeof req.body.rpasswordhint != 'string') || (req.session == null) || (typeof req.session.resettokenusername != 'string') || (typeof req.session.resettokenpassword != 'string')) { parent.debug('web', 'handleResetPasswordRequest: checks failed'); + delete req.session.u2fchallenge; delete req.session.loginmode; delete req.session.tokenuserid; delete req.session.tokenusername; @@ -1400,6 +1445,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { } else { // Failed, error out. parent.debug('web', 'handleResetPasswordRequest: failed authenticate()'); + delete req.session.u2fchallenge; delete req.session.loginmode; delete req.session.tokenuserid; delete req.session.tokenusername; @@ -2757,6 +2803,17 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { } handleLoginRequest(req, res, true); break; } + case 'pushlogin': { + if (req.body.hwstate) { + var cookie = obj.parent.decodeCookie(req.body.hwstate, obj.parent.loginCookieEncryptionKey, 1); + if ((cookie != null) && (typeof cookie.u == 'string') && (cookie.d == domain.id) && (cookie.a == 'pushAuth')) { + req.session = { userid: cookie.u, domainid: cookie.d } // Push authentication is a success, login the user + handleRootRequestEx(req, res, domain); + return; + } + } + handleLoginRequest(req, res, true); break; + } case 'changepassword': { handlePasswordChangeRequest(req, res, true); break; } case 'deleteaccount': { handleDeleteAccountRequest(req, res, true); break; } case 'createaccount': { handleCreateAccountRequest(req, res, true); break; } @@ -4233,6 +4290,58 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { ws.on('close', function (req) { }); } + // Handle the 2FA hold web socket + // Accept an hold a web socket connection until the 2FA response is received. + function handle2faHoldWebSocket(ws, req) { + const domain = checkUserIpAddress(ws, req); + if (domain == null) { return; } + ws._socket.setKeepAlive(true, 240000); // Set TCP keep alive + if (typeof req.query.c !== 'string') { ws.close(); return; } + const cookie = parent.decodeCookie(req.query.c, null, 1); + if ((cookie == null) || (cookie.d != domain.id)) { ws.close(); return; } + var user = obj.users[cookie.u]; + if ((user == null) || (typeof user.otpdev != 'string')) { ws.close(); return; } + + // 2FA event subscription + obj.parent.AddEventDispatch(['2fadev-' + cookie.s], ws); + ws.cookie = cookie; + ws.HandleEvent = function (source, event, ids, id) { + obj.parent.RemoveAllEventDispatch(this); + if ((event.approved === true) && (event.userid == this.cookie.u)) { + // Create a login cookie + const loginCookie = obj.parent.encodeCookie({ a: 'pushAuth', u: event.userid, d: event.domain }, obj.parent.loginCookieEncryptionKey); + try { ws.send(JSON.stringify({ approved: true, token: loginCookie })); } catch (ex) { } + } else { + // Reject the login + try { ws.send(JSON.stringify({ approved: false })); } catch (ex) { } + } + } + + // We do not accept any data on this connection. + ws.on('message', function (data) { this.close(); }); + + // If error, do nothing. + ws.on('error', function (err) { }); + + // If closed, unsubscribe + ws.on('close', function (req) { obj.parent.RemoveAllEventDispatch(this); }); + + // Perform push notification to device + try { + const deviceCookie = parent.encodeCookie({ a: 'checkAuth', c: cookie.c, u: cookie.u, n: cookie.n, s: cookie.s }); + var code = Buffer.from(cookie.c, 'base64').toString(); + var payload = { notification: { title: (domain.title ? domain.title : 'MeshCentral'), body: "Authentication - " + code }, data: { url: '2fa://auth?code=' + cookie.c + '&c=' + deviceCookie } }; + var options = { priority: 'High', timeToLive: 60 }; // TTL: 1 minute + parent.firebase.sendToDevice(user.otpdev, payload, options, function (id, err, errdesc) { + if (err == null) { + try { ws.send(JSON.stringify({ sent: true, code: code })); } catch (ex) { } + } else { + try { ws.send(JSON.stringify({ sent: false })); } catch (ex) { } + } + }); + } catch (ex) { console.log(ex); } + } + // Get the total size of all files in a folder and all sub-folders. (TODO: try to make all async version) function readTotalFileSize(path) { var r = 0, dir; @@ -5336,6 +5445,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { obj.app.post(url + 'oneclickrecovery.ashx', handleOneClickRecoveryFile); obj.app.get(url + 'userfiles/*', handleDownloadUserFiles); obj.app.ws(url + 'echo.ashx', handleEchoWebSocket); + obj.app.ws(url + '2fahold.ashx', handle2faHoldWebSocket); obj.app.ws(url + 'apf.ashx', function (ws, req) { obj.parent.mpsserver.onWebSocketConnection(ws, req); }) obj.app.get(url + 'webrelay.ashx', function (req, res) { res.send('Websocket connection expected'); }); obj.app.get(url + 'health.ashx', function (req, res) { res.send('ok'); }); // TODO: Perform more server checking.