зеркало из 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 ForceAuthView from '../views/force_auth';
|
||||
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 SupportView from '../views/support';
|
||||
import ReadyView from '../views/ready';
|
||||
|
@ -127,6 +129,8 @@ const Router = Backbone.Router.extend({
|
|||
),
|
||||
'cookies_disabled(/)': createViewHandler(CookiesDisabledView),
|
||||
'force_auth(/)': createViewHandler(ForceAuthView),
|
||||
'inline_totp_setup(/)': createViewHandler(InlineTotpSetupView),
|
||||
'inline_recovery_setup(/)': createViewHandler(InlineRecoverySetupView),
|
||||
'legal(/)': createViewHandler('legal'),
|
||||
'legal/privacy(/)': createViewHandler('pp'),
|
||||
'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 BaseView from './base';
|
||||
import Cocktail from 'cocktail';
|
||||
import ErrorRedirectMixin from './mixins/error-redirect-mixin';
|
||||
import OAuthErrors from '../lib/oauth-errors';
|
||||
import OAuthPrompt from '../lib/oauth-prompt';
|
||||
import SignInMixin from './mixins/signin-mixin';
|
||||
|
@ -59,15 +60,12 @@ class AuthorizationView extends BaseView {
|
|||
|
||||
_handlePromptNoneError(err) {
|
||||
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`
|
||||
// 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
|
||||
// case is being invoked when checking whether prompt=none can be used.
|
||||
return this.broker.sendOAuthResultToRelier({
|
||||
error: err.response_error_code,
|
||||
redirect: this.relier.get('redirectUri'),
|
||||
});
|
||||
return this.redirectWithErrorCode(err);
|
||||
}
|
||||
|
||||
// All other errors are handled at a higher level. If
|
||||
|
@ -77,14 +75,8 @@ class AuthorizationView extends BaseView {
|
|||
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;
|
||||
|
|
|
@ -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 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 {
|
||||
dependsOn: [ResumeTokenMixin],
|
||||
|
||||
|
@ -133,13 +129,10 @@ export default {
|
|||
AuthErrors.is(err, 'INSUFFICIENT_ACR_VALUES') ||
|
||||
OAuthErrors.is(err, 'MISMATCH_ACR_VALUES')
|
||||
) {
|
||||
err.forceMessage = t(
|
||||
'This request requires two step authentication enabled on your account. ' +
|
||||
'<a target="_blank" href=\'' +
|
||||
TOTP_SUPPORT_URL +
|
||||
"'>More Information</a>"
|
||||
);
|
||||
return this.unsafeDisplayError(err);
|
||||
return this.navigate('inline_totp_setup', {
|
||||
account: account,
|
||||
onSubmitComplete: this.onSignInSuccess.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
// re-throw error, it'll be handled elsewhere.
|
||||
|
|
|
@ -3,36 +3,31 @@
|
|||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Cocktail from 'cocktail';
|
||||
import AuthErrors from 'lib/auth-errors';
|
||||
import FormView from '../form';
|
||||
import ModalSettingsPanelMixin from '../mixins/modal-settings-panel-mixin';
|
||||
import Template from 'templates/settings/recovery_codes.mustache';
|
||||
import RecoveryCodePrintTemplate from 'templates/settings/recovery_codes_print.mustache';
|
||||
import RecoveryCode from '../../models/recovery-code';
|
||||
import RecoveryCodesMixin from '../mixins/recovery-codes-mixin';
|
||||
import preventDefaultThen from '../decorators/prevent_default_then';
|
||||
import SaveOptionsMixin from '../mixins/save-options-mixin';
|
||||
import UserAgentMixin from '../../lib/user-agent-mixin';
|
||||
import { getCode } from '../../lib/crypto/totp';
|
||||
|
||||
const t = msg => msg;
|
||||
|
||||
const RECOVERY_CODE_ELEMENT = '#recovery-codes';
|
||||
|
||||
const View = FormView.extend({
|
||||
template: Template,
|
||||
className: 'recovery-codes',
|
||||
viewName: 'settings.two-step-authentication.recovery-codes',
|
||||
|
||||
events: {
|
||||
'click .copy-option': preventDefaultThen('_copyCodes'),
|
||||
'click .download-option': '_downloadCodes',
|
||||
'click .print-option': preventDefaultThen('_printCodes'),
|
||||
'click .copy-option': preventDefaultThen('copyCodes'),
|
||||
'click .download-option': 'downloadCodes',
|
||||
'click .print-option': preventDefaultThen('printCodes'),
|
||||
'click .replace-codes-link': preventDefaultThen('_replaceRecoveryCodes'),
|
||||
'click .two-step-authentication-done': preventDefaultThen(
|
||||
'_showConfirmationForm'
|
||||
'showConfirmationForm'
|
||||
),
|
||||
'click .recovery-confirm-code': preventDefaultThen('_verifyCode'),
|
||||
'click .recovery-back': preventDefaultThen('_hideConfirmationForm'),
|
||||
'click .recovery-confirm-code': preventDefaultThen('verifyCode'),
|
||||
'click .recovery-back': preventDefaultThen('hideConfirmationForm'),
|
||||
},
|
||||
|
||||
_returnToTwoStepAuthentication() {
|
||||
|
@ -43,151 +38,40 @@ const View = FormView.extend({
|
|||
const account = this.getSignedInAccount();
|
||||
return this.invokeBrokerMethod('afterCompleteSignInWithCode', account);
|
||||
}
|
||||
const totpSecret = this.model.get('totpSecret');
|
||||
const done = () => {
|
||||
|
||||
this.onSetupComplete(() => {
|
||||
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() {
|
||||
const account = this.getSignedInAccount();
|
||||
return account.replaceRecoveryCodes().then(result => {
|
||||
this._setupRecoveryCodes(
|
||||
this.setupRecoveryCodes(
|
||||
result.recoveryCodes,
|
||||
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() {
|
||||
const account = this.getSignedInAccount();
|
||||
return account.checkTotpTokenExists().then(result => {
|
||||
if (!result.exists) {
|
||||
this.navigate('settings/two_step_authentication');
|
||||
}
|
||||
if (!result.verified && !this.model.get('recoveryCodes')) {
|
||||
this.navigate('settings/two_step_authentication');
|
||||
}
|
||||
});
|
||||
if (!this.verifyTotpStatus()) {
|
||||
this.navigate('settings/two_step_authentication');
|
||||
}
|
||||
},
|
||||
|
||||
initialize() {
|
||||
this._setupRecoveryCodes(this.model.get('recoveryCodes'));
|
||||
this.setupRecoveryCodes(this.model.get('recoveryCodes'));
|
||||
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;
|
||||
|
|
|
@ -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', () => {
|
||||
it('sends permitted errors to the RP', () => {
|
||||
sinon.stub(view, '_shouldSendErrorToRP').callsFake(() => true);
|
||||
// This error should cause a redirect.
|
||||
const oauthErr = OAuthErrors.toError(
|
||||
'PROMPT_NONE_DIFFERENT_USER_SIGNED_IN'
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
sinon
|
||||
.stub(broker, 'sendOAuthResultToRelier')
|
||||
.callsFake(() => Promise.resolve());
|
||||
relier.set('redirectUri', 'https://redirect.to');
|
||||
});
|
||||
|
||||
const err = OAuthErrors.toError('PROMPT_NONE_DIFFERENT_USER_SIGNED_IN');
|
||||
return view._handlePromptNoneError(err).then(() => {
|
||||
assert.isTrue(view._shouldSendErrorToRP.calledOnceWith(err));
|
||||
it('sends permitted errors to the RP', () => {
|
||||
relier.set('returnOnError', true);
|
||||
|
||||
return view._handlePromptNoneError(oauthErr).then(() => {
|
||||
assert.isTrue(
|
||||
broker.sendOAuthResultToRelier.calledOnceWith({
|
||||
error: 'account_selection_required',
|
||||
|
@ -236,14 +242,20 @@ describe('views/authorization', function() {
|
|||
});
|
||||
});
|
||||
|
||||
it('re-throws other errors', () => {
|
||||
sinon.stub(view, '_shouldSendErrorToRP').callsFake(() => false);
|
||||
sinon.stub(broker, 'sendOAuthResultToRelier');
|
||||
it('re-throws errors if RP does not allow returnOnError', () => {
|
||||
relier.set('returnOnError', false);
|
||||
|
||||
const err = OAuthErrors.toError('PROMPT_NONE_DIFFERENT_USER_SIGNED_IN');
|
||||
return view._handlePromptNoneError(err).then(assert.fail, _err => {
|
||||
assert.isTrue(view._shouldSendErrorToRP.calledOnceWith(err));
|
||||
assert.strictEqual(_err, err);
|
||||
return view._handlePromptNoneError(oauthErr).then(assert.fail, _err => {
|
||||
assert.strictEqual(_err, oauthErr);
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
let err;
|
||||
let err, succeeded, failed;
|
||||
|
||||
beforeEach(() => {
|
||||
err = AuthErrors.toError('TOTP_REQUIRED');
|
||||
|
@ -572,34 +572,25 @@ describe('views/mixins/signin-mixin', function() {
|
|||
sinon.stub(relier, 'isOAuth').callsFake(() => true);
|
||||
sinon.stub(relier, 'wantsTwoStepAuthentication').callsFake(() => true);
|
||||
|
||||
return view.signIn(account, 'password');
|
||||
});
|
||||
|
||||
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'
|
||||
return view.signIn(account, 'password').then(
|
||||
() => (succeeded = true),
|
||||
e => (failed = true)
|
||||
);
|
||||
});
|
||||
|
||||
it('did not navigate', () => {
|
||||
assert.equal(view.navigate.callCount, 0);
|
||||
it('succeeded', () => {
|
||||
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', () => {
|
||||
let err;
|
||||
let err, succeeded, failed;
|
||||
|
||||
beforeEach(() => {
|
||||
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, 'wantsTwoStepAuthentication').callsFake(() => true);
|
||||
|
||||
return view.signIn(account, 'password');
|
||||
});
|
||||
|
||||
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'
|
||||
return view.signIn(account, 'password').then(
|
||||
() => (succeeded = true),
|
||||
e => (failed = true)
|
||||
);
|
||||
});
|
||||
|
||||
it('did not navigate', () => {
|
||||
assert.equal(view.navigate.callCount, 0);
|
||||
it('succeeded', () => {
|
||||
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));
|
||||
}
|
||||
|
||||
|
@ -208,7 +210,7 @@ describe('views/settings/recovery_codes', () => {
|
|||
const padding = Array(256).join('1');
|
||||
email = `${padding}@email.com`;
|
||||
account.set('email', email);
|
||||
const formattedFilename = view._getFormatedRecoveryCodeFilename();
|
||||
const formattedFilename = view.getFormatedRecoveryCodeFilename();
|
||||
assert.equal(formattedFilename.length, 200, 'truncated filename');
|
||||
assert.equal(
|
||||
formattedFilename.indexOf('.txt') > 0,
|
||||
|
|
|
@ -2563,6 +2563,75 @@ const enableTotp = thenify(function() {
|
|||
.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
|
||||
* the first session for the given email address.
|
||||
|
@ -2658,6 +2727,7 @@ module.exports = {
|
|||
destroySessionForEmail,
|
||||
disableInProd,
|
||||
enableTotp,
|
||||
enableTotpInline,
|
||||
confirmRecoveryCode,
|
||||
fetchAllMetrics,
|
||||
fillOutChangePassword,
|
||||
|
|
|
@ -205,6 +205,34 @@ module.exports = {
|
|||
PASSWORD: 'input[type=password]',
|
||||
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: {
|
||||
HEADER: '.mzp-c-navigation',
|
||||
},
|
||||
|
|
|
@ -23,6 +23,7 @@ const {
|
|||
confirmTotpCode,
|
||||
createEmail,
|
||||
createUser,
|
||||
enableTotpInline,
|
||||
fillOutEmailFirstSignIn,
|
||||
fillOutEmailFirstSignUp,
|
||||
fillOutSignUpCode,
|
||||
|
@ -31,7 +32,6 @@ const {
|
|||
openPage,
|
||||
testElementExists,
|
||||
testElementTextInclude,
|
||||
testErrorTextInclude,
|
||||
thenify,
|
||||
type,
|
||||
visibleByQSA,
|
||||
|
@ -78,7 +78,7 @@ registerSuite('oauth require totp', {
|
|||
.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
|
||||
.then(createUser(email, PASSWORD, { preVerified: true }))
|
||||
.then(
|
||||
|
@ -87,8 +87,30 @@ registerSuite('oauth require totp', {
|
|||
})
|
||||
)
|
||||
.then(fillOutEmailFirstSignIn(email, PASSWORD))
|
||||
.then(testErrorTextInclude('requires two step authentication enabled'))
|
||||
.then(testErrorTextInclude('More information'));
|
||||
.then(enableTotpInline())
|
||||
.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() {
|
||||
|
|
Загрузка…
Ссылка в новой задаче