From e9381b506a8feb10f76e5775bbb300d5e3834c1e Mon Sep 17 00:00:00 2001 From: Ylian Saint-Hilaire Date: Thu, 7 Feb 2019 22:30:33 -0800 Subject: [PATCH] Completed support for hardware key 2-factor auth. --- meshuser.js | 12 +- public/scripts/u2f-api.js | 753 ++++++++++++++++++++++++++++++ views/default-min.handlebars | 2 +- views/default.handlebars | 31 +- views/login-min.handlebars | 2 +- views/login-mobile-min.handlebars | 2 +- views/login-mobile.handlebars | 24 +- views/login.handlebars | 24 +- webserver.js | 82 +++- 9 files changed, 908 insertions(+), 24 deletions(-) create mode 100644 public/scripts/u2f-api.js diff --git a/meshuser.js b/meshuser.js index c522f9ce..a322d89e 100644 --- a/meshuser.js +++ b/meshuser.js @@ -1440,7 +1440,7 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use obj.parent.db.SetUser(user); ws.send(JSON.stringify({ action: 'otpauth-setup', success: true })); // Report success - // Notify change + // Notify change TODO: Should be done on all sessions/servers for this user. try { ws.send(JSON.stringify({ action: 'userinfo', userinfo: obj.parent.CloneSafeUser(user) })); } catch (ex) { } } else { ws.send(JSON.stringify({ action: 'otpauth-setup', success: false })); // Report fail @@ -1490,7 +1490,9 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use if (actionTaken) { obj.parent.db.SetUser(user); } // Return one time passwords for this user - if (user.otpsecret) { ws.send(JSON.stringify({ action: 'otpauth-getpasswords', passwords: user.otpkeys?user.otpkeys.keys:null })); } + if (user.otpsecret || ((user.otphkeys != null) && (user.otphkeys.length > 0))) { + ws.send(JSON.stringify({ action: 'otpauth-getpasswords', passwords: user.otpkeys ? user.otpkeys.keys : null })); + } break; } case 'otp-hkey-get': @@ -1521,6 +1523,9 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use user.otphkeys.splice(foundAtIndex, 1); obj.parent.db.SetUser(user); } + + // Notify change TODO: Should be done on all sessions/servers for this user. + try { ws.send(JSON.stringify({ action: 'userinfo', userinfo: obj.parent.CloneSafeUser(user) })); } catch (ex) { } break; } case 'otp-hkey-yubikey-add': @@ -1585,6 +1590,9 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use user.otphkeys.push({ name: command.name, publicKey: result.publicKey, keyHandle: result.keyHandle, keyIndex: keyIndex }); obj.parent.db.SetUser(user); //console.log('KEYS', JSON.stringify(user.otphkeys)); + + // Notify change TODO: Should be done on all sessions/servers for this user. + try { ws.send(JSON.stringify({ action: 'userinfo', userinfo: obj.parent.CloneSafeUser(user) })); } catch (ex) { } } } break; diff --git a/public/scripts/u2f-api.js b/public/scripts/u2f-api.js new file mode 100644 index 00000000..d79e6d68 --- /dev/null +++ b/public/scripts/u2f-api.js @@ -0,0 +1,753 @@ +//Copyright 2014-2015 Google Inc. All rights reserved. + +//Use of this source code is governed by a BSD-style +//license that can be found in the LICENSE file or at +//https://developers.google.com/open-source/licenses/bsd + +/** + * @fileoverview The U2F api. + */ +'use strict'; +if (!window.u2f) { + + /** + * Namespace for the U2F api. + * @type {Object} + */ +var u2f = u2f || {}; + + /** + * FIDO U2F Javascript API Version + * @number + */ +var js_api_version; + + /** + * The U2F extension id + * @const {string} + */ +// The Chrome packaged app extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the package Chrome app and does not require installing the U2F Chrome extension. + u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd'; + // The U2F Chrome extension ID. + // Uncomment this if you want to deploy a server instance that uses + // the U2F Chrome extension to authenticate. + // u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne'; + + + /** + * Message types for messsages to/from the extension + * @const + * @enum {string} + */ +u2f.MessageTypes = { + 'U2F_REGISTER_REQUEST': 'u2f_register_request', + 'U2F_REGISTER_RESPONSE': 'u2f_register_response', + 'U2F_SIGN_REQUEST': 'u2f_sign_request', + 'U2F_SIGN_RESPONSE': 'u2f_sign_response', + 'U2F_GET_API_VERSION_REQUEST': 'u2f_get_api_version_request', + 'U2F_GET_API_VERSION_RESPONSE': 'u2f_get_api_version_response' + }; + + + /** + * Response status codes + * @const + * @enum {number} + */ +u2f.ErrorCodes = { + 'OK': 0, + 'OTHER_ERROR': 1, + 'BAD_REQUEST': 2, + 'CONFIGURATION_UNSUPPORTED': 3, + 'DEVICE_INELIGIBLE': 4, + 'TIMEOUT': 5 + }; + + + /** + * A message for registration requests + * @typedef {{ + * type: u2f.MessageTypes, + * appId: ?string, + * timeoutSeconds: ?number, + * requestId: ?number + * }} + */ +u2f.U2fRequest; + + + /** + * A message for registration responses + * @typedef {{ + * type: u2f.MessageTypes, + * responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), + * requestId: ?number + * }} + */ +u2f.U2fResponse; + + + /** + * An error object for responses + * @typedef {{ + * errorCode: u2f.ErrorCodes, + * errorMessage: ?string + * }} + */ +u2f.Error; + + /** + * Data object for a single sign request. + * @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC}} + */ +u2f.Transport; + + + /** + * Data object for a single sign request. + * @typedef {Array} + */ +u2f.Transports; + + /** + * Data object for a single sign request. + * @typedef {{ + * version: string, + * challenge: string, + * keyHandle: string, + * appId: string + * }} + */ +u2f.SignRequest; + + + /** + * Data object for a sign response. + * @typedef {{ + * keyHandle: string, + * signatureData: string, + * clientData: string + * }} + */ +u2f.SignResponse; + + + /** + * Data object for a registration request. + * @typedef {{ + * version: string, + * challenge: string + * }} + */ +u2f.RegisterRequest; + + + /** + * Data object for a registration response. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: Transports, + * appId: string + * }} + */ +u2f.RegisterResponse; + + + /** + * Data object for a registered key. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: ?Transports, + * appId: ?string + * }} + */ +u2f.RegisteredKey; + + + /** + * Data object for a get API register response. + * @typedef {{ + * js_api_version: number + * }} + */ +u2f.GetJsApiVersionResponse; + + + //Low level MessagePort API support + + /** + * Sets up a MessagePort to the U2F extension using the + * available mechanisms. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + */ +u2f.getMessagePort = function (callback) { + if (typeof chrome != 'undefined' && chrome.runtime) { + // The actual message here does not matter, but we need to get a reply + // for the callback to run. Thus, send an empty signature request + // in order to get a failure response. + var msg = { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: [] + }; + chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function () { + if (!chrome.runtime.lastError) { + // We are on a whitelisted origin and can talk directly + // with the extension. + u2f.getChromeRuntimePort_(callback); + } else { + // chrome.runtime was available, but we couldn't message + // the extension directly, use iframe + u2f.getIframePort_(callback); + } + }); + } else if (u2f.isAndroidChrome_()) { + u2f.getAuthenticatorPort_(callback); + } else if (u2f.isIosChrome_()) { + u2f.getIosPort_(callback); + } else { + // chrome.runtime was not available at all, which is normal + // when this origin doesn't have access to any extensions. + u2f.getIframePort_(callback); + } + }; + + /** + * Detect chrome running on android based on the browser's useragent. + * @private + */ +u2f.isAndroidChrome_ = function () { + var userAgent = navigator.userAgent; + return userAgent.indexOf('Chrome') != -1 && + userAgent.indexOf('Android') != -1; + }; + + /** + * Detect chrome running on iOS based on the browser's platform. + * @private + */ +u2f.isIosChrome_ = function () { + var r = ["iPhone", "iPad", "iPod"]; + for (var i in r) { if (navigator.platform == r[i]) { return true; } } + return false; + //return $.inArray(navigator.platform, ["iPhone", "iPad", "iPod"]) > -1; + }; + + /** + * Connects directly to the extension via chrome.runtime.connect. + * @param {function(u2f.WrappedChromeRuntimePort_)} callback + * @private + */ +u2f.getChromeRuntimePort_ = function (callback) { + var port = chrome.runtime.connect(u2f.EXTENSION_ID, + { 'includeTlsChannelId': true }); + setTimeout(function () { + callback(new u2f.WrappedChromeRuntimePort_(port)); + }, 0); + }; + + /** + * Return a 'port' abstraction to the Authenticator app. + * @param {function(u2f.WrappedAuthenticatorPort_)} callback + * @private + */ +u2f.getAuthenticatorPort_ = function (callback) { + setTimeout(function () { + callback(new u2f.WrappedAuthenticatorPort_()); + }, 0); + }; + + /** + * Return a 'port' abstraction to the iOS client app. + * @param {function(u2f.WrappedIosPort_)} callback + * @private + */ +u2f.getIosPort_ = function (callback) { + setTimeout(function () { + callback(new u2f.WrappedIosPort_()); + }, 0); + }; + + /** + * A wrapper for chrome.runtime.Port that is compatible with MessagePort. + * @param {Port} port + * @constructor + * @private + */ +u2f.WrappedChromeRuntimePort_ = function (port) { + this.port_ = port; + }; + + /** + * Format and return a sign request compliant with the JS API version supported by the extension. + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatSignRequest_ = + function (appId, challenge, registeredKeys, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: challenge, + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: signRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + appId: appId, + challenge: challenge, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + }; + + /** + * Format and return a register request compliant with the JS API version supported by the extension.. + * @param {Array} signRequests + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatRegisterRequest_ = + function (appId, registeredKeys, registerRequests, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + for (var i = 0; i < registerRequests.length; i++) { + registerRequests[i].appId = appId; + } + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: registerRequests[0], + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + signRequests: signRequests, + registerRequests: registerRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + appId: appId, + registerRequests: registerRequests, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + }; + + + /** + * Posts a message on the underlying channel. + * @param {Object} message + */ +u2f.WrappedChromeRuntimePort_.prototype.postMessage = function (message) { + this.port_.postMessage(message); + }; + + + /** + * Emulates the HTML 5 addEventListener interface. Works only for the + * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedChromeRuntimePort_.prototype.addEventListener = + function (eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message' || name == 'onmessage') { + this.port_.onMessage.addListener(function (message) { + // Emulate a minimal MessageEvent object + handler({ 'data': message }); + }); + } else { + console.error('WrappedChromeRuntimePort only supports onMessage'); + } + }; + + /** + * Wrap the Authenticator app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedAuthenticatorPort_ = function () { + this.requestId_ = -1; + this.requestObject_ = null; + } + + /** + * Launch the Authenticator intent. + * @param {Object} message + */ +u2f.WrappedAuthenticatorPort_.prototype.postMessage = function (message) { + var intentUrl = + u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + + ';S.request=' + encodeURIComponent(JSON.stringify(message)) + + ';end'; + document.location = intentUrl; + }; + + /** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedAuthenticatorPort_.prototype.getPortType = function () { + return "WrappedAuthenticatorPort_"; + }; + + + /** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function (eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message') { + var self = this; + /* Register a callback to that executes when + * chrome injects the response. */ + window.addEventListener( + 'message', self.onRequestUpdate_.bind(self, handler), false); + } else { + console.error('WrappedAuthenticatorPort only supports message'); + } + }; + + /** + * Callback invoked when a response is received from the Authenticator. + * @param function({data: Object}) callback + * @param {Object} message message Object + */ +u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = + function (callback, message) { + var messageObject = JSON.parse(message.data); + var intentUrl = messageObject['intentURL']; + + var errorCode = messageObject['errorCode']; + var responseObject = null; + if (messageObject.hasOwnProperty('data')) { + responseObject = /** @type {Object} */ ( + JSON.parse(messageObject['data'])); + } + + callback({ 'data': responseObject }); + }; + + /** + * Base URL for intents to Authenticator. + * @const + * @private + */ +u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = + 'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; + + /** + * Wrap the iOS client app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedIosPort_ = function () { }; + + /** + * Launch the iOS client app request + * @param {Object} message + */ +u2f.WrappedIosPort_.prototype.postMessage = function (message) { + var str = JSON.stringify(message); + var url = "u2f://auth?" + encodeURI(str); + location.replace(url); + }; + + /** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedIosPort_.prototype.getPortType = function () { + return "WrappedIosPort_"; + }; + + /** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedIosPort_.prototype.addEventListener = function (eventName, handler) { + var name = eventName.toLowerCase(); + if (name !== 'message') { + console.error('WrappedIosPort only supports message'); + } + }; + + /** + * Sets up an embedded trampoline iframe, sourced from the extension. + * @param {function(MessagePort)} callback + * @private + */ +u2f.getIframePort_ = function (callback) { + // Create the iframe + var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; + var iframe = document.createElement('iframe'); + iframe.src = iframeOrigin + '/u2f-comms.html'; + iframe.setAttribute('style', 'display:none'); + document.body.appendChild(iframe); + + var channel = new MessageChannel(); + var ready = function (message) { + if (message.data == 'ready') { + channel.port1.removeEventListener('message', ready); + callback(channel.port1); + } else { + console.error('First event on iframe port was not "ready"'); + } + }; + channel.port1.addEventListener('message', ready); + channel.port1.start(); + + iframe.addEventListener('load', function () { + // Deliver the port to the iframe and initialize + iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); + }); + }; + + + //High-level JS API + + /** + * Default extension response timeout in seconds. + * @const + */ +u2f.EXTENSION_TIMEOUT_SEC = 30; + + /** + * A singleton instance for a MessagePort to the extension. + * @type {MessagePort|u2f.WrappedChromeRuntimePort_} + * @private + */ +u2f.port_ = null; + + /** + * Callbacks waiting for a port + * @type {Array} + * @private + */ +u2f.waitingForPort_ = []; + + /** + * A counter for requestIds. + * @type {number} + * @private + */ +u2f.reqCounter_ = 0; + + /** + * A map from requestIds to client callbacks + * @type {Object.} + * @private + */ +u2f.callbackMap_ = {}; + + /** + * Creates or retrieves the MessagePort singleton to use. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + * @private + */ +u2f.getPortSingleton_ = function (callback) { + if (u2f.port_) { + callback(u2f.port_); + } else { + if (u2f.waitingForPort_.length == 0) { + u2f.getMessagePort(function (port) { + u2f.port_ = port; + u2f.port_.addEventListener('message', + /** @type {function(Event)} */ (u2f.responseHandler_)); + + // Careful, here be async callbacks. Maybe. + while (u2f.waitingForPort_.length) + u2f.waitingForPort_.shift()(u2f.port_); + }); + } + u2f.waitingForPort_.push(callback); + } + }; + + /** + * Handles response messages from the extension. + * @param {MessageEvent.} message + * @private + */ +u2f.responseHandler_ = function (message) { + var response = message.data; + var reqId = response['requestId']; + if (!reqId || !u2f.callbackMap_[reqId]) { + console.error('Unknown or missing requestId in response.'); + return; + } + var cb = u2f.callbackMap_[reqId]; + delete u2f.callbackMap_[reqId]; + cb(response['responseData']); + }; + + /** + * Dispatches an array of sign requests to available U2F tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the sign request. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sign = function (appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual sign request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version']; + //console.log("Extension JS API Version: ", js_api_version); + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual sign request in the supported API version. + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + } + }; + + /** + * Dispatches an array of sign requests to available U2F tokens. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendSignRequest = function (appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function (port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatSignRequest_(appId, challenge, registeredKeys, timeoutSeconds, reqId); + port.postMessage(req); + }); + }; + + /** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the register request. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.register = function (appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual register request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0: response['js_api_version']; + //console.log("Extension JS API Version: ", js_api_version); + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual register request in the supported API version. + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + } + }; + + /** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendRegisterRequest = function (appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function (port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatRegisterRequest_( + appId, registeredKeys, registerRequests, timeoutSeconds, reqId); + port.postMessage(req); + }); + }; + + + /** + * Dispatches a message to the extension to find out the supported + * JS API version. + * If the user is on a mobile phone and is thus using Google Authenticator instead + * of the Chrome extension, don't send the request and simply return 0. + * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.getApiVersion = function (callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function (port) { + // If we are using Android Google Authenticator or iOS client app, + // do not fire an intent to ask which JS API version to use. + if (port.getPortType) { + var apiVersion; + switch (port.getPortType()) { + case 'WrappedIosPort_': + case 'WrappedAuthenticatorPort_': + apiVersion = 1.1; + break; + + default: + apiVersion = 0; + break; + } + callback({ 'js_api_version': apiVersion }); + return; + } + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var req = { + type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST, + timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC), + requestId: reqId + }; + port.postMessage(req); + }); + }; + +} \ No newline at end of file diff --git a/views/default-min.handlebars b/views/default-min.handlebars index ae619e85..31a6e765 100644 --- a/views/default-min.handlebars +++ b/views/default-min.handlebars @@ -1 +1 @@ - MeshCentral
{{{title}}}
{{{title2}}}

{{{logoutControl}}}

 

\ No newline at end of file + MeshCentral
{{{title}}}
{{{title2}}}

{{{logoutControl}}}

 

\ No newline at end of file diff --git a/views/default.handlebars b/views/default.handlebars index a063829a..05bc7abf 100644 --- a/views/default.handlebars +++ b/views/default.handlebars @@ -25,6 +25,7 @@ + @@ -251,8 +252,8 @@ - + Change email address
Change password
Delete account
@@ -1172,7 +1173,7 @@ QV('verifyEmailId2', (userinfo.emailVerified !== true) && (userinfo.email != null) && (serverinfo.emailcheck == true)); QV('otpAuth', ((features & 4096) != 0) && (userinfo.otpsecret != 1)); QV('otpAuthRemove', ((features & 4096) != 0) && (userinfo.otpsecret == 1)); - QV('manageOtp', ((features & 4096) != 0) && (userinfo.otpsecret == 1)); + QV('manageOtp', ((features & 4096) != 0) && ((userinfo.otpsecret == 1) || (userinfo.otphkeys > 0))); QV('manageHardwareOtp', ((features & 0x5000) != 0)); // Requires 2-step login + YubiKey support } @@ -1457,13 +1458,15 @@ x += "
"; x += "
"; //x += ""; - if (window.u2f) { - x += ""; + + if (u2fSupported()) { + x += ""; } else { x += "No hardware key support on this browser."; } x += "

"; setDialogMode(2, "Manage Hardware Login Keys", 8, null, x, 'otpauth-hardware-manage'); + if (u2fSupported() && (message.keys.length > 0)) { QE('d2addkey', false); } break; } case 'otp-hkey-setup-request': { @@ -1471,7 +1474,7 @@ var x = "Press the key button now.

"; setDialogMode(2, "Add Hardware Login Key", 2, null, x); window.u2f.register(message.request.appId, [message.request], [], function (registrationResponse) { - if (registrationResponse.errorCode == 0) { + if (registrationResponse.registrationData) { meshserver.send({ action: 'otp-hkey-setup-response', request: message.request, response: registrationResponse, name: Q('dp1keyname').value }); setDialogMode(2, "Add Hardware Login Key", 0, null, '
Checking...


', 'otpauth-hardware-manage'); } else { @@ -5297,8 +5300,8 @@ function account_manageOtp(action) { if ((xxdialogMode == 2) && (xxdialogTag == 'otpauth-manage')) { dialogclose(0); } - if (xxdialogMode || (userinfo.otpsecret != 1) || ((features & 4096) == 0)) return; - meshserver.send({ action: 'otpauth-getpasswords', subaction: action }); + if (xxdialogMode || ((features & 4096) == 0)) return; + if ((userinfo.otpsecret == 1) || (userinfo.otphkeys > 0)) { meshserver.send({ action: 'otpauth-getpasswords', subaction: action }); } } function account_manageHardwareOtp() { @@ -5309,17 +5312,19 @@ function account_addhkey() { var x = "Type in the name of the key to add.

"; - x += addHtmlValue('Key Name', ''); + x += addHtmlValue('Key Name', ''); setDialogMode(2, "Add Hardware Login Key", 3, account_addhkeyEx, x); - account_addhkeyValidate(); + Q('dp1keyname').focus(); } - function account_addhkeyValidate() { - QE('idx_dlgOkButton', (Q('dp1keyname').value.length > 0)); + function account_addhkeyValidate(e) { + if ((e != null) && (e.keyCode == 13)) { dialogclose(1); } } function account_addhkeyEx() { - meshserver.send({ action: 'otp-hkey-setup-request', name: Q('dp1keyname').value }); + var name = Q('dp1keyname').value; + if (name == '') { name = 'MyKey'; } + meshserver.send({ action: 'otp-hkey-setup-request', name: name }); } function account_addYubiKey() { @@ -6943,6 +6948,8 @@ function focusTextBox(x) { setTimeout(function(){ Q(x).selectionStart = Q(x).selectionEnd = 65535; Q(x).focus(); }, 0); } function validateEmail(v) { var emailReg = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; return emailReg.test(v); } // New version function isPrivateIP(a) { return (a.startsWith('10.') || a.startsWith('172.16.') || a.startsWith('192.168.')); } + function u2fSupported() { return (window.u2f && ((navigator.userAgent.indexOf('Chrome/') > 0) || (navigator.userAgent.indexOf('Firefox/') > 0) || (navigator.userAgent.indexOf('Opera/') > 0) || (navigator.userAgent.indexOf('Safari/') > 0))); } + diff --git a/views/login-min.handlebars b/views/login-min.handlebars index 1f728d20..4a09c04c 100644 --- a/views/login-min.handlebars +++ b/views/login-min.handlebars @@ -1 +1 @@ - MeshCentral - Login
{{{title}}}
{{{title2}}}

Welcome

Connect to your home or office devices from anywhere in the world using MeshCentral, the real time, open source remote monitoring and management web site. You will need to download and install a management agent on your computers. Once installed, computers will show up in the "My Devices" section of this web site and you will be able to monitor them and take control of them.


\ No newline at end of file + MeshCentral - Login
{{{title}}}
{{{title2}}}

Welcome

Connect to your home or office devices from anywhere in the world using MeshCentral, the real time, open source remote monitoring and management web site. You will need to download and install a management agent on your computers. Once installed, computers will show up in the "My Devices" section of this web site and you will be able to monitor them and take control of them.


\ No newline at end of file diff --git a/views/login-mobile-min.handlebars b/views/login-mobile-min.handlebars index 0b320f18..16a50e47 100644 --- a/views/login-mobile-min.handlebars +++ b/views/login-mobile-min.handlebars @@ -1 +1 @@ - MeshCentral - Login
{{{title}}}
{{{title2}}}
\ No newline at end of file + MeshCentral - Login
{{{title}}}
{{{title2}}}
\ No newline at end of file diff --git a/views/login-mobile.handlebars b/views/login-mobile.handlebars index 228e2f4d..9edf2d0d 100644 --- a/views/login-mobile.handlebars +++ b/views/login-mobile.handlebars @@ -7,6 +7,7 @@ + MeshCentral - Login + MeshCentral - Login @@ -221,7 +222,11 @@ - +
Login token: + + + +
@@ -277,6 +282,7 @@ var newAccountPass = parseInt('{{{newAccountPass}}}'); var emailCheck = ('{{{emailcheck}}}' == 'true'); var passRequirements = "{{{passRequirements}}}"; + var hardwareKeyChallenge = '{{{hkey}}}'; if (passRequirements != "") { passRequirements = JSON.parse(decodeURIComponent(passRequirements)); } var features = parseInt('{{{features}}}'); var webPageFullScreen = getstore('webPageFullScreen', true); @@ -302,6 +308,21 @@ QV("newAccountPass", (newAccountPass == 1)); QV("resetAccountDiv", (emailCheck == true)); QV("hrAccountDiv", (emailCheck == true) || (newAccountPass == 1)); + + if ('{{loginmode}}' == '4') { + try { if (hardwareKeyChallenge.length > 0) { hardwareKeyChallenge = JSON.parse(hardwareKeyChallenge); } else { hardwareKeyChallenge = null; } } catch (ex) { hardwareKeyChallenge = null } + if ((hardwareKeyChallenge != null) && u2fSupported()) { + var c = hardwareKeyChallenge[0]; + window.u2f.sign(c.appId, c.challenge, hardwareKeyChallenge, function (authResponse) { + if (authResponse.signatureData) { + Q('hwtokenInput1').value = JSON.stringify(hardwareKeyChallenge); + Q('hwtokenInput2').value = JSON.stringify(authResponse); + QE('tokenOkButton', true); + Q('tokenOkButton').click(); + } + }); + } + } } function showPassHint() { @@ -522,6 +543,7 @@ function validateEmail(v) { var emailReg = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; return emailReg.test(v); } // New version function putstore(name, val) { try { if (typeof (localStorage) === 'undefined') return; localStorage.setItem(name, val); } catch (e) { } } function getstore(name, val) { try { if (typeof (localStorage) === 'undefined') return val; var v = localStorage.getItem(name); if ((v == null) || (v == null)) return val; return v; } catch (e) { return val; } } + function u2fSupported() { return (window.u2f && ((navigator.userAgent.indexOf('Chrome/') > 0) || (navigator.userAgent.indexOf('Firefox/') > 0) || (navigator.userAgent.indexOf('Opera/') > 0) || (navigator.userAgent.indexOf('Safari/') > 0))); } diff --git a/webserver.js b/webserver.js index dcd13ef1..7687abe5 100644 --- a/webserver.js +++ b/webserver.js @@ -336,6 +336,53 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { res.redirect(domain.url); } + // Return true if this user has 2-step auth active + function checkUserOneTimePasswordRequired(domain, user) { + return (user.otpsecret) || (user.otphkeys && (user.otphkeys.length > 0)); + } + + // Check the 2-step auth token + function checkUserOneTimePassword(domain, user, token, hwtoken1, hwtoken2) { + const twoStepLoginSupported = ((domain.auth != 'sspi') && (obj.parent.certificates.CommonName != 'un-configured') && (obj.args.lanonly !== true) && (obj.args.nousers !== true)); + if (twoStepLoginSupported == false) return true; + + // Check hardware key + if (user.otphkeys && (user.otphkeys.length > 0) && (typeof (hwtoken1) == 'string') && (typeof (hwtoken2) == 'string')) { + // Check hardware token + var authRequest = null, authResponse = null; + try { authRequest = JSON.parse(hwtoken1); } catch (ex) { } + try { authResponse = JSON.parse(hwtoken2); } catch (ex) { } + if ((authRequest != null) && (authResponse != null)) { + const u2f = require('u2f'); + const result = u2f.checkSignature(authRequest[0], authResponse, user.otphkeys[0].publicKey); + if (result.successful === true) return true; + } + } + + // Check Google Authenticator + const otplib = require('otplib') + if (user.otpsecret && (typeof (token) == 'string') && (otplib.authenticator.check(token, user.otpsecret) == true)) return true; + + // Check written down keys + if ((user.otpkeys != null) && (user.otpkeys.keys != null)) { + var tokenNumber = parseInt(token); + for (var i = 0; i < user.otpkeys.keys.length; i++) { if ((tokenNumber === user.otpkeys.keys[i].p) && (user.otpkeys.keys[i].u === true)) { user.otpkeys.keys[i].u = false; return true; } } + } + + return false; + } + + // Return a hardware key challenge + function getHardwareKeyChallenge(domain, user) { + if (user.otphkeys && (user.otphkeys.length > 0)) { + var requests = []; + const u2f = require('u2f'); + for (var i in user.otphkeys) { requests.push(u2f.request('https://' + obj.parent.certificates.CommonName, user.otphkeys[i].keyHandle)); } + return JSON.stringify(requests); + } + return ''; + } + function handleLoginRequest(req, res) { const domain = checkUserIpAddress(req, res); if (domain == null) return; @@ -349,6 +396,20 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { if (userid) { var user = obj.users[userid]; + // Check if this user has 2-step login active + if (checkUserOneTimePasswordRequired(domain, user)) { + if (checkUserOneTimePassword(domain, user, req.body.token, req.body.hwtoken1, req.body.hwtoken2) == false) { + // 2-step auth is required, but the token is not present or not valid. + if (user.otpsecret != null) { req.session.error = 'Invalid token, try again.'; } + req.session.loginmode = '4'; + req.session.tokenusername = xusername; + req.session.tokenpassword = xpassword; + res.redirect(domain.url); + return; + } + } + + /* // Check if this user has 2-step login active var tokenValid = 0; const twoStepLoginSupported = ((domain.auth != 'sspi') && (obj.parent.certificates.CommonName != 'un-configured') && (obj.args.lanonly !== true) && (obj.args.nousers !== true)); @@ -356,7 +417,8 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { otplib.authenticator.options = { window: 6 }; // Set +/- 3 minute window if (twoStepLoginSupported && user.otpsecret && ((typeof (req.body.token) != 'string') || ((tokenValid = otplib.authenticator.check(req.body.token, user.otpsecret)) !== true))) { // Failed OTP, check user's one time passwords - if ((req.body.token != null) && (user.otpkeys != null) && (user.otpkeys.keys != null)) { + console.log(user); + if ((req.body.token != null) && ((user.otpkeys != null) && (user.otpkeys.keys != null)) || (user.otphkeys && user.otphkeys.length > 0)) { var found = null; var tokenNumber = parseInt(req.body.token); for (var i = 0; i < user.otpkeys.keys.length; i++) { if ((tokenNumber === user.otpkeys.keys[i].p) && (user.otpkeys.keys[i].u === true)) { user.otpkeys.keys[i].u = false; found = i; } } @@ -379,6 +441,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { return; } } + */ // Save login time user.login = Math.floor(Date.now() / 1000); @@ -807,7 +870,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { if ((obj.parent.serverSelfWriteAllowed == true) && (user != null) && (user.siteadmin == 0xFFFFFFFF)) { features += 0x0800; } // Server can self-write (Allows self-update) if ((domain.auth != 'sspi') && (obj.parent.certificates.CommonName != 'un-configured') && (obj.args.lanonly !== true) && (obj.args.nousers !== true)) { features += 0x1000; } // 2-step login supported if (domain.agentnoproxy === true) { features += 0x2000; } // Indicates that agents should be installed without using a HTTP proxy - if (domain.yubikey && domain.yubikey.id && domain.yubikey.secret) { features += 0x4000; } // Indicates Yubikey support + if (domain.yubikey && domain.yubikey.id && domain.yubikey.secret) { features += 0x4000; } // Indicates Yubikey support (???) // Create a authentication cookie const authCookie = obj.parent.encodeCookie({ userid: user._id, domainid: domain.id }, obj.parent.loginCookieEncryptionKey); @@ -836,17 +899,26 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { if ((parent.config != null) && (parent.config.settings != null) && (parent.config.settings.allowframing == true)) { features += 32; } // Allow site within iframe var httpsPort = ((obj.args.aliasport == null) ? obj.args.port : obj.args.aliasport); // Use HTTPS alias port is specified + // If this is a 2 factor auth request, look for a hardware key challenge. + var hardwareKeyChallenge = ''; + if ((loginmode == '4') && (req.session.tokenusername)) { + var user = obj.users['user/' + domain.id + '/' + req.session.tokenusername]; + if (user != null) { + hardwareKeyChallenge = getHardwareKeyChallenge(domain, user); + } + } + if (obj.args.minify && !req.query.nominify) { // Try to server the minified version if we can. try { - res.render(obj.path.join(obj.parent.webViewsPath, isMobileBrowser(req) ? 'login-mobile-min' : 'login-min'), { loginmode: loginmode, rootCertLink: getRootCertLink(), title: domain.title, title2: domain.title2, newAccount: domain.newaccounts, newAccountPass: (((domain.newaccountspass == null) || (domain.newaccountspass == '')) ? 0 : 1), serverDnsName: obj.getWebServerName(domain), serverPublicPort: httpsPort, emailcheck: obj.parent.mailserver != null, features: features, sessiontime: args.sessiontime, passRequirements: passRequirements, footer: (domain.footer == null) ? '' : domain.footer }); + res.render(obj.path.join(obj.parent.webViewsPath, isMobileBrowser(req) ? 'login-mobile-min' : 'login-min'), { loginmode: loginmode, rootCertLink: getRootCertLink(), title: domain.title, title2: domain.title2, newAccount: domain.newaccounts, newAccountPass: (((domain.newaccountspass == null) || (domain.newaccountspass == '')) ? 0 : 1), serverDnsName: obj.getWebServerName(domain), serverPublicPort: httpsPort, emailcheck: obj.parent.mailserver != null, features: features, sessiontime: args.sessiontime, passRequirements: passRequirements, footer: (domain.footer == null) ? '' : domain.footer, hkey: hardwareKeyChallenge }); } catch (ex) { // In case of an exception, serve the non-minified version. - res.render(obj.path.join(obj.parent.webViewsPath, isMobileBrowser(req) ? 'login-mobile' : 'login'), { loginmode: loginmode, rootCertLink: getRootCertLink(), title: domain.title, title2: domain.title2, newAccount: domain.newaccounts, newAccountPass: (((domain.newaccountspass == null) || (domain.newaccountspass == '')) ? 0 : 1), serverDnsName: obj.getWebServerName(domain), serverPublicPort: httpsPort, emailcheck: obj.parent.mailserver != null, features: features, sessiontime: args.sessiontime, passRequirements: passRequirements, footer: (domain.footer == null) ? '' : domain.footer }); + res.render(obj.path.join(obj.parent.webViewsPath, isMobileBrowser(req) ? 'login-mobile' : 'login'), { loginmode: loginmode, rootCertLink: getRootCertLink(), title: domain.title, title2: domain.title2, newAccount: domain.newaccounts, newAccountPass: (((domain.newaccountspass == null) || (domain.newaccountspass == '')) ? 0 : 1), serverDnsName: obj.getWebServerName(domain), serverPublicPort: httpsPort, emailcheck: obj.parent.mailserver != null, features: features, sessiontime: args.sessiontime, passRequirements: passRequirements, footer: (domain.footer == null) ? '' : domain.footer, hkey: hardwareKeyChallenge }); } } else { // Serve non-minified version of web pages. - res.render(obj.path.join(obj.parent.webViewsPath, isMobileBrowser(req) ? 'login-mobile' : 'login'), { loginmode: loginmode, rootCertLink: getRootCertLink(), title: domain.title, title2: domain.title2, newAccount: domain.newaccounts, newAccountPass: (((domain.newaccountspass == null) || (domain.newaccountspass == '')) ? 0 : 1), serverDnsName: obj.getWebServerName(domain), serverPublicPort: httpsPort, emailcheck: obj.parent.mailserver != null, features: features, sessiontime: args.sessiontime, passRequirements: passRequirements, footer: (domain.footer == null) ? '' : domain.footer }); + res.render(obj.path.join(obj.parent.webViewsPath, isMobileBrowser(req) ? 'login-mobile' : 'login'), { loginmode: loginmode, rootCertLink: getRootCertLink(), title: domain.title, title2: domain.title2, newAccount: domain.newaccounts, newAccountPass: (((domain.newaccountspass == null) || (domain.newaccountspass == '')) ? 0 : 1), serverDnsName: obj.getWebServerName(domain), serverPublicPort: httpsPort, emailcheck: obj.parent.mailserver != null, features: features, sessiontime: args.sessiontime, passRequirements: passRequirements, footer: (domain.footer == null) ? '' : domain.footer, hkey: hardwareKeyChallenge }); } /*