MFA UI Changes (v3) (#14145)

* adds development workflow to mirage config

* adds mirage handler and factory for mfa workflow

* adds mfa handling to auth service and cluster adapter

* moves auth success logic from form to controller

* adds mfa form component

* shows delayed auth message for all methods

* adds new code delay to mfa form

* adds error views

* fixes merge conflict

* adds integration tests for mfa-form component

* fixes auth tests

* updates mfa response handling to align with backend

* updates mfa-form to handle multiple methods and constraints

* adds noDefault arg to Select component

* updates mirage mfa handler to align with backend and adds generator for various mfa scenarios

* adds tests

* flaky test fix attempt

* reverts test fix attempt

* adds changelog entry

* updates comments for todo items

* removes faker from mfa mirage factory and handler

* adds number to word helper

* fixes tests
This commit is contained in:
Jordan Reimer 2022-02-17 15:40:25 -07:00 committed by GitHub
parent 24d362aa8c
commit 7bd1992bc5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1070 additions and 164 deletions

3
changelog/14049.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: Adds multi-factor authentication support
```

View File

@ -126,6 +126,19 @@ export default ApplicationAdapter.extend({
return this.ajax(url, verb, options);
},
mfaValidate({ mfa_request_id, mfa_constraints }) {
const options = {
data: {
mfa_request_id,
mfa_payload: mfa_constraints.reduce((obj, { selectedMethod, passcode }) => {
obj[selectedMethod.id] = passcode ? [passcode] : [];
return obj;
}, {}),
},
};
return this.ajax('/v1/sys/mfa/validate', 'POST', options);
},
urlFor(endpoint) {
if (!ENDPOINTS.includes(endpoint)) {
throw new Error(

View File

@ -18,13 +18,13 @@ const BACKENDS = supportedAuthBackends();
*
* @example ```js
* // All properties are passed in via query params.
* <AuthForm @wrappedToken={{wrappedToken}} @cluster={{model}} @namespace={{namespaceQueryParam}} @redirectTo={{redirectTo}} @selectedAuth={{authMethod}}/>```
* <AuthForm @wrappedToken={{wrappedToken}} @cluster={{model}} @namespace={{namespaceQueryParam}} @selectedAuth={{authMethod}} @onSuccess={{action this.onSuccess}} />```
*
* @param wrappedToken=null {String} - The auth method that is currently selected in the dropdown.
* @param cluster=null {Object} - The auth method that is currently selected in the dropdown. This corresponds to an Ember Model.
* @param namespace=null {String} - The currently active namespace.
* @param redirectTo=null {String} - The name of the route to redirect to.
* @param selectedAuth=null {String} - The auth method that is currently selected in the dropdown.
* @param {string} wrappedToken - The auth method that is currently selected in the dropdown.
* @param {object} cluster - The auth method that is currently selected in the dropdown. This corresponds to an Ember Model.
* @param {string} namespace- The currently active namespace.
* @param {string} selectedAuth - The auth method that is currently selected in the dropdown.
* @param {function} onSuccess - Fired on auth success
*/
const DEFAULTS = {
@ -45,7 +45,6 @@ export default Component.extend(DEFAULTS, {
selectedAuth: null,
methods: null,
cluster: null,
redirectTo: null,
namespace: null,
wrappedToken: null,
// internal
@ -206,54 +205,18 @@ export default Component.extend(DEFAULTS, {
showLoading: or('isLoading', 'authenticate.isRunning', 'fetchMethods.isRunning', 'unwrapToken.isRunning'),
handleError(e, prefixMessage = true) {
this.set('loading', false);
let errors;
if (e.errors) {
errors = e.errors.map((error) => {
if (error.detail) {
return error.detail;
}
return error;
});
} else {
errors = [e];
}
let message = prefixMessage ? 'Authentication failed: ' : '';
this.set('error', `${message}${errors.join('.')}`);
},
authenticate: task(
waitFor(function* (backendType, data) {
let clusterId = this.cluster.id;
try {
if (backendType === 'okta') {
this.delayAuthMessageReminder.perform();
}
let authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data });
let { isRoot, namespace } = authResponse;
let transition;
let { redirectTo } = this;
if (redirectTo) {
// reset the value on the controller because it's bound here
this.set('redirectTo', '');
// here we don't need the namespace because it will be encoded in redirectTo
transition = this.router.transitionTo(redirectTo);
} else {
transition = this.router.transitionTo('vault.cluster', { queryParams: { namespace } });
}
// returning this w/then because if we keep it
// in the task, it will get cancelled when the component in un-rendered
yield transition.followRedirects().then(() => {
if (isRoot) {
this.flashMessages.warning(
'You have logged in with a root token. As a security precaution, this root token will not be stored by your browser and you will need to re-authenticate after the window is closed or refreshed.'
);
}
});
this.delayAuthMessageReminder.perform();
const authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data });
this.onSuccess(authResponse, backendType, data);
} catch (e) {
this.handleError(e);
this.set('loading', false);
if (!this.auth.mfaError) {
this.set('error', `Authentication failed: ${this.auth.handleError(e)}`);
}
}
})
),
@ -262,9 +225,9 @@ export default Component.extend(DEFAULTS, {
if (Ember.testing) {
this.showLoading = true;
yield timeout(0);
return;
} else {
yield timeout(5000);
}
yield timeout(5000);
}),
actions: {
@ -298,11 +261,10 @@ export default Component.extend(DEFAULTS, {
return this.authenticate.unlinked().perform(backend.type, data);
},
handleError(e) {
if (e) {
this.handleError(e, false);
} else {
this.set('error', null);
}
this.setProperties({
loading: false,
error: e ? this.auth.handleError(e) : null,
});
},
},
});

View File

@ -0,0 +1,43 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { TOTP_NOT_CONFIGURED } from 'vault/services/auth';
const TOTP_NA_MSG =
'Multi-factor authentication is required, but you have not set it up. In order to do so, please contact your administrator.';
const MFA_ERROR_MSG =
'Multi-factor authentication is required, but failed. Go back and try again, or contact your administrator.';
export { TOTP_NA_MSG, MFA_ERROR_MSG };
/**
* @module MfaError
* MfaError components are used to display mfa errors
*
* @example
* ```js
* <MfaError />
* ```
*/
export default class MfaError extends Component {
@service auth;
get isTotp() {
return this.auth.mfaErrors.includes(TOTP_NOT_CONFIGURED);
}
get title() {
return this.isTotp ? 'TOTP not set up' : 'Unauthorized';
}
get description() {
return this.isTotp ? TOTP_NA_MSG : MFA_ERROR_MSG;
}
@action
onClose() {
this.auth.set('mfaErrors', null);
if (this.args.onClose) {
this.args.onClose();
}
}
}

View File

@ -0,0 +1,89 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action, set } from '@ember/object';
import { task, timeout } from 'ember-concurrency';
import { numberToWord } from 'vault/helpers/number-to-word';
/**
* @module MfaForm
* The MfaForm component is used to enter a passcode when mfa is required to login
*
* @example
* ```js
* <MfaForm @clusterId={this.model.id} @authData={this.authData} />
* ```
* @param {string} clusterId - id of selected cluster
* @param {object} authData - data from initial auth request -- { mfa_requirement, backend, data }
* @param {function} onSuccess - fired when passcode passes validation
*/
export default class MfaForm extends Component {
@service auth;
@tracked passcode;
@tracked countdown;
@tracked errors;
get constraints() {
return this.args.authData.mfa_requirement.mfa_constraints;
}
get multiConstraint() {
return this.constraints.length > 1;
}
get singleConstraintMultiMethod() {
return !this.isMultiConstraint && this.constraints[0].methods.length > 1;
}
get singlePasscode() {
return (
!this.isMultiConstraint &&
this.constraints[0].methods.length === 1 &&
this.constraints[0].methods[0].uses_passcode
);
}
get description() {
let base = 'Multi-factor authentication is enabled for your account.';
if (this.singlePasscode) {
base += ' Enter your authentication code to log in.';
}
if (this.singleConstraintMultiMethod) {
base += ' Select the MFA method you wish to use.';
}
if (this.multiConstraint) {
const num = this.constraints.length;
base += ` ${numberToWord(num, true)} methods are required for successful authentication.`;
}
return base;
}
@task *validate() {
try {
const response = yield this.auth.totpValidate({
clusterId: this.args.clusterId,
...this.args.authData,
});
this.args.onSuccess(response);
} catch (error) {
this.errors = error.errors;
// TODO: update if specific error can be parsed for incorrect passcode
// this.newCodeDelay.perform();
}
}
@task *newCodeDelay() {
this.passcode = null;
this.countdown = 30;
while (this.countdown) {
yield timeout(1000);
this.countdown--;
}
}
@action onSelect(constraint, id) {
set(constraint, 'selectedId', id);
set(constraint, 'selectedMethod', constraint.methods.findBy('id', id));
}
@action submit(e) {
e.preventDefault();
this.validate.perform();
}
}

View File

@ -8,14 +8,19 @@ export default Controller.extend({
clusterController: controller('vault.cluster'),
namespaceService: service('namespace'),
featureFlagService: service('featureFlag'),
namespaceQueryParam: alias('clusterController.namespaceQueryParam'),
auth: service(),
router: service(),
queryParams: [{ authMethod: 'with', oidcProvider: 'o' }],
namespaceQueryParam: alias('clusterController.namespaceQueryParam'),
wrappedToken: alias('vaultController.wrappedToken'),
authMethod: '',
oidcProvider: '',
redirectTo: alias('vaultController.redirectTo'),
managedNamespaceRoot: alias('featureFlagService.managedNamespaceRoot'),
authMethod: '',
oidcProvider: '',
get managedNamespaceChild() {
let fullParam = this.namespaceQueryParam;
let split = fullParam.split('/');
@ -41,4 +46,39 @@ export default Controller.extend({
this.namespaceService.setNamespace(value, true);
this.set('namespaceQueryParam', value);
}).restartable(),
authSuccess({ isRoot, namespace }) {
let transition;
if (this.redirectTo) {
// here we don't need the namespace because it will be encoded in redirectTo
transition = this.router.transitionTo(this.redirectTo);
// reset the value on the controller because it's bound here
this.set('redirectTo', '');
} else {
transition = this.router.transitionTo('vault.cluster', { queryParams: { namespace } });
}
transition.followRedirects().then(() => {
if (isRoot) {
this.flashMessages.warning(
'You have logged in with a root token. As a security precaution, this root token will not be stored by your browser and you will need to re-authenticate after the window is closed or refreshed.'
);
}
});
},
actions: {
onAuthResponse(authResponse, backend, data) {
const { mfa_requirement } = authResponse;
// mfa methods handled by the backend are validated immediately in the auth service
// if the user must choose between methods or enter passcodes further action is required
if (mfa_requirement) {
this.set('mfaAuthData', { mfa_requirement, backend, data });
} else {
this.authSuccess(authResponse);
}
},
onMfaSuccess(authResponse) {
this.authSuccess(authResponse);
},
},
});

View File

@ -0,0 +1,22 @@
import { helper } from '@ember/component/helper';
export function numberToWord(number, capitalize) {
const word =
{
0: 'zero',
1: 'one',
2: 'two',
3: 'three',
4: 'four',
5: 'five',
6: 'six',
7: 'seven',
8: 'eight',
9: 'nine',
}[number] || number;
return capitalize && typeof word === 'string' ? `${word.charAt(0).toUpperCase()}${word.slice(1)}` : word;
}
export default helper(function ([number], { capitalize }) {
return numberToWord(number, capitalize);
});

View File

@ -3,6 +3,7 @@ import { resolve, reject } from 'rsvp';
import { assign } from '@ember/polyfills';
import { isArray } from '@ember/array';
import { computed, get } from '@ember/object';
import { capitalize } from '@ember/string';
import fetch from 'fetch';
import { getOwner } from '@ember/application';
@ -14,9 +15,10 @@ import { task, timeout } from 'ember-concurrency';
const TOKEN_SEPARATOR = '☃';
const TOKEN_PREFIX = 'vault-';
const ROOT_PREFIX = '_root_';
const TOTP_NOT_CONFIGURED = 'TOTP mfa required but not configured';
const BACKENDS = supportedAuthBackends();
export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX };
export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX, TOTP_NOT_CONFIGURED };
export default Service.extend({
permissions: service(),
@ -24,6 +26,8 @@ export default Service.extend({
IDLE_TIMEOUT: 3 * 60e3,
expirationCalcTS: null,
isRenewing: false,
mfaErrors: null,
init() {
this._super(...arguments);
this.checkForRootToken();
@ -322,16 +326,98 @@ export default Service.extend({
});
},
_parseMfaResponse(mfa_requirement) {
// mfa_requirement response comes back in a shape that is not easy to work with
// convert to array of objects and add necessary properties to satisfy the view
if (mfa_requirement) {
const { mfa_request_id, mfa_constraints } = mfa_requirement;
let requiresAction; // if multiple constraints or methods or passcode input is needed further action will be required
const constraints = [];
for (let key in mfa_constraints) {
const methods = mfa_constraints[key].any;
const isMulti = methods.length > 1;
if (isMulti || methods.findBy('uses_passcode')) {
requiresAction = true;
}
// friendly label for display in MfaForm
methods.forEach((m) => {
const typeFormatted = m.type === 'totp' ? m.type.toUpperCase() : capitalize(m.type);
m.label = `${typeFormatted} ${m.uses_passcode ? 'passcode' : 'push notification'}`;
});
constraints.push({
name: key,
methods,
selectedMethod: isMulti ? null : methods[0],
});
}
return {
mfa_requirement: { mfa_request_id, mfa_constraints: constraints },
requiresAction,
};
}
return {};
},
async authenticate(/*{clusterId, backend, data}*/) {
const [options] = arguments;
const adapter = this.clusterAdapter();
let resp;
let resp = await adapter.authenticate(options);
let authData = await this.persistAuthData(options, resp.auth || resp.data, this.namespaceService.path);
try {
resp = await adapter.authenticate(options);
} catch (e) {
// TODO: check for totp not configured mfa error before throwing
const errors = this.handleError(e);
// stubbing error - verify once API is finalized
if (errors.includes(TOTP_NOT_CONFIGURED)) {
this.set('mfaErrors', errors);
}
throw e;
}
const { mfa_requirement, requiresAction } = this._parseMfaResponse(resp.auth?.mfa_requirement);
if (mfa_requirement) {
if (requiresAction) {
return { mfa_requirement };
}
// silently make request to validate endpoint when passcode is not required
try {
resp = await adapter.mfaValidate(mfa_requirement);
} catch (e) {
// it's not clear in the auth-form component whether mfa validation is taking place for non-totp method
// since mfa errors display a screen rather than flash message handle separately
this.set('mfaErrors', this.handleError(e));
throw e;
}
}
return this.authSuccess(options, resp.auth || resp.data);
},
async totpValidate({ mfa_requirement, ...options }) {
const resp = await this.clusterAdapter().mfaValidate(mfa_requirement);
return this.authSuccess(options, resp.auth || resp.data);
},
async authSuccess(options, response) {
const authData = await this.persistAuthData(options, response, this.namespaceService.path);
await this.permissions.getPaths.perform();
return authData;
},
handleError(e) {
if (e.errors) {
return e.errors.map((error) => {
if (error.detail) {
return error.detail;
}
return error;
});
}
return [e];
},
getAuthType() {
if (!this.authData) return;
return this.authData.backend.type;

View File

@ -51,3 +51,7 @@
margin-right: 4px;
}
}
.icon-blue {
color: $blue;
}

View File

@ -237,3 +237,11 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12);
padding: $size-8;
width: 100%;
}
.icon-button {
background: transparent;
padding: 0;
margin: 0;
border: none;
cursor: pointer;
}

View File

@ -19,6 +19,9 @@
.is-borderless {
border: none !important;
}
.is-box-shadowless {
box-shadow: none !important;
}
.is-relative {
position: relative;
}
@ -188,6 +191,9 @@
.has-top-margin-xl {
margin-top: $spacing-xl;
}
.has-top-margin-xxl {
margin-top: $spacing-xxl;
}
.has-border-bottom-light {
border-radius: 0;
border-bottom: 1px solid $grey-light;
@ -204,7 +210,9 @@ ul.bullet {
.has-text-semibold {
font-weight: $font-weight-semibold;
}
.is-v-centered {
vertical-align: middle;
}
.has-text-grey-400 {
color: $ui-gray-400;
}

View File

@ -0,0 +1,15 @@
<div class="has-top-margin-xxl">
<EmptyState
@title={{this.title}}
@message={{this.description}}
@icon="alert-circle"
@bottomBorder={{true}}
@subTitle={{join ". " this.auth.mfaErrors}}
class="is-box-shadowless"
>
<button type="button" class="button is-ghost is-transparent" {{on "click" this.onClose}} data-test-go-back>
<Icon @name="chevron-left" />
Go back
</button>
</EmptyState>
</div>

View File

@ -0,0 +1,70 @@
<div class="auth-form" data-test-mfa-form>
<div class="box is-marginless is-shadowless">
<p data-test-mfa-description>
{{this.description}}
</p>
<form id="auth-form" {{on "submit" this.submit}}>
<MessageError @errors={{this.errors}} class="has-top-margin-s" />
<div class="field has-top-margin-l">
{{#each this.constraints as |constraint index|}}
{{#if index}}
<hr />
{{/if}}
{{#if (gt constraint.methods.length 1)}}
<Select
@label="Multi-factor authentication method"
@options={{constraint.methods}}
@valueAttribute={{"id"}}
@labelAttribute={{"label"}}
@isFullwidth={{true}}
@noDefault={{true}}
@selectedValue={{constraint.selectedId}}
@onChange={{fn this.onSelect constraint}}
data-test-mfa-select={{index}}
/>
{{/if}}
{{#if constraint.selectedMethod.uses_passcode}}
<label for="passcode" class="is-label" data-test-mfa-passcode-label>
{{constraint.selectedMethod.label}}
</label>
<div class="control">
<Input
id="passcode"
name="passcode"
class="input"
autocomplete="off"
spellcheck="false"
autofocus="true"
disabled={{or this.validate.isRunning this.newCodeDelay.isRunning}}
@value={{constraint.passcode}}
data-test-mfa-passcode={{index}}
/>
</div>
{{/if}}
{{/each}}
</div>
{{#if this.newCodeDelay.isRunning}}
<div>
<AlertInline
@type="danger"
@sizeSmall={{true}}
@message="This code is invalid. Please wait until a new code is available."
/>
</div>
{{/if}}
<button
id="validate"
type="submit"
disabled={{or this.validate.isRunning this.newCodeDelay.isRunning}}
class="button is-primary {{if this.validate.isRunning "is-loading"}}"
data-test-mfa-validate
>
Verify
</button>
{{#if this.newCodeDelay.isRunning}}
<Icon @name="delay" class="has-text-grey" />
<span class="has-text-grey is-v-centered" data-test-mfa-countdown>{{this.countdown}}</span>
{{/if}}
</form>
</div>
</div>

View File

@ -10,21 +10,26 @@
</div>
</Nav.items>
</NavHeader>
<UiWizard>
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-1 is-fullwidth">
<div class="columns is-centered is-gapless is-fullwidth">
<div class="column is-4-desktop is-6-tablet">
<div class="splash-page-header">
{{yield (hash header=(component "splash-page/splash-header"))}}
{{! bypass UiWizard and container styling }}
{{#if this.hasAltContent}}
{{yield (hash altContent=(component "splash-page/splash-content"))}}
{{else}}
<UiWizard>
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-1 is-fullwidth">
<div class="columns is-centered is-gapless is-fullwidth">
<div class="column is-4-desktop is-6-tablet">
<div class="splash-page-header">
{{yield (hash header=(component "splash-page/splash-header"))}}
</div>
<div class="splash-page-sub-header">
{{yield (hash sub-header=(component "splash-page/splash-header"))}}
</div>
<div class="login-form box is-paddingless is-relative">
{{yield (hash content=(component "splash-page/splash-content"))}}
</div>
{{yield (hash footer=(component "splash-page/splash-content"))}}
</div>
<div class="splash-page-sub-header">
{{yield (hash sub-header=(component "splash-page/splash-header"))}}
</div>
<div class="login-form box is-paddingless is-relative">
{{yield (hash content=(component "splash-page/splash-content"))}}
</div>
{{yield (hash footer=(component "splash-page/splash-content"))}}
</div>
</div>
</div>
</UiWizard>
</UiWizard>
{{/if}}

View File

@ -1,84 +1,101 @@
<SplashPage as |Page|>
<SplashPage @hasAltContent={{this.auth.mfaErrors}} as |Page|>
<Page.altContent>
<MfaError @onClose={{fn (mut this.mfaAuthData) null}} />
</Page.altContent>
<Page.header>
{{#if this.oidcProvider}}
<div class="box is-shadowless is-flex-v-centered" data-test-auth-logo>
<LogoEdition aria-label="Sign in with Hashicorp Vault" />
</div>
{{else}}
<h1 class="title is-3">
Sign in to Vault
</h1>
<div class="is-flex-row">
{{#if this.mfaAuthData}}
<button type="button" class="icon-button" {{on "click" (fn (mut this.mfaAuthData) null)}}>
<Icon @name="arrow-left" @size="24" aria-label="Back to login" class="icon-blue" />
</button>
{{/if}}
<h1 class="title is-3">
{{if this.mfaAuthData "Authenticate" "Sign in to Vault"}}
</h1>
</div>
{{/if}}
</Page.header>
{{#if this.managedNamespaceRoot}}
<Page.sub-header>
<Toolbar>
<div class="toolbar-namespace-picker" data-test-managed-namespace-toolbar>
<div class="field is-horizontal">
<div class="field-label">
<label class="is-label" for="namespace">Namespace</label>
{{#unless this.mfaAuthData}}
{{#if this.managedNamespaceRoot}}
<Page.sub-header>
<Toolbar>
<div class="toolbar-namespace-picker" data-test-managed-namespace-toolbar>
<div class="field is-horizontal">
<div class="field-label">
<label class="is-label" for="namespace">Namespace</label>
</div>
<div class="field-label">
<span class="has-text-grey" data-test-managed-namespace-root>/{{this.managedNamespaceRoot}}</span>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input
value={{this.managedNamespaceChild}}
placeholder="/ (Default)"
oninput={{perform this.updateManagedNamespace value="target.value"}}
autocomplete="off"
spellcheck="false"
name="namespace"
id="namespace"
class="input"
type="text"
/>
</div>
</div>
</div>
</div>
<div class="field-label">
<span class="has-text-grey" data-test-managed-namespace-root>/{{this.managedNamespaceRoot}}</span>
</div>
</Toolbar>
</Page.sub-header>
{{else if (has-feature "Namespaces")}}
<Page.sub-header>
<Toolbar class="toolbar-namespace-picker">
<div class="field is-horizontal" data-test-namespace-toolbar>
<div class="field-label is-normal">
<label class="is-label" for="namespace">Namespace</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input
value={{this.managedNamespaceChild}}
placeholder="/ (Default)"
oninput={{perform this.updateManagedNamespace value="target.value"}}
value={{this.namespaceQueryParam}}
placeholder="/ (Root)"
oninput={{perform this.updateNamespace value="target.value"}}
autocomplete="off"
spellcheck="false"
name="namespace"
id="namespace"
class="input"
type="text"
disabled={{this.oidcProvider}}
/>
</div>
</div>
</div>
</div>
</div>
</Toolbar>
</Page.sub-header>
{{else if (has-feature "Namespaces")}}
<Page.sub-header>
<Toolbar class="toolbar-namespace-picker">
<div class="field is-horizontal" data-test-namespace-toolbar>
<div class="field-label is-normal">
<label class="is-label" for="namespace">Namespace</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input
value={{this.namespaceQueryParam}}
placeholder="/ (Root)"
oninput={{perform this.updateNamespace value="target.value"}}
autocomplete="off"
spellcheck="false"
name="namespace"
id="namespace"
class="input"
type="text"
disabled={{this.oidcProvider}}
/>
</div>
</div>
</div>
</div>
</Toolbar>
</Page.sub-header>
{{/if}}
</Toolbar>
</Page.sub-header>
{{/if}}
{{/unless}}
<Page.content>
<AuthForm
@wrappedToken={{this.wrappedToken}}
@cluster={{this.model}}
@namespace={{this.namespaceQueryParam}}
@redirectTo={{this.redirectTo}}
@selectedAuth={{this.authMethod}}
/>
{{#if this.mfaAuthData}}
<MfaForm @clusterId={{this.model.id}} @authData={{this.mfaAuthData}} @onSuccess={{action "onMfaSuccess"}} />
{{else}}
<AuthForm
@wrappedToken={{this.wrappedToken}}
@cluster={{this.model}}
@namespace={{this.namespaceQueryParam}}
@redirectTo={{this.redirectTo}}
@selectedAuth={{this.authMethod}}
@onSuccess={{action "onAuthResponse"}}
/>
{{/if}}
</Page.content>
<Page.footer>
<div class="has-short-padding">

View File

@ -10,15 +10,16 @@ import layout from '../templates/components/select';
* <Select @label='Date Range' @options={{[{ value: 'berry', label: 'Berry' }]}} @onChange={{onChange}}/>
* ```
*
* @param label=null {String} - The label for the select element.
* @param options=null {Array} - A list of items that the user will select from. This can be an array of strings or objects.
* @param [selectedValue=null] {String} - The currently selected item. Can also be used to set the default selected item. This should correspond to the `value` of one of the `<option>`s.
* @param [name=null] {String} - The name of the select, used for the test selector.
* @param [valueAttribute=value] {String} - When `options` is an array objects, the key to check for when assigning the option elements value.
* @param [labelAttribute=label] {String} - When `options` is an array objects, the key to check for when assigning the option elements' inner text.
* @param [isInline=false] {Bool} - Whether or not the select should be displayed as inline-block or block.
* @param [isFullwidth=false] {Bool} - Whether or not the select should take up the full width of the parent element.
* @param onChange=null {Func} - The action to take once the user has selected an item. This method will be passed the `value` of the select.
* @param {string} [label=null] - The label for the select element.
* @param {Array} [options=null] - A list of items that the user will select from. This can be an array of strings or objects.
* @param {string} [selectedValue=null] - The currently selected item. Can also be used to set the default selected item. This should correspond to the `value` of one of the `<option>`s.
* @param {string} [name = null] - The name of the select, used for the test selector.
* @param {string} [valueAttribute = value]- When `options` is an array objects, the key to check for when assigning the option elements value.
* @param {string} [labelAttribute = label] - When `options` is an array objects, the key to check for when assigning the option elements' inner text.
* @param {boolean} [isInline = false] - Whether or not the select should be displayed as inline-block or block.
* @param {boolean} [isFullwidth = false] - Whether or not the select should take up the full width of the parent element.
* @param {boolean} [noDefault = false] - shows Select One with empty value as first option
* @param {Func} [onChange] - The action to take once the user has selected an item. This method will be passed the `value` of the select.
*/
export default Component.extend({
@ -32,5 +33,6 @@ export default Component.extend({
labelAttribute: 'label',
isInline: false,
isFullwidth: false,
noDefault: false,
onChange() {},
});

View File

@ -11,6 +11,11 @@
onchange={{action this.onChange value="target.value"}}
data-test-select={{this.name}}
>
{{#if this.noDefault}}
<option value="">
Select one
</option>
{{/if}}
{{#each this.options as |op|}}
<option
value={{or (get op this.valueAttribute) op}}

View File

@ -0,0 +1,12 @@
import { Factory } from 'ember-cli-mirage';
export default Factory.extend({
type: 'okta',
uses_passcode: false,
afterCreate(mfaMethod) {
if (mfaMethod.type === 'totp') {
mfaMethod.uses_passcode = true;
}
},
});

View File

@ -1,6 +1,7 @@
// add all handlers here
// individual lookup done in mirage config
import base from './base';
import mfa from './mfa';
import activity from './activity';
export { base, activity };
export { base, activity, mfa };

146
ui/mirage/handlers/mfa.js Normal file
View File

@ -0,0 +1,146 @@
import { Response } from 'miragejs';
import Ember from 'ember';
import fetch from 'fetch';
export default function (server) {
// initial auth response cache -- lookup by mfa_request_id key
const authResponses = {};
// mfa requirement cache -- lookup by mfa_request_id key
const mfaRequirement = {};
// generate different constraint scenarios and return mfa_requirement object
const generateMfaRequirement = (req, res) => {
const { user } = req.params;
// uses_passcode automatically set to true in factory for totp type
const m = (type, uses_passcode = false) => server.create('mfa-method', { type, uses_passcode });
let mfa_constraints = {};
let methods = []; // flat array of methods for easy lookup during validation
function generator() {
const methods = [];
const constraintObj = [...arguments].reduce((obj, methodArray, index) => {
obj[`test_${index}`] = { any: methodArray };
methods.push(...methodArray);
return obj;
}, {});
return [constraintObj, methods];
}
if (user === 'mfa-a') {
[mfa_constraints, methods] = generator([m('totp')]); // 1 constraint 1 passcode
} else if (user === 'mfa-b') {
[mfa_constraints, methods] = generator([m('okta')]); // 1 constraint 1 non-passcode
} else if (user === 'mfa-c') {
[mfa_constraints, methods] = generator([m('totp'), m('duo', true)]); // 1 constraint 2 passcodes
} else if (user === 'mfa-d') {
[mfa_constraints, methods] = generator([m('okta'), m('duo')]); // 1 constraint 2 non-passcode
} else if (user === 'mfa-e') {
[mfa_constraints, methods] = generator([m('okta'), m('totp')]); // 1 constraint 1 passcode 1 non-passcode
} else if (user === 'mfa-f') {
[mfa_constraints, methods] = generator([m('totp')], [m('duo', true)]); // 2 constraints 1 passcode for each
} else if (user === 'mfa-g') {
[mfa_constraints, methods] = generator([m('okta')], [m('duo')]); // 2 constraints 1 non-passcode for each
} else if (user === 'mfa-h') {
[mfa_constraints, methods] = generator([m('totp')], [m('okta')]); // 2 constraints 1 passcode 1 non-passcode
} else if (user === 'mfa-i') {
[mfa_constraints, methods] = generator([m('okta'), m('totp')], [m('totp')]); // 2 constraints 1 passcode/1 non-passcode 1 non-passcode
}
const numbers = (length) =>
Math.random()
.toString()
.substring(2, length + 2);
const mfa_request_id = `${numbers(8)}-${numbers(4)}-${numbers(4)}-${numbers(4)}-${numbers(12)}`;
const mfa_requirement = {
mfa_request_id,
mfa_constraints,
};
// cache mfa requests to test different validation scenarios
mfaRequirement[mfa_request_id] = { methods };
// cache auth response to be returned later by sys/mfa/validate
authResponses[mfa_request_id] = { ...res };
return mfa_requirement;
};
// passthrough original request, cache response and return mfa stub
const passthroughLogin = async (schema, req) => {
// test totp not configured scenario
if (req.params.user === 'totp-na') {
return new Response(400, {}, { errors: ['TOTP mfa required but not configured'] });
}
const mock = req.params.user ? req.params.user.includes('mfa') : null;
// bypass mfa for users that do not match type
if (!mock) {
req.passthrough();
} else if (Ember.testing) {
// use root token in test environment
const res = await fetch('/v1/auth/token/lookup-self', { headers: { 'X-Vault-Token': 'root' } });
if (res.status < 300) {
const json = res.json();
if (Ember.testing) {
json.auth = {
...json.data,
policies: [],
metadata: { username: 'foobar' },
};
json.data = null;
}
return { auth: { mfa_requirement: generateMfaRequirement(req, json) } };
}
return new Response(500, {}, { errors: ['Mirage error fetching root token in testing'] });
} else {
const xhr = req.passthrough();
xhr.onreadystatechange = () => {
if (xhr.readyState === 4 && xhr.status < 300) {
// XMLHttpRequest response prop only has a getter -- redefine as writable and set value
Object.defineProperty(xhr, 'response', {
writable: true,
value: JSON.stringify({
auth: { mfa_requirement: generateMfaRequirement(req, JSON.parse(xhr.responseText)) },
}),
});
}
};
}
};
server.post('/auth/:method/login/:user', passthroughLogin);
server.post('/sys/mfa/validate', (schema, req) => {
try {
const { mfa_request_id, mfa_payload } = JSON.parse(req.requestBody);
const mfaRequest = mfaRequirement[mfa_request_id];
if (!mfaRequest) {
return new Response(404, {}, { errors: ['MFA Request ID not found'] });
}
// validate request body
for (let constraintId in mfa_payload) {
// ensure ids were passed in map
const method = mfaRequest.methods.find(({ id }) => id === constraintId);
if (!method) {
return new Response(
400,
{},
{ errors: [`Invalid MFA constraint id ${constraintId} passed in map`] }
);
}
// test non-totp validation by rejecting all pingid requests
if (method.type === 'pingid') {
return new Response(403, {}, { errors: ['PingId MFA validation failed'] });
}
// validate totp passcode
const passcode = mfa_payload[constraintId][0];
if (method.uses_passcode) {
if (passcode !== 'test') {
const error = !passcode ? 'TOTP passcode not provided' : 'Incorrect TOTP passcode provided';
return new Response(403, {}, { errors: [error] });
}
} else if (passcode) {
// for okta and duo, reject if a passcode was provided
return new Response(400, {}, { errors: ['Passcode should only be provided for TOTP MFA type'] });
}
}
return authResponses[mfa_request_id];
} catch (error) {
console.log(error);
return new Response(500, {}, { errors: ['Mirage Handler Error: /sys/mfa/validate'] });
}
});
}

View File

@ -3,25 +3,21 @@
## AuthForm
The `AuthForm` is used to sign users into Vault.
**Params**
| Param | Type | Default | Description |
| --- | --- | --- | --- |
| wrappedToken | <code>String</code> | <code></code> | The auth method that is currently selected in the dropdown. |
| cluster | <code>Object</code> | <code></code> | The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. |
| namespace | <code>String</code> | <code></code> | The currently active namespace. |
| redirectTo | <code>String</code> | <code></code> | The name of the route to redirect to. |
| selectedAuth | <code>String</code> | <code></code> | The auth method that is currently selected in the dropdown. |
| Param | Type | Description |
| --- | --- | --- |
| wrappedToken | <code>string</code> | The auth method that is currently selected in the dropdown. |
| cluster | <code>object</code> | The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. |
| namespace- | <code>string</code> | The currently active namespace. |
| selectedAuth | <code>string</code> | The auth method that is currently selected in the dropdown. |
| onSuccess | <code>function</code> | Fired on auth success |
**Example**
```js
// All properties are passed in via query params.
<AuthForm
@wrappedToken={{wrappedToken}}
@cluster={{model}}
@namespace={{namespaceQueryParam}}
@redirectTo={{redirectTo}}
@selectedAuth={{authMethod}}/>```
<AuthForm @wrappedToken={{wrappedToken}} @cluster={{model}} @namespace={{namespaceQueryParam}} @selectedAuth={{authMethod}} @onSuccess={{action this.onSuccess}} />```
**See**

View File

@ -110,16 +110,10 @@ module('Acceptance | auth', function (hooks) {
assert.dom('[data-test-allow-expiration]').doesNotExist('hides beacon when the api is used again');
});
test('it shows the push notification warning only for okta auth method after submit', async function (assert) {
test('it shows the push notification warning after submit', async function (assert) {
await visit('/vault/auth');
await component.selectMethod('token');
await click('[data-test-auth-submit]');
assert
.dom('[data-test-auth-message="push"]')
.doesNotExist('message is not shown for other authentication methods');
await component.selectMethod('okta');
await click('[data-test-auth-submit]');
assert.dom('[data-test-auth-message="push"]').exists('shows push notification message');
});
});

View File

@ -0,0 +1,135 @@
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { click, currentRouteName, fillIn, visit } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import ENV from 'vault/config/environment';
ENV['ember-cli-mirage'].handler = 'mfa';
module('Acceptance | mfa', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.select = async (select = 0, option = 1) => {
const selector = `[data-test-mfa-select="${select}"]`;
const value = this.element.querySelector(`${selector} option:nth-child(${option + 1})`).value;
await fillIn(`${selector} select`, value);
};
});
const login = async (user) => {
// MfaHandler(server);
await visit('/vault/auth');
await fillIn('[data-test-select="auth-method"]', 'userpass');
await fillIn('[data-test-username]', user);
await fillIn('[data-test-password]', 'test');
await click('[data-test-auth-submit]');
};
const didLogin = (assert) => {
assert.equal(currentRouteName(), 'vault.cluster.secrets.backends', 'Route transitions after login');
};
const validate = async (multi) => {
await fillIn('[data-test-mfa-passcode="0"]', 'test');
if (multi) {
await fillIn('[data-test-mfa-passcode="1"]', 'test');
}
await click('[data-test-mfa-validate]');
};
test('it should handle single mfa constraint with passcode method', async function (assert) {
await login('mfa-a');
assert
.dom('[data-test-mfa-description]')
.includesText(
'Enter your authentication code to log in.',
'Mfa form displays with correct description'
);
assert.dom('[data-test-mfa-select]').doesNotExist('Select is hidden for single method');
assert.dom('[data-test-mfa-passcode]').exists({ count: 1 }, 'Single passcode input renders');
await validate();
didLogin(assert);
});
test('it should handle single mfa constraint with push method', async function (assert) {
await login('mfa-b');
didLogin(assert);
});
test('it should handle single mfa constraint with 2 passcode methods', async function (assert) {
await login('mfa-c');
assert
.dom('[data-test-mfa-description]')
.includesText('Select the MFA method you wish to use.', 'Mfa form displays with correct description');
assert
.dom('[data-test-mfa-select]')
.exists({ count: 1 }, 'Select renders for single constraint with multiple methods');
assert.dom('[data-test-mfa-passcode]').doesNotExist('Passcode input hidden until selection is made');
await this.select();
await validate();
didLogin(assert);
});
test('it should handle single mfa constraint with 2 push methods', async function (assert) {
await login('mfa-d');
await this.select();
await click('[data-test-mfa-validate]');
didLogin(assert);
});
test('it should handle single mfa constraint with 1 passcode and 1 push method', async function (assert) {
await login('mfa-e');
await this.select(0, 2);
assert.dom('[data-test-mfa-passcode]').exists('Passcode input renders');
await this.select();
assert.dom('[data-test-mfa-passcode]').doesNotExist('Passcode input is hidden for push method');
await click('[data-test-mfa-validate]');
didLogin(assert);
});
test('it should handle multiple mfa constraints with 1 passcode method each', async function (assert) {
await login('mfa-f');
assert
.dom('[data-test-mfa-description]')
.includesText(
'Two methods are required for successful authentication.',
'Mfa form displays with correct description'
);
assert.dom('[data-test-mfa-select]').doesNotExist('Selects do not render for single methods');
await validate(true);
didLogin(assert);
});
test('it should handle multi mfa constraint with 1 push method each', async function (assert) {
await login('mfa-g');
didLogin(assert);
});
test('it should handle multiple mfa constraints with 1 passcode and 1 push method', async function (assert) {
await login('mfa-h');
assert
.dom('[data-test-mfa-description]')
.includesText(
'Two methods are required for successful authentication.',
'Mfa form displays with correct description'
);
assert.dom('[data-test-mfa-select]').doesNotExist('Select is hidden for single method');
assert.dom('[data-test-mfa-passcode]').exists({ count: 1 }, 'Passcode input renders');
await validate();
didLogin(assert);
});
test('it should handle multiple mfa constraints with multiple mixed methods', async function (assert) {
await login('mfa-i');
assert
.dom('[data-test-mfa-description]')
.includesText(
'Two methods are required for successful authentication.',
'Mfa form displays with correct description'
);
await this.select();
await fillIn('[data-test-mfa-passcode="1"]', 'test');
await click('[data-test-mfa-validate]');
didLogin(assert);
});
});

View File

@ -18,6 +18,7 @@ const authService = Service.extend({
async authenticate() {
return fetch('http://localhost:2000');
},
handleError() {},
setLastFetch() {},
});
@ -25,6 +26,7 @@ const workingAuthService = Service.extend({
authenticate() {
return resolve({});
},
handleError() {},
setLastFetch() {},
});

View File

@ -0,0 +1,38 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { click } from '@ember/test-helpers';
import { TOTP_NOT_CONFIGURED } from 'vault/services/auth';
import { TOTP_NA_MSG, MFA_ERROR_MSG } from 'vault/components/mfa-error';
const UNAUTH = 'MFA authorization failed';
module('Integration | Component | mfa-error', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
const auth = this.owner.lookup('service:auth');
auth.set('mfaErrors', [TOTP_NOT_CONFIGURED]);
this.onClose = () => assert.ok(true, 'onClose event is triggered');
await render(hbs`<MfaError @onClose={{this.onClose}}/>`);
assert.dom('[data-test-empty-state-title]').hasText('TOTP not set up', 'Title renders for TOTP error');
assert
.dom('[data-test-empty-state-subText]')
.hasText(TOTP_NOT_CONFIGURED, 'Error message renders for TOTP error');
assert.dom('[data-test-empty-state-message]').hasText(TOTP_NA_MSG, 'Description renders for TOTP error');
auth.set('mfaErrors', [UNAUTH]);
await render(hbs`<MfaError @onClose={{this.onClose}}/>`);
assert.dom('[data-test-empty-state-title]').hasText('Unauthorized', 'Title renders for mfa error');
assert.dom('[data-test-empty-state-subText]').hasText(UNAUTH, 'Error message renders for mfa error');
assert.dom('[data-test-empty-state-message]').hasText(MFA_ERROR_MSG, 'Description renders for mfa error');
await click('[data-test-go-back]');
assert.equal(auth.mfaErrors, null, 'mfaErrors unset in auth service');
});
});

View File

@ -0,0 +1,190 @@
import { module, test, skip } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { fillIn, click, waitUntil } from '@ember/test-helpers';
import { run, later } from '@ember/runloop';
module('Integration | Component | mfa-form', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.clusterId = '123456';
this.mfaAuthData = {
backend: 'userpass',
data: { username: 'foo', password: 'bar' },
};
this.authService = this.owner.lookup('service:auth');
});
test('it should render correct descriptions', async function (assert) {
const totpConstraint = this.server.create('mfa-method', { type: 'totp' });
const oktaConstraint = this.server.create('mfa-method', { type: 'okta' });
const duoConstraint = this.server.create('mfa-method', { type: 'duo' });
this.mfaAuthData.mfa_requirement = this.authService._parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa_1: { any: [totpConstraint] } },
}).mfa_requirement;
await render(hbs`<MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} />`);
assert
.dom('[data-test-mfa-description]')
.includesText(
'Enter your authentication code to log in.',
'Correct description renders for single passcode'
);
this.mfaAuthData.mfa_requirement = this.authService._parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa_1: { any: [duoConstraint, oktaConstraint] } },
}).mfa_requirement;
await render(hbs`<MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} />`);
assert
.dom('[data-test-mfa-description]')
.includesText(
'Select the MFA method you wish to use.',
'Correct description renders for multiple methods'
);
this.mfaAuthData.mfa_requirement = this.authService._parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa_1: { any: [oktaConstraint] }, test_mfa_2: { any: [duoConstraint] } },
}).mfa_requirement;
await render(hbs`<MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} />`);
assert
.dom('[data-test-mfa-description]')
.includesText(
'Two methods are required for successful authentication.',
'Correct description renders for multiple constraints'
);
});
test('it should render method selects and passcode inputs', async function (assert) {
const duoConstraint = this.server.create('mfa-method', { type: 'duo', uses_passcode: true });
const oktaConstraint = this.server.create('mfa-method', { type: 'okta' });
const pingidConstraint = this.server.create('mfa-method', { type: 'pingid' });
const { mfa_requirement } = this.authService._parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: {
test_mfa_1: {
any: [pingidConstraint, oktaConstraint],
},
test_mfa_2: {
any: [duoConstraint],
},
},
});
this.mfaAuthData.mfa_requirement = mfa_requirement;
this.server.post('/sys/mfa/validate', (schema, req) => {
const json = JSON.parse(req.requestBody);
const payload = {
mfa_request_id: 'test-mfa-id',
mfa_payload: { [oktaConstraint.id]: [], [duoConstraint.id]: ['test-code'] },
};
assert.deepEqual(json, payload, 'Correct mfa payload passed to validate endpoint');
return {};
});
this.owner.lookup('service:auth').reopen({
// override to avoid authSuccess method since it expects an auth payload
async totpValidate({ mfa_requirement }) {
await this.clusterAdapter().mfaValidate(mfa_requirement);
return 'test response';
},
});
this.onSuccess = (resp) =>
assert.equal(resp, 'test response', 'Response is returned in onSuccess callback');
await render(hbs`
<MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onSuccess={{this.onSuccess}}
/>
`);
await fillIn('[data-test-mfa-select="0"] select', oktaConstraint.id);
await fillIn('[data-test-mfa-passcode="1"]', 'test-code');
await click('[data-test-mfa-validate]');
});
test('it should validate mfa requirement', async function (assert) {
const totpConstraint = this.server.create('mfa-method', { type: 'totp' });
const { mfa_requirement } = this.authService._parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: {
test_mfa: {
any: [totpConstraint],
},
},
});
this.mfaAuthData.mfa_requirement = mfa_requirement;
this.server.post('/sys/mfa/validate', (schema, req) => {
const json = JSON.parse(req.requestBody);
const payload = {
mfa_request_id: 'test-mfa-id',
mfa_payload: { [totpConstraint.id]: ['test-code'] },
};
assert.deepEqual(json, payload, 'Correct mfa payload passed to validate endpoint');
return {};
});
const expectedAuthData = { clusterId: this.clusterId, ...this.mfaAuthData };
this.owner.lookup('service:auth').reopen({
// override to avoid authSuccess method since it expects an auth payload
async totpValidate(authData) {
await waitUntil(() =>
assert.dom('[data-test-mfa-validate]').hasClass('is-loading', 'Loading class applied to button')
);
assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled while loading');
assert.deepEqual(authData, expectedAuthData, 'Mfa auth data passed to validate method');
await this.clusterAdapter().mfaValidate(authData.mfa_requirement);
return 'test response';
},
});
this.onSuccess = (resp) =>
assert.equal(resp, 'test response', 'Response is returned in onSuccess callback');
await render(hbs`
<MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onSuccess={{this.onSuccess}}
/>
`);
await fillIn('[data-test-mfa-passcode]', 'test-code');
await click('[data-test-mfa-validate]');
});
// commented out in component until specific error code can be parsed from the api response
skip('it should show countdown on passcode validation failure', async function (assert) {
this.owner.lookup('service:auth').reopen({
totpValidate() {
throw new Error('Incorrect passcode');
},
});
await render(hbs`
<MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
/>
`);
await fillIn('[data-test-mfa-passcode]', 'test-code');
later(() => run.cancelTimers(), 50);
await click('[data-test-mfa-validate]');
assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled during countdown');
assert.dom('[data-test-mfa-passcode]').isDisabled('Input is disabled during countdown');
assert.dom('[data-test-mfa-passcode]').hasNoValue('Input value is cleared on error');
assert.dom('[data-test-inline-error-message]').exists('Alert message renders');
assert.dom('[data-test-mfa-countdown]').exists('30 second countdown renders');
});
});