From 5c750e4ebb8fc62356d852a6fd3912ae24b2b3dd Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Thu, 18 Sep 2025 13:00:09 -0400 Subject: [PATCH] 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> --- ui/app/components/auth/page.hbs | 58 +- ui/app/components/auth/page.ts | 7 +- ui/app/components/mfa/form/choose-method.hbs | 64 +++ ui/app/components/mfa/form/choose-method.ts | 45 ++ ui/app/components/mfa/form/mfa-field.hbs | 80 +++ ui/app/components/mfa/form/mfa-field.ts | 29 + ui/app/components/mfa/form/self-enroll.hbs | 44 ++ ui/app/components/mfa/form/self-enroll.ts | 47 ++ ui/app/components/mfa/form/verify.hbs | 101 ++++ ui/app/components/mfa/form/verify.ts | 60 +++ ui/app/components/mfa/mfa-form.hbs | 96 +--- ui/app/components/mfa/mfa-form.js | 141 ----- ui/app/components/mfa/mfa-form.ts | 270 ++++++++++ ui/app/components/mfa/mfa-setup-step-one.hbs | 38 +- ui/app/components/mfa/mfa-setup-step-two.hbs | 60 +-- ui/app/components/mfa/qr-code-card.hbs | 20 + ui/app/components/mfa/splash-card.hbs | 66 +++ ui/app/components/splash-page.hbs | 2 +- ui/app/controllers/vault/cluster/mfa-setup.js | 4 + ui/app/models/mfa-method.js | 21 +- ui/app/services/auth.js | 8 +- ui/app/styles/components/splash-page.scss | 8 + .../access/mfa/methods/method/index.hbs | 8 +- ui/app/templates/vault/cluster/mfa-setup.hbs | 49 +- ui/mirage/factories/mfa-method.js | 1 + ui/mirage/factories/mfa-totp-method.js | 1 + ui/mirage/handlers/mfa-login.js | 113 +++- ui/tests/acceptance/mfa-login-test.js | 328 ++++++++++-- ui/tests/helpers/mfa/mfa-selectors.ts | 13 +- .../auth/page/listing-visibility-test.js | 15 +- .../components/auth/page/mfa-test.js | 12 +- .../integration/components/mfa-form-test.js | 304 ----------- .../components/mfa/mfa-form-test.js | 499 ++++++++++++++++++ ui/types/vault/auth/mfa.d.ts | 45 +- ui/types/vault/services/auth.d.ts | 6 + 35 files changed, 1924 insertions(+), 739 deletions(-) create mode 100644 ui/app/components/mfa/form/choose-method.hbs create mode 100644 ui/app/components/mfa/form/choose-method.ts create mode 100644 ui/app/components/mfa/form/mfa-field.hbs create mode 100644 ui/app/components/mfa/form/mfa-field.ts create mode 100644 ui/app/components/mfa/form/self-enroll.hbs create mode 100644 ui/app/components/mfa/form/self-enroll.ts create mode 100644 ui/app/components/mfa/form/verify.hbs create mode 100644 ui/app/components/mfa/form/verify.ts delete mode 100644 ui/app/components/mfa/mfa-form.js create mode 100644 ui/app/components/mfa/mfa-form.ts create mode 100644 ui/app/components/mfa/qr-code-card.hbs create mode 100644 ui/app/components/mfa/splash-card.hbs delete mode 100644 ui/tests/integration/components/mfa-form-test.js create mode 100644 ui/tests/integration/components/mfa/mfa-form-test.js diff --git a/ui/app/components/auth/page.hbs b/ui/app/components/auth/page.hbs index 336d4ae7fa..d238d10bbf 100644 --- a/ui/app/components/auth/page.hbs +++ b/ui/app/components/auth/page.hbs @@ -10,7 +10,7 @@ {{/if}} {{#if this.mfaErrors}} - + +{{else if this.mfaAuthData}} + {{else}} <:header> @@ -40,35 +48,25 @@ <:content> - {{#if this.mfaAuthData}} - - {{else}} - - {{! yielded for accessibility so namespace submits as an input of the
element }} - {{#if (has-feature "Namespaces")}} - - {{/if}} - - {{/if}} + + {{! yielded for accessibility so namespace submits as an input of the element }} + {{#if (has-feature "Namespaces")}} + + {{/if}} + <:footer> diff --git a/ui/app/components/auth/page.ts b/ui/app/components/auth/page.ts index 7ea4ef6a5d..a9bf2189e1 100644 --- a/ui/app/components/auth/page.ts +++ b/ui/app/components/auth/page.ts @@ -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', diff --git a/ui/app/components/mfa/form/choose-method.hbs b/ui/app/components/mfa/form/choose-method.hbs new file mode 100644 index 0000000000..72cf9658e2 --- /dev/null +++ b/ui/app/components/mfa/form/choose-method.hbs @@ -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 }} + + <:additionalContent> + {{#if this.singleConstraint}} + {{#each this.nonSelfEnrollMethods as |method|}} + + {{/each}} + + {{! Only one self-enroll method is supported per constraint right now }} + {{#if this.singleConstraint.selfEnrollMethod}} + +
+ + Or + +
+
+ + {{#let this.singleConstraint.selfEnrollMethod as |method|}} + + {{/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}} +
+ {{/if}} + + {{/each}} + {{/if}} + + + <:actions> + + +
\ No newline at end of file diff --git a/ui/app/components/mfa/form/choose-method.ts b/ui/app/components/mfa/form/choose-method.ts new file mode 100644 index 0000000000..093125e952 --- /dev/null +++ b/ui/app/components/mfa/form/choose-method.ts @@ -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 { + 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; +} diff --git a/ui/app/components/mfa/form/mfa-field.hbs b/ui/app/components/mfa/form/mfa-field.hbs new file mode 100644 index 0000000000..6bb1bd4a4f --- /dev/null +++ b/ui/app/components/mfa/form/mfa-field.hbs @@ -0,0 +1,80 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + +{{#if (eq @fieldType "passcode")}} + {{! PASSCODE INPUT }} + + + {{@label}} + + + +{{else if (eq @fieldType "select")}} + {{! SELECT METHOD DROPDOWN }} + {{#if (gt @constraint.methods.length 1)}} + + Multi-factor Login Enforcement + Select which method you would like to verify with. + + + {{#each @constraint.methods as |method|}} + + {{/each}} + + + {{/if}} + +{{else if (eq @fieldType "verified")}} + {{! PENDING VERIFICATION }} + + {{@label}} + + + Your recently enrolled device will be verified along with any other MFA methods. + + + +{{else if (eq @fieldType "push")}} + {{! PUSH NOTIFICATION }} + + {{@label}} + + + {{#unless @isInvalid}} + + Check device for push notification + + {{/unless}} + +{{/if}} + +{{! Renders method specific validation error }} +{{#if @isInvalid}} + + Validation failed. + +{{/if}} \ No newline at end of file diff --git a/ui/app/components/mfa/form/mfa-field.ts b/ui/app/components/mfa/form/mfa-field.ts new file mode 100644 index 0000000000..62c73c5ad8 --- /dev/null +++ b/ui/app/components/mfa/form/mfa-field.ts @@ -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 { + @action + setPasscode(constraint: MfaConstraintState, e: HTMLElementEvent) { + const { value } = e.target; + constraint.setPasscode(value); + } + + @action + handleSelect(constraint: MfaConstraintState, e: HTMLElementEvent) { + const { value: id } = e.target; + this.args.onSelect(constraint, id); + } +} diff --git a/ui/app/components/mfa/form/self-enroll.hbs b/ui/app/components/mfa/form/self-enroll.hbs new file mode 100644 index 0000000000..c1d9944bef --- /dev/null +++ b/ui/app/components/mfa/form/self-enroll.hbs @@ -0,0 +1,44 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + + + + {{! Step One: Scan QR Code }} + <:qrCode> + {{#unless this.hasScannedQrCode}} + + {{/unless}} + + + {{! Step Two: Validate TOTP }} + <:additionalContent> + {{#if this.hasScannedQrCode}} + + + + {{/if}} + + + <:actions> + {{#if this.hasScannedQrCode}} + + {{else}} + + {{/if}} + + + + \ No newline at end of file diff --git a/ui/app/components/mfa/form/self-enroll.ts b/ui/app/components/mfa/form/self-enroll.ts new file mode 100644 index 0000000000..d0f53f92de --- /dev/null +++ b/ui/app/components/mfa/form/self-enroll.ts @@ -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 { + @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) { + 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); +} diff --git a/ui/app/components/mfa/form/verify.hbs b/ui/app/components/mfa/form/verify.hbs new file mode 100644 index 0000000000..6be9648b69 --- /dev/null +++ b/ui/app/components/mfa/form/verify.hbs @@ -0,0 +1,101 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + + + + <:alerts> + + + + <:additionalContent> +
+ {{#each (this.sortConstraints @constraints) as |constraint index|}} + {{#if index}} +
+ {{/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)}} + + {{/if}} + + {{#if constraint.selectedMethod.uses_passcode}} + {{#if (and (@methodAlreadyEnrolled constraint.selectedMethod.id) (not @error))}} + + {{else}} + + {{/if}} + {{else if (or (eq constraint.methods.length 1) @isLoading)}} + + {{/if}} + + {{/each}} + + + {{#if @countdown}} + + {{/if}} + + + <:actions> + + + {{#if (and (gt this.singleConstraint.methods.length 1) (not @isLoading))}} + + {{else}} + + {{/if}} + + {{#if @countdown}} + + {{@countdown}} + + {{/if}} + + +
\ No newline at end of file diff --git a/ui/app/components/mfa/form/verify.ts b/ui/app/components/mfa/form/verify.ts new file mode 100644 index 0000000000..352bff4b3f --- /dev/null +++ b/ui/app/components/mfa/form/verify.ts @@ -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 { + 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) { + 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); +} diff --git a/ui/app/components/mfa/mfa-form.hbs b/ui/app/components/mfa/mfa-form.hbs index 9e42d4ab5f..0d27ee9cd5 100644 --- a/ui/app/components/mfa/mfa-form.hbs +++ b/ui/app/components/mfa/mfa-form.hbs @@ -3,73 +3,29 @@ SPDX-License-Identifier: BUSL-1.1 }} - - -

- {{this.description}} -

-
- -
- {{#each this.constraints as |constraint index|}} - {{#if index}} -
- {{/if}} - {{#if (gt constraint.methods.length 1)}} - - {{! template-lint-enable no-autofocus-attribute}} -
- {{else if (eq constraint.methods.length 1)}} -

- Check device for push notification -

- {{/if}} - {{/each}} - - {{#if this.countdown}} - - {{/if}} - - {{#if this.countdown}} - - {{this.countdown}} - {{/if}} - -
\ No newline at end of file +{{#if this.fetchQrCode.isRunning}} + +{{else if this.currentSelfEnrollConstraint.qrCode}} + {{! Clicking "Continue" clears this.currentSelfEnrollConstraint.qrCode }} + +{{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 }} + +{{else}} + +{{/if}} \ No newline at end of file diff --git a/ui/app/components/mfa/mfa-form.js b/ui/app/components/mfa/mfa-form.js deleted file mode 100644 index 56e2b95007..0000000000 --- a/ui/app/components/mfa/mfa-form.js +++ /dev/null @@ -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 - * - * ``` - * @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(); - } -} diff --git a/ui/app/components/mfa/mfa-form.ts b/ui/app/components/mfa/mfa-form.ts new file mode 100644 index 0000000000..687fbfab4d --- /dev/null +++ b/ui/app/components/mfa/mfa-form.ts @@ -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 + * + * ``` + * @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 { + @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(); + + 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(); + 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); +} diff --git a/ui/app/components/mfa/mfa-setup-step-one.hbs b/ui/app/components/mfa/mfa-setup-step-one.hbs index 41c00cfe64..4585b293d5 100644 --- a/ui/app/components/mfa/mfa-setup-step-one.hbs +++ b/ui/app/components/mfa/mfa-setup-step-one.hbs @@ -3,19 +3,18 @@ SPDX-License-Identifier: BUSL-1.1 }} -
-

- 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. -

-
+ + + <:alerts> -
+ + + <:additionalContent> + - {{! template-lint-disable no-autofocus-attribute}}

Enter the UUID for your multi-factor authentication method. This can be provided to you by your administrator.

-
+ + - - - - - -
\ No newline at end of file + <:actions> + + + + +
\ No newline at end of file diff --git a/ui/app/components/mfa/mfa-setup-step-two.hbs b/ui/app/components/mfa/mfa-setup-step-two.hbs index 88b9878f59..4fa2179e6e 100644 --- a/ui/app/components/mfa/mfa-setup-step-two.hbs +++ b/ui/app/components/mfa/mfa-setup-step-two.hbs @@ -3,51 +3,27 @@ SPDX-License-Identifier: BUSL-1.1 }} -
-

- 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. -

-
+ + + <:alerts> {{#if @warning}} - + MFA enabled {{@warning}} - {{else}} -
-
- -
-
-
-
-
-

- After you leave this page, this QR code will be removed and - cannot - be regenerated. -

-
-
{{/if}} -
- - -
-
-
\ No newline at end of file + + + <:qrCode> + {{#if @qrCode}} + + {{/if}} + + + <:actions> + + + + + \ No newline at end of file diff --git a/ui/app/components/mfa/qr-code-card.hbs b/ui/app/components/mfa/qr-code-card.hbs new file mode 100644 index 0000000000..790f7c2f18 --- /dev/null +++ b/ui/app/components/mfa/qr-code-card.hbs @@ -0,0 +1,20 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + + + + + + + Or + + + + + + + For your security, this code is only shown once. Please scan or copy the setup URL into your authenticator app now. + + \ No newline at end of file diff --git a/ui/app/components/mfa/splash-card.hbs b/ui/app/components/mfa/splash-card.hbs new file mode 100644 index 0000000000..79c49a314f --- /dev/null +++ b/ui/app/components/mfa/splash-card.hbs @@ -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 }} + + + <:header> + {{#if @renderLogo}} +
+
+ +
+
+ {{/if}} +

{{@header}}

+ {{#if @subheader}} + + {{@subheader}} + + {{/if}} + + + <:content> +
+ + {{#if @isLoading}} + + + + {{else}} + {{#if @subtitle}} + + {{@subtitle}} + + {{/if}} + + {{#if @description}} + + {{@description}} + + {{/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}} +
+ + + <:footer> + + {{yield to="actions"}} + + + +
\ No newline at end of file diff --git a/ui/app/components/splash-page.hbs b/ui/app/components/splash-page.hbs index 77e613ace7..4f33644a67 100644 --- a/ui/app/components/splash-page.hbs +++ b/ui/app/components/splash-page.hbs @@ -3,7 +3,7 @@ SPDX-License-Identifier: BUSL-1.1 }} -
+
diff --git a/ui/app/controllers/vault/cluster/mfa-setup.js b/ui/app/controllers/vault/cluster/mfa-setup.js index 78d727a461..8162e0794a 100644 --- a/ui/app/controllers/vault/cluster/mfa-setup.js +++ b/ui/app/controllers/vault/cluster/mfa-setup.js @@ -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; } diff --git a/ui/app/models/mfa-method.js b/ui/app/models/mfa-method.js index 7598de2a51..5ba150c8c0 100644 --- a/ui/app/models/mfa-method.js +++ b/ui/app/models/mfa-method.js @@ -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); diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index 51ced768e1..663192e414 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -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 }; } diff --git a/ui/app/styles/components/splash-page.scss b/ui/app/styles/components/splash-page.scss index 9185a821ef..453975eee8 100644 --- a/ui/app/styles/components/splash-page.scss +++ b/ui/app/styles/components/splash-page.scss @@ -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%; + } +} diff --git a/ui/app/templates/vault/cluster/access/mfa/methods/method/index.hbs b/ui/app/templates/vault/cluster/access/mfa/methods/method/index.hbs index 2b72ca4fbf..74a4c32b32 100644 --- a/ui/app/templates/vault/cluster/access/mfa/methods/method/index.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/methods/method/index.hbs @@ -21,11 +21,17 @@