зеркало из https://github.com/mozilla/fxa.git
feat(totp): integrate TOTP setup into login flow
- If the request contains acr_values=AAL2 but the user does not have 2FA configured, continue into 2FA setup flow after login.
This commit is contained in:
Родитель
70333b9e54
Коммит
5b333ac2a3
|
@ -31,6 +31,8 @@ import DisplayNameView from '../views/settings/display_name';
|
||||||
import EmailsView from '../views/settings/emails';
|
import EmailsView from '../views/settings/emails';
|
||||||
import ForceAuthView from '../views/force_auth';
|
import ForceAuthView from '../views/force_auth';
|
||||||
import IndexView from '../views/index';
|
import IndexView from '../views/index';
|
||||||
|
import InlineTotpSetupView from '../views/inline_totp_setup';
|
||||||
|
import InlineRecoverySetupView from '../views/inline_recovery_setup';
|
||||||
import PermissionsView from '../views/permissions';
|
import PermissionsView from '../views/permissions';
|
||||||
import SupportView from '../views/support';
|
import SupportView from '../views/support';
|
||||||
import ReadyView from '../views/ready';
|
import ReadyView from '../views/ready';
|
||||||
|
@ -127,6 +129,8 @@ const Router = Backbone.Router.extend({
|
||||||
),
|
),
|
||||||
'cookies_disabled(/)': createViewHandler(CookiesDisabledView),
|
'cookies_disabled(/)': createViewHandler(CookiesDisabledView),
|
||||||
'force_auth(/)': createViewHandler(ForceAuthView),
|
'force_auth(/)': createViewHandler(ForceAuthView),
|
||||||
|
'inline_totp_setup(/)': createViewHandler(InlineTotpSetupView),
|
||||||
|
'inline_recovery_setup(/)': createViewHandler(InlineRecoverySetupView),
|
||||||
'legal(/)': createViewHandler('legal'),
|
'legal(/)': createViewHandler('legal'),
|
||||||
'legal/privacy(/)': createViewHandler('pp'),
|
'legal/privacy(/)': createViewHandler('pp'),
|
||||||
'legal/terms(/)': createViewHandler('tos'),
|
'legal/terms(/)': createViewHandler('tos'),
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
<div id="main-content" class="card inline-recovery-setup">
|
||||||
|
{{^showConfirmation}}
|
||||||
|
<header>
|
||||||
|
<h1 id="fxa-save-recovery-codes">
|
||||||
|
<!-- L10N: For languages structured like English, the second phrase can read "to continue to %(serviceName)s" -->
|
||||||
|
{{#t}}Save recovery codes{{/t}} <span class="service">{{#t}}Continue to %(serviceName)s{{/t}}</span>
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<section>
|
||||||
|
<div class="error"></div>
|
||||||
|
<div class="success"></div>
|
||||||
|
<p>
|
||||||
|
{{#t}}Store these one-time use codes in a safe place for when you don't have your mobile device.{{/t}}
|
||||||
|
</p>
|
||||||
|
<div id="recovery-codes">
|
||||||
|
<div id="recovery-code-container">
|
||||||
|
{{#recoveryCodes}}
|
||||||
|
<div class="recovery-code">{{code}}</div>
|
||||||
|
{{/recoveryCodes}}
|
||||||
|
{{^isIos}}
|
||||||
|
<div class="button-row save-options">
|
||||||
|
<button type="button" class="save-option download-option">
|
||||||
|
<div class="graphic graphic-download-option"></div>
|
||||||
|
<p class="name">{{#t}}Download{{/t}}</p>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="save-option copy-option">
|
||||||
|
<div class="graphic graphic-copy-option"></div>
|
||||||
|
<p class="name">{{#t}}Copy{{/t}}</p>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="save-option print-option">
|
||||||
|
<div class="graphic graphic-print-option"></div>
|
||||||
|
<p class="name">{{#t}}Print{{/t}}</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{/isIos}}
|
||||||
|
|
||||||
|
{{#isIos}}
|
||||||
|
<div class="button-row">
|
||||||
|
<button type="button" class="save-option copy-option">
|
||||||
|
<div class="graphic graphic-copy-option"></div>
|
||||||
|
<p class="name">{{#t}}Copy{{/t}}</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{/isIos}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button class="primary-button recovery-setup-done">{{#t}}Continue{{/t}}</button>
|
||||||
|
</div>
|
||||||
|
<div class="links">
|
||||||
|
<a href="#" class="cancel enabled recovery-cancel">{{#t}}Cancel setup{{/t}}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{/showConfirmation}}
|
||||||
|
{{#showConfirmation}}
|
||||||
|
<header>
|
||||||
|
<h1 id="fxa-confirm-recovery-code">
|
||||||
|
<!-- L10N: For languages structured like English, the second phrase can read "to continue to %(serviceName)s" -->
|
||||||
|
{{#t}}Confirm recovery code{{/t}} <span class="service">{{#t}}Continue to %(serviceName)s{{/t}}</span>
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<section>
|
||||||
|
<div class="error"></div>
|
||||||
|
<div class="success"></div>
|
||||||
|
<form novalidate>
|
||||||
|
<div id="recovery-codes">
|
||||||
|
<div class="graphic graphic-recovery-codes"></div>
|
||||||
|
<p>
|
||||||
|
{{#t}}To ensure that you will be able to regain access to your account, in the event of a lost device, please enter one of your saved recovery codes.{{/t}}
|
||||||
|
</p>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" class="tooltip-below recovery-code" placeholder="{{#t}}Recovery code{{/t}}" required autofocus autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button type="submit" class="primary-button recovery-confirm-code">{{#t}}Confirm{{/t}}</button>
|
||||||
|
</div>
|
||||||
|
<div class="links">
|
||||||
|
<a href="#" class="left recovery-back">{{#t}}Back{{/t}}</a>
|
||||||
|
<a href="#" class="right recovery-cancel">{{#t}}Cancel setup{{/t}}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{/showConfirmation}}
|
||||||
|
</section>
|
||||||
|
</div>
|
|
@ -0,0 +1,86 @@
|
||||||
|
<div id="main-content" class="card inline-totp-setup">
|
||||||
|
{{#showIntro}}
|
||||||
|
<header>
|
||||||
|
<h1 id="fxa-inline-totp-setup">
|
||||||
|
<!-- L10N: For languages structured like English, the second phrase can read "to continue to %(serviceName)s" -->
|
||||||
|
{{#t}}Enable two-step authentication{{/t}} <span class="service">{{#t}}Continue to %(serviceName)s{{/t}}</span>
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<section>
|
||||||
|
<div class="error"></div>
|
||||||
|
<div class="success"></div>
|
||||||
|
|
||||||
|
<div class="graphic graphic-two-factor-auth"></div>
|
||||||
|
<p>
|
||||||
|
{{#unsafeTranslate}}Add a layer of security to your account by requiring security codes from one of <a %(escapedTotpSupportAttributes)s>these authentication apps</a>.{{/unsafeTranslate}}
|
||||||
|
</p>
|
||||||
|
<div class="button-row">
|
||||||
|
<button type="submit" class="primary-button totp-continue">{{#t}}Continue{{/t}}</button>
|
||||||
|
</div>
|
||||||
|
<div class="links">
|
||||||
|
<a href="#" class="cancel enabled totp-cancel">{{#t}}Cancel setup{{/t}}</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{/showIntro}}
|
||||||
|
{{^showIntro}}
|
||||||
|
<header>
|
||||||
|
{{#showQRImage}}
|
||||||
|
<h1 id="fxa-totp-qr-image">
|
||||||
|
<!-- L10N: For languages structured like English, the second phrase can read "to continue to %(serviceName)s" -->
|
||||||
|
{{#t}}Scan authentication code{{/t}} <span class="service">{{#t}}Continue to %(serviceName)s{{/t}}</span>
|
||||||
|
</h1>
|
||||||
|
{{/showQRImage}}
|
||||||
|
{{^showQRImage}}
|
||||||
|
<h1 id="fxa-totp-code-text">
|
||||||
|
<!-- L10N: For languages structured like English, the second phrase can read "to continue to %(serviceName)s" -->
|
||||||
|
{{#t}}Enter code manually{{/t}} <span class="service">{{#t}}Continue to %(serviceName)s{{/t}}</span>
|
||||||
|
</h1>
|
||||||
|
{{/showQRImage}}
|
||||||
|
</header>
|
||||||
|
<section>
|
||||||
|
<div class="error"></div>
|
||||||
|
<div class="success"></div>
|
||||||
|
|
||||||
|
<form novalidate>
|
||||||
|
<div id="totp" class="totp-details">
|
||||||
|
{{#showQRImage}}
|
||||||
|
<p>
|
||||||
|
{{#t}}Scan the QR code in your authentication app and then enter the security code it provides.{{/t}}
|
||||||
|
<a href="#" class="show-code-link">{{#t}}Can’t scan code?{{/t}}</a>
|
||||||
|
</p>
|
||||||
|
<div class="qr-image-container">
|
||||||
|
<img class="qr-image" alt="{{ qrImageAltText }}"/>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{{#t}}Once complete, it will begin generating security codes for you to enter.{{/t}}
|
||||||
|
<p>
|
||||||
|
{{/showQRImage}}
|
||||||
|
{{^showQRImage}}
|
||||||
|
<p>
|
||||||
|
{{#t}}Type this secret key into your authentication app.{{/t}}
|
||||||
|
<a href="#" class="hide-code-link">{{#t}}Scan QR code instead?{{/t}}</a></p>
|
||||||
|
</p>
|
||||||
|
<div class="qr-code-container">
|
||||||
|
<div class="qr-code-text">{{secret}}</div>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{{#t}}Once complete, it will begin generating security codes for you to enter.{{/t}}
|
||||||
|
</p>
|
||||||
|
{{/showQRImage}}
|
||||||
|
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="number" pattern="\d+" length="6" class="tooltip-below totp-code" placeholder="{{#t}}Security code{{/t}}" value="{{ code }}" required autofocus autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button type="submit" class="primary-button totp-confirm-code">{{#t}}Ready{{/t}}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="links">
|
||||||
|
<a href="#" class="cancel enabled totp-cancel">{{#t}}Cancel setup{{/t}}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{/showIntro}}
|
||||||
|
</div>
|
|
@ -8,6 +8,7 @@
|
||||||
import AuthErrors from '../lib/auth-errors';
|
import AuthErrors from '../lib/auth-errors';
|
||||||
import BaseView from './base';
|
import BaseView from './base';
|
||||||
import Cocktail from 'cocktail';
|
import Cocktail from 'cocktail';
|
||||||
|
import ErrorRedirectMixin from './mixins/error-redirect-mixin';
|
||||||
import OAuthErrors from '../lib/oauth-errors';
|
import OAuthErrors from '../lib/oauth-errors';
|
||||||
import OAuthPrompt from '../lib/oauth-prompt';
|
import OAuthPrompt from '../lib/oauth-prompt';
|
||||||
import SignInMixin from './mixins/signin-mixin';
|
import SignInMixin from './mixins/signin-mixin';
|
||||||
|
@ -59,15 +60,12 @@ class AuthorizationView extends BaseView {
|
||||||
|
|
||||||
_handlePromptNoneError(err) {
|
_handlePromptNoneError(err) {
|
||||||
return Promise.resolve().then(() => {
|
return Promise.resolve().then(() => {
|
||||||
if (this._shouldSendErrorToRP(err)) {
|
if (err.response_error_code) {
|
||||||
// Unless the RP overrides this behavior, errors with a `response_error_code`
|
// Unless the RP overrides this behavior, errors with a `response_error_code`
|
||||||
// redirect back to the RP with `response_error_code` as the `error` parameter.
|
// redirect back to the RP with `response_error_code` as the `error` parameter.
|
||||||
// The override is used by the functional tests to ensure the expected error
|
// The override is used by the functional tests to ensure the expected error
|
||||||
// case is being invoked when checking whether prompt=none can be used.
|
// case is being invoked when checking whether prompt=none can be used.
|
||||||
return this.broker.sendOAuthResultToRelier({
|
return this.redirectWithErrorCode(err);
|
||||||
error: err.response_error_code,
|
|
||||||
redirect: this.relier.get('redirectUri'),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// All other errors are handled at a higher level. If
|
// All other errors are handled at a higher level. If
|
||||||
|
@ -77,14 +75,8 @@ class AuthorizationView extends BaseView {
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_shouldSendErrorToRP(err) {
|
|
||||||
return (
|
|
||||||
err.response_error_code && this.relier.get('returnOnError') !== false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Cocktail.mixin(AuthorizationView, SignInMixin);
|
Cocktail.mixin(AuthorizationView, ErrorRedirectMixin, SignInMixin);
|
||||||
|
|
||||||
export default AuthorizationView;
|
export default AuthorizationView;
|
||||||
|
|
|
@ -0,0 +1,77 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
import AuthErrors from 'lib/auth-errors';
|
||||||
|
import BackMixin from './mixins/back-mixin';
|
||||||
|
import Cocktail from 'cocktail';
|
||||||
|
import ErrorRedirectMixin from './mixins/error-redirect-mixin';
|
||||||
|
import FlowEventsMixin from './mixins/flow-events-mixin';
|
||||||
|
import FormView from './form';
|
||||||
|
import preventDefaultThen from './decorators/prevent_default_then';
|
||||||
|
import RecoveryCodesMixin from './mixins/recovery-codes-mixin';
|
||||||
|
import SaveOptionsMixin from './mixins/save-options-mixin';
|
||||||
|
import ServiceMixin from './mixins/service-mixin';
|
||||||
|
import TimerMixin from './mixins/timer-mixin';
|
||||||
|
import UserAgentMixin from '../lib/user-agent-mixin';
|
||||||
|
|
||||||
|
import Template from 'templates/inline_recovery_setup.mustache';
|
||||||
|
|
||||||
|
var View = FormView.extend({
|
||||||
|
template: Template,
|
||||||
|
className: 'inline-recovery-setup',
|
||||||
|
viewName: 'inline-recovery-setup',
|
||||||
|
|
||||||
|
events: {
|
||||||
|
'click .copy-option': preventDefaultThen('copyCodes'),
|
||||||
|
'click .download-option': 'downloadCodes',
|
||||||
|
'click .print-option': preventDefaultThen('printCodes'),
|
||||||
|
'click .recovery-setup-done': preventDefaultThen('showConfirmationForm'),
|
||||||
|
'click .recovery-confirm-code': preventDefaultThen('verifyCode'),
|
||||||
|
'click .recovery-back': preventDefaultThen('hideConfirmationForm'),
|
||||||
|
'click .recovery-cancel': preventDefaultThen('_cancel'),
|
||||||
|
},
|
||||||
|
|
||||||
|
_returnToTwoStepAuthentication() {
|
||||||
|
this.onSetupComplete(() => {
|
||||||
|
// Pause for a bit to allow to user to notice and read the success UI.
|
||||||
|
return this.setTimeout(() => {
|
||||||
|
const account = this.getSignedInAccount();
|
||||||
|
this.invokeBrokerMethod('afterCompleteSignInWithCode', account);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// If the user cancels out of the flow, redirect back to the RP with
|
||||||
|
// an error message that's easy to parse. The user will need to be shown
|
||||||
|
// the 2FA-required instructions again.
|
||||||
|
_cancel() {
|
||||||
|
const err = AuthErrors.toError('TOTP_REQUIRED');
|
||||||
|
this.redirectWithErrorCode(err);
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeRender() {
|
||||||
|
if (!this.verifyTotpStatus()) {
|
||||||
|
this.navigate('inline_totp_setup');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
initialize() {
|
||||||
|
this.setupRecoveryCodes(this.model.get('recoveryCodes'));
|
||||||
|
this.listenTo(this.model, 'change', this.render);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Cocktail.mixin(
|
||||||
|
View,
|
||||||
|
BackMixin,
|
||||||
|
ErrorRedirectMixin,
|
||||||
|
FlowEventsMixin,
|
||||||
|
RecoveryCodesMixin,
|
||||||
|
SaveOptionsMixin,
|
||||||
|
ServiceMixin,
|
||||||
|
TimerMixin,
|
||||||
|
UserAgentMixin
|
||||||
|
);
|
||||||
|
|
||||||
|
export default View;
|
|
@ -0,0 +1,166 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
import _ from 'underscore';
|
||||||
|
import AuthErrors from 'lib/auth-errors';
|
||||||
|
import BackMixin from './mixins/back-mixin';
|
||||||
|
import { check } from '../lib/crypto/totp';
|
||||||
|
import Cocktail from 'cocktail';
|
||||||
|
import ErrorRedirectMixin from './mixins/error-redirect-mixin';
|
||||||
|
import FlowEventsMixin from './mixins/flow-events-mixin';
|
||||||
|
import FormView from './form';
|
||||||
|
import preventDefaultThen from './decorators/prevent_default_then';
|
||||||
|
import ServiceMixin from './mixins/service-mixin';
|
||||||
|
import VerificationReasonMixin from './mixins/verification-reason-mixin';
|
||||||
|
|
||||||
|
const CODE_INPUT_SELECTOR = 'input.totp-code';
|
||||||
|
const TOTP_SUPPORT_URL =
|
||||||
|
'https://support.mozilla.org/kb/secure-firefox-account-two-step-authentication';
|
||||||
|
const t = msg => msg;
|
||||||
|
|
||||||
|
import Template from 'templates/inline_totp_setup.mustache';
|
||||||
|
|
||||||
|
var View = FormView.extend({
|
||||||
|
template: Template,
|
||||||
|
className: 'inline-totp-setup',
|
||||||
|
viewName: 'inline-totp-setup',
|
||||||
|
|
||||||
|
events: {
|
||||||
|
'click .totp-continue': preventDefaultThen('_continue'),
|
||||||
|
'click .show-code-link': preventDefaultThen('_showCode'),
|
||||||
|
'click .hide-code-link': preventDefaultThen('_hideCode'),
|
||||||
|
'click .totp-cancel': preventDefaultThen('_cancel'),
|
||||||
|
},
|
||||||
|
|
||||||
|
initialize(options) {
|
||||||
|
this._account = this.user.initAccount(this.model.get('account'));
|
||||||
|
this._totpToken = false;
|
||||||
|
this.onSubmitComplete = this.model.get('onSubmitComplete');
|
||||||
|
this.model.set('showQRImage', true);
|
||||||
|
this.model.set('showIntro', true);
|
||||||
|
},
|
||||||
|
|
||||||
|
getTotpToken() {
|
||||||
|
if (this._totpToken) {
|
||||||
|
return Promise.resolve(this._totpToken);
|
||||||
|
}
|
||||||
|
const account = this.getSignedInAccount();
|
||||||
|
return account.createTotpToken().then(result => {
|
||||||
|
this._totpToken = result;
|
||||||
|
this._recoveryCodes = result.recoveryCodes;
|
||||||
|
this.model.set('secret', this._getFormattedCode(result.secret));
|
||||||
|
return this._totpToken;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getAccount() {
|
||||||
|
return this._account;
|
||||||
|
},
|
||||||
|
|
||||||
|
_getMissingSessionTokenScreen() {
|
||||||
|
return this.isSignUp() ? 'signup' : 'signin';
|
||||||
|
},
|
||||||
|
|
||||||
|
setInitialContext(context) {
|
||||||
|
context.set({
|
||||||
|
escapedTotpSupportAttributes: _.escape(
|
||||||
|
'class=totp-support-link target=_blank href=' + TOTP_SUPPORT_URL
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeRender() {
|
||||||
|
const account = this.getAccount();
|
||||||
|
if (!account.get('sessionToken')) {
|
||||||
|
this.navigate(this._getMissingSessionTokenScreen());
|
||||||
|
}
|
||||||
|
return account.checkTotpTokenExists().then(result => {
|
||||||
|
if (result.exists && result.verified) {
|
||||||
|
return this.onSubmitComplete();
|
||||||
|
}
|
||||||
|
// pre-generate the TOTP token
|
||||||
|
return this.getTotpToken();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
afterRender() {
|
||||||
|
return this.renderQRImage();
|
||||||
|
},
|
||||||
|
|
||||||
|
_continue() {
|
||||||
|
this.model.set('showIntro', false);
|
||||||
|
this.render();
|
||||||
|
},
|
||||||
|
|
||||||
|
_showCode() {
|
||||||
|
this.model.set('showQRImage', false);
|
||||||
|
this.rerender();
|
||||||
|
},
|
||||||
|
|
||||||
|
_hideCode() {
|
||||||
|
this.model.set('showQRImage', true);
|
||||||
|
this.rerender();
|
||||||
|
},
|
||||||
|
|
||||||
|
_getFormattedCode(code) {
|
||||||
|
// Insert spaces every 4 characters
|
||||||
|
return code.replace(/(\w{4})/g, '$1 ');
|
||||||
|
},
|
||||||
|
|
||||||
|
// If the user cancels out of the flow, redirect back to the RP with
|
||||||
|
// an error message that's easy to parse. The user will need to be shown
|
||||||
|
// the 2FA-required instructions again.
|
||||||
|
_cancel() {
|
||||||
|
const err = AuthErrors.toError('TOTP_REQUIRED');
|
||||||
|
this.redirectWithErrorCode(err);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderQRImage() {
|
||||||
|
this.getTotpToken().then(token => {
|
||||||
|
this.$('.qr-image').attr('src', token.qrCodeUrl);
|
||||||
|
const qrImageAltText = t(
|
||||||
|
'Use the code %(code)s to set up two-step authentication in supported applications.'
|
||||||
|
);
|
||||||
|
this.$('.qr-image').attr(
|
||||||
|
'alt',
|
||||||
|
this.translate(qrImageAltText, { code: token.secret })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
const code = this.getElementValue('input.totp-code');
|
||||||
|
const secret = this.model.get('secret');
|
||||||
|
|
||||||
|
//preverify code
|
||||||
|
return this.checkCode(secret, code).then(ok => {
|
||||||
|
if (!ok) {
|
||||||
|
return this.showValidationError(
|
||||||
|
this.$(CODE_INPUT_SELECTOR),
|
||||||
|
AuthErrors.toError('INVALID_TOTP_CODE')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.navigate('/inline_recovery_setup', {
|
||||||
|
recoveryCodes: this._recoveryCodes,
|
||||||
|
totpSecret: secret,
|
||||||
|
onSubmitComplete: this.onSubmitComplete,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
checkCode(secret, code) {
|
||||||
|
return check(secret, code);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Cocktail.mixin(
|
||||||
|
View,
|
||||||
|
BackMixin,
|
||||||
|
ErrorRedirectMixin,
|
||||||
|
FlowEventsMixin,
|
||||||
|
ServiceMixin,
|
||||||
|
VerificationReasonMixin
|
||||||
|
);
|
||||||
|
|
||||||
|
export default View;
|
|
@ -0,0 +1,23 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirects back to RP with error code when errors are encountered in the
|
||||||
|
* inline 2FA setup or prompt=none flows, unless the RP has disabled error
|
||||||
|
* redirects, in which case the error is thrown. Mixed into views.
|
||||||
|
*
|
||||||
|
* @class ErrorRedirectMixin
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default {
|
||||||
|
redirectWithErrorCode(err) {
|
||||||
|
if (this.relier.get('returnOnError') !== false) {
|
||||||
|
return this.broker.sendOAuthResultToRelier({
|
||||||
|
error: err.response_error_code || err.errno,
|
||||||
|
redirect: this.relier.get('redirectUri'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,146 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
// Shared code used by recovery key setup step, the second part of TOTP setup.
|
||||||
|
// Shared by views in login flow and settings page.
|
||||||
|
|
||||||
|
import AuthErrors from 'lib/auth-errors';
|
||||||
|
import { getCode } from '../../lib/crypto/totp';
|
||||||
|
import RecoveryCode from '../../models/recovery-code';
|
||||||
|
import RecoveryCodePrintTemplate from 'templates/settings/recovery_codes_print.mustache';
|
||||||
|
import SaveOptionsMixin from './save-options-mixin';
|
||||||
|
|
||||||
|
const RECOVERY_CODE_ELEMENT = '#recovery-codes';
|
||||||
|
|
||||||
|
const t = msg => msg;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
dependsOn: [SaveOptionsMixin],
|
||||||
|
|
||||||
|
copyCodes() {
|
||||||
|
this.logFlowEvent('copy-option', this.viewName);
|
||||||
|
return this.copy(this.recoveryCodesText, RECOVERY_CODE_ELEMENT);
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadCodes() {
|
||||||
|
this.logFlowEvent('download-option', this.viewName);
|
||||||
|
this.download(
|
||||||
|
this.recoveryCodesText,
|
||||||
|
this.getFormatedRecoveryCodeFilename(),
|
||||||
|
RECOVERY_CODE_ELEMENT
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
printCodes() {
|
||||||
|
this.logFlowEvent('print-option', this.viewName);
|
||||||
|
const recoveryCodes = this.recoveryCodes.map(code => {
|
||||||
|
return new RecoveryCode({ code }).toJSON();
|
||||||
|
});
|
||||||
|
this.print(RecoveryCodePrintTemplate({ recoveryCodes }));
|
||||||
|
},
|
||||||
|
|
||||||
|
showConfirmationForm() {
|
||||||
|
if (this.model.get('totpSecret')) {
|
||||||
|
this.model.set('showConfirmation', true);
|
||||||
|
this.render();
|
||||||
|
} else {
|
||||||
|
this._returnToTwoStepAuthentication();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
hideConfirmationForm() {
|
||||||
|
this.model.set('showConfirmation', false);
|
||||||
|
this.render();
|
||||||
|
},
|
||||||
|
|
||||||
|
verifyCode() {
|
||||||
|
const input = this.getElementValue('input.recovery-code');
|
||||||
|
const codes = this.model.get('recoveryCodes');
|
||||||
|
if (!codes.includes(input.toLowerCase())) {
|
||||||
|
const e =
|
||||||
|
input.length > 0 ? 'INVALID_RECOVERY_CODE' : 'RECOVERY_CODE_REQUIRED';
|
||||||
|
return this.showValidationError(
|
||||||
|
this.$('input.recovery-code'),
|
||||||
|
AuthErrors.toError(e)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._returnToTwoStepAuthentication();
|
||||||
|
},
|
||||||
|
|
||||||
|
getFormatedRecoveryCodeFilename() {
|
||||||
|
const account = this.getSignedInAccount();
|
||||||
|
let formattedFilename =
|
||||||
|
account.get('email') + ' ' + t('Firefox Recovery Codes');
|
||||||
|
if (formattedFilename.length > 200) {
|
||||||
|
// 200 bytes (close to filesystem max) - 4 for '.txt' extension
|
||||||
|
formattedFilename = formattedFilename.substring(0, 196);
|
||||||
|
}
|
||||||
|
return `${formattedFilename}.txt`;
|
||||||
|
},
|
||||||
|
|
||||||
|
setupRecoveryCodes(codes, msg) {
|
||||||
|
// Store a readable version of recovery codes so that they can
|
||||||
|
// be copied, printed and downloaded
|
||||||
|
this.recoveryCodesText = '';
|
||||||
|
if (codes) {
|
||||||
|
this.recoveryCodes = codes;
|
||||||
|
this.recoveryCodesText = this.recoveryCodes.join(' \r\n');
|
||||||
|
this.model.set('recoveryCodes', codes);
|
||||||
|
|
||||||
|
if (msg) {
|
||||||
|
this.model.set('modalSuccessMsg', msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setInitialContext(context) {
|
||||||
|
let recoveryCodes = this.model.get('recoveryCodes');
|
||||||
|
if (recoveryCodes) {
|
||||||
|
recoveryCodes = recoveryCodes.map(code => {
|
||||||
|
return new RecoveryCode({ code }).toJSON();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
recoveryCodes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalSuccessMsg = this.model.get('modalSuccessMsg');
|
||||||
|
|
||||||
|
context.set({
|
||||||
|
isIos: this.getUserAgent().isIos(),
|
||||||
|
// There can be several modalSuccessMsg's, make sure they are translated
|
||||||
|
// before displaying to the user user. See #6728
|
||||||
|
modalSuccessMsg: modalSuccessMsg && this.translate(modalSuccessMsg),
|
||||||
|
recoveryCodes,
|
||||||
|
showRecoveryCodes: recoveryCodes.length > 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
verifyTotpStatus() {
|
||||||
|
const account = this.getSignedInAccount();
|
||||||
|
return account.checkTotpTokenExists().then(result => {
|
||||||
|
return !!(
|
||||||
|
result.exists &&
|
||||||
|
result.verified &&
|
||||||
|
this.model.get('recoveryCodes')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onSetupComplete(done) {
|
||||||
|
const totpSecret = this.model.get('totpSecret');
|
||||||
|
const account = this.getSignedInAccount();
|
||||||
|
if (totpSecret) {
|
||||||
|
return getCode(totpSecret)
|
||||||
|
.then(code => {
|
||||||
|
return account.verifyTotpCode(code, this.relier.get('service'));
|
||||||
|
})
|
||||||
|
.then(() =>
|
||||||
|
this.displaySuccess(t('Two-step authentication enabled'), {})
|
||||||
|
)
|
||||||
|
.then(done, err => this.displayError(err));
|
||||||
|
}
|
||||||
|
done();
|
||||||
|
},
|
||||||
|
};
|
|
@ -11,10 +11,6 @@ import ResumeTokenMixin from './resume-token-mixin';
|
||||||
import VerificationMethods from '../../lib/verification-methods';
|
import VerificationMethods from '../../lib/verification-methods';
|
||||||
import VerificationReasons from '../../lib/verification-reasons';
|
import VerificationReasons from '../../lib/verification-reasons';
|
||||||
|
|
||||||
const t = msg => msg;
|
|
||||||
const TOTP_SUPPORT_URL =
|
|
||||||
'https://support.mozilla.org/kb/secure-firefox-account-two-step-authentication';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
dependsOn: [ResumeTokenMixin],
|
dependsOn: [ResumeTokenMixin],
|
||||||
|
|
||||||
|
@ -133,13 +129,10 @@ export default {
|
||||||
AuthErrors.is(err, 'INSUFFICIENT_ACR_VALUES') ||
|
AuthErrors.is(err, 'INSUFFICIENT_ACR_VALUES') ||
|
||||||
OAuthErrors.is(err, 'MISMATCH_ACR_VALUES')
|
OAuthErrors.is(err, 'MISMATCH_ACR_VALUES')
|
||||||
) {
|
) {
|
||||||
err.forceMessage = t(
|
return this.navigate('inline_totp_setup', {
|
||||||
'This request requires two step authentication enabled on your account. ' +
|
account: account,
|
||||||
'<a target="_blank" href=\'' +
|
onSubmitComplete: this.onSignInSuccess.bind(this),
|
||||||
TOTP_SUPPORT_URL +
|
});
|
||||||
"'>More Information</a>"
|
|
||||||
);
|
|
||||||
return this.unsafeDisplayError(err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// re-throw error, it'll be handled elsewhere.
|
// re-throw error, it'll be handled elsewhere.
|
||||||
|
|
|
@ -3,36 +3,31 @@
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
import Cocktail from 'cocktail';
|
import Cocktail from 'cocktail';
|
||||||
import AuthErrors from 'lib/auth-errors';
|
|
||||||
import FormView from '../form';
|
import FormView from '../form';
|
||||||
import ModalSettingsPanelMixin from '../mixins/modal-settings-panel-mixin';
|
import ModalSettingsPanelMixin from '../mixins/modal-settings-panel-mixin';
|
||||||
import Template from 'templates/settings/recovery_codes.mustache';
|
import Template from 'templates/settings/recovery_codes.mustache';
|
||||||
import RecoveryCodePrintTemplate from 'templates/settings/recovery_codes_print.mustache';
|
import RecoveryCodesMixin from '../mixins/recovery-codes-mixin';
|
||||||
import RecoveryCode from '../../models/recovery-code';
|
|
||||||
import preventDefaultThen from '../decorators/prevent_default_then';
|
import preventDefaultThen from '../decorators/prevent_default_then';
|
||||||
import SaveOptionsMixin from '../mixins/save-options-mixin';
|
import SaveOptionsMixin from '../mixins/save-options-mixin';
|
||||||
import UserAgentMixin from '../../lib/user-agent-mixin';
|
import UserAgentMixin from '../../lib/user-agent-mixin';
|
||||||
import { getCode } from '../../lib/crypto/totp';
|
|
||||||
|
|
||||||
const t = msg => msg;
|
const t = msg => msg;
|
||||||
|
|
||||||
const RECOVERY_CODE_ELEMENT = '#recovery-codes';
|
|
||||||
|
|
||||||
const View = FormView.extend({
|
const View = FormView.extend({
|
||||||
template: Template,
|
template: Template,
|
||||||
className: 'recovery-codes',
|
className: 'recovery-codes',
|
||||||
viewName: 'settings.two-step-authentication.recovery-codes',
|
viewName: 'settings.two-step-authentication.recovery-codes',
|
||||||
|
|
||||||
events: {
|
events: {
|
||||||
'click .copy-option': preventDefaultThen('_copyCodes'),
|
'click .copy-option': preventDefaultThen('copyCodes'),
|
||||||
'click .download-option': '_downloadCodes',
|
'click .download-option': 'downloadCodes',
|
||||||
'click .print-option': preventDefaultThen('_printCodes'),
|
'click .print-option': preventDefaultThen('printCodes'),
|
||||||
'click .replace-codes-link': preventDefaultThen('_replaceRecoveryCodes'),
|
'click .replace-codes-link': preventDefaultThen('_replaceRecoveryCodes'),
|
||||||
'click .two-step-authentication-done': preventDefaultThen(
|
'click .two-step-authentication-done': preventDefaultThen(
|
||||||
'_showConfirmationForm'
|
'showConfirmationForm'
|
||||||
),
|
),
|
||||||
'click .recovery-confirm-code': preventDefaultThen('_verifyCode'),
|
'click .recovery-confirm-code': preventDefaultThen('verifyCode'),
|
||||||
'click .recovery-back': preventDefaultThen('_hideConfirmationForm'),
|
'click .recovery-back': preventDefaultThen('hideConfirmationForm'),
|
||||||
},
|
},
|
||||||
|
|
||||||
_returnToTwoStepAuthentication() {
|
_returnToTwoStepAuthentication() {
|
||||||
|
@ -43,151 +38,40 @@ const View = FormView.extend({
|
||||||
const account = this.getSignedInAccount();
|
const account = this.getSignedInAccount();
|
||||||
return this.invokeBrokerMethod('afterCompleteSignInWithCode', account);
|
return this.invokeBrokerMethod('afterCompleteSignInWithCode', account);
|
||||||
}
|
}
|
||||||
const totpSecret = this.model.get('totpSecret');
|
|
||||||
const done = () => {
|
this.onSetupComplete(() => {
|
||||||
this.navigate('settings/two_step_authentication');
|
this.navigate('settings/two_step_authentication');
|
||||||
};
|
|
||||||
if (totpSecret) {
|
|
||||||
return getCode(totpSecret)
|
|
||||||
.then(code => {
|
|
||||||
const account = this.getSignedInAccount();
|
|
||||||
return account.verifyTotpCode(code, this.relier.get('service'));
|
|
||||||
})
|
|
||||||
.then(() =>
|
|
||||||
this.displaySuccess(t('Two-step authentication enabled'), {})
|
|
||||||
)
|
|
||||||
.then(done, err => this.displayError(err));
|
|
||||||
}
|
|
||||||
done();
|
|
||||||
},
|
|
||||||
|
|
||||||
_showConfirmationForm() {
|
|
||||||
if (this.model.get('totpSecret')) {
|
|
||||||
this.model.set('showConfirmation', true);
|
|
||||||
this.render();
|
|
||||||
} else {
|
|
||||||
this._returnToTwoStepAuthentication();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
_hideConfirmationForm() {
|
|
||||||
this.model.set('showConfirmation', false);
|
|
||||||
this.render();
|
|
||||||
},
|
|
||||||
|
|
||||||
_verifyCode() {
|
|
||||||
const input = this.getElementValue('input.recovery-code');
|
|
||||||
const codes = this.model.get('recoveryCodes');
|
|
||||||
if (!codes.includes(input.toLowerCase())) {
|
|
||||||
const e =
|
|
||||||
input.length > 0 ? 'INVALID_RECOVERY_CODE' : 'RECOVERY_CODE_REQUIRED';
|
|
||||||
return this.showValidationError(
|
|
||||||
this.$('input.recovery-code'),
|
|
||||||
AuthErrors.toError(e)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._returnToTwoStepAuthentication();
|
|
||||||
},
|
|
||||||
|
|
||||||
_getFormatedRecoveryCodeFilename() {
|
|
||||||
const account = this.getSignedInAccount();
|
|
||||||
let formattedFilename =
|
|
||||||
account.get('email') + ' ' + t('Firefox Recovery Codes');
|
|
||||||
if (formattedFilename.length > 200) {
|
|
||||||
// 200 bytes (close to filesystem max) - 4 for '.txt' extension
|
|
||||||
formattedFilename = formattedFilename.substring(0, 196);
|
|
||||||
}
|
|
||||||
return `${formattedFilename}.txt`;
|
|
||||||
},
|
|
||||||
|
|
||||||
_copyCodes() {
|
|
||||||
this.logFlowEvent('copy-option', this.viewName);
|
|
||||||
return this.copy(this.recoveryCodesText, RECOVERY_CODE_ELEMENT);
|
|
||||||
},
|
|
||||||
|
|
||||||
_downloadCodes() {
|
|
||||||
this.logFlowEvent('download-option', this.viewName);
|
|
||||||
this.download(
|
|
||||||
this.recoveryCodesText,
|
|
||||||
this._getFormatedRecoveryCodeFilename(),
|
|
||||||
RECOVERY_CODE_ELEMENT
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
_printCodes() {
|
|
||||||
this.logFlowEvent('print-option', this.viewName);
|
|
||||||
const recoveryCodes = this.recoveryCodes.map(code => {
|
|
||||||
return new RecoveryCode({ code }).toJSON();
|
|
||||||
});
|
});
|
||||||
this.print(RecoveryCodePrintTemplate({ recoveryCodes }));
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_replaceRecoveryCodes() {
|
_replaceRecoveryCodes() {
|
||||||
const account = this.getSignedInAccount();
|
const account = this.getSignedInAccount();
|
||||||
return account.replaceRecoveryCodes().then(result => {
|
return account.replaceRecoveryCodes().then(result => {
|
||||||
this._setupRecoveryCodes(
|
this.setupRecoveryCodes(
|
||||||
result.recoveryCodes,
|
result.recoveryCodes,
|
||||||
t('New recovery codes generated')
|
t('New recovery codes generated')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
_setupRecoveryCodes(codes, msg) {
|
|
||||||
// Store a readable version of recovery codes so that they can
|
|
||||||
// be copied, printed and downloaded
|
|
||||||
this.recoveryCodesText = '';
|
|
||||||
if (codes) {
|
|
||||||
this.recoveryCodes = codes;
|
|
||||||
this.recoveryCodesText = this.recoveryCodes.join(' \r\n');
|
|
||||||
this.model.set('recoveryCodes', codes);
|
|
||||||
|
|
||||||
if (msg) {
|
|
||||||
this.model.set('modalSuccessMsg', msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
beforeRender() {
|
beforeRender() {
|
||||||
const account = this.getSignedInAccount();
|
if (!this.verifyTotpStatus()) {
|
||||||
return account.checkTotpTokenExists().then(result => {
|
this.navigate('settings/two_step_authentication');
|
||||||
if (!result.exists) {
|
}
|
||||||
this.navigate('settings/two_step_authentication');
|
|
||||||
}
|
|
||||||
if (!result.verified && !this.model.get('recoveryCodes')) {
|
|
||||||
this.navigate('settings/two_step_authentication');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
this._setupRecoveryCodes(this.model.get('recoveryCodes'));
|
this.setupRecoveryCodes(this.model.get('recoveryCodes'));
|
||||||
this.listenTo(this.model, 'change', this.render);
|
this.listenTo(this.model, 'change', this.render);
|
||||||
},
|
},
|
||||||
|
|
||||||
setInitialContext(context) {
|
|
||||||
let recoveryCodes = this.model.get('recoveryCodes');
|
|
||||||
if (recoveryCodes) {
|
|
||||||
recoveryCodes = recoveryCodes.map(code => {
|
|
||||||
return new RecoveryCode({ code }).toJSON();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
recoveryCodes = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const modalSuccessMsg = this.model.get('modalSuccessMsg');
|
|
||||||
|
|
||||||
context.set({
|
|
||||||
isIos: this.getUserAgent().isIos(),
|
|
||||||
// There can be several modalSuccessMsg's, make sure they are translated
|
|
||||||
// before displaying to the user user. See #6728
|
|
||||||
modalSuccessMsg: modalSuccessMsg && this.translate(modalSuccessMsg),
|
|
||||||
recoveryCodes,
|
|
||||||
showRecoveryCodes: recoveryCodes.length > 0,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Cocktail.mixin(View, ModalSettingsPanelMixin, SaveOptionsMixin, UserAgentMixin);
|
Cocktail.mixin(
|
||||||
|
View,
|
||||||
|
ModalSettingsPanelMixin,
|
||||||
|
RecoveryCodesMixin,
|
||||||
|
SaveOptionsMixin,
|
||||||
|
UserAgentMixin
|
||||||
|
);
|
||||||
|
|
||||||
export default View;
|
export default View;
|
||||||
|
|
|
@ -311,3 +311,12 @@ input[type='text'] ~ .show-password:focus + .show-password-label {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qr-code-container {
|
||||||
|
margin: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-text {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
|
@ -217,16 +217,22 @@ describe('views/authorization', function() {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('_handlePromptNoneError', () => {
|
describe('_handlePromptNoneError', () => {
|
||||||
it('sends permitted errors to the RP', () => {
|
// This error should cause a redirect.
|
||||||
sinon.stub(view, '_shouldSendErrorToRP').callsFake(() => true);
|
const oauthErr = OAuthErrors.toError(
|
||||||
|
'PROMPT_NONE_DIFFERENT_USER_SIGNED_IN'
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
sinon
|
sinon
|
||||||
.stub(broker, 'sendOAuthResultToRelier')
|
.stub(broker, 'sendOAuthResultToRelier')
|
||||||
.callsFake(() => Promise.resolve());
|
.callsFake(() => Promise.resolve());
|
||||||
relier.set('redirectUri', 'https://redirect.to');
|
relier.set('redirectUri', 'https://redirect.to');
|
||||||
|
});
|
||||||
|
|
||||||
const err = OAuthErrors.toError('PROMPT_NONE_DIFFERENT_USER_SIGNED_IN');
|
it('sends permitted errors to the RP', () => {
|
||||||
return view._handlePromptNoneError(err).then(() => {
|
relier.set('returnOnError', true);
|
||||||
assert.isTrue(view._shouldSendErrorToRP.calledOnceWith(err));
|
|
||||||
|
return view._handlePromptNoneError(oauthErr).then(() => {
|
||||||
assert.isTrue(
|
assert.isTrue(
|
||||||
broker.sendOAuthResultToRelier.calledOnceWith({
|
broker.sendOAuthResultToRelier.calledOnceWith({
|
||||||
error: 'account_selection_required',
|
error: 'account_selection_required',
|
||||||
|
@ -236,14 +242,20 @@ describe('views/authorization', function() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('re-throws other errors', () => {
|
it('re-throws errors if RP does not allow returnOnError', () => {
|
||||||
sinon.stub(view, '_shouldSendErrorToRP').callsFake(() => false);
|
relier.set('returnOnError', false);
|
||||||
sinon.stub(broker, 'sendOAuthResultToRelier');
|
|
||||||
|
|
||||||
const err = OAuthErrors.toError('PROMPT_NONE_DIFFERENT_USER_SIGNED_IN');
|
return view._handlePromptNoneError(oauthErr).then(assert.fail, _err => {
|
||||||
return view._handlePromptNoneError(err).then(assert.fail, _err => {
|
assert.strictEqual(_err, oauthErr);
|
||||||
assert.isTrue(view._shouldSendErrorToRP.calledOnceWith(err));
|
});
|
||||||
assert.strictEqual(_err, err);
|
});
|
||||||
|
|
||||||
|
it('re-throws other errors', () => {
|
||||||
|
// This error lacks an error_response_code, so it should not redirect.
|
||||||
|
const authErr = AuthErrors.toError('USER_CANCELED_LOGIN');
|
||||||
|
|
||||||
|
return view._handlePromptNoneError(authErr).then(assert.fail, _err => {
|
||||||
|
assert.strictEqual(_err, authErr);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -562,7 +562,7 @@ describe('views/mixins/signin-mixin', function() {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('relier wants TOTP', () => {
|
describe('relier wants TOTP', () => {
|
||||||
let err;
|
let err, succeeded, failed;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
err = AuthErrors.toError('TOTP_REQUIRED');
|
err = AuthErrors.toError('TOTP_REQUIRED');
|
||||||
|
@ -572,34 +572,25 @@ describe('views/mixins/signin-mixin', function() {
|
||||||
sinon.stub(relier, 'isOAuth').callsFake(() => true);
|
sinon.stub(relier, 'isOAuth').callsFake(() => true);
|
||||||
sinon.stub(relier, 'wantsTwoStepAuthentication').callsFake(() => true);
|
sinon.stub(relier, 'wantsTwoStepAuthentication').callsFake(() => true);
|
||||||
|
|
||||||
return view.signIn(account, 'password');
|
return view.signIn(account, 'password').then(
|
||||||
});
|
() => (succeeded = true),
|
||||||
|
e => (failed = true)
|
||||||
it('failed', () => {
|
|
||||||
assert.isTrue(AuthErrors.is(err, 'TOTP_REQUIRED'));
|
|
||||||
assert.isTrue(view.unsafeDisplayError.calledWith(err));
|
|
||||||
const link =
|
|
||||||
'https://support.mozilla.org/kb/secure-firefox-account-two-step-authentication';
|
|
||||||
assert.isTrue(
|
|
||||||
err.forceMessage.indexOf(link) > 0,
|
|
||||||
'contains setup link'
|
|
||||||
);
|
|
||||||
|
|
||||||
const args = user.signInAccount.args[0];
|
|
||||||
assert.equal(
|
|
||||||
args[3].verificationMethod,
|
|
||||||
VerificationMethods.TOTP_2FA,
|
|
||||||
'correct verification method set'
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('did not navigate', () => {
|
it('succeeded', () => {
|
||||||
assert.equal(view.navigate.callCount, 0);
|
assert.isTrue(succeeded);
|
||||||
|
assert.isUndefined(failed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigated to the 2FA setup screen', () => {
|
||||||
|
assert.equal(view.navigate.callCount, 1);
|
||||||
|
assert.equal(view.navigate.args[0][0], 'inline_totp_setup');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('relier has mismatch acr values', () => {
|
describe('relier has mismatch acr values', () => {
|
||||||
let err;
|
let err, succeeded, failed;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
err = OAuthErrors.toError('MISMATCH_ACR_VALUES');
|
err = OAuthErrors.toError('MISMATCH_ACR_VALUES');
|
||||||
|
@ -609,29 +600,20 @@ describe('views/mixins/signin-mixin', function() {
|
||||||
sinon.stub(relier, 'isOAuth').callsFake(() => true);
|
sinon.stub(relier, 'isOAuth').callsFake(() => true);
|
||||||
sinon.stub(relier, 'wantsTwoStepAuthentication').callsFake(() => true);
|
sinon.stub(relier, 'wantsTwoStepAuthentication').callsFake(() => true);
|
||||||
|
|
||||||
return view.signIn(account, 'password');
|
return view.signIn(account, 'password').then(
|
||||||
});
|
() => (succeeded = true),
|
||||||
|
e => (failed = true)
|
||||||
it('failed', () => {
|
|
||||||
assert.isTrue(OAuthErrors.is(err, 'MISMATCH_ACR_VALUES'));
|
|
||||||
assert.isTrue(view.unsafeDisplayError.calledWith(err));
|
|
||||||
const link =
|
|
||||||
'https://support.mozilla.org/kb/secure-firefox-account-two-step-authentication';
|
|
||||||
assert.isTrue(
|
|
||||||
err.forceMessage.indexOf(link) > 0,
|
|
||||||
'contains setup link'
|
|
||||||
);
|
|
||||||
|
|
||||||
const args = user.signInAccount.args[0];
|
|
||||||
assert.equal(
|
|
||||||
args[3].verificationMethod,
|
|
||||||
VerificationMethods.TOTP_2FA,
|
|
||||||
'correct verification method set'
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('did not navigate', () => {
|
it('succeeded', () => {
|
||||||
assert.equal(view.navigate.callCount, 0);
|
assert.isTrue(succeeded);
|
||||||
|
assert.isUndefined(failed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigated to the 2FA setup screen', () => {
|
||||||
|
assert.equal(view.navigate.callCount, 1);
|
||||||
|
assert.equal(view.navigate.args[0][0], 'inline_totp_setup');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -54,6 +54,8 @@ describe('views/settings/recovery_codes', () => {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
sinon.stub(view, 'verifyTotpStatus').callsFake(() => hasToken);
|
||||||
|
|
||||||
return view.render().then(() => $('#container').html(view.$el));
|
return view.render().then(() => $('#container').html(view.$el));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -208,7 +210,7 @@ describe('views/settings/recovery_codes', () => {
|
||||||
const padding = Array(256).join('1');
|
const padding = Array(256).join('1');
|
||||||
email = `${padding}@email.com`;
|
email = `${padding}@email.com`;
|
||||||
account.set('email', email);
|
account.set('email', email);
|
||||||
const formattedFilename = view._getFormatedRecoveryCodeFilename();
|
const formattedFilename = view.getFormatedRecoveryCodeFilename();
|
||||||
assert.equal(formattedFilename.length, 200, 'truncated filename');
|
assert.equal(formattedFilename.length, 200, 'truncated filename');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
formattedFilename.indexOf('.txt') > 0,
|
formattedFilename.indexOf('.txt') > 0,
|
||||||
|
|
|
@ -2563,6 +2563,75 @@ const enableTotp = thenify(function() {
|
||||||
.then(() => secret)
|
.then(() => secret)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable TOTP inline in the login flow, for cases where the RP includes
|
||||||
|
* the acr_values=AAL2 parameter in the request, and the user did not already
|
||||||
|
* have 2FA enabled.
|
||||||
|
*
|
||||||
|
* @returns {promise} resolves when complete
|
||||||
|
*/
|
||||||
|
const enableTotpInline = thenify(function() {
|
||||||
|
let secret, recoveryCode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.parent
|
||||||
|
// first, user sees the intro screen and clicks through
|
||||||
|
.then(testElementExists(selectors.INLINE_TOTP.HEADER))
|
||||||
|
.then(click(selectors.INLINE_TOTP.INTRO_CONTINUE_BUTTON))
|
||||||
|
|
||||||
|
// on the TOTP screen, get the secret text code, generate a code using otplib, submit
|
||||||
|
.then(testElementExists(selectors.INLINE_TOTP.TOTP_SETUP_HEADER))
|
||||||
|
.then(click(selectors.INLINE_TOTP.SHOW_CODE_LINK))
|
||||||
|
.then(testElementExists(selectors.INLINE_TOTP.TOTP_CODE_TEXT))
|
||||||
|
.then(visibleByQSA(selectors.INLINE_TOTP.TOTP_CODE_TEXT))
|
||||||
|
.findByCssSelector(selectors.INLINE_TOTP.TOTP_CODE_TEXT)
|
||||||
|
.getVisibleText()
|
||||||
|
.then(secretKey => {
|
||||||
|
secret = secretKey;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
.then(() => {
|
||||||
|
return this.parent.then(
|
||||||
|
type(
|
||||||
|
selectors.INLINE_TOTP.CONFIRM_CODE_INPUT,
|
||||||
|
generateTotpCode(secret)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then(click(selectors.INLINE_TOTP.READY_BUTTON))
|
||||||
|
|
||||||
|
// on the recovery codes screen, get the codes and advance to the confirm screen
|
||||||
|
.then(testElementExists(selectors.INLINE_RECOVERY_CODES.HEADER))
|
||||||
|
.then(visibleByQSA(selectors.INLINE_RECOVERY_CODES.RECOVERY_CODES))
|
||||||
|
.findByCssSelector(selectors.INLINE_RECOVERY_CODES.RECOVERY_CODES)
|
||||||
|
.getVisibleText()
|
||||||
|
.then(code => {
|
||||||
|
recoveryCode = code;
|
||||||
|
return this.parent.then(
|
||||||
|
click(selectors.INLINE_RECOVERY_CODES.DONE_BUTTON)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
|
||||||
|
// on the confirm code screen, enter the saved code and we're done
|
||||||
|
.then(() => {
|
||||||
|
return this.parent.then(
|
||||||
|
visibleByQSA(selectors.INLINE_CONFIRM_RECOVERY.HEADER)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
return this.parent.then(
|
||||||
|
type(
|
||||||
|
selectors.INLINE_CONFIRM_RECOVERY.RECOVERY_CODE_INPUT,
|
||||||
|
recoveryCode
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then(click(selectors.INLINE_CONFIRM_RECOVERY.CONFIRM_BUTTON))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Destroy the session for the given `email`. Only destroys
|
* Destroy the session for the given `email`. Only destroys
|
||||||
* the first session for the given email address.
|
* the first session for the given email address.
|
||||||
|
@ -2658,6 +2727,7 @@ module.exports = {
|
||||||
destroySessionForEmail,
|
destroySessionForEmail,
|
||||||
disableInProd,
|
disableInProd,
|
||||||
enableTotp,
|
enableTotp,
|
||||||
|
enableTotpInline,
|
||||||
confirmRecoveryCode,
|
confirmRecoveryCode,
|
||||||
fetchAllMetrics,
|
fetchAllMetrics,
|
||||||
fillOutChangePassword,
|
fillOutChangePassword,
|
||||||
|
|
|
@ -205,6 +205,34 @@ module.exports = {
|
||||||
PASSWORD: 'input[type=password]',
|
PASSWORD: 'input[type=password]',
|
||||||
SUB_HEADER: '#fxa-force-auth-header .service',
|
SUB_HEADER: '#fxa-force-auth-header .service',
|
||||||
},
|
},
|
||||||
|
INLINE_TOTP: {
|
||||||
|
HEADER: '#fxa-inline-totp-setup',
|
||||||
|
INTRO_CONTINUE_BUTTON: '.totp-continue',
|
||||||
|
CANCEL_BUTTON: '.totp-cancel',
|
||||||
|
TOTP_SETUP_HEADER: '#fxa-totp-qr-image',
|
||||||
|
QR_IMAGE: '.qr-image',
|
||||||
|
SHOW_CODE_LINK: '.show-code-link',
|
||||||
|
TOTP_CODE_TEXT: '.qr-code-text',
|
||||||
|
SHOW_IMAGE_LINK: '.hide-code-link',
|
||||||
|
CONFIRM_CODE_INPUT: '.totp-code',
|
||||||
|
READY_BUTTON: '.totp-confirm-code',
|
||||||
|
},
|
||||||
|
INLINE_RECOVERY_CODES: {
|
||||||
|
HEADER: '#fxa-save-recovery-codes',
|
||||||
|
RECOVERY_CODES: '#recovery-code-container .recovery-code:first-child',
|
||||||
|
COPY_BUTTON: '.copy-option',
|
||||||
|
DOWNLOAD_BUTTON: '.download-option',
|
||||||
|
PRINT_BUTTON: '.print-option',
|
||||||
|
CANCEL_BUTTON: '.recovery-cancel',
|
||||||
|
DONE_BUTTON: '.recovery-setup-done',
|
||||||
|
},
|
||||||
|
INLINE_CONFIRM_RECOVERY: {
|
||||||
|
HEADER: '#fxa-confirm-recovery-code',
|
||||||
|
RECOVERY_CODE_INPUT: 'input.recovery-code',
|
||||||
|
BACK_BUTTON: '.recovery-back',
|
||||||
|
CANCEL_BUTTON: '.recovery-cancel',
|
||||||
|
CONFIRM_BUTTON: '.recovery-confirm-code',
|
||||||
|
},
|
||||||
MOZILLA_ORG_SYNC: {
|
MOZILLA_ORG_SYNC: {
|
||||||
HEADER: '.mzp-c-navigation',
|
HEADER: '.mzp-c-navigation',
|
||||||
},
|
},
|
||||||
|
|
|
@ -23,6 +23,7 @@ const {
|
||||||
confirmTotpCode,
|
confirmTotpCode,
|
||||||
createEmail,
|
createEmail,
|
||||||
createUser,
|
createUser,
|
||||||
|
enableTotpInline,
|
||||||
fillOutEmailFirstSignIn,
|
fillOutEmailFirstSignIn,
|
||||||
fillOutEmailFirstSignUp,
|
fillOutEmailFirstSignUp,
|
||||||
fillOutSignUpCode,
|
fillOutSignUpCode,
|
||||||
|
@ -31,7 +32,6 @@ const {
|
||||||
openPage,
|
openPage,
|
||||||
testElementExists,
|
testElementExists,
|
||||||
testElementTextInclude,
|
testElementTextInclude,
|
||||||
testErrorTextInclude,
|
|
||||||
thenify,
|
thenify,
|
||||||
type,
|
type,
|
||||||
visibleByQSA,
|
visibleByQSA,
|
||||||
|
@ -78,7 +78,7 @@ registerSuite('oauth require totp', {
|
||||||
.then(testElementExists(selectors.SIGNIN_PASSWORD.HEADER));
|
.then(testElementExists(selectors.SIGNIN_PASSWORD.HEADER));
|
||||||
},
|
},
|
||||||
|
|
||||||
'fails for account without TOTP': function() {
|
'account without TOTP redirects to TOTP setup and completes TOTP flow': function() {
|
||||||
return this.remote
|
return this.remote
|
||||||
.then(createUser(email, PASSWORD, { preVerified: true }))
|
.then(createUser(email, PASSWORD, { preVerified: true }))
|
||||||
.then(
|
.then(
|
||||||
|
@ -87,8 +87,30 @@ registerSuite('oauth require totp', {
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.then(fillOutEmailFirstSignIn(email, PASSWORD))
|
.then(fillOutEmailFirstSignIn(email, PASSWORD))
|
||||||
.then(testErrorTextInclude('requires two step authentication enabled'))
|
.then(enableTotpInline())
|
||||||
.then(testErrorTextInclude('More information'));
|
.then(testAtOAuthApp());
|
||||||
|
},
|
||||||
|
|
||||||
|
'after enabling TOTP in the login flow, account bypasses TOTP setup on second visit': function() {
|
||||||
|
return this.remote
|
||||||
|
.then(createUser(email, PASSWORD, { preVerified: true }))
|
||||||
|
.then(
|
||||||
|
openFxaFromRp('two-step-authentication', {
|
||||||
|
header: selectors.ENTER_EMAIL.HEADER,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.then(fillOutEmailFirstSignIn(email, PASSWORD))
|
||||||
|
.then(enableTotpInline())
|
||||||
|
.then(testAtOAuthApp())
|
||||||
|
.then(click(selectors['123DONE'].LINK_LOGOUT))
|
||||||
|
.then(visibleByQSA(selectors['123DONE'].BUTTON_SIGNIN))
|
||||||
|
.then(
|
||||||
|
openFxaFromRp('two-step-authentication', {
|
||||||
|
header: selectors.SIGNIN_PASSWORD.HEADER,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.then(click(selectors.SIGNIN_PASSWORD.SUBMIT_USE_SIGNED_IN))
|
||||||
|
.then(testAtOAuthApp());
|
||||||
},
|
},
|
||||||
|
|
||||||
'succeed for account with TOTP': function() {
|
'succeed for account with TOTP': function() {
|
||||||
|
|
Загрузка…
Ссылка в новой задаче