diff --git a/src/images/loading.svg b/src/images/loading.svg new file mode 100644 index 00000000000..70763105168 --- /dev/null +++ b/src/images/loading.svg @@ -0,0 +1,6 @@ + + + Loading... + + diff --git a/src/popup/app/accounts/accountsLoginController.js b/src/popup/app/accounts/accountsLoginController.js index 6fe187c6b09..0f1284b95a7 100644 --- a/src/popup/app/accounts/accountsLoginController.js +++ b/src/popup/app/accounts/accountsLoginController.js @@ -40,7 +40,8 @@ $state.go('twoFactor', { animation: 'in-slide-left', email: model.email, - masterPassword: model.masterPassword + masterPassword: model.masterPassword, + providers: response.twoFactorProviders }); } else { diff --git a/src/popup/app/accounts/accountsLoginTwoFactorController.js b/src/popup/app/accounts/accountsLoginTwoFactorController.js index 2b814f9808c..3ded0d4d89e 100644 --- a/src/popup/app/accounts/accountsLoginTwoFactorController.js +++ b/src/popup/app/accounts/accountsLoginTwoFactorController.js @@ -2,23 +2,30 @@ .module('bit.accounts') .controller('accountsLoginTwoFactorController', function ($scope, $state, authService, toastr, utilsService, - $analytics, i18nService, $stateParams) { + $analytics, i18nService, $stateParams, $filter, constantsService, $timeout, $window, cryptoService) { $scope.i18n = i18nService; - $scope.model = {}; utilsService.initListSectionItemListeners($(document), angular); - $('#code').focus(); + var constants = constantsService; var email = $stateParams.email; var masterPassword = $stateParams.masterPassword; + var providers = $stateParams.providers; + + $scope.twoFactorEmail = null; + $scope.token = null; + $scope.constantsProvider = constants.twoFactorProvider; + $scope.providerType = $stateParams.provider ? $stateParams.provider : getDefaultProvider(providers); + $scope.u2fReady = false; + init(); $scope.loginPromise = null; - $scope.login = function (model) { - if (!model.code) { + $scope.login = function (token) { + if (!token) { toastr.error(i18nService.verificationCodeRequired, i18nService.errorsOccurred); return; } - $scope.loginPromise = authService.logIn(email, masterPassword, 0, model.code); + $scope.loginPromise = authService.logIn(email, masterPassword, $scope.providerType, token); $scope.loginPromise.then(function () { $analytics.eventTrack('Logged In From Two-step'); $state.go('tabs.vault', { animation: 'in-slide-left', syncOnLoad: true }); @@ -29,4 +36,94 @@ $analytics.eventTrack('Selected Lost 2FA App'); chrome.tabs.create({ url: 'https://help.bitwarden.com/article/lost-two-step-device/' }); }; + + $scope.sendEmail = function (doToast) { + if ($scope.providerType !== constants.twoFactorProvider.email) { + return; + } + + var key = cryptoService.makeKey(masterPassword, email); + var hash = cryptoService.hashPassword(masterPassword, key); + apiService.postTwoFactorEmail({ + email: email, + masterPasswordHash: hash + }, function () { + if (doToast) { + toastr.success('Verification email sent to ' + $scope.twoFactorEmail + '.'); + } + }, function () { + toastr.error('Could not send verification email.'); + }); + }; + + function getDefaultProvider(twoFactorProviders) { + var keys = Object.keys(twoFactorProviders); + var providerType = null; + var providerPriority = -1; + for (var i = 0; i < keys.length; i++) { + var provider = $filter('filter')(constants.twoFactorProviderInfo, { type: keys[i], active: true }); + if (provider.length && provider[0].priority > providerPriority) { + if (provider[0].type == constants.twoFactorProvider.u2f && + !utilsService.isChrome() && !utilsService.isOpera()) { + continue; + } + + providerType = provider[0].type; + providerPriority = provider[0].priority; + } + } + return parseInt(providerType); + } + + function init() { + $timeout(function () { + $('#code').focus(); + + if ($scope.providerType === constants.twoFactorProvider.duo) { + var params = providers[constants.twoFactorProvider.duo]; + + $window.Duo.init({ + host: params.Host, + sig_request: params.Signature, + submit_callback: function (theForm) { + var response = $(theForm).find('input[name="sig_response"]').val(); + $scope.login(response); + } + }); + } + else if ($scope.providerType === constants.twoFactorProvider.u2f) { + var params = providers[constants.twoFactorProvider.u2f]; + var challenges = JSON.parse(params.Challenges); + + var u2f = new U2f(function (data) { + $scope.login(data); + $scope.$apply(); + }, function (error) { + toastr.error(error, i18nService.errorsOccurred); + $scope.$apply(); + }, function (info) { + if (info === 'ready') { + $scope.u2fReady = true; + } + $scope.$apply(); + }); + + u2f.init({ + appId: challenges[0].appId, + challenge: challenges[0].challenge, + keys: [{ + version: challenges[0].version, + keyHandle: challenges[0].keyHandle + }] + }); + } + else if ($scope.providerType === constants.twoFactorProvider.email) { + var params = providers[constants.twoFactorProvider.email]; + $scope.twoFactorEmail = params.Email; + if (Object.keys(providers).length > 1) { + $scope.sendEmail(false); + } + } + }, 500); + } }); diff --git a/src/popup/app/accounts/views/accountsLoginTwoFactor.html b/src/popup/app/accounts/views/accountsLoginTwoFactor.html index f247bc29d1d..81ef904d058 100644 --- a/src/popup/app/accounts/views/accountsLoginTwoFactor.html +++ b/src/popup/app/accounts/views/accountsLoginTwoFactor.html @@ -1,4 +1,5 @@ -
+
{{i18n.login}} @@ -16,7 +17,7 @@
- +
+ +
+
+ +
Duo
+
+
+
+ +
+
+
+ +
+
+ +
+ + +
+
YubiKey
+
+
+
+
+
+
+ + + +
+
+ +
+
+
+
+ +
+
+ +
+ +
+
FIDO U2F
+
+
+
Loading...
+
Touch button
+ +
+
diff --git a/src/popup/app/config.js b/src/popup/app/config.js index bda3de7cded..eec1de97c08 100644 --- a/src/popup/app/config.js +++ b/src/popup/app/config.js @@ -66,7 +66,7 @@ controller: 'accountsLoginTwoFactorController', templateUrl: 'app/accounts/views/accountsLoginTwoFactor.html', data: { authorize: false }, - params: { animation: null, email: null, masterPassword: null } + params: { animation: null, email: null, masterPassword: null, providers: null, provider: null } }) .state('register', { url: '/register', diff --git a/src/popup/index.html b/src/popup/index.html index 0f18e5b3306..88f45037b24 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -17,6 +17,8 @@ + + diff --git a/src/popup/less/components.less b/src/popup/less/components.less index da1e25e57c1..9b7bf8b11ea 100644 --- a/src/popup/less/components.less +++ b/src/popup/less/components.less @@ -493,3 +493,16 @@ width: 100%; } } + +#duoFrameWrapper { + background: ~"url('../../images/loading.svg') 0 0 no-repeat"; + width: 100%; + height: 100%; + + iframe { + width: 100%; + height: 100%; + border: none; + margin-bottom: -5px; + } +} diff --git a/src/scripts/duo.js b/src/scripts/duo.js new file mode 100644 index 00000000000..e2a6d7c8d34 --- /dev/null +++ b/src/scripts/duo.js @@ -0,0 +1,430 @@ +/** + * Duo Web SDK v2 + * Copyright 2017, Duo Security + */ + +(function (root, factory) { + /*eslint-disable */ + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define([], factory); + /*eslint-enable */ + } else if (typeof module === 'object' && module.exports) { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(); + } else { + // Browser globals (root is window) + var Duo = factory(); + // If the Javascript was loaded via a script tag, attempt to autoload + // the frame. + Duo._onReady(Duo.init); + + // Attach Duo to the `window` object + root.Duo = Duo; + } +}(this, function () { + var DUO_MESSAGE_FORMAT = /^(?:AUTH|ENROLL)+\|[A-Za-z0-9\+\/=]+\|[A-Za-z0-9\+\/=]+$/; + var DUO_ERROR_FORMAT = /^ERR\|[\w\s\.\(\)]+$/; + var DUO_OPEN_WINDOW_FORMAT = /^DUO_OPEN_WINDOW\|/; + var VALID_OPEN_WINDOW_DOMAINS = [ + 'duo.com', + 'duosecurity.com', + 'duomobile.s3-us-west-1.amazonaws.com' + ]; + + var iframeId = 'duo_iframe', + postAction = '', + postArgument = 'sig_response', + host, + sigRequest, + duoSig, + appSig, + iframe, + submitCallback; + + function throwError(message, url) { + throw new Error( + 'Duo Web SDK error: ' + message + + (url ? ('\n' + 'See ' + url + ' for more information') : '') + ); + } + + function hyphenize(str) { + return str.replace(/([a-z])([A-Z])/, '$1-$2').toLowerCase(); + } + + // cross-browser data attributes + function getDataAttribute(element, name) { + if ('dataset' in element) { + return element.dataset[name]; + } else { + return element.getAttribute('data-' + hyphenize(name)); + } + } + + // cross-browser event binding/unbinding + function on(context, event, fallbackEvent, callback) { + if ('addEventListener' in window) { + context.addEventListener(event, callback, false); + } else { + context.attachEvent(fallbackEvent, callback); + } + } + + function off(context, event, fallbackEvent, callback) { + if ('removeEventListener' in window) { + context.removeEventListener(event, callback, false); + } else { + context.detachEvent(fallbackEvent, callback); + } + } + + function onReady(callback) { + on(document, 'DOMContentLoaded', 'onreadystatechange', callback); + } + + function offReady(callback) { + off(document, 'DOMContentLoaded', 'onreadystatechange', callback); + } + + function onMessage(callback) { + on(window, 'message', 'onmessage', callback); + } + + function offMessage(callback) { + off(window, 'message', 'onmessage', callback); + } + + /** + * Parse the sig_request parameter, throwing errors if the token contains + * a server error or if the token is invalid. + * + * @param {String} sig Request token + */ + function parseSigRequest(sig) { + if (!sig) { + // nothing to do + return; + } + + // see if the token contains an error, throwing it if it does + if (sig.indexOf('ERR|') === 0) { + throwError(sig.split('|')[1]); + } + + // validate the token + if (sig.indexOf(':') === -1 || sig.split(':').length !== 2) { + throwError( + 'Duo was given a bad token. This might indicate a configuration ' + + 'problem with one of Duo\'s client libraries.', + 'https://www.duosecurity.com/docs/duoweb#first-steps' + ); + } + + var sigParts = sig.split(':'); + + // hang on to the token, and the parsed duo and app sigs + sigRequest = sig; + duoSig = sigParts[0]; + appSig = sigParts[1]; + + return { + sigRequest: sig, + duoSig: sigParts[0], + appSig: sigParts[1] + }; + } + + /** + * This function is set up to run when the DOM is ready, if the iframe was + * not available during `init`. + */ + function onDOMReady() { + iframe = document.getElementById(iframeId); + + if (!iframe) { + throw new Error( + 'This page does not contain an iframe for Duo to use.' + + 'Add an element like ' + + 'to this page. ' + + 'See https://www.duosecurity.com/docs/duoweb#3.-show-the-iframe ' + + 'for more information.' + ); + } + + // we've got an iframe, away we go! + ready(); + + // always clean up after yourself + offReady(onDOMReady); + } + + /** + * Validate that a MessageEvent came from the Duo service, and that it + * is a properly formatted payload. + * + * The Google Chrome sign-in page injects some JS into pages that also + * make use of postMessage, so we need to do additional validation above + * and beyond the origin. + * + * @param {MessageEvent} event Message received via postMessage + */ + function isDuoMessage(event) { + return Boolean( + event.origin === ('https://' + host) && + typeof event.data === 'string' && + ( + event.data.match(DUO_MESSAGE_FORMAT) || + event.data.match(DUO_ERROR_FORMAT) || + event.data.match(DUO_OPEN_WINDOW_FORMAT) + ) + ); + } + + /** + * Validate the request token and prepare for the iframe to become ready. + * + * All options below can be passed into an options hash to `Duo.init`, or + * specified on the iframe using `data-` attributes. + * + * Options specified using the options hash will take precedence over + * `data-` attributes. + * + * Example using options hash: + * ```javascript + * Duo.init({ + * iframe: "some_other_id", + * host: "api-main.duo.test", + * sig_request: "...", + * post_action: "/auth", + * post_argument: "resp" + * }); + * ``` + * + * Example using `data-` attributes: + * ``` + * + * ``` + * + * @param {Object} options + * @param {String} options.iframe The iframe, or id of an iframe to set up + * @param {String} options.host Hostname + * @param {String} options.sig_request Request token + * @param {String} [options.post_action=''] URL to POST back to after successful auth + * @param {String} [options.post_argument='sig_response'] Parameter name to use for response token + * @param {Function} [options.submit_callback] If provided, duo will not submit the form instead execute + * the callback function with reference to the "duo_form" form object + * submit_callback can be used to prevent the webpage from reloading. + */ + function init(options) { + if (options) { + if (options.host) { + host = options.host; + } + + if (options.sig_request) { + parseSigRequest(options.sig_request); + } + + if (options.post_action) { + postAction = options.post_action; + } + + if (options.post_argument) { + postArgument = options.post_argument; + } + + if (options.iframe) { + if (options.iframe.tagName) { + iframe = options.iframe; + } else if (typeof options.iframe === 'string') { + iframeId = options.iframe; + } + } + + if (typeof options.submit_callback === 'function') { + submitCallback = options.submit_callback; + } + } + + // if we were given an iframe, no need to wait for the rest of the DOM + if (false && iframe) { + ready(); + } else { + // try to find the iframe in the DOM + iframe = document.getElementById(iframeId); + + // iframe is in the DOM, away we go! + if (iframe) { + ready(); + } else { + // wait until the DOM is ready, then try again + onReady(onDOMReady); + } + } + + // always clean up after yourself! + offReady(init); + } + + /** + * This function is called when a message was received from another domain + * using the `postMessage` API. Check that the event came from the Duo + * service domain, and that the message is a properly formatted payload, + * then perform the post back to the primary service. + * + * @param event Event object (contains origin and data) + */ + function onReceivedMessage(event) { + if (isDuoMessage(event)) { + if (event.data.match(DUO_OPEN_WINDOW_FORMAT)) { + var url = event.data.substring("DUO_OPEN_WINDOW|".length); + if (isValidUrlToOpen(url)) { + // Open the URL that comes after the DUO_WINDOW_OPEN token. + window.open(url, "_self"); + } + } + else { + // the event came from duo, do the post back + doPostBack(event.data); + + // always clean up after yourself! + offMessage(onReceivedMessage); + } + } + } + + /** + * Validate that this passed in URL is one that we will actually allow to + * be opened. + * @param url String URL that the message poster wants to open + * @returns {boolean} true if we allow this url to be opened in the window + */ + function isValidUrlToOpen(url) { + if (!url) { + return false; + } + + var parser = document.createElement('a'); + parser.href = url; + + if (parser.protocol === "duotrustedendpoints:") { + return true; + } else if (parser.protocol !== "https:") { + return false; + } + + for (var i = 0; i < VALID_OPEN_WINDOW_DOMAINS.length; i++) { + if (parser.hostname.endsWith("." + VALID_OPEN_WINDOW_DOMAINS[i]) || + parser.hostname === VALID_OPEN_WINDOW_DOMAINS[i]) { + return true; + } + } + return false; + } + + /** + * Point the iframe at Duo, then wait for it to postMessage back to us. + */ + function ready() { + if (!host) { + host = getDataAttribute(iframe, 'host'); + + if (!host) { + throwError( + 'No API hostname is given for Duo to use. Be sure to pass ' + + 'a `host` parameter to Duo.init, or through the `data-host` ' + + 'attribute on the iframe element.', + 'https://www.duosecurity.com/docs/duoweb#3.-show-the-iframe' + ); + } + } + + if (!duoSig || !appSig) { + parseSigRequest(getDataAttribute(iframe, 'sigRequest')); + + if (!duoSig || !appSig) { + throwError( + 'No valid signed request is given. Be sure to give the ' + + '`sig_request` parameter to Duo.init, or use the ' + + '`data-sig-request` attribute on the iframe element.', + 'https://www.duosecurity.com/docs/duoweb#3.-show-the-iframe' + ); + } + } + + // if postAction/Argument are defaults, see if they are specified + // as data attributes on the iframe + if (postAction === '') { + postAction = getDataAttribute(iframe, 'postAction') || postAction; + } + + if (postArgument === 'sig_response') { + postArgument = getDataAttribute(iframe, 'postArgument') || postArgument; + } + + // point the iframe at Duo + iframe.src = [ + 'https://', host, '/frame/web/v1/auth?tx=', duoSig, + '&parent=', encodeURIComponent(document.location.href), + '&v=2.6' + ].join(''); + + // listen for the 'message' event + onMessage(onReceivedMessage); + } + + /** + * We received a postMessage from Duo. POST back to the primary service + * with the response token, and any additional user-supplied parameters + * given in form#duo_form. + */ + function doPostBack(response) { + // create a hidden input to contain the response token + var input = document.createElement('input'); + input.type = 'hidden'; + input.name = postArgument; + input.value = response + ':' + appSig; + + // user may supply their own form with additional inputs + var form = document.getElementById('duo_form'); + + // if the form doesn't exist, create one + if (!form) { + form = document.createElement('form'); + + // insert the new form after the iframe + iframe.parentElement.insertBefore(form, iframe.nextSibling); + } + + // make sure we are actually posting to the right place + form.method = 'POST'; + form.action = postAction; + + // add the response token input to the form + form.appendChild(input); + + // away we go! + if (typeof submitCallback === "function") { + submitCallback.call(null, form); + } else { + form.submit(); + } + } + + return { + init: init, + _onReady: onReady, + _parseSigRequest: parseSigRequest, + _isDuoMessage: isDuoMessage, + _doPostBack: doPostBack + }; +})); diff --git a/src/scripts/u2f.js b/src/scripts/u2f.js new file mode 100644 index 00000000000..6234c6300cb --- /dev/null +++ b/src/scripts/u2f.js @@ -0,0 +1,50 @@ +function U2f(successCallback, errorCallback, infoCallback) { + this.success = successCallback; + this.error = errorCallback; + this.info = infoCallback; + this.iframe = null; +}; + +U2f.prototype.init = function (data) { + var self = this; + + iframe = document.getElementById('u2f_iframe'); + iframe.src = 'https://vault.bitwarden.com/u2f-connector.html' + + '?data=' + this.base64Encode(JSON.stringify(data)) + + '&parent=' + encodeURIComponent(document.location.href) + + '&v=1'; + + window.addEventListener('message', function (event) { + if (!self.validMessage(event)) { + self.error('Invalid message.'); + return; + } + + var parts = event.data.split('|'); + if (parts[0] === 'success' && self.success) { + self.success(parts[1]); + } + else if (parts[0] === 'error' && self.error) { + self.error(parts[1]); + } + else if (parts[0] === 'info') { + if (self.info) { + self.info(parts[1]); + } + } + }, false); +}; + +U2f.prototype.validMessage = function (event) { + if (event.origin !== 'https://vault.bitwarden.com') { + return false; + } + + return event.data.indexOf('success|') === 0 || event.data.indexOf('error|') === 0 || event.data.indexOf('info|') === 0; +} + +U2f.prototype.base64Encode = function (str) { + return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) { + return String.fromCharCode('0x' + p1); + })); +} diff --git a/src/services/apiService.js b/src/services/apiService.js index dec5051ebd0..8cbf1dff39b 100644 --- a/src/services/apiService.js +++ b/src/services/apiService.js @@ -1,21 +1,21 @@ function ApiService(tokenService, appIdService, utilsService, logoutCallback) { - // Desktop + // Desktop //this.baseUrl = 'http://localhost:4000'; //this.identityBaseUrl = 'http://localhost:33656'; - // Desktop HTTPS + // Desktop HTTPS //this.baseUrl = 'https://localhost:44377'; //this.identityBaseUrl = 'https://localhost:44392'; - // Desktop external + // Desktop external //this.baseUrl = 'http://192.168.1.6:4000'; //this.identityBaseUrl = 'http://192.168.1.6:33656'; - // Preview + // Preview //this.baseUrl = 'https://preview-api.bitwarden.com'; //this.identityBaseUrl = 'https://preview-identity.bitwarden.com'; - // Production + // Production this.baseUrl = 'https://api.bitwarden.com'; this.identityBaseUrl = 'https://identity.bitwarden.com'; @@ -43,9 +43,9 @@ function initApiService() { success(new IdentityTokenResponse(response)); }, error: function (jqXHR, textStatus, errorThrown) { - if (jqXHR.responseJSON && jqXHR.responseJSON.TwoFactorProviders && - jqXHR.responseJSON.TwoFactorProviders.length) { - successWithTwoFactor(); + if (jqXHR.responseJSON && jqXHR.responseJSON.TwoFactorProviders2 && + Object.keys(jqXHR.responseJSON.TwoFactorProviders2).length) { + successWithTwoFactor(jqXHR.responseJSON.TwoFactorProviders2); } else { error(new ErrorResponse(jqXHR, true)); @@ -54,6 +54,23 @@ function initApiService() { }); }; + // Two Factor APIs + + ApiService.prototype.postTwoFactorEmail = function (success, error) { + var self = this; + $.ajax({ + type: 'POST', + url: self.baseUrl + '/two-factor/send-email?' + token, + dataType: 'json', + success: function (response) { + success(response); + }, + error: function (jqXHR, textStatus, errorThrown) { + handleError(error, jqXHR, false, self); + } + }); + }; + // Account APIs ApiService.prototype.getAccountRevisionDate = function (success, error) { diff --git a/src/services/constantsService.js b/src/services/constantsService.js index d67f5671299..dce35bbd52a 100644 --- a/src/services/constantsService.js +++ b/src/services/constantsService.js @@ -13,6 +13,58 @@ function ConstantsService() { Rsa2048_OaepSha1_B64: 4, Rsa2048_OaepSha256_HmacSha256_B64: 5, Rsa2048_OaepSha1_HmacSha256_B64: 6 - } + }, + twoFactorProvider: { + u2f: 4, + yubikey: 3, + duo: 2, + authenticator: 0, + email: 1, + remember: 5 + }, + twoFactorProviderInfo: [ + { + type: 0, + name: 'Authenticator App', + description: 'Use an authenticator app (such as Authy or Google Authenticator) to generate time-based ' + + 'verification codes.', + active: true, + free: true, + displayOrder: 0, + priority: 1 + }, + { + type: 3, + name: 'YubiKey OTP Security Key', + description: 'Use a YubiKey to access your account. Works with YubiKey 4, 4 Nano, 4C, and NEO devices.', + active: true, + displayOrder: 1, + priority: 3 + }, + { + type: 2, + name: 'Duo', + description: 'Verify with Duo Security using the Duo Mobile app, SMS, phone call, or U2F security key.', + active: true, + displayOrder: 2, + priority: 2 + }, + { + type: 4, + name: 'FIDO U2F Security Key', + description: 'Use any FIDO U2F enabled security key to access your account.', + active: true, + displayOrder: 3, + priority: 4 + }, + { + type: 1, + name: 'Email', + description: 'Verification codes will be emailed to you.', + active: true, + displayOrder: 4, + priority: 0 + } + ] }; };