More work on device 2FA.

This commit is contained in:
Ylian Saint-Hilaire 2021-04-14 13:28:51 -07:00
parent 36b5163534
commit 48d5abca40
6 changed files with 178 additions and 73 deletions

View File

@ -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;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@ -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.

View File

@ -284,20 +284,26 @@
</form>
</div>
<div id=waitpushpanel style="display:none">
<div id=message8></div>
<table style="width:100%">
<tr>
<td style="align-content:center;padding-top:10px">
<img src="images/login/push-150.png" srcset="images/login/push-300.png 2x" loading="lazy" width="265" height="150" />
</td>
</tr>
<tr>
<td style="align-content:center;padding-top:10px">
<label id=tokenInputRememberLabel2><input id=tokenInputRemember2 name=remembertoken type=checkbox /><span id=tokenInputRememberSpan2></span></label>
</td>
</tr>
</table>
<hr /><a onclick="return xgo(1,event);" href="#" style=cursor:pointer>Back to login</a>
<form method=post>
<input type=hidden name=action value=pushlogin />
<div id=message8></div>
<table style="width:100%">
<tr>
<td style="align-content:center;padding-top:10px">
<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" />
</td>
</tr>
<tr>
<td style="align-content:center;padding-top:10px">
<label id=tokenInputRememberLabel2><input id=tokenInputRemember2 name=remembertoken type=checkbox /><span id=tokenInputRememberSpan2></span></label>
</td>
</tr>
</table>
<hr /><a onclick="return xgo(1,event);" href="#" style=cursor:pointer>Back to login</a>
<input id=pushtokenformargs name="urlargs" type="hidden" value="" />
<input id=pushtokenInput name="hwstate" type="hidden" value="" />
<input id=pushOkButton type=submit style="display:none" />
</form>
</div>
</td>
</tr>
@ -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 = ('<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 />'); }
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', '<span class="msg success"><b>' + format("Request sent, {0}.", r.code) + '</b></span><br /><br />');
} else if (r.sent === false) {
// Request failed to send
QH('message8', '<span class="msg error"><b style=color:#8C001A>' + "Failed to send request." + '<b></span><br /><br />');
this.close();
} else if (r.approved === true) {
// Request approved
this.close();
QV('tokenInputRememberLabel2', false);
QH('message8', '<span class="msg success"><b>' + "Request Accepted." + '</b></span><br /><br />');
Q('pushtokenInput').value = r.token;
Q('pushOkButton').click();
} else {
// Request rejected
QH('message8', '<span class="msg error"><b style=color:#8C001A>' + "Access Rejected." + '<b></span><br /><br />');
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', '<span class="msg error"><b style=color:#8C001A>' + "Connection Error" + '<b></span><br /><br />');
QS('waitpushpanelimage')['opacity'] = '0.5';
}
}
*/
}
// Use a hardware security key

View File

@ -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.