UI: Implement MFA TOTP self-enrollment (#9161) (#9427)

* support wide width splash page

* add enable_self_enrollment param to mfa-method config

* build and implement mfa setup-card display only component

* fix transition bug navigating away from mfa method

* rename mfa card

* WIP implement self-enrollment workflow

* wip integration tests

* convert mfa-form to typescript

* remove unused import

* show alert whenver there is a QR code

* organze mfa steps into Mfa::VerifyForm and Mfa::SelfEnroll

* WIP stretch goals of mfa redesign

* add copyright headers

* update test

* add support for multiple constraints with self-enrollment

* remove comment

* fix multi-method UX

* fix state for failed validation

* remove changing button for error states

* add error handling and validation messages

* minor cleanup for params

* first round of cleanup and reorganization

* final round of logic cleanup and organization

* touch ups after testing with live backend

* fix comment

* final test cleanup!

* Apply suggestions from code review

* improve mirage error handling to more accurately mimic real failures

* add test coverage

* make qr rendering logic easier

* address PR feedback

* submit enroll form on enter, remove code digit number from copy, reset enroll state

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Vault Automation 2025-09-18 13:00:09 -04:00 committed by GitHub
parent 24cf5eef07
commit 5c750e4ebb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1924 additions and 739 deletions

View File

@ -10,7 +10,7 @@
{{/if}}
{{#if this.mfaErrors}}
<Hds::ApplicationState class="has-top-margin-xxl" data-test-error as |A|>
<Hds::ApplicationState class="has-top-margin-xxl" data-test-page-error as |A|>
<A.Header @title="Authentication error" />
<A.Body
@text="Multi-factor authentication is required, but failed. Go back and try again, or contact your administrator."
@ -20,6 +20,14 @@
<F.Button @icon="arrow-left" @color="tertiary" @text="Go back" {{on "click" this.onMfaErrorDismiss}} />
</A.Footer>
</Hds::ApplicationState>
{{else if this.mfaAuthData}}
<Mfa::MfaForm
@clusterId={{@cluster.id}}
@authData={{this.mfaAuthData}}
@onSuccess={{this.onMfaSuccess}}
@onCancel={{this.onCancelMfa}}
@onError={{fn (mut this.mfaErrors)}}
/>
{{else}}
<SplashPage>
<:header>
@ -40,35 +48,25 @@
</:header>
<:content>
{{#if this.mfaAuthData}}
<Mfa::MfaForm
@clusterId={{@cluster.id}}
@authData={{this.mfaAuthData}}
@onSuccess={{this.onMfaSuccess}}
@onCancel={{this.onCancelMfa}}
@onError={{fn (mut this.mfaErrors)}}
/>
{{else}}
<Auth::FormTemplate
@alternateView={{this.formViews.alternateView}}
@cluster={{@cluster}}
@defaultView={{this.formViews.defaultView}}
@formQueryParams={{this.formQueryParams}}
@initialFormState={{this.initialFormState}}
@onSuccess={{this.onAuthResponse}}
@visibleMountTypes={{this.visibleMountTypes}}
>
{{! yielded for accessibility so namespace submits as an input of the <form> element }}
{{#if (has-feature "Namespaces")}}
<Auth::NamespaceInput
@disabled={{if @oidcProviderQueryParam true false}}
@handleNamespaceUpdate={{@onNamespaceUpdate}}
@namespaceQueryParam={{@namespaceQueryParam}}
@shouldRefocusNamespaceInput={{@shouldRefocusNamespaceInput}}
/>
{{/if}}
</Auth::FormTemplate>
{{/if}}
<Auth::FormTemplate
@alternateView={{this.formViews.alternateView}}
@cluster={{@cluster}}
@defaultView={{this.formViews.defaultView}}
@formQueryParams={{this.formQueryParams}}
@initialFormState={{this.initialFormState}}
@onSuccess={{this.onAuthResponse}}
@visibleMountTypes={{this.visibleMountTypes}}
>
{{! yielded for accessibility so namespace submits as an input of the <form> element }}
{{#if (has-feature "Namespaces")}}
<Auth::NamespaceInput
@disabled={{if @oidcProviderQueryParam true false}}
@handleNamespaceUpdate={{@onNamespaceUpdate}}
@namespaceQueryParam={{@namespaceQueryParam}}
@shouldRefocusNamespaceInput={{@shouldRefocusNamespaceInput}}
/>
{{/if}}
</Auth::FormTemplate>
</:content>
<:footer>

View File

@ -13,6 +13,7 @@ import type { NormalizedAuthData, UnauthMountsByType, UnauthMountsResponse } fro
import type AuthService from 'vault/vault/services/auth';
import type ClusterModel from 'vault/models/cluster';
import type CspEventService from 'vault/services/csp-event';
import type { MfaAuthData } from 'vault/vault/auth/mfa';
/**
* @module AuthPage
@ -89,12 +90,6 @@ interface Args {
roleQueryParam?: string;
}
interface MfaAuthData {
mfaRequirement: object;
authMethodType: string;
authMountPath: string;
}
enum FormView {
DROPDOWN = 'dropdown',
TABS = 'tabs',

View File

@ -0,0 +1,64 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
{{! This component renders when there are either multiple constraints with self-enroll methods or a single constraint with multiple methods }}
<Mfa::SplashCard
@header="Verify your identity"
@subheader="Multi-factor authentication is enabled for your account. Choose one of the following methods to continue:"
@renderLogo={{true}}
data-test-mfa-form
>
<:additionalContent>
{{#if this.singleConstraint}}
{{#each this.nonSelfEnrollMethods as |method|}}
<Hds::Button
{{on "click" (fn @onSelect this.singleConstraint method.id)}}
@color="secondary"
@icon={{this.displayIcon method.type}}
@isFullWidth={{true}}
@text="Verify with {{this.displayLabel method.type}}"
class="has-top-margin-s has-bottom-margin-xs"
data-test-button="Verify with {{this.displayLabel method.type}}"
/>
{{/each}}
{{! Only one self-enroll method is supported per constraint right now }}
{{#if this.singleConstraint.selfEnrollMethod}}
<Hds::Layout::Flex @gap="16" @justify="center" @align="center">
<hr class="has-background-gray-300 is-flex-1" />
<Hds::Text::Body @color="faint">
Or
</Hds::Text::Body>
<hr class="has-background-gray-300 is-flex-1" />
</Hds::Layout::Flex>
{{#let this.singleConstraint.selfEnrollMethod as |method|}}
<Hds::Button
{{on "click" (fn @onSelect this.singleConstraint method.id)}}
@color="secondary"
@icon={{this.displayIcon method.type}}
@isFullWidth={{true}}
@text="Setup to verify with {{this.displayLabel method.type}}"
class="has-top-margin-xxs has-bottom-margin-xs"
data-test-button="Setup to verify with {{this.displayLabel method.type}}"
/>
{{/let}}
{{/if}}
{{else}}
{{! If there are multiple constraints that support self-enrollment, the user needs to choose
if they want to verify with a self-enroll method or another method for each one }}
{{#each this.selfEnrollConstraints as |constraint index|}}
{{#if index}}
<hr class="has-background-gray-300" />
{{/if}}
<Mfa::Form::MfaField @fieldType="select" @constraint={{constraint}} @index={{index}} @onSelect={{@onSelect}} />
{{/each}}
{{/if}}
</:additionalContent>
<:actions>
<Hds::Button @text="Cancel" @color="secondary" {{on "click" @onCancel}} data-test-cancel />
</:actions>
</Mfa::SplashCard>

View File

@ -0,0 +1,45 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import type { MfaConstraintState } from 'vault/vault/auth/mfa';
interface Args {
constraints: MfaConstraintState[];
onSelect: CallableFunction;
}
const METHOD_MAP = {
totp: { label: 'TOTP', icon: 'history' },
duo: { label: 'Duo', icon: 'duo-color' },
okta: { label: 'Okta', icon: 'okta-color' },
pingid: { label: 'PingID', icon: 'ping-identity-color' },
};
export default class MfaFormChooseMethod extends Component<Args> {
get nonSelfEnrollMethods() {
return this.singleConstraint?.methods.filter((m) => !m.self_enrollment_enabled);
}
get selfEnrollConstraints() {
return this.args.constraints.filter((c) => !!c.selfEnrollMethod);
}
get singleConstraint() {
// Prioritize self-enroll constraints so the user can setup TOTP before moving onto validating.
if (this.selfEnrollConstraints?.length === 1) {
return this.selfEnrollConstraints[0];
}
if (this.args.constraints.length === 1) {
return this.args.constraints[0];
}
return null;
}
// TEMPLATE HELPERS
displayIcon = (methodType: 'duo' | 'okta' | 'totp' | 'pingid') => METHOD_MAP[methodType].icon;
displayLabel = (methodType: 'duo' | 'okta' | 'totp' | 'pingid') => METHOD_MAP[methodType].label;
}

View File

@ -0,0 +1,80 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
{{#if (eq @fieldType "passcode")}}
{{! PASSCODE INPUT }}
<Hds::Form::TextInput::Field
{{on "input" (fn this.setPasscode @constraint)}}
@isInvalid={{@isInvalid}}
@value={{@constraint.passcode}}
autocomplete="off"
disabled={{@disabled}}
name="passcode"
placeholder="Enter passcode"
data-test-mfa-passcode={{or @index 0}}
as |F|
>
<F.Label data-test-mfa-label>
{{@label}}
</F.Label>
</Hds::Form::TextInput::Field>
{{else if (eq @fieldType "select")}}
{{! SELECT METHOD DROPDOWN }}
{{#if (gt @constraint.methods.length 1)}}
<Hds::Form::Select::Field
{{on "change" (fn this.handleSelect @constraint)}}
class="has-bottom-margin-m"
name={{@constraint.name}}
data-test-mfa-select={{@index}}
disabled={{@disabled}}
as |F|
>
<F.Label>Multi-factor Login Enforcement</F.Label>
<F.HelperText>Select which method you would like to verify with.</F.HelperText>
<F.Options>
<option value="">Select one</option>
{{#each @constraint.methods as |method|}}
<option selected={{eq method.id @constraint.selectedMethod.id}} value={{method.id}}>
{{method.label}}
{{#if method.self_enrollment_enabled}}
(supports self-enrollment)
{{/if}}
</option>
{{/each}}
</F.Options>
</Hds::Form::Select::Field>
{{/if}}
{{else if (eq @fieldType "verified")}}
{{! PENDING VERIFICATION }}
<Hds::Text::Body @tag="p" @weight="semibold" class="has-top-bottom-margin-xxs" data-test-mfa-verified={{@label}}>
{{@label}}
</Hds::Text::Body>
<Hds::Text::Body @tag="p" @color="faint" @size="100" class="has-top-bottom-margin-xxs">
Your recently enrolled device will be verified along with any other MFA methods.
</Hds::Text::Body>
<Hds::Badge @text="Verification pending" />
{{else if (eq @fieldType "push")}}
{{! PUSH NOTIFICATION }}
<Hds::Text::Body @tag="p" @weight="semibold" class="has-top-bottom-margin-xxs" data-test-mfa-label>
{{@label}}
</Hds::Text::Body>
{{#unless @isInvalid}}
<Hds::Text::Body @tag="p" @color="faint" @size="100" class="has-top-bottom-margin-xxs" data-test-mfa-push-instruction>
Check device for push notification
</Hds::Text::Body>
{{/unless}}
{{/if}}
{{! Renders method specific validation error }}
{{#if @isInvalid}}
<Hds::Alert @type="compact" @color="critical" class="has-top-margin-xs" as |A|>
<A.Description>Validation failed.</A.Description>
</Hds::Alert>
{{/if}}

View File

@ -0,0 +1,29 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { action } from '@ember/object';
import Component from '@glimmer/component';
import type { HTMLElementEvent } from 'vault/forms';
import type { MfaConstraintState } from 'vault/vault/auth/mfa';
interface Args {
constraint: MfaConstraintState;
onSelect: CallableFunction;
}
export default class MfaFormMfaField extends Component<Args> {
@action
setPasscode(constraint: MfaConstraintState, e: HTMLElementEvent<HTMLInputElement>) {
const { value } = e.target;
constraint.setPasscode(value);
}
@action
handleSelect(constraint: MfaConstraintState, e: HTMLElementEvent<HTMLInputElement>) {
const { value: id } = e.target;
this.args.onSelect(constraint, id);
}
}

View File

@ -0,0 +1,44 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Mfa::SplashCard
@header="Set up MFA TOTP to continue"
@subheader="Your organization has enforced MFA TOTP to protect your accounts. Set up to continue."
@subtitle={{unless this.hasScannedQrCode "Scan the QR code to continue"}}
@description={{this.description}}
@renderLogo={{true}}
data-test-mfa-form
>
{{! Step One: Scan QR Code }}
<:qrCode>
{{#unless this.hasScannedQrCode}}
<Mfa::QrCodeCard @qrCode={{this.selfEnrollConstraint.qrCode}} />
{{/unless}}
</:qrCode>
{{! Step Two: Validate TOTP }}
<:additionalContent>
{{#if this.hasScannedQrCode}}
<form id="mfa-enroll-form" {{on "submit" this.handleSubmit}}>
<Mfa::Form::MfaField
@fieldType="passcode"
@constraint={{this.selfEnrollConstraint}}
@label="Enter your one-time code"
/>
</form>
{{/if}}
</:additionalContent>
<:actions>
{{#if this.hasScannedQrCode}}
<Hds::Button @text="Verify" form="mfa-enroll-form" id="validate" type="submit" data-test-button="Verify" />
{{else}}
<Hds::Button @text="Continue" {{on "click" (fn (mut this.hasScannedQrCode) true)}} data-test-button="Continue" />
{{/if}}
<Hds::Button @text="Cancel" @color="secondary" {{on "click" @onCancel}} data-test-cancel />
</:actions>
</Mfa::SplashCard>

View File

@ -0,0 +1,47 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { action } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { HTMLElementEvent } from 'vault/forms';
import type { MfaConstraintState } from 'vault/vault/auth/mfa';
interface Args {
constraints: MfaConstraintState[];
onVerify: CallableFunction;
onCancel: CallableFunction;
}
export default class MfaFormSelfEnroll extends Component<Args> {
@tracked hasScannedQrCode = false;
get description() {
return this.hasScannedQrCode
? 'To verify your device, enter the code generated from your authenticator.'
: 'Scan the QR code with your authenticator app. If you currently do not have a device on hand, you can copy the MFA secret below and enter it manually.';
}
get selfEnrollConstraint() {
// Find the constraint with the QR code, only one will have one at a time
return this.args.constraints.find((c) => !!c.qrCode);
}
@action
handleSubmit(e: HTMLElementEvent<HTMLFormElement>) {
e.preventDefault();
// Clear out the QR Code
const constraint = this.findConstraint();
if (constraint) {
constraint.qrCode = '';
}
this.args.onVerify();
}
private findConstraint = () =>
this.args.constraints.find((c) => c.name === this.selfEnrollConstraint?.name);
}

View File

@ -0,0 +1,101 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Mfa::SplashCard
@header="Sign in to Vault"
@subheader={{this.headerText.subheader}}
@description={{this.description}}
@renderLogo={{true}}
data-test-mfa-form
>
<:alerts>
<MessageError @errorMessage={{@error}} />
</:alerts>
<:additionalContent>
<form id="mfa-form" {{on "submit" this.handleSubmit}}>
{{#each (this.sortConstraints @constraints) as |constraint index|}}
{{#if index}}
<hr class="has-background-gray-300" />
{{/if}}
{{! Only render the select if there is more than one constraint
because otherwise the method is selected in Mfa::Form::ChooseMethod }}
{{#if (gt @constraints.length 1)}}
<Mfa::Form::MfaField
@fieldType="select"
@constraint={{constraint}}
@index={{index}}
@onSelect={{@onSelect}}
@disabled={{@methodAlreadyEnrolled constraint.selectedMethod.id}}
/>
{{/if}}
{{#if constraint.selectedMethod.uses_passcode}}
{{#if (and (@methodAlreadyEnrolled constraint.selectedMethod.id) (not @error))}}
<Mfa::Form::MfaField
@fieldType="verified"
@label={{constraint.selectedMethod.label}}
@isInvalid={{this.hasValidationError constraint.selectedMethod.id}}
/>
{{else}}
<Mfa::Form::MfaField
@fieldType="passcode"
@constraint={{constraint}}
@disabled={{or @isLoading @countdown}}
@index={{index}}
@isInvalid={{this.hasValidationError constraint.selectedMethod.id}}
@label={{constraint.selectedMethod.label}}
/>
{{/if}}
{{else if (or (eq constraint.methods.length 1) @isLoading)}}
<Mfa::Form::MfaField
@fieldType="push"
@label={{constraint.selectedMethod.label}}
@isInvalid={{this.hasValidationError constraint.selectedMethod.id}}
/>
{{/if}}
{{/each}}
</form>
{{#if @countdown}}
<AlertInline @type="danger" class="has-top-padding-m" @message={{@codeDelayMessage}} />
{{/if}}
</:additionalContent>
<:actions>
<Hds::Button
@icon={{if @isLoading "loading"}}
@text="Verify"
disabled={{or @isLoading @countdown}}
form="mfa-form"
id="validate"
type="submit"
data-test-button="Verify"
/>
{{#if (and (gt this.singleConstraint.methods.length 1) (not @isLoading))}}
<Hds::Button
{{on "click" (fn @onSelect this.singleConstraint "")}}
@color="tertiary"
@icon="arrow-right"
@iconPosition="trailing"
@text="Try another method"
disabled={{@countdown}}
data-test-button="Try another method"
/>
{{else}}
<Hds::Button {{on "click" @onCancel}} @color="secondary" @text="Cancel" disabled={{@countdown}} data-test-cancel />
{{/if}}
{{#if @countdown}}
<Hds::Alert @type="compact" @icon="delay" class="align-self-center" as |A|>
<A.Description data-test-mfa-countdown>{{@countdown}}</A.Description>
</Hds::Alert>
{{/if}}
</:actions>
</Mfa::SplashCard>

View File

@ -0,0 +1,60 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { numberToWord } from 'vault/helpers/number-to-word';
import type { MfaConstraintState } from 'vault/vault/auth/mfa';
import type { HTMLElementEvent } from 'vault/forms';
interface Args {
codeDelayMessage: string;
constraints: MfaConstraintState[];
countdown: string;
error: string;
isLoading: boolean;
methodAlreadyEnrolled: CallableFunction;
onCancel: CallableFunction;
onSelect: CallableFunction;
onVerify: CallableFunction;
}
export default class MfaFormVerify extends Component<Args> {
get description() {
const base = 'Multi-factor authentication is enabled for your account.';
if (this.args.constraints.length > 1) {
const num = this.args.constraints.length;
const word = num === 1 ? 'method' : 'methods';
return base + ` ${numberToWord(num, true)} ${word} are required for successful authentication.`;
}
if (this.singleConstraint?.selectedMethod?.uses_passcode) {
return base + ' Enter your authentication code to log in.';
}
return base;
}
get singleConstraint() {
return this.args.constraints.length === 1 ? this.args.constraints[0] : null;
}
@action
handleSubmit(e: HTMLElementEvent<HTMLFormElement>) {
e.preventDefault();
this.args.onVerify();
}
// Template helper
sortConstraints = (constraints: MfaConstraintState[]) => {
const userInteraction = constraints.filter((c) => !c.selectedMethod);
const others = constraints.filter((c) => c.selectedMethod);
return [...userInteraction, ...others];
};
// Even if multiple methods fail, the API seems to only ever return whichever failed first.
// Sample error message:
// 'login MFA validation failed for methodID: [9e953c14-9d8e-7443-079e-4b13723e2aef]'
hasValidationError = (id: string) => this.args.error?.includes(id);
}

View File

@ -3,73 +3,29 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Card::Container @level="high" @hasBorder={{true}} class="has-padding-l overflow-auto" data-test-mfa-form>
<Hds::Button @text="Back" @icon="arrow-left" @color="tertiary" {{on "click" @onCancel}} data-test-back-button />
<p data-test-mfa-description class="has-top-margin-m">
{{this.description}}
</p>
<form id="auth-form" {{on "submit" this.submit}}>
<MessageError @errorMessage={{this.error}} />
<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}}
/>
{{else}}
<label for="passcode" class="is-label" data-test-mfa-label>
{{constraint.selectedMethod.label}}
</label>
{{/if}}
{{#if constraint.selectedMethod.uses_passcode}}
<div class="control">
{{! template-lint-disable no-autofocus-attribute}}
<Input
id="passcode"
name="passcode"
class="input"
autocomplete="off"
placeholder={{if (gt constraint.methods.length 1) "Enter passcode"}}
spellcheck="false"
autofocus="true"
disabled={{or this.validate.isRunning this.countdown}}
@value={{constraint.passcode}}
data-test-mfa-passcode={{index}}
/>
{{! template-lint-enable no-autofocus-attribute}}
</div>
{{else if (eq constraint.methods.length 1)}}
<p class="has-text-grey-light" data-test-mfa-push-instruction>
Check device for push notification
</p>
{{/if}}
{{/each}}
</div>
{{#if this.countdown}}
<AlertInline @type="danger" class="has-bottom-padding-m" @message={{this.codeDelayMessage}} />
{{/if}}
<Hds::Button
@text="Verify"
@icon={{if this.validate.isRunning "loading"}}
id="validate"
type="submit"
disabled={{or this.validate.isRunning this.countdown}}
data-test-mfa-validate
/>
{{#if this.countdown}}
<Icon @name="delay" class="has-text-grey" />
<span class="has-text-grey is-v-centered" data-test-mfa-countdown>{{this.countdown}}</span>
{{/if}}
</form>
</Hds::Card::Container>
{{#if this.fetchQrCode.isRunning}}
<Mfa::SplashCard @header="Redirecting..." @isLoading={{true}} data-test-mfa-form />
{{else if this.currentSelfEnrollConstraint.qrCode}}
{{! Clicking "Continue" clears this.currentSelfEnrollConstraint.qrCode }}
<Mfa::Form::SelfEnroll
@constraints={{this.constraints}}
@onCancel={{@onCancel}}
@onVerify={{this.checkStateAndValidate}}
/>
{{else if (and (not this.validate.isRunning) this.needsToChoose)}}
{{! This component only renders when one enforcement with multiple methods is configured
Or there are multiple enforcements and each one has methods that support self-enrollment }}
<Mfa::Form::ChooseMethod @constraints={{this.constraints}} @onCancel={{@onCancel}} @onSelect={{this.onSelect}} />
{{else}}
<Mfa::Form::Verify
@codeDelayMessage={{this.codeDelayMessage}}
@constraints={{this.constraints}}
@countdown={{this.countdown}}
@error={{this.error}}
@isLoading={{this.validate.isRunning}}
@methodAlreadyEnrolled={{this.methodAlreadyEnrolled}}
@onCancel={{@onCancel}}
@onSelect={{this.onSelect}}
@onVerify={{this.checkStateAndValidate}}
/>
{{/if}}

View File

@ -1,141 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Ember from 'ember';
import Component from '@glimmer/component';
import { 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';
import errorMessage from 'vault/utils/error-message';
/**
* @module MfaForm
* The MfaForm component is used to enter a passcode when mfa is required to login
*
* @example
* ```js
* <Mfa::MfaForm @clusterId={this.model.id} @authData={this.authData} />
* ```
* @param {string} clusterId - id of selected cluster
* @param {object} authData - data from initial auth request -- { mfaRequirement, backend, data }
* @param {function} onSuccess - fired when passcode passes validation
* @param {function} onError - fired for multi-method or non-passcode method validation errors
*/
export const TOTP_VALIDATION_ERROR =
'The passcode failed to validate. If you entered the correct passcode, contact your administrator.';
export default class MfaForm extends Component {
@service auth;
@tracked countdown = 0;
@tracked error;
@tracked codeDelayMessage;
constructor() {
super(...arguments);
// trigger validation immediately when passcode is not required
const passcodeOrSelect = this.constraints.filter((constraint) => {
return constraint.methods.length > 1 || constraint.methods.find((m) => m.uses_passcode);
});
if (!passcodeOrSelect.length) {
this.validate.perform();
}
}
get constraints() {
return this.args.authData.mfaRequirement.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 {
this.error = null;
const response = yield this.auth.totpValidate({
clusterId: this.args.clusterId,
...this.args.authData,
});
// calls onMfaSuccess in auth/page.js
this.args.onSuccess(response);
} catch (error) {
const errors = error.errors || [];
const codeUsed = errors.find((e) => e.includes('code already used'));
const rateLimit = errors.find((e) => e.includes('maximum TOTP validation attempts'));
const delayMessage = codeUsed || rateLimit;
if (delayMessage) {
const reason = codeUsed ? 'This code has already been used' : 'Maximum validation attempts exceeded';
this.codeDelayMessage = `${reason}. Please wait until a new code is available.`;
this.newCodeDelay.perform(delayMessage);
} else if (this.singlePasscode) {
this.error = TOTP_VALIDATION_ERROR;
} else {
this.args.onError(errorMessage(error));
}
}
}
@task *newCodeDelay(errorMessage) {
let delay;
// parse validity period from error string to initialize countdown
const delayRegExMatches = errorMessage.match(/(\d+\w seconds)/);
if (delayRegExMatches && delayRegExMatches.length) {
delay = delayRegExMatches[0].split(' ')[0];
} else {
// default to 30 seconds if error message doesn't specify one
delay = 30;
}
this.countdown = parseInt(delay);
// skip countdown in testing environment
if (Ember.testing) return;
while (this.countdown > 0) {
yield timeout(1000);
this.countdown--;
}
}
@action onSelect(constraint, id) {
set(constraint, 'selectedId', id);
set(
constraint,
'selectedMethod',
constraint.methods.find((m) => m.id === id)
);
}
@action submit(e) {
e.preventDefault();
this.validate.perform();
}
}

View File

@ -0,0 +1,270 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Ember from 'ember';
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { task, timeout } from 'ember-concurrency';
import errorMessage from 'vault/utils/error-message';
import type AuthService from 'vault/vault/services/auth';
import type Store from '@ember-data/store';
import type VersionService from 'vault/services/version';
import type {
MfaAuthData,
MfaConstraintState,
ParsedMfaConstraint,
ParsedMfaMethod,
} from 'vault/vault/auth/mfa';
/**
* @module MfaForm
* The MfaForm component is used to enter a passcode when mfa is required to login
*
* @example
* ```js
* <Mfa::MfaForm @clusterId={this.model.id} @authData={this.authData} />
* ```
* @param {string} clusterId - id of selected cluster
* @param {object} authData - data from initial auth request -- { mfaRequirement, backend, data }
* @param {function} onSuccess - fired when passcode passes validation
* @param {function} onError - fired for multi-method or non-passcode method validation errors
*/
export const TOTP_VALIDATION_ERROR =
'The passcode failed to validate. If you entered the correct passcode, contact your administrator.';
interface Args {
authData: MfaAuthData;
clusterId: string;
onSuccess: CallableFunction;
onError: CallableFunction;
onCancel: CallableFunction;
}
class MfaLoginEnforcement implements MfaConstraintState {
@tracked name: string;
@tracked methods: ParsedMfaMethod[] = [];
@tracked selectedMethod: ParsedMfaMethod | undefined = undefined;
// These are set on the login enforcement and not the MFA method
// because they only correspond to the selectedMethod.
@tracked passcode = '';
@tracked qrCode = '';
constructor(constraint: ParsedMfaConstraint) {
const { name, methods } = constraint;
this.name = name;
this.methods = methods;
// Only set selectedMethod if there is only one method.
// Otherwise, the user must select which method they want to verify with.
this.selectedMethod = this.methods.length === 1 ? methods[0] : undefined;
}
@action
setPasscode(value: string) {
this.passcode = value;
}
@action
setSelectedMethod(id: string) {
const method = this.methods.find((m) => m.id === id);
this.selectedMethod = method;
}
// To be "true", the login enforcement must have a selected method set
// and passcode, if applicable.
get isSatisfied() {
if (this.selectedMethod) {
return this.selectedMethod.uses_passcode ? !!this.passcode : true;
}
return false;
}
get selfEnrollMethod(): ParsedMfaMethod | null {
// Self-enrollment is an enterprise only feature and self_enrollment_enabled will always be false for CE
// It also returns false if the user already has an MFA secret (meaning they have already enrolled.)
const selfEnroll = this.methods.filter((m) => m?.self_enrollment_enabled);
// At this time we just support one self-enroll method per constraint
return selfEnroll.length === 1 && selfEnroll[0] ? selfEnroll[0] : null;
}
get validateData() {
return {
methods: this.methods,
passcode: this.passcode,
selectedMethod: this.selectedMethod,
};
}
}
export default class MfaForm extends Component<Args> {
@service declare readonly auth: AuthService;
@service declare readonly store: Store;
@service declare readonly version: VersionService;
@tracked constraints: MfaLoginEnforcement[] = [];
@tracked codeDelayMessage = '';
@tracked countdown = 0;
@tracked error = '';
// Self-enrollment is per MFA method, not per login enforcement (constraint)
// Track method IDs used to fetch a QR code so we don't re-request if a user just enrolled.
@tracked enrolledMethods = new Set<string>();
constructor(owner: unknown, args: Args) {
super(owner, args);
const { mfa_constraints = [] } = this.args.authData.mfaRequirement;
this.constraints = mfa_constraints.map((constraint) => new MfaLoginEnforcement(constraint));
// Trigger validation immediately if passcode or user selection is not required
this.checkStateAndValidate();
// Filter for constraints that have only one MFA method and it supports self-enrollment
const filteredConstraints = this.constraints.filter((c) => c.selfEnrollMethod && c.methods.length === 1);
// If there is only one then fetch the QR code because self-enrolling is unavoidable.
if (filteredConstraints.length === 1 && filteredConstraints[0]) {
const [constraint] = filteredConstraints;
const method = constraint.selfEnrollMethod;
if (method) this.fetchQrCode.perform(method.id, constraint);
}
}
get everyConstraintSatisfied() {
return this.constraints.every((constraint) => constraint.isSatisfied);
}
get currentSelfEnrollConstraint() {
return this.constraints.find((c) => c.qrCode !== '');
}
get needsToChoose() {
// If any self-enroll constraints are missing selections
const missingSelfEnrollSelection = this.constraints
.filter((c) => !!c.selfEnrollMethod)
.some((c) => !c.selectedMethod);
// If there is only one constraint but it has multiple methods
const missingSelection = this.constraints.length === 1 && !this.constraints.some((c) => c.selectedMethod);
return missingSelfEnrollSelection || missingSelection;
}
// There is only one login enforcement with only one method configured
get singleLoginEnforcement() {
if (this.constraints.length === 1) {
const loginEnforcement = this.constraints[0];
// Return a value if there is only one MFA method configured.
return loginEnforcement?.methods.length === 1 ? loginEnforcement : null;
}
return null;
}
validate = task(async () => {
const { authMethodType, authMountPath, mfaRequirement } = this.args.authData;
const submitData = {
mfa_request_id: mfaRequirement.mfa_request_id,
mfa_constraints: this.constraints.map((c) => c.validateData),
};
try {
this.error = '';
const response = await this.auth.totpValidate({
clusterId: this.args.clusterId,
authMethodType,
authMountPath,
mfaRequirement: submitData,
});
// calls onMfaSuccess in auth/page.js
this.args.onSuccess(response);
} catch (error) {
// Reset enrolled methods if there's an error
this.enrolledMethods = new Set<string>();
const errorMsg = errorMessage(error);
const codeUsed = errorMsg.includes('code already used');
const rateLimit = errorMsg.includes('maximum TOTP validation attempts');
const delayMessage = codeUsed || rateLimit ? errorMsg : null;
if (delayMessage) {
const reason = codeUsed ? 'This code has already been used' : 'Maximum validation attempts exceeded';
this.codeDelayMessage = `${reason}. Please wait until a new code is available.`;
this.newCodeDelay.perform(delayMessage);
} else if (this.singleLoginEnforcement?.selectedMethod?.uses_passcode) {
this.error = TOTP_VALIDATION_ERROR;
} else {
this.error = errorMsg;
}
}
});
fetchQrCode = task(async (mfa_method_id: string, constraint: MfaLoginEnforcement) => {
// Self-enrollment is an enterprise only feature
if (this.version.isCommunity) return;
const adapter = this.store.adapterFor('application');
const { mfaRequirement } = this.args.authData;
try {
const { data } = await adapter.ajax('/v1/identity/mfa/method/totp/self-enroll', 'POST', {
unauthenticated: true,
data: { mfa_method_id, mfa_request_id: mfaRequirement.mfa_request_id },
});
if (data?.url) {
// Set QR code which recomputes currentSelfEnrollConstraint and renders it for the user to scan
constraint.qrCode = data.url;
// Add mfa_method_id to list of already enrolled methods for client-side tracking
this.enrolledMethods.add(mfa_method_id);
return;
}
// Not sure it's realistic to get here without the endpoint throwing an error, but just in case!
this.error = 'There was a problem generating the QR code. Please try again.';
} catch (error) {
this.error = errorMessage(error);
}
});
newCodeDelay = task(async (errorMessage) => {
let delay;
// parse validity period from error string to initialize countdown
const delayRegExMatches = errorMessage.match(/(\d+\w seconds)/);
if (delayRegExMatches && delayRegExMatches.length) {
delay = delayRegExMatches[0].split(' ')[0];
} else {
// default to 30 seconds if error message doesn't specify one
delay = 30;
}
this.countdown = parseInt(delay);
// skip countdown in testing environment
if (Ember.testing) return;
while (this.countdown > 0) {
await timeout(1000);
this.countdown--;
}
});
@action
async onSelect(constraint: MfaLoginEnforcement, methodId: string) {
// Set selectedMethod on the MfaLoginEnforcement class
// If id is an empty string, it clears the selected method
constraint.setSelectedMethod(methodId);
const selectedMethod = constraint.selectedMethod;
if (selectedMethod?.self_enrollment_enabled && !this.methodAlreadyEnrolled(selectedMethod.id)) {
await this.fetchQrCode.perform(selectedMethod.id, constraint);
}
this.checkStateAndValidate();
}
@action
checkStateAndValidate() {
// Whenever all login enforcements are satisfied perform validation to save the user extra clicks
if (this.everyConstraintSatisfied) {
this.validate.perform();
}
}
// Template helpers
methodAlreadyEnrolled = (methodId: string) => this.enrolledMethods.has(methodId);
}

View File

@ -3,19 +3,18 @@
SPDX-License-Identifier: BUSL-1.1
}}
<div ...attributes>
<p>
TOTP Multi-factor authentication (MFA) can be enabled here if it is required by your administrator. This will ensure that
you are not prevented from logging into Vault in the future, once MFA is fully enforced.
</p>
<form id="mfa-setup-step-one" {{on "submit" this.verifyUUID}}>
<Mfa::SplashCard @header={{@header}} @description={{@description}} data-test-step-one>
<:alerts>
<MessageError @errorMessage={{this.error}} />
<div class="field has-top-margin-l">
</:alerts>
<:additionalContent>
<form id="mfa-setup-step-one" {{on "submit" this.verifyUUID}}>
<label class="is-label">
Method ID
</label>
{{! template-lint-disable no-autofocus-attribute}}
<p class="sub-text">Enter the UUID for your multi-factor authentication method. This can be provided to you by your
administrator.</p>
<Input
@ -24,15 +23,22 @@
class="input"
autocomplete="off"
spellcheck="false"
autofocus="true"
@value={{this.UUID}}
data-test-input="uuid"
/>
</div>
</form>
</:additionalContent>
<Hds::ButtonSet>
<Hds::Button @text="Verify" id="continue" type="submit" disabled={{(is-empty-value this.UUID)}} data-test-verify />
<Hds::Button @text="Cancel" @color="secondary" id="cancel" {{on "click" this.redirectPreviousPage}} />
</Hds::ButtonSet>
</form>
</div>
<:actions>
<Hds::Button
@text="Verify"
id="continue"
type="submit"
form="mfa-setup-step-one"
disabled={{is-empty-value this.UUID}}
data-test-verify
/>
<Hds::Button @text="Cancel" @color="secondary" id="cancel" {{on "click" this.redirectPreviousPage}} />
</:actions>
</Mfa::SplashCard>

View File

@ -3,51 +3,27 @@
SPDX-License-Identifier: BUSL-1.1
}}
<div ...attributes>
<p>
TOTP Multi-factor authentication (MFA) can be enabled here if it is required by your administrator. This will ensure that
you are not prevented from logging into Vault in the future, once MFA is fully enforced.
</p>
<div class="field has-top-margin-l">
<Mfa::SplashCard @header={{@header}} @description={{@description}} data-test-step-two>
<:alerts>
<MessageError @errorMessage={{this.error}} />
{{#if @warning}}
<Hds::Alert
@type="inline"
@color="highlight"
class="has-bottom-margin-s has-top-margin-l"
data-test-mfa-enabled-warning
as |A|
>
<Hds::Alert @type="inline" @color="highlight" class="has-bottom-margin-s" data-test-mfa-enabled-warning as |A|>
<A.Title>MFA enabled</A.Title>
<A.Description>{{@warning}}</A.Description>
</Hds::Alert>
{{else}}
<div class="list-item-row">
<div class="center-display">
<QrCode
@text={{@qrCode}}
@colorLight="#F7F7F7"
@width={{155}}
@height={{155}}
@correctLevel="L"
data-test-qrcode
/>
</div>
</div>
<div class="has-top-margin-s">
<div class="info-table-row has-no-shadow">
<div class="column info-table-row-edit"><Icon @name="alert-triangle-fill" class="has-text-highlight" /></div>
<p class="is-size-8">
After you leave this page, this QR code will be removed and
<strong>cannot</strong>
be regenerated.
</p>
</div>
</div>
{{/if}}
<div class="is-flex-start has-gap has-top-margin-l">
<Hds::Button @text="Restart setup" @color="critical" id="restart" {{on "click" this.restartSetup}} data-test-restart />
<Hds::Button @text="Done" id="cancel" {{on "click" this.redirectPreviousPage}} data-test-done />
</div>
</div>
</div>
</:alerts>
<:qrCode>
{{#if @qrCode}}
<Mfa::QrCodeCard @qrCode={{@qrCode}} />
{{/if}}
</:qrCode>
<:actions>
<Hds::Button @text="Restart setup" @color="critical" id="restart" {{on "click" this.restartSetup}} data-test-restart />
<Hds::Button @text="Done" id="cancel" {{on "click" this.redirectPreviousPage}} data-test-done />
</:actions>
</Mfa::SplashCard>

View File

@ -0,0 +1,20 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Layout::Flex @justify="space-between" @align="center">
<Hds::Card::Container @hasBorder={{true}} class="has-padding-l">
<QrCode @text={{@qrCode}} @colorLight="#F7F7F7" @width={{175}} @height={{175}} @correctLevel="L" data-test-qrcode />
</Hds::Card::Container>
<Hds::Text::Body @tag="p" @weight="semibold">
Or
</Hds::Text::Body>
<Hds::Copy::Button @text="Copy TOTP setup URL" @textToCopy={{@qrCode}} data-test-copy-button />
</Hds::Layout::Flex>
<Hds::Alert @color="warning" @type="compact" class="has-top-padding-s" as |A|>
<A.Description>
For your security, this code is only shown once. Please scan or copy the setup URL into your authenticator app now.
</A.Description>
</Hds::Alert>

View File

@ -0,0 +1,66 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
{{! Display only template so all MFA steps are styled the same inside the SplashPage component }}
<SplashPage class="wide-content" ...attributes>
<:header>
{{#if @renderLogo}}
<div class="is-flex-v-centered has-bottom-margin-xxl">
<div class="brand-icon-large">
<Icon @name="vault" @size="24" @stretched={{true}} />
</div>
</div>
{{/if}}
<h1 class="title is-4" data-test-page-title>{{@header}}</h1>
{{#if @subheader}}
<Hds::Text::Body @tag="h2" class="has-top-bottom-margin-xxs" data-test-mfa-subheader>
{{@subheader}}
</Hds::Text::Body>
{{/if}}
</:header>
<:content>
<div class="has-padding-l">
{{#if @isLoading}}
<Hds::Layout::Flex @justify="center" @align="center" class="is-medium-height">
<VaultLogoSpinner />
</Hds::Layout::Flex>
{{else}}
{{#if @subtitle}}
<Hds::Text::Body @tag="p" @weight="semibold" class="has-top-bottom-margin-xxs" data-test-mfa-subtitle>
{{@subtitle}}
</Hds::Text::Body>
{{/if}}
{{#if @description}}
<Hds::Text::Body @tag="p" class="has-bottom-margin-l" data-test-mfa-description>
{{@description}}
</Hds::Text::Body>
{{/if}}
{{#if (has-block "alerts")}}
{{yield to="alerts"}}
{{/if}}
{{#if (has-block "qrCode")}}
{{yield to="qrCode"}}
{{/if}}
{{#if (has-block "additionalContent")}}
{{yield to="additionalContent"}}
{{/if}}
{{/if}}
</div>
</:content>
<:footer>
<Hds::ButtonSet class="has-top-padding-m">
{{yield to="actions"}}
</Hds::ButtonSet>
</:footer>
</SplashPage>

View File

@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
}}
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-grow-1 is-fullwidth">
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-grow-1 is-fullwidth" ...attributes>
<div class="columns is-centered is-gapless is-fullwidth">
<div class="column is-4-desktop is-6-tablet">
<div class="splash-page-header" data-test-splash-page-header>

View File

@ -15,6 +15,10 @@ export default class VaultClusterMfaSetupController extends Controller {
@tracked uuid = '';
@tracked qrCode = '';
header = 'MFA Setup';
description =
'TOTP Multi-factor authentication (MFA) can be enabled here if it is required by your administrator. This will ensure that you are not prevented from logging into Vault in the future, once MFA is fully enforced.';
get entityId() {
return this.auth.authData.entityId;
}

View File

@ -13,7 +13,17 @@ const METHOD_PROPS = {
common: [],
duo: ['username_format', 'secret_key', 'integration_key', 'api_hostname', 'push_info', 'use_passcode'],
okta: ['username_format', 'mount_accessor', 'org_name', 'api_token', 'base_url', 'primary_email'],
totp: ['issuer', 'period', 'key_size', 'qr_size', 'algorithm', 'digits', 'skew', 'max_validation_attempts'],
totp: [
'issuer',
'period',
'key_size',
'qr_size',
'algorithm',
'digits',
'skew',
'max_validation_attempts',
'enable_self_enrollment',
],
pingid: [
'username_format',
'settings_file_base64',
@ -163,6 +173,15 @@ export default class MfaMethod extends Model {
})
skew;
@attr('number') max_validation_attempts;
@attr('boolean', {
label: 'Enable self-enrollment',
editType: 'toggleButton',
helperTextEnabled:
'Let end users enroll in this MFA method on their own. You still control which auth mounts, groups, or entities it applies to.',
helperTextDisabled:
'Let end users enroll in this MFA method on their own. You still control which auth mounts, groups, or entities it applies to.',
})
enable_self_enrollment;
get name() {
return this.type === 'totp' ? this.type.toUpperCase() : capitalize(this.type);

View File

@ -428,18 +428,12 @@ export default Service.extend({
const constraints = [];
for (const key in mfa_constraints) {
const methods = mfa_constraints[key].any;
const isMulti = methods.length > 1;
// 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],
});
constraints.push({ name: key, methods });
}
return { mfa_request_id, mfa_constraints: constraints };
}

View File

@ -32,3 +32,11 @@
.splash-page-header {
padding: size_variables.$spacing-14 0;
}
// Override default SplashPage styling which uses styles in columns.scss
.wide-content {
.column.is-4-desktop {
max-width: 640px;
width: 75%;
}
}

View File

@ -21,11 +21,17 @@
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless">
<nav class="tabs" aria-label="tabs">
<ul>
<LinkTo @route="vault.cluster.access.mfa.methods.method" @query={{hash tab="config"}} data-test-tab="config">
<LinkTo
@route="vault.cluster.access.mfa.methods.method"
@model={{this.model.method.id}}
@query={{hash tab="config"}}
data-test-tab="config"
>
Configuration
</LinkTo>
<LinkTo
@route="vault.cluster.access.mfa.methods.method"
@model={{this.model.method.id}}
@query={{hash tab="enforcements"}}
data-test-tab="enforcements"
>

View File

@ -3,31 +3,24 @@
SPDX-License-Identifier: BUSL-1.1
}}
<SplashPage>
<:header>
<h1 class="title is-4">MFA setup</h1>
</:header>
<:content>
<div class="has-padding-l" data-test-mfa-form>
{{#if (eq this.onStep 1)}}
<Mfa::MfaSetupStepOne
@isUUIDVerified={{this.isUUIDVerified}}
@restartFlow={{this.restartFlow}}
@saveUUIDandQrCode={{this.saveUUIDandQrCode}}
@showWarning={{this.showWarning}}
data-test-step-one
/>
{{/if}}
{{#if (eq this.onStep 2)}}
<Mfa::MfaSetupStepTwo
@entityId={{this.entityId}}
@uuid={{this.uuid}}
@qrCode={{this.qrCode}}
@restartFlow={{this.restartFlow}}
@warning={{this.warning}}
data-test-step-two
/>
{{/if}}
</div>
</:content>
</SplashPage>
{{#if (eq this.onStep 1)}}
<Mfa::MfaSetupStepOne
@header={{this.header}}
@description={{this.description}}
@isUUIDVerified={{this.isUUIDVerified}}
@restartFlow={{this.restartFlow}}
@saveUUIDandQrCode={{this.saveUUIDandQrCode}}
@showWarning={{this.showWarning}}
/>
{{/if}}
{{#if (eq this.onStep 2)}}
<Mfa::MfaSetupStepTwo
@header={{this.header}}
@description={{this.description}}
@entityId={{this.entityId}}
@uuid={{this.uuid}}
@qrCode={{this.qrCode}}
@restartFlow={{this.restartFlow}}
@warning={{this.warning}}
/>
{{/if}}

View File

@ -8,6 +8,7 @@ import { Factory } from 'miragejs';
export default Factory.extend({
type: 'okta',
uses_passcode: false,
self_enrollment_enabled: false,
afterCreate(mfaMethod) {
if (mfaMethod.type === 'totp') {

View File

@ -16,6 +16,7 @@ export default Factory.extend({
period: 30,
qr_size: 200,
skew: 1,
self_enrollment_enabled: false,
type: 'totp',
afterCreate(record) {

View File

@ -3,13 +3,37 @@
* SPDX-License-Identifier: BUSL-1.1
*/
/*
* HOW TO USE THIS HANDLER
- Create the mfa users below in Vault (e.g. "mfa-a") - there is a config.sh script in the vault-tools repo to expedite this
- do NOT configure MFA, this mirage handler stubs all of that
- For TOTP the passcode for successful login is "test"
- PingID is used to test push failure states
*/
import { Response } from 'miragejs';
import Ember from 'ember';
export const QR_CODE_URL =
'otpauth://totp/vault-not-self-enroll:daf8420c-0b6b-34e6-ff38-ee1ed093bea9?algorithm=SHA1\u0026digits=6\u0026issuer=vault-not-self-enroll\u0026period=30\u0026secret=JGPHY3TZBIUCHWYN7ZO3LHISKQIAJZGL';
// initial auth response cache -- lookup by mfa_request_id key
const authResponses = {};
// mfa requirement cache -- lookup by mfa_request_id key
const mfaRequirement = {};
// mfa constraint cache -- lookup by mfa_request_id key
const mfaConstraints = {};
export const buildEnforcementError = (method, constraintName) => {
// Although this block is just for pingid, adding failure message for posterity and testing other method error states
const failure =
method.type === 'totp'
? 'failed to validate TOTP passcode'
: `${method.type} authentication failed: "Login request denied."`;
const name = constraintName || 'My Secure Enforcement';
const msg = `failed to satisfy enforcement ${name}. error: 2 errors occurred:\n\t* ${failure}\n\t* login MFA validation failed for methodID: [${method.id}]\n\n`;
return new Response(403, {}, { errors: [msg] });
};
// may be imported in tests when the validation request needs to be intercepted to make assertions prior to returning a response
// in that case it may be helpful to still use this validation logic to ensure to payload is as expected
@ -17,23 +41,24 @@ export const validationHandler = (schema, req) => {
try {
const { mfa_request_id, mfa_payload } = JSON.parse(req.requestBody);
const mfaRequest = mfaRequirement[mfa_request_id];
const constraintNameLookup = mfaConstraints[mfa_request_id];
if (!mfaRequest) {
return new Response(404, {}, { errors: ['MFA Request ID not found'] });
}
// validate request body
for (const constraintId in mfa_payload) {
for (const methodId in mfa_payload) {
// ensure ids were passed in map
const method = mfaRequest.methods.find(({ id }) => id === constraintId);
const method = mfaRequest.methods.find(({ id }) => id === methodId);
if (!method) {
return new Response(400, {}, { errors: [`Invalid MFA constraint id ${constraintId} passed in map`] });
return new Response(400, {}, { errors: [`Invalid MFA method id ${methodId} 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'] });
return buildEnforcementError(method, constraintNameLookup[method.id]);
}
// validate totp passcode
const passcode = mfa_payload[constraintId][0];
const passcode = mfa_payload[methodId][0];
if (method.uses_passcode) {
const expectedPasscode = method.type === 'duo' ? 'passcode=test' : 'test';
if (passcode !== expectedPasscode) {
@ -42,9 +67,12 @@ export const validationHandler = (schema, req) => {
used: 'code already used; new code is available in 30 seconds',
limit:
'maximum TOTP validation attempts 4 exceeded the allowed attempts 3. Please try again in 15 seconds',
}[passcode] || 'failed to validate';
}[passcode] || null;
console.log(error); // eslint-disable-line
return new Response(403, {}, { errors: [error] });
if (error) {
return new Response(403, {}, { errors: [error] });
}
return buildEnforcementError(method, constraintNameLookup[method.id]);
}
} else if (passcode) {
// for okta and duo, reject if a passcode was provided
@ -63,7 +91,8 @@ export default function (server) {
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 });
const m = (type, { uses_passcode = false, self_enrollment_enabled = false } = {}) =>
server.create('mfa-method', { type, uses_passcode, self_enrollment_enabled });
let mfa_constraints = {};
let methods = []; // flat array of methods for easy lookup during validation
@ -82,24 +111,63 @@ export default function (server) {
} 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
[mfa_constraints, methods] = generator([m('totp'), m('duo', { uses_passcode: 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
[mfa_constraints, methods] = generator([m('totp')], [m('duo', { uses_passcode: 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
[mfa_constraints, methods] = generator([m('okta'), m('totp')], [m('duo', { uses_passcode: true })]); // 2 constraints 1 non-passcode or 1 non-passcode and 1 passcode
} else if (user === 'mfa-j') {
[mfa_constraints, methods] = generator([m('pingid')]); // use to test push failures
} else if (user === 'mfa-k') {
[mfa_constraints, methods] = generator([m('duo', true)]); // test duo passcode and prepending passcode= to user input
// * SELF-ENROLLMENT USERS BELOW
// users match counterpart config scenario above
// e.g. "mfa-a" is the same as "mfa-a-self", but with self-enroll enabled
} else if (user === 'mfa-a-self') {
// 1 constraint 1 passcode
[mfa_constraints, methods] = generator([m('totp', { self_enrollment_enabled: true })]);
} else if (user === 'mfa-c-self') {
// 1 constraint 2 passcodes
[mfa_constraints, methods] = generator([
m('totp', { self_enrollment_enabled: true }),
m('duo', { uses_passcode: true }),
]);
} else if (user === 'mfa-f-self') {
// 2 constraints 1 passcode for each
[mfa_constraints, methods] = generator(
[m('totp', { self_enrollment_enabled: true })],
[m('duo', { uses_passcode: true })]
);
} else if (user === 'mfa-h-self') {
// 2 constraints 1 passcode 1 non-passcode
[mfa_constraints, methods] = generator([m('totp', { self_enrollment_enabled: true })], [m('okta')]);
} else if (user === 'mfa-i-self') {
// 2 constraints 1 non-passcode or 1 non-passcode and 1 passcode
[mfa_constraints, methods] = generator(
[m('okta'), m('totp', { self_enrollment_enabled: true })],
[m('duo', { uses_passcode: true })]
);
} else if (user === 'mfa-z-self') {
// We've discussed that this scenario likely won't be allowed in the real-world
// by having the API restrict self-enrollment so it's only be possible when only ONE
// constraint has self_enrollment_enabled.
// 3 constraints, two have 2 methods (and each includes a method with self_enrollment_enabled)
[mfa_constraints, methods] = generator(
[m('totp', { self_enrollment_enabled: true }), m('pingid')],
[m('totp', { self_enrollment_enabled: true }), m('okta')],
[m('duo')]
);
}
const mfa_request_id = crypto.randomUUID();
const mfa_requirement = {
mfa_request_id,
@ -107,6 +175,17 @@ export default function (server) {
};
// cache mfa requests to test different validation scenarios
mfaRequirement[mfa_request_id] = { methods };
// cache login enforcement names
for (const [constraintName, constraint] of Object.entries(mfa_constraints)) {
// Create lookup by method ID
constraint.any.forEach((method) => {
if (!mfaConstraints[mfa_request_id]) {
mfaConstraints[mfa_request_id] = {};
}
mfaConstraints[mfa_request_id][method.id] = constraintName;
});
}
// cache auth response to be returned later by sys/mfa/validate
authResponses[mfa_request_id] = { ...res };
return mfa_requirement;
@ -155,4 +234,16 @@ export default function (server) {
server.post('/auth/:method/login/:user', passthroughLogin);
server.post('/sys/mfa/validate', validationHandler);
server.post('/identity/mfa/method/totp/self-enroll', async () => {
// For this endpoint to return a legitimate QR code, the user has to actually exist in Vault.
// Since we're using mirage to stub MFA users and methods this just returns a dummy QR code for testing.
return {
data: {
barcode:
'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',
url: QR_CODE_URL,
},
};
});
}

View File

@ -7,9 +7,11 @@ import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { click, currentRouteName, fillIn, visit, waitUntil, find, waitFor } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import mfaLoginHandler, { validationHandler } from '../../mirage/handlers/mfa-login';
import mfaLoginHandler, { validationHandler } from 'vault/mirage/handlers/mfa-login';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
import { MFA_SELECTORS } from 'vault/tests/helpers/mfa/mfa-selectors';
import { overrideResponse } from 'vault/tests/helpers/stubs';
module('Acceptance | mfa-login', function (hooks) {
setupApplicationTest(hooks);
@ -19,12 +21,13 @@ module('Acceptance | mfa-login', function (hooks) {
mfaLoginHandler(this.server);
this.auth = this.owner.lookup('service:auth');
this.select = async (select = 0, option = 1) => {
const selector = `[data-test-mfa-select="${select}"]`;
const selector = MFA_SELECTORS.select(select);
const value = this.element.querySelector(`${selector} option:nth-child(${option + 1})`).value;
await fillIn(`${selector} select`, value);
await fillIn(selector, value);
};
return visit('/vault/logout');
});
hooks.afterEach(function () {
// Manually clear token after each so that future tests don't get into a weird state
this.auth.deleteCurrentToken();
@ -45,46 +48,55 @@ module('Acceptance | mfa-login', function (hooks) {
assert.strictEqual(currentRouteName(), 'vault.cluster.dashboard', 'Route transitions after login');
};
const validate = async (multi) => {
await fillIn('[data-test-mfa-passcode="0"]', 'test');
await fillIn(MFA_SELECTORS.passcode(0), 'test');
if (multi) {
await fillIn('[data-test-mfa-passcode="1"]', 'test');
await fillIn(MFA_SELECTORS.passcode(1), 'test');
}
await click('[data-test-mfa-validate]');
await click(GENERAL.button('Verify'));
};
test('it should handle single mfa constraint with passcode method', async function (assert) {
assert.expect(4);
const assertSelfEnroll = async (assert) => {
await waitFor(MFA_SELECTORS.qrCode);
assert.dom(MFA_SELECTORS.qrCode).exists('it renders a QR code');
await click(GENERAL.button('Continue'));
assert.dom(MFA_SELECTORS.label).hasText('Enter your one-time code');
assert.dom(MFA_SELECTORS.passcode()).exists({ count: 1 }, '1 passcode inputs renders');
};
test('it should handle single constraint with passcode method', async function (assert) {
assert.expect(5);
await login('mfa-a');
assert.dom(GENERAL.title).hasText('Sign in to Vault');
assert
.dom('[data-test-mfa-description]')
.dom(MFA_SELECTORS.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');
assert.dom(MFA_SELECTORS.select()).doesNotExist('Select is hidden for single method');
assert.dom(MFA_SELECTORS.passcode()).exists({ count: 1 }, 'Single passcode input renders');
await validate();
await didLogin(assert);
});
test('it should handle single mfa constraint with push method', async function (assert) {
test('it should handle single constraint with push method', async function (assert) {
assert.expect(6);
server.post('/sys/mfa/validate', async (schema, req) => {
await waitUntil(() => find('[data-test-mfa-description]'));
await waitUntil(() => find(MFA_SELECTORS.description));
assert
.dom('[data-test-mfa-description]')
.dom(MFA_SELECTORS.description)
.hasText(
'Multi-factor authentication is enabled for your account.',
'Mfa form displays with correct description'
);
assert.dom('[data-test-mfa-label]').hasText('Okta push notification', 'Correct method renders');
assert.dom(MFA_SELECTORS.label).hasText('Okta push notification', 'Correct method renders');
assert
.dom('[data-test-mfa-push-instruction]')
.dom(MFA_SELECTORS.push)
.hasText('Check device for push notification', 'Push notification instruction renders');
assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled while validating');
assert.dom(GENERAL.button('Verify')).isDisabled('Button is disabled while validating');
assert
.dom('[data-test-mfa-validate] [data-test-icon="loading"]')
.dom(`${GENERAL.button('Verify')} ${GENERAL.icon('loading')}`)
.exists('Loading icon shows while validating');
return validationHandler(schema, req);
});
@ -93,50 +105,55 @@ module('Acceptance | mfa-login', function (hooks) {
await didLogin(assert);
});
test('it should handle single mfa constraint with 2 passcode methods', async function (assert) {
assert.expect(4);
test('it should handle single constraint with 2 passcode methods', async function (assert) {
assert.expect(6);
await login('mfa-c');
assert.dom(GENERAL.title).hasText('Verify your identity');
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();
.dom(MFA_SELECTORS.subheader)
.hasText(
'Multi-factor authentication is enabled for your account. Choose one of the following methods to continue:',
'Mfa form displays with correct description'
);
assert.dom(GENERAL.button('Verify with Duo')).exists('It renders button for Duo');
assert.dom(GENERAL.button('Verify with TOTP')).exists('It renders button for TOTP');
assert.dom(MFA_SELECTORS.passcode()).doesNotExist('Passcode input hidden until selection is made');
await click(GENERAL.button('Verify with TOTP'));
await validate();
await didLogin(assert);
});
test('it should handle single mfa constraint with 2 push methods', async function (assert) {
assert.expect(1);
test('it should handle single constraint with 2 push methods', async function (assert) {
assert.expect(3);
await login('mfa-d');
await this.select();
await click('[data-test-mfa-validate]');
assert.dom(GENERAL.button('Verify with Okta')).exists('It renders button for Okta');
assert.dom(GENERAL.button('Verify with Duo')).exists('It renders button for Duo');
await click(GENERAL.button('Verify with Okta'));
await didLogin(assert);
});
test('it should handle single mfa constraint with 1 passcode and 1 push method', async function (assert) {
test('it should handle single constraint with 1 passcode and 1 push method', async function (assert) {
assert.expect(3);
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]');
assert.dom(GENERAL.button('Verify with Okta')).exists('It renders button for Okta');
await click(GENERAL.button('Verify with TOTP'));
assert.dom(MFA_SELECTORS.passcode()).exists('Passcode input renders');
await click(GENERAL.button('Try another method'));
// Clicking "Verify with Okta" automatically starts validation so no need to click "Verify"
await click(GENERAL.button('Verify with Okta'));
await didLogin(assert);
});
test('it should handle multiple mfa constraints with 1 passcode method each', async function (assert) {
test('it should handle multiple constraints with 1 passcode method each', async function (assert) {
assert.expect(3);
await login('mfa-f');
assert
.dom('[data-test-mfa-description]')
.dom(MFA_SELECTORS.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');
assert.dom(MFA_SELECTORS.select()).doesNotExist('Selects do not render for single methods');
await validate(true);
await didLogin(assert);
});
@ -147,47 +164,246 @@ module('Acceptance | mfa-login', function (hooks) {
await didLogin(assert);
});
test('it should handle multiple mfa constraints with 1 passcode and 1 push method', async function (assert) {
test('it should handle multiple constraints with 1 passcode and 1 push method', async function (assert) {
assert.expect(4);
await login('mfa-h');
assert
.dom('[data-test-mfa-description]')
.dom(MFA_SELECTORS.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');
assert.dom(MFA_SELECTORS.select()).doesNotExist('Select is hidden for single method');
assert.dom(MFA_SELECTORS.passcode()).exists({ count: 1 }, 'Passcode input renders');
await validate();
await didLogin(assert);
});
test('it should handle multiple mfa constraints with multiple mixed methods', async function (assert) {
test('it should handle multiple constraints with multiple mixed methods', async function (assert) {
assert.expect(2);
await login('mfa-i');
assert
.dom('[data-test-mfa-description]')
.dom(MFA_SELECTORS.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]');
await fillIn(MFA_SELECTORS.passcode(1), 'test');
await click(GENERAL.button('Verify'));
await didLogin(assert);
});
test('it should render unauthorized message for push failure', async function (assert) {
await login('mfa-j');
await waitFor('[data-test-error]');
assert.dom('[data-test-mfa-form]').doesNotExist('MFA form does not render');
assert.dom('[data-test-auth-form]').doesNotExist('Auth form does not render');
await waitFor(GENERAL.messageError);
assert.dom(AUTH_FORM.form).doesNotExist('Auth form does not render');
// Using hasTextContaining because the UUID is regenerated each time in mirage
assert
.dom('[data-test-error]')
.hasText(
'Authentication error Multi-factor authentication is required, but failed. Go back and try again, or contact your administrator. Error: PingId MFA validation failed Go back'
.dom(GENERAL.messageError)
.hasTextContaining(
'Error failed to satisfy enforcement test_0 pingid authentication failed: "Login request denied." login MFA validation failed for methodID:'
);
await click('[data-test-error] button');
assert.dom('[data-test-auth-form]').exists('Auth form renders after mfa error dismissal');
await click(GENERAL.cancelButton);
assert.dom(AUTH_FORM.form).exists('Auth form renders after mfa error dismissal');
});
/*
* SELF-ENROLLMENT TESTS
* Even though self-enrollment is an enterprise-only feature, these tests use Mirage so we don't need to filter them out of CE test runs
*/
test('self-enroll: single constraint with one TOTP passcode', async function (assert) {
await login('mfa-a-self');
await waitFor(MFA_SELECTORS.qrCode);
assert.dom(MFA_SELECTORS.qrCode).exists('it renders QR code');
assert.dom(GENERAL.title).hasText('Set up MFA TOTP to continue');
await click(GENERAL.button('Continue'));
assert.dom(GENERAL.button('Continue')).doesNotExist('"Continue" button is replaced by "Verify"');
assert.dom(MFA_SELECTORS.qrCode).doesNotExist('Clicking "Continue" removes QR code');
assert.dom(GENERAL.button('Verify')).exists();
assert.dom(MFA_SELECTORS.qrCode).doesNotExist('Clicking "Continue" removes QR code');
assert.dom(MFA_SELECTORS.passcode()).exists({ count: 1 }, 'Single passcode input renders');
await validate();
await didLogin(assert);
});
test('self-enroll: single constraint with 2 passcode methods', async function (assert) {
await login('mfa-c-self');
// Buttons render for both Duo and TOTP, we want to click TOTP to initiate self-enrollment flow
assert.dom(GENERAL.title).hasText('Verify your identity');
assert.dom(GENERAL.button('Verify with Duo')).exists();
await click(GENERAL.button('Setup to verify with TOTP'));
await waitFor(MFA_SELECTORS.qrCode);
assert.dom(MFA_SELECTORS.qrCode).exists('it renders QR code');
assert.dom(GENERAL.title).hasText('Set up MFA TOTP to continue');
// Click "Continue" for second setup step to verify passcode
await click(GENERAL.button('Continue'));
assert
.dom(MFA_SELECTORS.description)
.hasText('To verify your device, enter the code generated from your authenticator.');
assert.dom(GENERAL.button('Continue')).doesNotExist('"Continue" button is replaced by "Verify"');
assert.dom(MFA_SELECTORS.qrCode).doesNotExist('Clicking "Continue" removes QR code');
assert.dom(GENERAL.button('Verify')).exists();
assert.dom(MFA_SELECTORS.passcode()).exists({ count: 1 }, 'Passcode input renders');
await validate();
await didLogin(assert);
});
test('self-enroll: multiple constraints with 1 passcode method each', async function (assert) {
await login('mfa-f-self');
await waitFor(MFA_SELECTORS.qrCode);
assert.dom(MFA_SELECTORS.qrCode).exists('it renders QR code');
assert.dom(GENERAL.title).hasText('Set up MFA TOTP to continue');
// Click "Continue" for second setup step to verify passcode
await click(GENERAL.button('Continue'));
assert.dom(GENERAL.button('Continue')).doesNotExist('"Continue" button is replaced by "Verify"');
assert.dom(MFA_SELECTORS.qrCode).doesNotExist('Clicking "Continue" removes QR code');
assert
.dom(MFA_SELECTORS.description)
.hasText('To verify your device, enter the code generated from your authenticator.');
assert.dom(MFA_SELECTORS.label).hasText('Enter your one-time code');
assert.dom(MFA_SELECTORS.passcode()).exists({ count: 1 }, '1 passcode inputs renders');
// Fill in and click "Verify" which should render passcode for second constraint
await fillIn(MFA_SELECTORS.passcode(0), 'test');
await click(GENERAL.button('Verify'));
assert
.dom(MFA_SELECTORS.description)
.hasText(
'Multi-factor authentication is enabled for your account. Two methods are required for successful authentication.'
);
assert.dom(MFA_SELECTORS.verifyBadge('TOTP passcode')).hasText('TOTP passcode');
assert.dom(GENERAL.button('Verify')).exists();
assert.dom(MFA_SELECTORS.label).hasText('Duo passcode');
assert.dom(MFA_SELECTORS.passcode()).exists({ count: 1 }, '1 passcode inputs renders');
assert.dom('hr').exists({ count: 1 }, 'only one separator renders');
await fillIn(MFA_SELECTORS.passcode(1), 'test');
await click(GENERAL.button('Verify'));
await didLogin(assert);
});
test('self-enroll: multiple constraints with 1 passcode and 1 push method', async function (assert) {
await login('mfa-h-self');
await waitFor(MFA_SELECTORS.qrCode);
assert.dom(MFA_SELECTORS.qrCode).exists('it renders QR code');
assert.dom(GENERAL.title).hasText('Set up MFA TOTP to continue');
// Click "Continue" for second setup step to verify passcode
await click(GENERAL.button('Continue'));
assert.dom(GENERAL.button('Continue')).doesNotExist('"Continue" button is replaced by "Verify"');
assert.dom(MFA_SELECTORS.qrCode).doesNotExist('Clicking "Continue" removes QR code');
assert
.dom(MFA_SELECTORS.description)
.hasText('To verify your device, enter the code generated from your authenticator.');
assert.dom(MFA_SELECTORS.label).hasText('Enter your one-time code');
assert.dom(MFA_SELECTORS.passcode()).exists({ count: 1 }, '1 passcode inputs renders');
// Fill in and click "Verify" which should immediately trigger MFA validation because the
// second constraint is a push notification and no user input is required.
await fillIn(MFA_SELECTORS.passcode(0), 'test');
await click(GENERAL.button('Verify'));
await didLogin(assert);
});
test('self-enroll: multiple constraints with multiple mixed methods', async function (assert) {
await login('mfa-i-self');
assert.dom(GENERAL.title).hasText('Verify your identity');
assert
.dom(MFA_SELECTORS.subheader)
.hasText(
'Multi-factor authentication is enabled for your account. Choose one of the following methods to continue:'
);
assert.dom(GENERAL.button('Verify with Okta')).exists();
await click(GENERAL.button('Setup to verify with TOTP'));
await waitFor(MFA_SELECTORS.qrCode);
assert.dom(MFA_SELECTORS.qrCode).exists('it renders QR code');
assert.dom(GENERAL.title).hasText('Set up MFA TOTP to continue');
// Click "Continue" to validate TOTP
await click(GENERAL.button('Continue'));
assert.dom(GENERAL.button('Continue')).doesNotExist('"Continue" button is replaced by "Verify"');
assert.dom(MFA_SELECTORS.qrCode).doesNotExist('Clicking "Continue" removes QR code');
assert
.dom(MFA_SELECTORS.description)
.hasText('To verify your device, enter the code generated from your authenticator.');
assert.dom(MFA_SELECTORS.label).hasText('Enter your one-time code');
assert.dom(MFA_SELECTORS.passcode()).exists({ count: 1 }, '1 passcode inputs renders');
// Fill in and click "Verify" which should render passcode for second constraint
await fillIn(MFA_SELECTORS.passcode(0), 'test');
await click(GENERAL.button('Verify'));
assert.dom(MFA_SELECTORS.verifyBadge('TOTP passcode')).hasText('TOTP passcode');
assert.dom(MFA_SELECTORS.select(0)).isDisabled('Select with self-enrolled TOTP is disabled');
assert.dom(MFA_SELECTORS.passcode()).exists({ count: 1 }, '1 passcode inputs renders');
assert.dom(MFA_SELECTORS.label).hasText('Duo passcode');
await fillIn(MFA_SELECTORS.passcode(1), 'test');
await click(GENERAL.button('Verify'));
await didLogin(assert);
});
test('self-enroll: multiple constraints, 1 with 2 methods (one that supports self-enroll), 1 with push method', async function (assert) {
await login('mfa-z-self');
// For the constraint that supports self-enrollment, user must select it first.
assert.dom(MFA_SELECTORS.select(0)).exists();
assert.dom(MFA_SELECTORS.select(1)).exists();
assert.dom('hr').exists({ count: 1 }, 'only one separator renders');
await this.select(0, 1);
await assertSelfEnroll(assert);
await fillIn(MFA_SELECTORS.passcode(0), 'test');
await click(GENERAL.button('Verify'));
assert
.dom(`${MFA_SELECTORS.select(0)} option:nth-child(2)`)
.hasText('TOTP passcode (supports self-enrollment)', 'TOTP is pre-selected for the first constraint');
await this.select(1, 2);
// On second selection we are redirected
assert.dom(GENERAL.title).hasText('Sign in to Vault');
assert.dom(MFA_SELECTORS.verifyForm).exists('it renders mfa validation form');
assert.dom(MFA_SELECTORS.select(0)).isDisabled();
assert.dom(MFA_SELECTORS.verifyBadge('TOTP passcode')).exists('pending verification badge exists');
assert.dom(MFA_SELECTORS.select(1)).isNotDisabled();
await didLogin(assert);
});
test('self-enroll: if validation fails it resets the enrollment status', async function (assert) {
await login('mfa-c-self');
await click(GENERAL.button('Setup to verify with TOTP'));
await waitFor(MFA_SELECTORS.qrCode);
await click(GENERAL.button('Continue'));
await fillIn(MFA_SELECTORS.passcode(0), '123456'); // not a valid code so it fails
await click(GENERAL.button('Verify'));
await waitFor(MFA_SELECTORS.verifyForm);
assert.dom(MFA_SELECTORS.verifyBadge('TOTP passcode')).doesNotExist();
assert.dom(MFA_SELECTORS.passcode(0)).exists().hasValue('123456', 'input has last used passcode');
});
module('error handling', function (hooks) {
hooks.beforeEach(function () {
// TODO confirm with backend what errors could be returned
this.server.post('/identity/mfa/method/totp/self-enroll', async () => {
return overrideResponse(500, JSON.stringify({ errors: ['uh oh!'] }));
});
});
test('single constraint with one TOTP passcode', async function (assert) {
await login('mfa-a-self');
await waitFor(MFA_SELECTORS.verifyForm);
assert.dom(MFA_SELECTORS.qrCode).doesNotExist('it does not enter self-enroll workflow');
assert.dom(GENERAL.button('Continue')).doesNotExist();
assert.dom(GENERAL.messageError).hasText('Error uh oh!', 'it renders error messages');
});
test('single constraint with 2 passcode methods', async function (assert) {
await login('mfa-c-self');
// Buttons render for both Duo and TOTP, we want to click TOTP to initiate self-enrollment flow
assert.dom(GENERAL.button('Verify with Duo')).exists();
await click(GENERAL.button('Setup to verify with TOTP'));
await waitFor(MFA_SELECTORS.verifyForm);
assert.dom(MFA_SELECTORS.qrCode).doesNotExist('it does not enter self-enroll workflow');
assert.dom(GENERAL.button('Continue')).doesNotExist();
assert.dom(GENERAL.messageError).hasText('Error uh oh!', 'it renders error messages');
});
test('multiple constraints with 1 passcode method each', async function (assert) {
await login('mfa-f-self');
await waitFor(MFA_SELECTORS.verifyForm);
assert.dom(MFA_SELECTORS.qrCode).doesNotExist('it does not enter self-enroll workflow');
assert.dom(GENERAL.button('Continue')).doesNotExist();
assert.dom(GENERAL.messageError).hasText('Error uh oh!', 'it renders error messages');
});
});
});

View File

@ -4,7 +4,16 @@
*/
export const MFA_SELECTORS = {
countdown: '[data-test-mfa-countdown]',
description: '[data-test-mfa-description]',
label: '[data-test-mfa-label]',
mfaForm: '[data-test-mfa-form]',
passcode: (idx: number) => `[data-test-mfa-passcode="${idx}"]`,
validate: '[data-test-mfa-validate]',
passcode: (idx: number) => (idx ? `[data-test-mfa-passcode="${idx}"]` : '[data-test-mfa-passcode]'),
push: '[data-test-mfa-push-instruction]',
verifyBadge: (label: string) => `[data-test-mfa-verified="${label}"]`,
qrCode: '[data-test-qrcode]',
select: (idx: number) => (idx ? `[data-test-mfa-select="${idx}"]` : '[data-test-mfa-select]'),
subheader: '[data-test-mfa-subheader]',
subtitle: '[data-test-mfa-subtitle]',
verifyForm: 'form#mfa-form',
};

View File

@ -122,8 +122,8 @@ module('Integration | Component | auth | page | listing visibility', function (h
test('it prioritizes auth type from canceled mfa instead of direct link for path', async function (assert) {
assert.expect(1);
this.directLinkData = this.directLinkIsVisibleMount;
const authType = 'okta';
this.directLinkData = this.directLinkIsVisibleMount; // type is "oidc"
const authType = 'okta'; // set to a type that differs from direct link
this.server.post(`/auth/okta/login/matilda`, () => setupTotpMfaResponse(authType));
await this.renderComponent();
await click(GENERAL.button('Sign in with other methods'));
@ -131,20 +131,21 @@ module('Integration | Component | auth | page | listing visibility', function (h
await fillInLoginFields({ username: 'matilda', password: 'password' });
await click(GENERAL.submitButton);
await waitFor('[data-test-mfa-description]'); // wait until MFA validation renders
await click(GENERAL.backButton);
await click(GENERAL.cancelButton);
assert.dom(AUTH_FORM.selectMethod).hasValue(authType, 'Okta is selected in dropdown');
});
test('it prioritizes auth type from canceled mfa instead of direct link with type', async function (assert) {
assert.expect(1);
this.directLinkData = this.directLinkIsJustType;
const authType = 'userpass';
this.server.post(`/auth/okta/login/matilda`, () => setupTotpMfaResponse(authType));
this.directLinkData = this.directLinkIsJustType; // type is "okta"
const authType = 'userpass'; // set to a type that differs from direct link
this.server.post(`/auth/userpass/login/matilda`, () => setupTotpMfaResponse(authType));
await this.renderComponent();
await fillIn(AUTH_FORM.selectMethod, authType);
await fillInLoginFields({ username: 'matilda', password: 'password' });
await click(GENERAL.submitButton);
await click(GENERAL.backButton);
await waitFor('[data-test-mfa-description]'); // wait until MFA validation renders
await click(GENERAL.cancelButton);
assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true');
});
});

View File

@ -40,9 +40,9 @@ const mfaTests = (test) => {
assert
.dom(MFA_SELECTORS.mfaForm)
.hasText(
'Back Multi-factor authentication is enabled for your account. Enter your authentication code to log in. TOTP passcode Verify'
'Sign in to Vault Multi-factor authentication is enabled for your account. Enter your authentication code to log in. TOTP passcode Verify Cancel'
);
await click(GENERAL.backButton);
await click(GENERAL.cancelButton);
assert.dom(AUTH_FORM.form).exists('clicking back returns to auth form');
assert.dom(AUTH_FORM.selectMethod).hasValue(this.authType, 'preserves method type on back');
for (const field of loginKeys) {
@ -72,9 +72,9 @@ const mfaTests = (test) => {
assert
.dom(MFA_SELECTORS.mfaForm)
.hasText(
'Back Multi-factor authentication is enabled for your account. Enter your authentication code to log in. TOTP passcode Verify'
'Sign in to Vault Multi-factor authentication is enabled for your account. Enter your authentication code to log in. TOTP passcode Verify Cancel'
);
await click(GENERAL.backButton);
await click(GENERAL.cancelButton);
assert.dom(AUTH_FORM.form).exists('clicking back returns to auth form');
assert.dom(AUTH_FORM.selectMethod).hasValue(this.authType, 'preserves method type on back');
for (const field of loginKeys) {
@ -106,7 +106,7 @@ const mfaTests = (test) => {
await click(GENERAL.submitButton);
await waitFor(MFA_SELECTORS.mfaForm);
await fillIn(MFA_SELECTORS.passcode(0), expectedOtp);
await click(MFA_SELECTORS.validate);
await click(GENERAL.button('Verify'));
});
test('it submits mfa requirement for custom paths', async function (assert) {
@ -134,7 +134,7 @@ const mfaTests = (test) => {
await click(GENERAL.submitButton);
await waitFor(MFA_SELECTORS.mfaForm);
await fillIn(MFA_SELECTORS.passcode(0), expectedOtp);
await click(MFA_SELECTORS.validate);
await click(GENERAL.button('Verify'));
});
};

View File

@ -1,304 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, settled, fillIn, click, waitUntil, waitFor } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { _cancelTimers as cancelTimers, later } from '@ember/runloop';
import { TOTP_VALIDATION_ERROR } from 'vault/components/mfa/mfa-form';
import sinon from 'sinon';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
module('Integration | Component | mfa-form', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.onCancel = sinon.spy();
this.clusterId = '123456';
this.mfaAuthData = {
authMethodType: 'userpass',
authMountPath: 'userpass',
};
this.authService = this.owner.lookup('service:auth');
// setup basic totp mfaRequirement
// override in tests that require different scenarios
this.totpConstraint = this.server.create('mfa-method', { type: 'totp' });
const mfaRequirement = this.authService.parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa: { any: [this.totpConstraint] } },
});
this.mfaAuthData.mfaRequirement = mfaRequirement;
});
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.mfaRequirement = this.authService.parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa_1: { any: [totpConstraint] } },
});
await render(
hbs`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onError={{fn (mut this.error)}}
@onCancel={{this.onCancel}}
/>`
);
assert
.dom('[data-test-mfa-description]')
.includesText(
'Enter your authentication code to log in.',
'Correct description renders for single passcode'
);
this.mfaAuthData.mfaRequirement = this.authService.parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa_1: { any: [duoConstraint, oktaConstraint] } },
});
await render(
hbs`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onError={{fn (mut this.error)}}
@onCancel={{this.onCancel}}
/>`
);
assert
.dom('[data-test-mfa-description]')
.includesText(
'Select the MFA method you wish to use.',
'Correct description renders for multiple methods'
);
this.mfaAuthData.mfaRequirement = this.authService.parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa_1: { any: [oktaConstraint] }, test_mfa_2: { any: [duoConstraint] } },
});
await render(
hbs`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onError={{fn (mut this.error)}}
@onCancel={{this.onCancel}}
/>`
);
assert
.dom('[data-test-mfa-description]')
.includesText(
'Two methods are required for successful authentication.',
'Correct description renders for multiple constraints'
);
});
test('it should render a submit button', async function (assert) {
await render(hbs`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onCancel={{this.onCancel}}
/>`);
assert.dom('[data-test-mfa-validate]').isNotDisabled('Button is not disabled by default');
});
test('it should render method selects and passcode inputs', async function (assert) {
assert.expect(2);
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 mfaRequirement = this.authService.parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: {
test_mfa_1: {
any: [pingidConstraint, oktaConstraint],
},
test_mfa_2: {
any: [duoConstraint],
},
},
});
this.mfaAuthData.mfaRequirement = mfaRequirement;
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]: ['passcode=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({ mfaRequirement }) {
await this.clusterAdapter().mfaValidate(mfaRequirement);
return 'test response';
},
});
this.onSuccess = (resp) =>
assert.strictEqual(resp, 'test response', 'Response is returned in onSuccess callback');
await render(
hbs`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onSuccess={{this.onSuccess}}
@onCancel={{this.onCancel}}
/>`
);
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) {
assert.expect(5);
this.server.post('/sys/mfa/validate', (schema, req) => {
const json = JSON.parse(req.requestBody);
const payload = {
mfa_request_id: 'test-mfa-id',
mfa_payload: { [this.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] [data-test-icon="loading"]')
.exists('Loading icon shows on 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.mfaRequirement);
return 'test response';
},
});
this.onSuccess = (resp) =>
assert.strictEqual(resp, 'test response', 'Response is returned in onSuccess callback');
await render(
hbs`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onSuccess={{this.onSuccess}}
@onCancel={{this.onCancel}}
/>`
);
await fillIn('[data-test-mfa-passcode]', 'test-code');
await click('[data-test-mfa-validate]');
});
test('it should show countdown on passcode already used and rate limit errors', async function (assert) {
const messages = {
used: 'code already used; new code is available in 30 seconds',
// note: the backend returns a duplicate "s" in "30s seconds" in the limit message below. we have intentionally left it as is to ensure our regex for parsing the delay time can handle it
limit:
'maximum TOTP validation attempts 4 exceeded the allowed attempts 3. Please try again in 30s seconds',
};
const codes = ['used', 'limit'];
for (const code of codes) {
this.owner.lookup('service:auth').reopen({
totpValidate() {
throw { errors: [messages[code]] };
},
});
await render(hbs`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onCancel={{this.onCancel}}
/>`);
await fillIn('[data-test-mfa-passcode]', 'foo');
await click('[data-test-mfa-validate]');
await waitFor('[data-test-mfa-countdown]');
assert
.dom('[data-test-mfa-countdown]')
.includesText('30', 'countdown renders with correct initial value from error response');
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-inline-error-message]').exists('Alert message renders');
}
});
test('it defaults countdown to 30 seconds if error message does not indicate when user can try again ', async function (assert) {
this.owner.lookup('service:auth').reopen({
totpValidate() {
throw {
errors: ['maximum TOTP validation attempts 4 exceeded the allowed attempts 3. Beep-boop.'],
};
},
});
await render(hbs`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onCancel={{this.onCancel}}
/>`);
await fillIn('[data-test-mfa-passcode]', 'foo');
await click('[data-test-mfa-validate]');
await waitFor('[data-test-mfa-countdown]');
assert
.dom('[data-test-mfa-countdown]')
.includesText('30', 'countdown renders with correct initial value from error response');
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-inline-error-message]').exists('Alert message renders');
});
test('it should show error message for passcode invalid error', async function (assert) {
this.owner.lookup('service:auth').reopen({
totpValidate() {
throw { errors: ['failed to validate'] };
},
});
await render(hbs`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onCancel={{this.onCancel}}
/>`);
await fillIn('[data-test-mfa-passcode]', 'test-code');
later(() => cancelTimers(), 50);
await settled();
await click('[data-test-mfa-validate]');
assert
.dom('[data-test-message-error]')
.includesText(TOTP_VALIDATION_ERROR, 'Generic error message renders for passcode validation error');
});
test('it should call onCancel callback', async function (assert) {
await render(hbs`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onCancel={{this.onCancel}}
/>`);
await click(GENERAL.backButton);
assert.true(this.onCancel.calledOnce, 'it fires onCancel callback');
});
});

View File

@ -0,0 +1,499 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, settled, fillIn, click, waitUntil, waitFor } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { _cancelTimers as cancelTimers, later } from '@ember/runloop';
import { TOTP_VALIDATION_ERROR } from 'vault/components/mfa/mfa-form';
import sinon from 'sinon';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { MFA_SELECTORS } from 'vault/tests/helpers/mfa/mfa-selectors';
import { QR_CODE_URL } from 'vault/mirage/handlers/mfa-login';
module('Integration | Component | mfa-form', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.onCancel = sinon.spy();
this.onSuccess = sinon.spy();
this.onError = sinon.spy();
this.authService = this.owner.lookup('service:auth');
// setup basic totp mfaRequirement
// override in tests that require different scenarios
this.totpConstraint = this.server.create('mfa-method', { type: 'totp' });
const mfaRequirement = this.authService.parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa: { any: [this.totpConstraint] } },
});
this.renderComponent = () => {
return render(hbs`
<Mfa::MfaForm
@authData={{this.mfaAuthData}}
@clusterId="123456"
@onCancel={{this.onCancel}}
@onError={{this.onError}}
@onSuccess={{this.onSuccess}}
/>`);
};
this.setMfaAuthData = (mfaRequirement) => {
this.mfaAuthData = {
mfaRequirement: mfaRequirement,
authMethodType: 'userpass',
authMountPath: 'userpass',
};
};
this.setMfaAuthData(mfaRequirement);
});
test('it renders correct text for single passcode', async function (assert) {
const totpConstraint = this.server.create('mfa-method', { type: 'totp' });
this.mfaAuthData.mfaRequirement = this.authService.parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa_1: { any: [totpConstraint] } },
});
await this.renderComponent();
assert
.dom(MFA_SELECTORS.description)
.hasText(
'Multi-factor authentication is enabled for your account. Enter your authentication code to log in.'
);
});
test('it renders correct text for multiple methods', async function (assert) {
const oktaConstraint = this.server.create('mfa-method', { type: 'okta' });
const duoConstraint = this.server.create('mfa-method', { type: 'duo' });
this.mfaAuthData.mfaRequirement = this.authService.parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa_1: { any: [duoConstraint, oktaConstraint] } },
});
await this.renderComponent();
assert
.dom(MFA_SELECTORS.subheader)
.hasText(
'Multi-factor authentication is enabled for your account. Choose one of the following methods to continue:'
);
});
test('it renders correct text for multiple constraints', async function (assert) {
const oktaConstraint = this.server.create('mfa-method', { type: 'okta' });
const duoConstraint = this.server.create('mfa-method', { type: 'duo' });
this.mfaAuthData.mfaRequirement = this.authService.parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa_1: { any: [oktaConstraint] }, test_mfa_2: { any: [duoConstraint] } },
});
await this.renderComponent();
assert
.dom(MFA_SELECTORS.description)
.hasText(
'Multi-factor authentication is enabled for your account. Two methods are required for successful authentication.'
);
});
test('it should render a submit button', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.button('Verify')).isNotDisabled('Button is not disabled by default');
});
test('it should render method selects and passcode inputs', async function (assert) {
assert.expect(2);
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 mfaRequirement = this.authService.parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: {
test_mfa_1: {
any: [pingidConstraint, oktaConstraint],
},
test_mfa_2: {
any: [duoConstraint],
},
},
});
this.mfaAuthData.mfaRequirement = mfaRequirement;
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]: ['passcode=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({ mfaRequirement }) {
await this.clusterAdapter().mfaValidate(mfaRequirement);
return 'test response';
},
});
this.onSuccess = (resp) =>
assert.strictEqual(resp, 'test response', 'Response is returned in onSuccess callback');
await this.renderComponent();
await fillIn(MFA_SELECTORS.select(0), oktaConstraint.id);
await fillIn(MFA_SELECTORS.passcode(1), 'test-code');
await click(GENERAL.button('Verify'));
});
test('it should validate mfa requirement', async function (assert) {
assert.expect(5);
this.server.post('/sys/mfa/validate', (schema, req) => {
const json = JSON.parse(req.requestBody);
const payload = {
mfa_request_id: 'test-mfa-id',
mfa_payload: { [this.totpConstraint.id]: ['test-code'] },
};
assert.deepEqual(json, payload, 'Correct mfa payload passed to validate endpoint');
return {};
});
const expectedAuthData = {
clusterId: '123456',
authMethodType: 'userpass',
authMountPath: 'userpass',
mfaRequirement: {
mfa_constraints: [
{
methods: [this.totpConstraint],
passcode: 'test-code', // Added by the MfaForm
selectedMethod: this.totpConstraint,
},
],
mfa_request_id: 'test-mfa-id',
},
};
this.owner.lookup('service:auth').reopen({
// override to avoid authSuccess method since it expects an auth payload
async totpValidate(authData) {
await waitUntil(() =>
assert
.dom(`${GENERAL.button('Verify')} ${GENERAL.icon('loading')}`)
.exists('Loading icon shows on button')
);
assert.dom(GENERAL.button('Verify')).isDisabled('Button is disabled while loading');
assert.deepEqual(authData, expectedAuthData, 'Mfa auth data passed to validate method');
await this.clusterAdapter().mfaValidate(authData.mfaRequirement);
return 'test response';
},
});
this.onSuccess = (resp) =>
assert.strictEqual(resp, 'test response', 'Response is returned in onSuccess callback');
await this.renderComponent();
await fillIn(MFA_SELECTORS.passcode(), 'test-code');
await click(GENERAL.button('Verify'));
});
test('it should show countdown on passcode already used and rate limit errors', async function (assert) {
const messages = {
used: 'code already used; new code is available in 30 seconds',
// note: the backend returns a duplicate "s" in "30s seconds" in the limit message below. we have intentionally left it as is to ensure our regex for parsing the delay time can handle it
limit:
'failed to satisfy enforcement userpass2-not-self-enroll. error: 2 errors occurred:\n\t* maximum TOTP validation attempts 2 exceeded the allowed attempts 1. Please try again in 30s seconds\n\t* login MFA validation failed for methodID: [1f260334-ee5f-6e47-8e86-57be05d457d2]\n\n',
};
const codes = ['used', 'limit'];
for (const code of codes) {
this.owner.lookup('service:auth').reopen({
totpValidate() {
throw new Error(messages[code]);
},
});
await this.renderComponent();
await fillIn(MFA_SELECTORS.passcode(), 'foo');
await click(GENERAL.button('Verify'));
await waitFor(MFA_SELECTORS.countdown);
assert
.dom(MFA_SELECTORS.countdown)
.includesText('30', 'countdown renders with correct initial value from error response');
assert.dom(GENERAL.button('Verify')).isDisabled();
assert.dom(GENERAL.cancelButton).isDisabled();
assert.dom(MFA_SELECTORS.passcode()).isDisabled('Input is disabled during countdown');
assert.dom(GENERAL.inlineError).exists('Alert message renders');
}
});
test('it defaults countdown to 30 seconds if error message does not indicate when user can try again ', async function (assert) {
const msg = 'maximum TOTP validation attempts 4 exceeded the allowed attempts 3. Beep-boop.';
this.owner.lookup('service:auth').reopen({
totpValidate() {
throw new Error(msg);
},
});
await this.renderComponent();
await fillIn(MFA_SELECTORS.passcode(), 'foo');
await click(GENERAL.button('Verify'));
await waitFor(MFA_SELECTORS.countdown);
assert
.dom(MFA_SELECTORS.countdown)
.includesText('30', 'countdown renders with correct initial value from error response');
assert.dom(GENERAL.button('Verify')).isDisabled('Button is disabled during countdown');
assert.dom(MFA_SELECTORS.passcode()).isDisabled('Input is disabled during countdown');
assert.dom(GENERAL.inlineError).exists('Alert message renders');
});
test('it should show error message for passcode invalid error', async function (assert) {
this.owner.lookup('service:auth').reopen({
totpValidate() {
throw { errors: ['failed to validate'] };
},
});
await this.renderComponent();
await fillIn(MFA_SELECTORS.passcode(), 'test-code');
later(() => cancelTimers(), 50);
await settled();
await click(GENERAL.button('Verify'));
assert
.dom(GENERAL.messageError)
.includesText(TOTP_VALIDATION_ERROR, 'Generic error message renders for passcode validation error');
});
test('it should call onCancel callback', async function (assert) {
await this.renderComponent();
await click(GENERAL.cancelButton);
assert.true(this.onCancel.calledOnce, 'it fires onCancel callback');
});
module('self-enrollment', function (hooks) {
hooks.beforeEach(function () {
// Self-enrollment is an enterprise only feature
this.version = this.owner.lookup('service:version');
this.version.type = 'enterprise';
this.server.post('/identity/mfa/method/totp/self-enroll', async () => {
return {
data: {
barcode:
'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',
url: QR_CODE_URL,
},
};
});
});
test('it makes request to self-enroll endpoint when self_enrollment_enabled is true', async function (assert) {
assert.expect(3);
const request_id = crypto.randomUUID();
const totpConstraint = this.server.create('mfa-method', {
type: 'totp',
self_enrollment_enabled: true,
});
this.mfaAuthData.mfaRequirement = this.authService.parseMfaResponse({
mfa_request_id: request_id,
mfa_constraints: { test_mfa_1: { any: [totpConstraint] } },
});
this.server.post('/identity/mfa/method/totp/self-enroll', async (schema, req) => {
const { mfa_method_id, mfa_request_id } = JSON.parse(req.requestBody);
assert.true(true, 'Request made to /self-enroll');
assert.strictEqual(mfa_request_id, request_id, 'payload has expected request id');
assert.strictEqual(mfa_method_id, totpConstraint.id, 'payload has expected method id');
return {
data: {
barcode:
'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',
url: QR_CODE_URL,
},
};
});
await this.renderComponent();
await click(GENERAL.button('Continue'));
});
test('it renders correct text for single passcode', async function (assert) {
const totpConstraint = this.server.create('mfa-method', {
type: 'totp',
self_enrollment_enabled: true,
});
this.mfaAuthData.mfaRequirement = this.authService.parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa_1: { any: [totpConstraint] } },
});
await this.renderComponent();
assert.dom(GENERAL.title).hasText('Set up MFA TOTP to continue');
assert
.dom(MFA_SELECTORS.subheader)
.hasText('Your organization has enforced MFA TOTP to protect your accounts. Set up to continue.');
assert.dom(MFA_SELECTORS.subtitle).hasText('Scan the QR code to continue');
assert
.dom(MFA_SELECTORS.description)
.hasText(
'Scan the QR code with your authenticator app. If you currently do not have a device on hand, you can copy the MFA secret below and enter it manually.',
'Correct description renders for single passcode'
);
assert.dom(GENERAL.button('Continue')).exists();
assert.dom(GENERAL.cancelButton).exists();
// Go on to next step
await click(GENERAL.button('Continue'));
assert.dom(GENERAL.title).hasText('Set up MFA TOTP to continue');
assert
.dom(MFA_SELECTORS.subheader)
.hasText('Your organization has enforced MFA TOTP to protect your accounts. Set up to continue.');
assert.dom(MFA_SELECTORS.subtitle).doesNotExist();
assert
.dom(MFA_SELECTORS.description)
.hasText('To verify your device, enter the code generated from your authenticator.');
assert.dom(MFA_SELECTORS.label).hasText('Enter your one-time code');
assert.dom(GENERAL.button('Verify')).exists();
assert.dom(GENERAL.cancelButton).exists();
});
test('it renders correct text for multiple methods (1 passcode 1 push)', async function (assert) {
const oktaConstraint = this.server.create('mfa-method', { type: 'okta' });
const totpConstraint = this.server.create('mfa-method', {
type: 'totp',
self_enrollment_enabled: true,
});
this.mfaAuthData.mfaRequirement = this.authService.parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa_1: { any: [totpConstraint, oktaConstraint] } },
});
await this.renderComponent();
assert.dom(GENERAL.title).hasText('Verify your identity');
assert
.dom(MFA_SELECTORS.subheader)
.hasText(
'Multi-factor authentication is enabled for your account. Choose one of the following methods to continue:'
);
assert.dom(MFA_SELECTORS.subtitle).doesNotExist();
assert.dom(GENERAL.button('Verify')).doesNotExist();
assert.dom(GENERAL.cancelButton).exists();
// Select TOTP
await click(GENERAL.button('Setup to verify with TOTP'));
await waitFor(MFA_SELECTORS.qrCode);
assert.dom(GENERAL.title).hasText('Set up MFA TOTP to continue');
assert
.dom(MFA_SELECTORS.subheader)
.hasText('Your organization has enforced MFA TOTP to protect your accounts. Set up to continue.');
assert.dom(MFA_SELECTORS.subtitle).hasText('Scan the QR code to continue');
assert
.dom(MFA_SELECTORS.description)
.hasText(
'Scan the QR code with your authenticator app. If you currently do not have a device on hand, you can copy the MFA secret below and enter it manually.',
'Correct description renders for single passcode'
);
assert.dom(GENERAL.button('Continue')).exists();
assert.dom(GENERAL.cancelButton).exists();
// Go on to next step
await click(GENERAL.button('Continue'));
assert.dom(GENERAL.title).hasText('Set up MFA TOTP to continue');
assert
.dom(MFA_SELECTORS.subheader)
.hasText('Your organization has enforced MFA TOTP to protect your accounts. Set up to continue.');
assert.dom(MFA_SELECTORS.subtitle).doesNotExist();
assert
.dom(MFA_SELECTORS.description)
.hasText('To verify your device, enter the code generated from your authenticator.');
assert.dom(MFA_SELECTORS.label).hasText('Enter your one-time code');
assert.dom(GENERAL.button('Verify')).exists();
assert.dom(GENERAL.cancelButton).exists('it renders "Cancel" after self-enroll workflow');
});
test('it renders correct text for multiple methods (2 passcodes)', async function (assert) {
const duoConstraint = this.server.create('mfa-method', { type: 'duo', uses_passcode: true });
const totpConstraint = this.server.create('mfa-method', {
type: 'totp',
self_enrollment_enabled: true,
});
this.mfaAuthData.mfaRequirement = this.authService.parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa_1: { any: [totpConstraint, duoConstraint] } },
});
await this.renderComponent();
assert.dom(GENERAL.title).hasText('Verify your identity');
assert
.dom(MFA_SELECTORS.subheader)
.hasText(
'Multi-factor authentication is enabled for your account. Choose one of the following methods to continue:'
);
assert.dom(MFA_SELECTORS.subtitle).doesNotExist();
assert.dom(GENERAL.button('Verify')).doesNotExist();
assert.dom(GENERAL.cancelButton).exists();
// Select TOTP
await click(GENERAL.button('Setup to verify with TOTP'));
await waitFor(MFA_SELECTORS.qrCode);
assert.dom(GENERAL.title).hasText('Set up MFA TOTP to continue');
assert
.dom(MFA_SELECTORS.subheader)
.hasText('Your organization has enforced MFA TOTP to protect your accounts. Set up to continue.');
assert.dom(MFA_SELECTORS.subtitle).hasText('Scan the QR code to continue');
assert
.dom(MFA_SELECTORS.description)
.hasText(
'Scan the QR code with your authenticator app. If you currently do not have a device on hand, you can copy the MFA secret below and enter it manually.',
'Correct description renders for single passcode'
);
assert.dom(GENERAL.button('Continue')).exists();
assert.dom(GENERAL.cancelButton).exists();
// Go on to next step
await click(GENERAL.button('Continue'));
assert.dom(GENERAL.title).hasText('Set up MFA TOTP to continue');
assert
.dom(MFA_SELECTORS.subheader)
.hasText('Your organization has enforced MFA TOTP to protect your accounts. Set up to continue.');
assert.dom(MFA_SELECTORS.subtitle).doesNotExist();
assert
.dom(MFA_SELECTORS.description)
.hasText('To verify your device, enter the code generated from your authenticator.');
assert.dom(MFA_SELECTORS.label).hasText('Enter your one-time code');
assert.dom(GENERAL.button('Verify')).exists();
assert.dom(GENERAL.cancelButton).exists('it renders "Cancel" after self-enroll workflow');
});
test('it should render qr code and copy button', async function (assert) {
const clipboardSpy = sinon.stub(navigator.clipboard, 'writeText').resolves();
const totpConstraint = this.server.create('mfa-method', {
type: 'totp',
self_enrollment_enabled: true,
});
const mfaRequirement = this.authService.parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa: { any: [totpConstraint] } },
});
this.setMfaAuthData(mfaRequirement);
await this.renderComponent();
await waitFor(MFA_SELECTORS.qrCode);
assert
.dom(MFA_SELECTORS.mfaForm)
.hasText(
'Set up MFA TOTP to continue Your organization has enforced MFA TOTP to protect your accounts. Set up to continue. Scan the QR code to continue Scan the QR code with your authenticator app. If you currently do not have a device on hand, you can copy the MFA secret below and enter it manually. Or Copy TOTP setup URL For your security, this code is only shown once. Please scan or copy the setup URL into your authenticator app now. Continue Cancel',
'it renders self-enrollment text'
);
assert.dom(MFA_SELECTORS.qrCode).exists('it renders qr code');
assert.dom(GENERAL.cancelButton).exists();
assert.dom(MFA_SELECTORS.verifyForm).doesNotExist('it does not render input field for TOTP code');
assert.dom(GENERAL.button('Verify')).doesNotExist('it does not render Validate button');
await click(GENERAL.copyButton);
assert.strictEqual(clipboardSpy.firstCall.args[0], QR_CODE_URL, 'copy value is qr code URL');
// Restore original clipboard
clipboardSpy.restore(); // cleanup
});
});
});

View File

@ -7,6 +7,14 @@ export interface MfaRequirementApiResponse {
mfa_request_id: string;
mfa_constraints: MfaConstraints;
}
interface MfaTotpSelfEnrollApiResponse {
data: SelfEnrollmentData;
}
interface SelfEnrollmentData {
barcode: string;
url: string;
}
interface MfaConstraint {
type: string;
@ -20,22 +28,39 @@ interface MfaConstraints {
};
}
export interface ParsedMfaRequirement {
mfaRequirement: {
mfa_request_id: string;
mfa_constraints: MfaConstraint[];
};
interface ParsedMfaRequirement {
mfa_request_id: string;
mfa_constraints: ParsedMfaConstraint[];
}
interface MfaMethod {
interface ParsedMfaConstraint {
name: string;
methods: ParsedMfaMethod[];
selectedMethod: ParsedMfaMethod | null;
passcode?: string; // DUMB
}
interface ParsedMfaMethod {
type: string;
id: string;
uses_passcode: boolean;
label: string;
self_enrollment_enabled?: boolean;
}
interface MfaConstraint {
name: string;
methods: MfaMethod[];
selectedMethod: MfaMethod;
interface MfaAuthData {
mfaRequirement: ParsedMfaRequirement;
authMethodType: string;
authMountPath: string;
}
interface MfaConstraintState {
methods: ParsedMfaMethod[];
name: string;
passcode: string;
qrCode?: string;
selectedMethod?: ParsedMfaMethod;
selfEnrollMethod: ParsedMfaMethod | null;
isSatisfied: boolean;
setPasscode(value: string): void;
setSelectedMethod(value: string): void;
}

View File

@ -54,4 +54,10 @@ export default class AuthService extends Service {
authResponse: AuthResponseAuthKey | AuthResponseDataKey,
normalizedProperties: NormalizedProps
): NormalizedAuthData;
totpValidate({
clusterId: string,
mfaRequirement: ParsedMfaRequirement,
authMethodType: string,
authMountPath: string,
}): Promise<AuthSuccessResponse>;
}