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:
Jared Hirsch 2020-04-20 17:51:18 -07:00
Родитель 70333b9e54
Коммит 5b333ac2a3
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 7929C0057BF4E690
17 изменённых файлов: 801 добавлений и 219 удалений

Просмотреть файл

@ -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}}Cant 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() {