feat(totp): initial totp implementation (#5962), r=@vladikoff

This commit is contained in:
Vijay Budhram 2018-03-13 15:09:58 +00:00 коммит произвёл GitHub
Родитель bc6e567c9b
Коммит 8a3b610c93
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
29 изменённых файлов: 1080 добавлений и 10 удалений

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

@ -0,0 +1,26 @@
<svg width="122" height="138" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient x1="-170.039%" y1="49.981%" x2="257.195%" y2="49.981%" id="b">
<stop stop-color="#00C8D7" offset="0%"/>
<stop stop-color="#0A84FF" offset="100%"/>
</linearGradient>
<rect id="a" x="18" width="76" height="130" rx="4"/>
<path d="M45-8h22a1 1 0 0 1 1 1v110a1 1 0 0 1-1 1H45a1 1 0 0 1-1-1V-7a1 1 0 0 1 1-1z" id="c"/>
</defs>
<g transform="translate(5 4)" fill="none" fill-rule="evenodd">
<use fill="#FFF" xlink:href="#a"/>
<rect stroke="url(#b)" stroke-width="4" x="16" y="-2" width="80" height="134" rx="4"/>
<rect fill="#CCEDF0" x="26" y="10" width="60" height="100" rx="1"/>
<g transform="rotate(-90 56 48)">
<use fill="#FFF" xlink:href="#c"/>
<path stroke="#FFF" stroke-width="5"
d="M45-10.5h22A3.5 3.5 0 0 1 70.5-7v110a3.5 3.5 0 0 1-3.5 3.5H45a3.5 3.5 0 0 1-3.5-3.5V-7a3.5 3.5 0 0 1 3.5-3.5z"/>
<path stroke="url(#b)" stroke-width="3"
d="M45-9.5h22A2.5 2.5 0 0 1 69.5-7v110a2.5 2.5 0 0 1-2.5 2.5H45a2.5 2.5 0 0 1-2.5-2.5V-7A2.5 2.5 0 0 1 45-9.5z"/>
</g>
<path d="M32.986 48.216l-3.446.781 2.342 2.748L30.261 53l-1.78-3.174L26.726 53l-1.621-1.232 2.32-2.77L24 48.215l.608-1.942 3.198 1.444L27.468 44h2.027l-.338 3.742 3.198-1.492.631 1.966zm10.638 0l-3.446.781 2.342 2.748L40.899 53l-1.78-3.174L37.363 53l-1.622-1.232 2.32-2.77-3.423-.782.608-1.942 3.198 1.444L38.106 44h2.027l-.338 3.742 3.198-1.492.63 1.966zm10.637 0l-3.446.781 2.343 2.748L51.536 53l-1.78-3.174L48 53l-1.621-1.232 2.32-2.77-3.424-.782.608-1.942 3.198 1.444L48.743 44h2.027l-.337 3.742 3.198-1.492.63 1.966zm10.638 0l-3.446.781 2.342 2.748L62.174 53l-1.78-3.174L58.638 53l-1.622-1.232 2.32-2.77-3.423-.782.608-1.942 3.198 1.444L59.38 44h2.027l-.338 3.742 3.198-1.492.63 1.966zm10.637 0l-3.445.781 2.342 2.748L72.81 53l-1.779-3.174L69.275 53l-1.621-1.232 2.32-2.77-3.424-.782.608-1.942 3.198 1.444L70.02 44h2.027l-.338 3.742 3.198-1.492.63 1.966zm10.638 0l-3.446.781 2.342 2.748L83.45 53l-1.78-3.174L79.914 53l-1.622-1.232 2.32-2.77-3.423-.782.608-1.942 3.198 1.444L80.656 44h2.027l-.338 3.742 3.198-1.492.63 1.966z"
fill="url(#b)"/>
<rect fill="#CCEDF0" x="50" y="4" width="12" height="1" rx=".5"/>
<circle fill="#CCEDF0" cx="56" cy="120" r="6"/>
</g>
</svg>

После

Ширина:  |  Высота:  |  Размер: 2.4 KiB

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

@ -206,6 +206,10 @@ define(function (require, exports, module) {
errno: 153,
message: t('This verification code has expired')
},
INVALID_TOTP_CODE: {
errno: 154,
message: t('Invalid two-step authentication code')
},
// Secondary Email errors end
SERVER_BUSY: {
errno: 201,

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

@ -962,7 +962,41 @@ define(function (require, exports, module) {
* @param {String} code tokenCode
* @returns {Promise} resolves when complete
*/
verifyTokenCode: createClientDelegate('verifyTokenCode')
verifyTokenCode: createClientDelegate('verifyTokenCode'),
/**
* Creates a new TOTP token for the current user.
*
* @param {String} sessionToken SessionToken obtained from signIn
* @returns {Promise} resolves when complete
*/
createTotpToken: createClientDelegate('createTotpToken'),
/**
* Deletes the current user's TOTP token.
*
* @param {String} sessionToken SessionToken obtained from signIn
* @returns {Promise} resolves when complete
*/
deleteTotpToken: createClientDelegate('deleteTotpToken'),
/**
* Checks to see if the current user has a TOTP token.
*
* @param {String} sessionToken SessionToken obtained from signIn
* @returns {Promise} resolves when complete
*/
checkTotpTokenExists: createClientDelegate('checkTotpTokenExists'),
/**
* Checks to see if the TOTP code is valid and verifies the TOTP token
* if it is.
*
* @param {String} sessionToken SessionToken obtained from signIn
* @param {String} code TOTP code
* @returns {Promise} resolves when complete
*/
verifyTotpCode: createClientDelegate('verifyTotpCode')
};
module.exports = FxaClientWrapper;

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

@ -39,6 +39,7 @@ define(function (require, exports, module) {
const SettingsView = require('../views/settings');
const SignInBouncedView = require('../views/sign_in_bounced');
const SignInTokenCodeView = require('../views/sign_in_token_code');
const SignInTotpCodeView = require('../views/sign_in_totp_code');
const SignInPasswordView = require('../views/sign_in_password');
const SignInReportedView = require('../views/sign_in_reported');
const SignInUnblockView = require('../views/sign_in_unblock');
@ -49,6 +50,7 @@ define(function (require, exports, module) {
const SmsSentView = require('../views/sms_sent');
const Storage = require('./storage');
const TosView = require('../views/tos');
const TwoStepAuthenticationView = require('../views/settings/two_step_authentication');
const VerificationReasons = require('./verification-reasons');
const WhyConnectAnotherDeviceView = require('../views/why_connect_another_device');
@ -107,12 +109,14 @@ define(function (require, exports, module) {
'settings/delete_account(/)': createChildViewHandler(DeleteAccountView, SettingsView),
'settings/display_name(/)': createChildViewHandler(DisplayNameView, SettingsView),
'settings/emails(/)': createChildViewHandler(EmailsView, SettingsView),
'settings/two_step_authentication(/)': createChildViewHandler(TwoStepAuthenticationView, SettingsView),
'signin(/)': 'onSignIn',
'signin_bounced(/)': createViewHandler(SignInBouncedView),
'signin_confirmed(/)': createViewHandler(ReadyView, { type: VerificationReasons.SIGN_IN }),
'signin_permissions(/)': createViewHandler(PermissionsView, { type: VerificationReasons.SIGN_IN }),
'signin_reported(/)': createViewHandler(SignInReportedView),
'signin_token_code(/)': createViewHandler(SignInTokenCodeView),
'signin_totp_code(/)': createViewHandler(SignInTotpCodeView),
'signin_unblock(/)': createViewHandler(SignInUnblockView),
'signin_verified(/)': createViewHandler(ReadyView, { type: VerificationReasons.SIGN_IN }),
'signup(/)': 'onSignUp',

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

@ -15,6 +15,7 @@ define(function (require, exports, module) {
EMAIL: 'email',
EMAIL_2FA: 'email-2fa',
EMAIL_CAPTCHA: 'email-captcha',
TOTP_2FA: 'totp-2fa'
};
});

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

@ -1228,7 +1228,61 @@ define(function (require, exports, module) {
this.get('sessionToken'),
email
);
},
/**
* Creates a new TOTP token for a user.
*
* @returns {Promise}
*/
createTotpToken () {
return this._fxaClient.createTotpToken(
this.get('sessionToken'),
{
metricsContext: this._metrics.getFlowEventMetadata()
}
);
},
/**
* Deletes the current TOTP token for a user.
*
* @returns {Promise}
*/
deleteTotpToken () {
return this._fxaClient.deleteTotpToken(
this.get('sessionToken')
);
},
/**
* Verifies a TOTP code. If code is verified, token will be marked as verified.
*
* @param {String} code
*
* @returns {Promise}
*/
verifyTotpCode (code) {
return this._fxaClient.verifyTotpCode(
this.get('sessionToken'),
code,
{
metricsContext: this._metrics.getFlowEventMetadata(),
}
);
},
/**
* Check to see if the current user has a verified TOTP token.
*
* @returns {Promise}
*/
checkTotpTokenExists () {
return this._fxaClient.checkTotpTokenExists(
this.get('sessionToken')
);
}
}, {
ALLOWED_KEYS: ALLOWED_KEYS,
PERMISSIONS_TO_KEYS: PERMISSIONS_TO_KEYS

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

@ -75,7 +75,7 @@ define(function (require, exports, module) {
success: t('Secondary email verified successfully')
}),
afterCompleteSignIn: new NavigateBehavior('signin_verified'),
afterCompleteSignInTokenCode: new NavigateBehavior('settings'),
afterCompleteSignInWithCode: new NavigateBehavior('settings'),
afterCompleteSignUp: new NavigateBehavior('signup_verified'),
afterDeleteAccount: new NullBehavior(),
afterForceAuth: new NavigateBehavior('signin_confirmed'),
@ -239,8 +239,8 @@ define(function (require, exports, module) {
.then(() => this.getBehavior('afterCompleteSignIn'));
},
afterCompleteSignInTokenCode () {
return Promise.resolve(this.getBehavior('afterCompleteSignInTokenCode'));
afterCompleteSignInWithCode () {
return Promise.resolve(this.getBehavior('afterCompleteSignInWithCode'));
},
/**

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

@ -81,7 +81,11 @@ define(function (require, exports, module) {
*/
_notifyRelierOfLogin (account) {
return proto._notifyRelierOfLogin.call(this, account);
}
},
afterCompleteSignInWithCode (account) {
return this._notifyRelierOfLogin(account);
},
});
module.exports = FxiOSV1AuthenticationBroker;

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

@ -42,7 +42,7 @@ define(function (require, exports, module) {
return channel;
},
afterCompleteSignInTokenCode (account) {
afterCompleteSignInWithCode (account) {
return this._notifyRelierOfLogin(account)
.then(() => proto.afterSignInConfirmationPoll.call(this, account));
},

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

@ -212,7 +212,7 @@ define(function (require, exports, module) {
.then(() => proto.afterSignInConfirmationPoll.call(this, account));
},
afterCompleteSignInTokenCode (account) {
afterCompleteSignInWithCode (account) {
return this.finishOAuthSignInFlow(account)
.then(() => proto.afterSignIn.call(this, account));
},

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

@ -0,0 +1,84 @@
<div id="totp-section" class="settings-unit {{#isPanelOpen}}open{{/isPanelOpen}} totp-panel">
<div class="settings-unit-stub">
<header class="settings-unit-summary">
<h2 class="settings-unit-title">{{#t}}Two-step authentication{{/t}}</h2>
</header>
{{^hasToken}}
<button class="settings-button primary settings-unit-toggle totp-create" data-href="settings/two_step_authentication">
{{#t}}Enable…{{/t}}
</button>
{{/hasToken}}
{{#hasToken}}
<button class="settings-button secondary settings-unit-toggle" data-href="settings/two_step_authentication">
{{#t}}Change…{{/t}}
</button>
{{/hasToken}}
</div>
<div class="settings-unit-details">
<form novalidate>
<p>
{{#t}}Add an extra layer of security to your account by requiring security codes.{{/t}}
</p>
<ul id="totp-status" class="totp-list">
<li class="totp-type">
<div class="name">Current status</div>
<div class="details">
{{#hasToken}}
<div class="enabled">{{#t}}Enabled{{/t}}</div>
{{/hasToken}}
{{^hasToken}}
<div class="disabled">{{#t}}Disabled{{/t}}</div>
{{/hasToken}}
</div>
<div class="settings-button-group">
{{#hasToken}}
<button class="settings-button warning totp-delete">{{#t}}Disable{{/t}}</button>
{{/hasToken}}
{{^hasToken}}
<button class="settings-button secondary totp-create">{{#t}}Enable{{/t}}</button>
{{/hasToken}}
</div>
</li>
</ul>
<div id="totp" class="totp-details hidden">
<div class="qr-image-container">
<img class="qr-image"/>
</div>
<p class="setup-description">
{{#t}}Scan the QR code in your app and confirm it is setup correctly by entering the security code it provides.{{/t}}
<a class="show-code-link">{{#t}}Can't scan code?{{/t}}</a>
</p>
<div class="manual-code hidden">
<p class="setup-description">{{#t}}Manually enter this secret key into your authentication app:{{/t}}</p>
<div class="code"></div>
</div>
<div class="input-row">
<label class="label-helper"></label>
<input type="text" class="text totp-code tooltip-below" placeholder="{{#t}}Security code{{/t}}" value="{{ code }}" required autofocus autocomplete="off"/>
</div>
</div>
<div class="button-row">
{{#hasToken}}
<button type="submit" class="settings-button primary totp-refresh">{{#t}}Refresh{{/t}}</button>
{{/hasToken}}
{{^hasToken}}
<button type="submit" class="settings-button primary totp-refresh">{{#t}}Refresh{{/t}}</button>
<button type="submit" class="settings-button primary hidden totp-confirm-code">{{#t}}Confirm{{/t}}</button>
{{/hasToken}}
<button class="settings-button cancel secondary enabled totp-cancel">{{#t}}Cancel{{/t}}</button>
</div>
</form>
</div>
</div>

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

@ -0,0 +1,33 @@
<div id="main-content" class="card">
<header>
<h1 id="fxa-totp-code-header">{{#t}}Enter security code{{/t}}
{{#serviceName}}
<span class="service">{{#t}}Continue to %(serviceName)s{{/t}}</span>
{{/serviceName}}
</h1>
</header>
<section>
<div class="error"></div>
<div class="success"></div>
<div class="graphic graphic-two-factor-auth"></div>
<p class="verification-totp-message">
{{#t}}Open your authentication app and enter the security code it provides.{{/t}}
</p>
<form novalidate>
<div class="input-row token-code-row">
<input type="text" class="tooltip-below totp-code" placeholder="{{#t}}Enter 6-digit code{{/t}}" required autofocus />
</div>
<div class="button-row">
<button type="submit" class="use-logged-in">{{#t}}Verify{{/t}}</button>
</div>
</form>
<div class="links"></div>
</section>
</div>

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

@ -33,6 +33,10 @@ define(function (require, exports, module) {
this.navigate('settings/clients');
},
_returnToTwoFactorAuthentication () {
this.navigate('settings/two_step_authentication');
},
_returnToSettings () {
this.navigate('settings');
},

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

@ -149,6 +149,11 @@ define(function (require, exports, module) {
return this.navigate('signin_token_code', {account});
}
if (verificationReason === VerificationReasons.SIGN_IN &&
verificationMethod === VerificationMethods.TOTP_2FA) {
return this.navigate('signin_totp_code', {account});
}
return this.navigate('confirm', {account});
}

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

@ -31,10 +31,13 @@ define(function (require, exports, module) {
const Template = require('templates/settings.mustache');
const UserAgentMixin = require('../lib/user-agent-mixin');
const TwoStepAuthenticationView = require('./settings/two_step_authentication');
var PANEL_VIEWS = [
AvatarView,
DisplayNameView,
EmailsView,
TwoStepAuthenticationView,
ClientsView,
ClientDisconnectView,
CommunicationPreferencesView,

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

@ -0,0 +1,164 @@
/* 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/. */
'use strict';
const AvatarMixin = require('../mixins/avatar-mixin');
const AuthErrors = require('lib/auth-errors');
const BaseView = require('../base');
const Cocktail = require('cocktail');
const FloatingPlaceholderMixin = require('../mixins/floating-placeholder-mixin');
const FormView = require('../form');
const SettingsPanelMixin = require('../mixins/settings-panel-mixin');
const SearchParamMixin = require('../../lib/search-param-mixin');
const Template = require('templates/settings/two_step_authentication.mustache');
const preventDefaultThen = require('../base').preventDefaultThen;
const showProgressIndicator = require('../decorators/progress_indicator');
var t = BaseView.t;
const CODE_INPUT_SELECTOR = 'input.totp-code';
const CODE_REFRESH_SELECTOR = 'button.settings-button.totp-refresh';
const CODE_REFRESH_DELAY_MS = 350;
const View = FormView.extend({
template: Template,
className: 'two-step-authentication',
viewName: 'settings.two-step-authentication',
events: {
'click .show-code-link': preventDefaultThen('_showCode'),
'click .totp-cancel': preventDefaultThen('cancel'),
'click .totp-confirm-code': preventDefaultThen('confirmCode'),
'click .totp-create': preventDefaultThen('createToken'),
'click .totp-delete': preventDefaultThen('deleteToken'),
'click .totp-refresh': preventDefaultThen('refresh'),
},
_checkTokenExists() {
const account = this.getSignedInAccount();
return account.checkTotpTokenExists()
.then((result) => {
this._hasToken = result.exists;
});
},
_sanitizeCode(code) {
// Remove spaces and `-`
return code.replace(/[- ]*/g, '');
},
_showQrCode() {
this.$('#totp').removeClass('hidden');
},
_hideStatus() {
this.$('.totp-list').addClass('hidden');
this.$('.totp-refresh').addClass('hidden');
this.$('.totp-confirm-code').removeClass('hidden');
},
_showCode() {
this.$('.manual-code').removeClass('hidden');
this.$('.show-code-link').addClass('hidden');
},
_getFormattedCode(code) {
// Insert spaces every 4 characters
return code.replace(/(\w{4})/g, '$1 ');
},
_isPanelEnabled() {
if (this.getSearchParam('showTwoStepAuthentication')) {
return true;
}
return false;
},
beforeRender() {
// This panel is currently behind a feature flag. Only show it
// when the query param `showTwoStepAuthentication` is specified.
if (! this._isPanelEnabled()) {
return this.remove();
} else {
return this._checkTokenExists();
}
},
initialize() {
this._hasToken = false;
this._statusVisible = true;
},
setInitialContext(context) {
context.set({
hasToken: this._hasToken,
isPanelOpen: this.isPanelOpen(),
statusVisible: this._statusVisible
});
},
cancel() {
return this.render()
.then(() => this.navigate('/settings'));
},
createToken() {
const account = this.getSignedInAccount();
return account.createTotpToken()
.then(result => {
this.$('.qr-image').attr('src', result.qrCodeUrl);
this.$('.code').html(this._getFormattedCode(result.secret));
this._showQrCode();
this._hideStatus();
});
},
deleteToken() {
const account = this.getSignedInAccount();
return account.deleteTotpToken()
.then(() => {
this.displaySuccess(t('Two-step authentication removed'), {
closePanel: true
});
return this.render();
})
.then(() => this.navigate('/settings'));
},
confirmCode() {
const account = this.getSignedInAccount();
const code = this._sanitizeCode(this.getElementValue('input.totp-code'));
return account.verifyTotpCode(code)
.then((result) => {
if (result.success) {
this.displaySuccess(t('Two-step authentication enabled'), {});
this.render();
} else {
throw AuthErrors.toError('INVALID_TOTP_CODE');
}
})
.catch((err) => this.showValidationError(this.$(CODE_INPUT_SELECTOR), err));
},
submit() {
return this.confirmCode();
},
refresh: showProgressIndicator(function () {
return this.render();
}, CODE_REFRESH_SELECTOR, CODE_REFRESH_DELAY_MS),
});
Cocktail.mixin(
View,
AvatarMixin,
SettingsPanelMixin,
FloatingPlaceholderMixin,
SearchParamMixin
);
module.exports = View;

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

@ -51,7 +51,7 @@ define(function (require, exports, module) {
return account.verifyTokenCode(code)
.then(() => {
this.logViewEvent('success');
return this.invokeBrokerMethod('afterCompleteSignInTokenCode', account);
return this.invokeBrokerMethod('afterCompleteSignInWithCode', account);
}, (err) => {
this.displayError(err);
});

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

@ -0,0 +1,79 @@
/* 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/. */
const AuthErrors = require('lib/auth-errors');
const Cocktail = require('cocktail');
const Constants = require('../lib/constants');
const FormView = require('./form');
const SignInMixin = require('./mixins/signin-mixin');
const ServiceMixin = require('./mixins/service-mixin');
const Template = require('templates/sign_in_totp_code.mustache');
const VerificationReasonMixin = require('./mixins/verification-reason-mixin');
const CODE_INPUT_SELECTOR = 'input.totp-code';
const View = FormView.extend({
className: 'sign-in-totp-code',
template: Template,
_sanitizeCode (code) {
// Remove spaces and `-`
return code.replace(/[- ]*/g, '');
},
beforeRender () {
// user cannot confirm if they have not initiated a sign in.
const account = this.getSignedInAccount();
if (! account || ! account.get('sessionToken')) {
this.navigate(this._getAuthPage());
}
},
setInitialContext (context) {
// This needs to point to correct support link
const supportLink = Constants.BLOCKED_SIGNIN_SUPPORT_URL;
context.set({
escapedSupportLink: encodeURI(supportLink),
hasSupportLink: !! supportLink
});
},
submit () {
const account = this.getSignedInAccount();
const code = this._sanitizeCode(this.getElementValue('input.totp-code'));
return account.verifyTotpCode(code)
.then((result) => {
if (result.success) {
this.logViewEvent('success');
return this.invokeBrokerMethod('afterCompleteSignInWithCode', account);
} else {
throw AuthErrors.toError('INVALID_TOTP_CODE');
}
})
.catch((err) => this.showValidationError(this.$(CODE_INPUT_SELECTOR), err));
},
/**
* Get the URL of the page for users that
* must enter their password.
*
* @returns {String}
*/
_getAuthPage () {
const authPage =
this.model.get('lastPage') === 'force_auth' ? 'force_auth' : 'signin';
return this.broker.transformLink(authPage);
}
});
Cocktail.mixin(
View,
SignInMixin,
ServiceMixin,
VerificationReasonMixin
);
module.exports = View;

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

@ -10,6 +10,7 @@
@import 'modules/custom-rows';
@import 'modules/modal';
@import 'modules/settings';
@import 'modules/settings-totp';
@import 'modules/legal';
@import 'modules/spinner';
@import 'modules/avatar';

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

@ -78,7 +78,8 @@ ul.links {
}
.verification-email-message,
.signed-in-email-message {
.signed-in-email-message,
.verification-totp-message {
word-wrap: break-word;
}

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

@ -66,3 +66,12 @@
height: 100px;
}
}
.graphic-two-factor-auth {
background-image: url('/images/graphic_two_factor_auth.svg');
background-position: center center;
background-repeat: no-repeat;
height: 138px;
margin-top: 20px;
width: auto;
}

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

@ -40,5 +40,20 @@
font-size: 18px;
}
}
.modal-success {
background-color: $settings-success-background-color;
border-color: $settings-success-border-color;
border-style: solid;
color: $success-text-color;
font-size: $base-font;
margin: 0 auto;
text-align: center;
top: 0;
a {
color: $success-text-color;
}
}
}
}

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

@ -0,0 +1,106 @@
.totp-panel {
.totp-list {
padding: 0;
.totp-type {
height: 40px;
list-style: none;
margin: 10px 0;
position: relative;
html[dir='ltr'] & {
background-position: left 2px;
}
html[dir='rtl'] & {
background-position: right 2px;
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@include respond-to('big') {
width: calc(95% - 95px);
}
@include respond-to('small') {
width: 100%;
}
}
.details {
color: $color-grey;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: calc(95% - 95px);
& .disabled {
color: $color-red;
}
& .enabled {
color: $color-green;
}
}
.settings-button-group {
position: absolute;
top: 0;
html[dir='ltr'] & {
right: 0;
}
html[dir='rtl'] & {
left: 0;
}
.settings-button {
height: 35px;
margin-left: 10px;
/*minimum width required for the button to look good without occupying too much space*/
/*is also the default computed width on desktop screen*/
min-width: 100px;
text-align: center;
width: 20%;
}
}
}
}
.totp-details {
.qr-image-container {
margin: auto;
padding-bottom: 10px;
width: 50%;
}
.setup-description,
.code-description {
margin-bottom: 10px;
}
.show-code-link {
margin-bottom: 10px;
}
.code {
color: $color-green-darker;
letter-spacing: .1rem;
padding-bottom: 5px;
}
}
.input-row {
margin-top: 24px;
}
}
.recovery-code {
font-size: 1.5em;
font-weight: bold;
}

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

@ -1498,5 +1498,65 @@ define(function (require, exports, module) {
});
});
});
describe('createTotpToken', () => {
it('delegates to the fxa-js-client', () => {
const resp = {
qrCodeUrl: 'data:image/png;base64,iVBOR:',
secret: 'superdupersecretcode'
};
sinon.stub(realClient, 'createTotpToken').callsFake(() => Promise.resolve(resp));
return client.createTotpToken()
.then((_resp) => {
assert.strictEqual(_resp, resp);
assert.isTrue(realClient.createTotpToken.calledOnce);
});
});
});
describe('deleteTotpToken', () => {
it('delegates to the fxa-js-client', () => {
const resp = {};
sinon.stub(realClient, 'deleteTotpToken').callsFake(() => Promise.resolve(resp));
return client.deleteTotpToken()
.then((_resp) => {
assert.strictEqual(_resp, resp);
assert.isTrue(realClient.deleteTotpToken.calledOnce);
});
});
});
describe('checkTotpTokenExists', () => {
it('delegates to the fxa-js-client', () => {
const resp = {
exists: true
};
sinon.stub(realClient, 'checkTotpTokenExists').callsFake(() => Promise.resolve(resp));
return client.checkTotpTokenExists()
.then((_resp) => {
assert.strictEqual(_resp, resp);
assert.isTrue(realClient.checkTotpTokenExists.calledOnce);
});
});
});
describe('verifyTotpCode', () => {
it('delegates to the fxa-js-client', () => {
const resp = {
success: true
};
sinon.stub(realClient, 'verifyTotpCode').callsFake(() => Promise.resolve(resp));
return client.verifyTotpCode('code')
.then((_resp) => {
assert.strictEqual(_resp, resp);
assert.isTrue(realClient.verifyTotpCode.calledOnce);
assert.isTrue(realClient.verifyTotpCode.calledWith('code'));
});
});
});
});
});

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

@ -0,0 +1,191 @@
/* 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/. */
const $ = require('jquery');
const assert = require('chai').assert;
const Metrics = require('lib/metrics');
const Notifier = require('lib/channels/notifier');
const sinon = require('sinon');
const TestHelpers = require('../../../lib/helpers');
const User = require('models/user');
const View = require('views/settings/two_step_authentication');
describe('views/settings/two_step_authentication', () => {
let account;
let email;
let metrics;
let notifier;
let featureEnabled;
let hasToken;
let validCode;
const UID = '123';
let user;
let view;
function initView() {
view = new View({
metrics: metrics,
notifier: notifier,
user: user
});
sinon.stub(view, '_isPanelEnabled').callsFake(() => featureEnabled);
sinon.stub(view, 'getSignedInAccount').callsFake(() => account);
sinon.spy(view, 'remove');
return view.render()
.then(() => $('#container').html(view.$el));
}
beforeEach(() => {
email = TestHelpers.createEmail();
notifier = new Notifier();
metrics = new Metrics({notifier});
user = new User();
account = user.initAccount({
email: email,
sessionToken: 'abc123',
uid: UID,
verified: true
});
sinon.stub(account, 'checkTotpTokenExists').callsFake(() => {
return Promise.resolve({exists: hasToken});
});
sinon.stub(account, 'verifyTotpCode').callsFake(() => {
return Promise.resolve({success: validCode});
});
sinon.stub(account, 'deleteTotpToken').callsFake(() => Promise.resolve({}));
sinon.stub(account, 'createTotpToken').callsFake(() => {
return Promise.resolve({
qrCodeUrl: 'data:image/png;base64,iVBOR',
secret: 'MZEE 4ODK'
});
});
featureEnabled = true;
hasToken = true;
});
afterEach(() => {
view.remove();
view.destroy();
view = null;
});
describe('feature disabled', () => {
beforeEach(() => {
featureEnabled = false;
return initView();
});
it('should remove panel if `showTwoStepAuthentication` query is not specified', () => {
assert.equal(view.remove.callCount, 1);
});
});
describe('feature enabled', () => {
beforeEach(() => {
featureEnabled = true;
return initView();
});
describe('should show token status', () => {
beforeEach(() => {
hasToken = true;
return initView();
});
it('should show token status view if user has token', () => {
assert.equal(view.$('#totp-status .enabled').length, 1);
assert.equal(view.$('#totp.hidden').length, 1);
});
});
describe('should create new token', () => {
beforeEach(() => {
hasToken = false;
return initView()
.then(() => view.createToken());
});
it('should show QR code', () => {
assert.equal(view.$('#totp').is(':visible'), true);
assert.equal(view.$('img.qr-image').attr('src'), 'data:image/png;base64,iVBOR');
});
it('should not show status section', () => {
assert.equal(view.$('.totp-list.hidden').length, 1);
});
describe('should show code and hide `show code link`', () => {
beforeEach(() => {
assert.equal(view.$('.manual-code.hidden').length, 1);
assert.equal(view.$('.show-code-link:not(hidden)').length, 1);
return view.$('.show-code-link').click();
});
it('shows correct links', () => {
assert.equal(view.$('.code')[0].innerText, 'MZEE 4ODK');
assert.equal(view.$('.manual-code:not(hidden)').length, 1);
assert.equal(view.$('.show-code-link.hidden').length, 1);
});
});
});
describe('should display error for invalid code', () => {
beforeEach(() => {
validCode = false;
return initView()
.then(() => {
sinon.spy(view, 'showValidationError');
return view.confirmCode();
});
});
it('display error', () => {
assert.equal(view.showValidationError.callCount, 1);
assert.equal(view.showValidationError.args[0][1].errno, 154, 'invalid code errno');
});
});
describe('should validate token code', () => {
beforeEach(() => {
validCode = true;
return initView()
.then(() => {
sinon.spy(view, 'render');
sinon.spy(view, 'displaySuccess');
view.confirmCode();
});
});
it('confirms code', () => {
assert.equal(view.render.callCount, 1);
assert.equal(view.displaySuccess.callCount, 1);
});
});
describe('should delete token', () => {
beforeEach(() => {
hasToken = true;
return initView();
});
it('deletes a token and shows success', () => {
sinon.spy(view, 'navigate');
sinon.spy(view, 'displaySuccess');
return view.deleteToken()
.then(() => {
assert.equal(account.deleteTotpToken.callCount, 1, 'called delete token');
assert.equal(view.displaySuccess.callCount, 1, 'displayed success');
assert.equal(view.navigate.args[0][0], '/settings', 'navigated to settings');
});
});
});
});
});

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

@ -146,7 +146,7 @@ define(function (require, exports, module) {
it('calls correct broker methods', () => {
assert.isTrue(account.verifyTokenCode.calledWith(TOKEN_CODE), 'verify with correct code');
assert.isTrue(view.invokeBrokerMethod.calledWith('afterCompleteSignInTokenCode', account));
assert.isTrue(view.invokeBrokerMethod.calledWith('afterCompleteSignInWithCode', account));
});
});

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

@ -0,0 +1,184 @@
/* 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/. */
const $ = require('jquery');
const _ = require('underscore');
const {assert} = require('chai');
const Account = require('models/account');
const AuthErrors = require('lib/auth-errors');
const Backbone = require('backbone');
const BaseBroker = require('models/auth_brokers/base');
const Constants = require('lib/constants');
const {createRandomHexString} = require('../../lib/helpers');
const Metrics = require('lib/metrics');
const Relier = require('models/reliers/relier');
const sinon = require('sinon');
const View = require('views/sign_in_totp_code');
const WindowMock = require('../../mocks/window');
const TOTP_CODE = createRandomHexString(Constants.UNBLOCK_CODE_LENGTH);
describe('views/sign_in_totp_code', () => {
let account;
let broker;
let metrics;
let model;
let notifier;
let relier;
let view;
let windowMock;
beforeEach(() => {
windowMock = new WindowMock();
relier = new Relier({
window: windowMock
});
broker = new BaseBroker({
relier: relier,
window: windowMock
});
account = new Account({
email: 'a@a.com',
sessionToken: 'someToken',
uid: 'uid'
});
model = new Backbone.Model({
account: account,
lastPage: 'signin',
password: 'password'
});
notifier = _.extend({}, Backbone.Events);
metrics = new Metrics({notifier});
view = new View({
broker,
canGoBack: true,
metrics,
model,
notifier,
relier,
viewName: 'sign-in-totp-code',
window: windowMock
});
sinon.stub(view, 'getSignedInAccount').callsFake(() => model.get('account'));
return view.render()
.then(() => $('#container').html(view.$el));
});
afterEach(() => {
metrics.destroy();
view.remove();
view.destroy();
view = metrics = null;
});
describe('render', () => {
it('renders the view', () => {
assert.lengthOf(view.$('#fxa-totp-code-header'), 1);
assert.include(view.$('.verification-totp-message').text(), 'security code');
});
describe('without an account', () => {
beforeEach(() => {
account = model.get('account').unset('sessionToken');
sinon.spy(view, 'navigate');
return view.render();
});
it('redirects to the signin page', () => {
assert.isTrue(view.navigate.calledWith('signin'));
});
});
});
describe('validateAndSubmit', () => {
beforeEach(() => {
sinon.stub(view, 'submit').callsFake(() => Promise.resolve());
sinon.spy(view, 'showValidationError');
});
describe('with an empty code', () => {
beforeEach(() => {
view.$('#totp-code').val('');
return view.validateAndSubmit().then(assert.fail, () => {});
});
it('displays a tooltip, does not call submit', () => {
assert.isTrue(view.showValidationError.called);
assert.isFalse(view.submit.called);
});
});
const validCodes = [
TOTP_CODE,
' ' + TOTP_CODE,
TOTP_CODE + ' ',
' ' + TOTP_CODE + ' ',
'001-001',
'111 111'
];
validCodes.forEach((code) => {
describe(`with a valid code: '${code}'`, () => {
beforeEach(() => {
view.$('.totp-code').val(code);
return view.validateAndSubmit();
});
it('calls submit', () => {
assert.equal(view.submit.callCount, 1);
});
});
});
});
describe('submit', () => {
describe('success', () => {
beforeEach(() => {
sinon.stub(account, 'verifyTotpCode').callsFake(() => Promise.resolve({success: true}));
sinon.stub(view, 'invokeBrokerMethod').callsFake(() => Promise.resolve());
view.$('.totp-code').val(TOTP_CODE);
return view.submit();
});
it('calls correct broker methods', () => {
assert.isTrue(account.verifyTotpCode.calledWith(TOTP_CODE), 'verify with correct code');
assert.isTrue(view.invokeBrokerMethod.calledWith('afterCompleteSignInWithCode', account));
});
});
describe('invalid TOTP code', () => {
beforeEach(() => {
sinon.stub(account, 'verifyTotpCode').callsFake(() => Promise.resolve({success: false}));
sinon.spy(view, 'showValidationError');
view.$('.totp-code').val(TOTP_CODE);
return view.submit();
});
it('rejects with the error for display', () => {
assert.equal(view.showValidationError.args[0][1].errno, 154, 'correct error thrown');
});
});
describe('errors', () => {
beforeEach(() => {
sinon.stub(account, 'verifyTotpCode').callsFake(() => Promise.reject(AuthErrors.toError('UNEXPECTED_ERROR')));
sinon.spy(view, 'showValidationError');
view.$('.totp-code').val(TOTP_CODE);
return view.submit();
});
it('rejects with the error for display', () => {
assert.equal(view.showValidationError.args[0][1].errno, 999, 'correct error thrown');
});
});
});
});

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

@ -204,12 +204,14 @@ require('./spec/views/settings/communication_preferences');
require('./spec/views/settings/delete_account');
require('./spec/views/settings/display_name');
require('./spec/views/settings/emails');
require('./spec/views/settings/two_step_authentication');
require('./spec/views/sign_in');
require('./spec/views/sign_in_bounced');
require('./spec/views/sign_in_password');
require('./spec/views/sign_in_reported');
require('./spec/views/sign_in_unblock');
require('./spec/views/sign_in_token_code');
require('./spec/views/sign_in_totp_code');
require('./spec/views/sign_up');
require('./spec/views/sign_up_password');
require('./spec/views/sms_send');

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

@ -39,9 +39,11 @@ module.exports = function () {
'settings/delete_account',
'settings/display_name',
'settings/emails',
'settings/two_step_authentication',
'signin',
'signin_bounced',
'signin_token_code',
'signin_totp_code',
'signin_confirmed',
'signin_permissions',
'signin_reported',