More work on device 2FA.

Ylian Saint-Hilaire committed Apr 14, 2021 at 13:28 UTC 48d5abca40c55f1f85e0f3ea63feba63ad347f4b
6 files changed +178 -73
meshagent.js
+7 -5
@@ -1525,9 +1525,6 @@ module.exports.CreateMeshAgent = function (parent, db, ws, req, args, domain) {
1525 try { url = require('url').parse(command.url); } catch (ex) { }
1526 if (url == null) return;
1527
1528 - // For now, do nothing if authentication is not approved.
1529 - if (command.approve == false) return;
1530 -
1528 // Decode the cookie
1529 var urlSplit = url.query.split('&c=');
1530 if (urlSplit.length != 2) return;
@@ -1541,6 +1538,10 @@ module.exports.CreateMeshAgent = function (parent, db, ws, req, args, domain) {
1538
1539 // Add this device as the authentication push notification device for this user
1540 if (authCookie.a == 'addAuth') {
1541 + // Do nothing if authentication is not approved.
1542 + // We do not want to indicate that the remote user responded to this.
1543 + if (command.approved !== true) return;
1544 +
1545 // Change the user
1546 user.otpdev = obj.dbNodeKey;
1547 parent.db.SetUser(user);
@@ -1555,8 +1556,9 @@ module.exports.CreateMeshAgent = function (parent, db, ws, req, args, domain) {
1556
1557 // Complete 2FA checking
1558 if (authCookie.a == 'checkAuth') {
1558 - // TODO
1559 - //console.log(authCookie);
1559 + if (typeof authCookie.s != 'string') return;
1560 + // Notify 2FA response
1561 + 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 });
1562 }
1563
1564 break;
public/images/login/push-150.png
Binary files a/public/images/login/push-150.png and b/public/images/login/push-150.png differ
public/images/login/push-300.png
Binary files a/public/images/login/push-300.png and b/public/images/login/push-300.png differ
views/default.handlebars
+1 -2
@@ -2032,8 +2032,7 @@
2032 QV('authKeySetupCheck', userinfo.otphkeys > 0);
2033 QV('authPushAuthDevCheck', (userinfo.otpdev > 0) && ((features2 & 2) != 0));
2034 QV('authCodesSetupCheck', userinfo.otpkeys > 0);
2035 - //QV('managePushAuthDev', (features2 & 2) && (count2factoraAuths() > 0));
2036 - QV('managePushAuthDev', false);
2035 + QV('managePushAuthDev', (features2 & 2) && (count2factoraAuths() > 0));
2036 mainUpdate(4 + 128 + 4096);
2037
2038 // Check if none or at least 2 factors are enabled.
views/login2.handlebars
+54 -60
@@ -284,20 +284,26 @@
284 </form>
285 </div>
286 <div id=waitpushpanel style="display:none">
287 - <div id=message8></div>
288 - <table style="width:100%">
289 - <tr>
290 - <td style="align-content:center;padding-top:10px">
291 - <img src="images/login/push-150.png" srcset="images/login/push-300.png 2x" loading="lazy" width="265" height="150" />
292 - </td>
293 - </tr>
294 - <tr>
295 - <td style="align-content:center;padding-top:10px">
296 - <label id=tokenInputRememberLabel2><input id=tokenInputRemember2 name=remembertoken type=checkbox /><span id=tokenInputRememberSpan2></span></label>
297 - </td>
298 - </tr>
299 - </table>
300 - <hr /><a onclick="return xgo(1,event);" href="#" style=cursor:pointer>Back to login</a>
287 + <form method=post>
288 + <input type=hidden name=action value=pushlogin />
289 + <div id=message8></div>
290 + <table style="width:100%">
291 + <tr>
292 + <td style="align-content:center;padding-top:10px">
293 + <img id="waitpushpanelimage" src="images/login/push-150.png" srcset="images/login/push-300.png 2x" style="opacity:0.3" loading="lazy" width="265" height="150" />
294 + </td>
295 + </tr>
296 + <tr>
297 + <td style="align-content:center;padding-top:10px">
298 + <label id=tokenInputRememberLabel2><input id=tokenInputRemember2 name=remembertoken type=checkbox /><span id=tokenInputRememberSpan2></span></label>
299 + </td>
300 + </tr>
301 + </table>
302 + <hr /><a onclick="return xgo(1,event);" href="#" style=cursor:pointer>Back to login</a>
303 + <input id=pushtokenformargs name="urlargs" type="hidden" value="" />
304 + <input id=pushtokenInput name="hwstate" type="hidden" value="" />
305 + <input id=pushOkButton type=submit style="display:none" />
306 + </form>
307 </div>
308 </td>
309 </tr>
@@ -351,6 +357,7 @@
357 var otppush = (decodeURIComponent('{{{otppush}}}') === 'true');
358 var twoFactorCookieDays = parseInt('{{{twoFactorCookieDays}}}');
359 var authStrategies = '{{{authStrategies}}}'.split(',');
360 + var websocket = null;
361
362 function startup() {
363 if (decodeURIComponent('{{{loginpicture}}}') == 'true') { Q('loginPicture').src = "loginlogo.png"; }
@@ -361,13 +368,12 @@
368 // Display the right server message
369 var i;
370 var messageid = parseInt('{{{messageid}}}');
364 - 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}."];
371 + var okmessages = ['', "If valid, reset mail sent.", "Email sent.", "Email verification required, check your mailbox and click the confirmation link.", "SMS sent.", "Sending notification..."];
372 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."];
373 if (messageid > 0) {
374 var msg = '';
375 if ((messageid < 100) && (messageid < okmessages.length)) { msg = okmessages[messageid]; }
376 else if ((messageid >= 100) && ((messageid - 100) < failmessages.length)) { msg = failmessages[messageid - 100]; }
370 - if (messageid == 5) { msg = format(msg, passhint); }
377 if (msg != '') {
378 if (messageid >= 100) { msg = ('<span class="msg error"><b style=color:#8C001A>' + msg + '<b></span><br /><br />'); } else { msg = ('<span class="msg success"><b>' + msg + '</b></span><br /><br />'); }
379 for (i = 1; i < 9; i++) { QH('message' + i, msg); }
@@ -403,16 +409,12 @@
409 Q('createformargs').value = xurlargs;
410 Q('resetformargs').value = xurlargs;
411 Q('tokenformargs').value = xurlargs;
412 + Q('pushtokenformargs').value = xurlargs;
413 Q('resettokenformargs').value = xurlargs;
414 Q('resetpasswordformargs').value = xurlargs;
415 Q('checkemailformargs').value = xurlargs;
416 }
417
411 - //var webPageFullScreen = getstore('webPageFullScreen', true);
412 - //if (webPageFullScreen == 'false') { webPageFullScreen = false; }
413 - //if (webPageFullScreen == 'true') { webPageFullScreen = true; }
414 - //toggleFullScreen();
415 -
418 if ((features & 32) == 0) {
419 // Guard against other site's top frames (web bugs).
420 var loc = null;
@@ -442,16 +444,6 @@
444 if (authStrategies.indexOf('saml') >= 0) { QV('auth-saml', true); }
445 }
446
445 - // Display the welcome text
446 - /*
447 - if (welcomeText) {
448 - QH('welcomeText', welcomeText);
449 - } else {
450 - QH('welcomeText', addTextLink('MeshCentral', Q('welcomeText').innerHTML, 'http://www.meshcommander.com/meshcentral2'));
451 - }
452 - QV('welcomeText', true);
453 - */
454 -
447 validateLogin();
448 validateCreate();
449 if (loginMode.length != 0) { go(parseInt(loginMode)); } else { go(1); }
@@ -487,38 +479,40 @@
479 QV('2farow2', twofakey || emailkey || smskey || pushkey);
480 }
481
490 - /*
491 - if (loginMode == '5') {
492 - try { if (hardwareKeyChallenge.length > 0) { hardwareKeyChallenge = JSON.parse(hardwareKeyChallenge); } else { hardwareKeyChallenge = null; } } catch (ex) { hardwareKeyChallenge = null }
493 - if ((hardwareKeyChallenge != null) && (hardwareKeyChallenge.type == 'webAuthn')) {
494 - if (typeof hardwareKeyChallenge.challenge == 'string') { hardwareKeyChallenge.challenge = Uint8Array.from(atob(hardwareKeyChallenge.challenge), function (c) { return c.charCodeAt(0) }).buffer; }
495 -
496 - publicKeyCredentialRequestOptions = { challenge: hardwareKeyChallenge.challenge, allowCredentials: [], timeout: hardwareKeyChallenge.timeout }
497 - for (var i = 0; i < hardwareKeyChallenge.keyIds.length; i++) {
498 - publicKeyCredentialRequestOptions.allowCredentials.push(
499 - { id: Uint8Array.from(atob(hardwareKeyChallenge.keyIds[i]), function (c) { return c.charCodeAt(0) }), type: 'public-key', transports: ['usb', 'ble', 'nfc', 'internal'] }
500 - );
482 + if (loginMode == '8') {
483 + // Perform websocket connection to server to wait for device authentication
484 + websocket = new WebSocket(passhint);
485 + websocket.onopen = function (e) { QS('waitpushpanelimage')['opacity'] = '1'; }
486 + websocket.onmessage = function (e) {
487 + if (typeof e.data != 'string') { this.close(); }
488 + var r = null;
489 + try { r = JSON.parse(e.data); } catch (ex) { }
490 + if (r.sent === true) {
491 + // Request was sent
492 + QH('message8', '<span class="msg success"><b>' + format("Request sent, {0}.", r.code) + '</b></span><br /><br />');
493 + } else if (r.sent === false) {
494 + // Request failed to send
495 + QH('message8', '<span class="msg error"><b style=color:#8C001A>' + "Failed to send request." + '<b></span><br /><br />');
496 + this.close();
497 + } else if (r.approved === true) {
498 + // Request approved
499 + this.close();
500 + QV('tokenInputRememberLabel2', false);
501 + QH('message8', '<span class="msg success"><b>' + "Request Accepted." + '</b></span><br /><br />');
502 + Q('pushtokenInput').value = r.token;
503 + Q('pushOkButton').click();
504 + } else {
505 + // Request rejected
506 + QH('message8', '<span class="msg error"><b style=color:#8C001A>' + "Access Rejected." + '<b></span><br /><br />');
507 + this.close();
508 }
502 -
503 - // New WebAuthn hardware keys
504 - navigator.credentials.get({ publicKey: publicKeyCredentialRequestOptions }).then(
505 - function (rawAssertion) {
506 - var assertion = {
507 - id: btoa(String.fromCharCode.apply(null, new Uint8Array(rawAssertion.rawId))),
508 - clientDataJSON: btoa(String.fromCharCode.apply(null, new Uint8Array(rawAssertion.response.clientDataJSON))),
509 - userHandle: btoa(String.fromCharCode.apply(null, new Uint8Array(rawAssertion.response.userHandle))),
510 - signature: btoa(String.fromCharCode.apply(null, new Uint8Array(rawAssertion.response.signature))),
511 - authenticatorData: btoa(String.fromCharCode.apply(null, new Uint8Array(rawAssertion.response.authenticatorData))),
512 - };
513 - Q('resetHwtokenInput').value = JSON.stringify(assertion);
514 - QE('resetTokenOkButton', true);
515 - Q('resetTokenOkButton').click();
516 - },
517 - function (error) { console.log('credentials-get error', error); }
518 - );
509 + }
510 + websocket.onclose = function (e) { QS('waitpushpanelimage')['opacity'] = '0.3'; }
511 + websocket.onerror = function (e) {
512 + QH('message8', '<span class="msg error"><b style=color:#8C001A>' + "Connection Error" + '<b></span><br /><br />');
513 + QS('waitpushpanelimage')['opacity'] = '0.5';
514 }
515 }
521 - */
516 }
517
518 // Use a hardware security key
webserver.js
+116 -6
@@ -953,17 +953,59 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
953 }
954
955 if ((req.body.hwtoken == '**push**') && push2fa) {
956 - // Cause push notification to device
957 - const logincode = obj.common.zeroPad(getRandomSixDigitInteger(), 6);
958 - const code = Buffer.from(logincode).toString('base64');
959 - const authCookie = parent.encodeCookie({ a: 'checkAuth', c: code, u: user._id, n: user.otpdev });
960 - var payload = { notification: { title: "MeshCentral", body: user.name + " authentication" }, data: { url: '2fa://auth?code=' + code + '&c=' + authCookie } };
956 + const logincodeb64 = Buffer.from(obj.common.zeroPad(getRandomSixDigitInteger(), 6)).toString('base64');
957 + const sessioncode = obj.crypto.randomBytes(24).toString('base64');
958 +
959 + // Create a browser cookie so the browser can connect using websocket and wait for device accept/reject.
960 + const browserCookie = parent.encodeCookie({ a: 'waitAuth', c: logincodeb64, u: user._id, n: user.otpdev, s: sessioncode, d: domain.id });
961 +
962 + // Get the HTTPS port
963 + var httpsPort = ((obj.args.aliasport == null) ? obj.args.port : obj.args.aliasport); // Use HTTPS alias port if specified
964 + if (obj.args.agentport != null) { httpsPort = obj.args.agentport; } // If an agent only port is enabled, use that.
965 + if (obj.args.agentaliasport != null) { httpsPort = obj.args.agentaliasport; } // If an agent alias port is specified, use that.
966 +
967 + // Get the agent connection server name
968 + var serverName = obj.getWebServerName(domain);
969 + if (typeof obj.args.agentaliasdns == 'string') { serverName = obj.args.agentaliasdns; }
970 +
971 + // Build the connection URL. If we are using a sub-domain or one with a DNS, we need to craft the URL correctly.
972 + var xdomain = (domain.dns == null) ? domain.id : '';
973 + if (xdomain != '') xdomain += '/';
974 + var url = 'wss://' + serverName + ':' + httpsPort + '/' + xdomain + '2fahold.ashx?c=' + browserCookie;
975 +
976 + // Request that the login page wait for device auth
977 + req.session.messageid = 5; // "Sending notification..." message
978 + req.session.passhint = url;
979 + req.session.loginmode = '8';
980 + if (direct === true) { handleRootRequestEx(req, res, domain); } else { res.redirect(domain.url + getQueryPortion(req)); }
981 +
982 + /*
983 + // Perform push notification to device
984 + const deviceCookie = parent.encodeCookie({ a: 'checkAuth', c: logincodeb64, u: user._id, n: user.otpdev, s: sessioncode });
985 + var payload = { notification: { title: "MeshCentral", body: "Authentication - " + logincode }, data: { url: '2fa://auth?code=' + logincodeb64 + '&c=' + deviceCookie } };
986 var options = { priority: 'High', timeToLive: 60 }; // TTL: 1 minute
987 parent.firebase.sendToDevice(user.otpdev, payload, options, function (id, err, errdesc) {
988 if (err == null) {
989 + // Create a browser cookie so the browser can connect using websocket and wait for device accept/reject.
990 + const browserCookie = parent.encodeCookie({ a: 'waitAuth', c: logincodeb64, u: user._id, n: user.otpdev, s: sessioncode, d: domain.id });
991 +
992 + // Get the HTTPS port
993 + var httpsPort = ((obj.args.aliasport == null) ? obj.args.port : obj.args.aliasport); // Use HTTPS alias port if specified
994 + if (obj.args.agentport != null) { httpsPort = obj.args.agentport; } // If an agent only port is enabled, use that.
995 + if (obj.args.agentaliasport != null) { httpsPort = obj.args.agentaliasport; } // If an agent alias port is specified, use that.
996 +
997 + // Get the agent connection server name
998 + var serverName = obj.getWebServerName(domain);
999 + if (typeof obj.args.agentaliasdns == 'string') { serverName = obj.args.agentaliasdns; }
1000 +
1001 + // Build the connection URL. If we are using a sub-domain or one with a DNS, we need to craft the URL correctly.
1002 + var xdomain = (domain.dns == null) ? domain.id : '';
1003 + if (xdomain != '') xdomain += '/';
1004 + var url = 'wss://' + serverName + ':' + httpsPort + '/' + xdomain + '2fahold.ashx?c=' + browserCookie;
1005 +
1006 // Request that the login page wait for device auth
1007 req.session.messageid = 5; // "Notification sent." message
966 - req.session.passhint = logincode;
1008 + req.session.passhint = logincode + '|' + url;
1009 req.session.loginmode = '8';
1010 } else {
1011 // Indicate the push notification failed
@@ -972,6 +1014,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
1014 }
1015 if (direct === true) { handleRootRequestEx(req, res, domain); } else { res.redirect(domain.url + getQueryPortion(req)); }
1016 });
1017 + */
1018 return;
1019 }
1020
@@ -1116,6 +1159,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
1159 // Regenerate session when signing in to prevent fixation
1160 //req.session.regenerate(function () {
1161 // Store the user's primary key in the session store to be retrieved, or in this case the entire user object
1162 + delete req.session.u2fchallenge;
1163 delete req.session.loginmode;
1164 delete req.session.tokenuserid;
1165 delete req.session.tokenusername;
@@ -1318,6 +1362,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
1362 // Check everything is ok
1363 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')) {
1364 parent.debug('web', 'handleResetPasswordRequest: checks failed');
1365 + delete req.session.u2fchallenge;
1366 delete req.session.loginmode;
1367 delete req.session.tokenuserid;
1368 delete req.session.tokenusername;
@@ -1400,6 +1445,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
1445 } else {
1446 // Failed, error out.
1447 parent.debug('web', 'handleResetPasswordRequest: failed authenticate()');
1448 + delete req.session.u2fchallenge;
1449 delete req.session.loginmode;
1450 delete req.session.tokenuserid;
1451 delete req.session.tokenusername;
@@ -2757,6 +2803,17 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
2803 }
2804 handleLoginRequest(req, res, true); break;
2805 }
2806 + case 'pushlogin': {
2807 + if (req.body.hwstate) {
2808 + var cookie = obj.parent.decodeCookie(req.body.hwstate, obj.parent.loginCookieEncryptionKey, 1);
2809 + if ((cookie != null) && (typeof cookie.u == 'string') && (cookie.d == domain.id) && (cookie.a == 'pushAuth')) {
2810 + req.session = { userid: cookie.u, domainid: cookie.d } // Push authentication is a success, login the user
2811 + handleRootRequestEx(req, res, domain);
2812 + return;
2813 + }
2814 + }
2815 + handleLoginRequest(req, res, true); break;
2816 + }
2817 case 'changepassword': { handlePasswordChangeRequest(req, res, true); break; }
2818 case 'deleteaccount': { handleDeleteAccountRequest(req, res, true); break; }
2819 case 'createaccount': { handleCreateAccountRequest(req, res, true); break; }
@@ -4233,6 +4290,58 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
4290 ws.on('close', function (req) { });
4291 }
4292
4293 + // Handle the 2FA hold web socket
4294 + // Accept an hold a web socket connection until the 2FA response is received.
4295 + function handle2faHoldWebSocket(ws, req) {
4296 + const domain = checkUserIpAddress(ws, req);
4297 + if (domain == null) { return; }
4298 + ws._socket.setKeepAlive(true, 240000); // Set TCP keep alive
4299 + if (typeof req.query.c !== 'string') { ws.close(); return; }
4300 + const cookie = parent.decodeCookie(req.query.c, null, 1);
4301 + if ((cookie == null) || (cookie.d != domain.id)) { ws.close(); return; }
4302 + var user = obj.users[cookie.u];
4303 + if ((user == null) || (typeof user.otpdev != 'string')) { ws.close(); return; }
4304 +
4305 + // 2FA event subscription
4306 + obj.parent.AddEventDispatch(['2fadev-' + cookie.s], ws);
4307 + ws.cookie = cookie;
4308 + ws.HandleEvent = function (source, event, ids, id) {
4309 + obj.parent.RemoveAllEventDispatch(this);
4310 + if ((event.approved === true) && (event.userid == this.cookie.u)) {
4311 + // Create a login cookie
4312 + const loginCookie = obj.parent.encodeCookie({ a: 'pushAuth', u: event.userid, d: event.domain }, obj.parent.loginCookieEncryptionKey);
4313 + try { ws.send(JSON.stringify({ approved: true, token: loginCookie })); } catch (ex) { }
4314 + } else {
4315 + // Reject the login
4316 + try { ws.send(JSON.stringify({ approved: false })); } catch (ex) { }
4317 + }
4318 + }
4319 +
4320 + // We do not accept any data on this connection.
4321 + ws.on('message', function (data) { this.close(); });
4322 +
4323 + // If error, do nothing.
4324 + ws.on('error', function (err) { });
4325 +
4326 + // If closed, unsubscribe
4327 + ws.on('close', function (req) { obj.parent.RemoveAllEventDispatch(this); });
4328 +
4329 + // Perform push notification to device
4330 + try {
4331 + const deviceCookie = parent.encodeCookie({ a: 'checkAuth', c: cookie.c, u: cookie.u, n: cookie.n, s: cookie.s });
4332 + var code = Buffer.from(cookie.c, 'base64').toString();
4333 + var payload = { notification: { title: (domain.title ? domain.title : 'MeshCentral'), body: "Authentication - " + code }, data: { url: '2fa://auth?code=' + cookie.c + '&c=' + deviceCookie } };
4334 + var options = { priority: 'High', timeToLive: 60 }; // TTL: 1 minute
4335 + parent.firebase.sendToDevice(user.otpdev, payload, options, function (id, err, errdesc) {
4336 + if (err == null) {
4337 + try { ws.send(JSON.stringify({ sent: true, code: code })); } catch (ex) { }
4338 + } else {
4339 + try { ws.send(JSON.stringify({ sent: false })); } catch (ex) { }
4340 + }
4341 + });
4342 + } catch (ex) { console.log(ex); }
4343 + }
4344 +
4345 // Get the total size of all files in a folder and all sub-folders. (TODO: try to make all async version)
4346 function readTotalFileSize(path) {
4347 var r = 0, dir;
@@ -5336,6 +5445,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
5445 obj.app.post(url + 'oneclickrecovery.ashx', handleOneClickRecoveryFile);
5446 obj.app.get(url + 'userfiles/*', handleDownloadUserFiles);
5447 obj.app.ws(url + 'echo.ashx', handleEchoWebSocket);
5448 + obj.app.ws(url + '2fahold.ashx', handle2faHoldWebSocket);
5449 obj.app.ws(url + 'apf.ashx', function (ws, req) { obj.parent.mpsserver.onWebSocketConnection(ws, req); })
5450 obj.app.get(url + 'webrelay.ashx', function (req, res) { res.send('Websocket connection expected'); });
5451 obj.app.get(url + 'health.ashx', function (req, res) { res.send('ok'); }); // TODO: Perform more server checking.