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.