diff --git a/changelog/14049.txt b/changelog/14049.txt new file mode 100644 index 000000000..93af683bb --- /dev/null +++ b/changelog/14049.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Adds multi-factor authentication support +``` \ No newline at end of file diff --git a/ui/app/adapters/cluster.js b/ui/app/adapters/cluster.js index 4b52350b1..f86004426 100644 --- a/ui/app/adapters/cluster.js +++ b/ui/app/adapters/cluster.js @@ -126,6 +126,19 @@ export default ApplicationAdapter.extend({ return this.ajax(url, verb, options); }, + mfaValidate({ mfa_request_id, mfa_constraints }) { + const options = { + data: { + mfa_request_id, + mfa_payload: mfa_constraints.reduce((obj, { selectedMethod, passcode }) => { + obj[selectedMethod.id] = passcode ? [passcode] : []; + return obj; + }, {}), + }, + }; + return this.ajax('/v1/sys/mfa/validate', 'POST', options); + }, + urlFor(endpoint) { if (!ENDPOINTS.includes(endpoint)) { throw new Error( diff --git a/ui/app/components/auth-form.js b/ui/app/components/auth-form.js index 5e7f8a7fc..8b1ed8aa8 100644 --- a/ui/app/components/auth-form.js +++ b/ui/app/components/auth-form.js @@ -18,13 +18,13 @@ const BACKENDS = supportedAuthBackends(); * * @example ```js * // All properties are passed in via query params. - * ``` + * ``` * - * @param wrappedToken=null {String} - The auth method that is currently selected in the dropdown. - * @param cluster=null {Object} - The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. - * @param namespace=null {String} - The currently active namespace. - * @param redirectTo=null {String} - The name of the route to redirect to. - * @param selectedAuth=null {String} - The auth method that is currently selected in the dropdown. + * @param {string} wrappedToken - The auth method that is currently selected in the dropdown. + * @param {object} cluster - The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. + * @param {string} namespace- The currently active namespace. + * @param {string} selectedAuth - The auth method that is currently selected in the dropdown. + * @param {function} onSuccess - Fired on auth success */ const DEFAULTS = { @@ -45,7 +45,6 @@ export default Component.extend(DEFAULTS, { selectedAuth: null, methods: null, cluster: null, - redirectTo: null, namespace: null, wrappedToken: null, // internal @@ -206,54 +205,18 @@ export default Component.extend(DEFAULTS, { showLoading: or('isLoading', 'authenticate.isRunning', 'fetchMethods.isRunning', 'unwrapToken.isRunning'), - handleError(e, prefixMessage = true) { - this.set('loading', false); - let errors; - if (e.errors) { - errors = e.errors.map((error) => { - if (error.detail) { - return error.detail; - } - return error; - }); - } else { - errors = [e]; - } - let message = prefixMessage ? 'Authentication failed: ' : ''; - this.set('error', `${message}${errors.join('.')}`); - }, - authenticate: task( waitFor(function* (backendType, data) { let clusterId = this.cluster.id; try { - if (backendType === 'okta') { - this.delayAuthMessageReminder.perform(); - } - let authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data }); - - let { isRoot, namespace } = authResponse; - let transition; - let { redirectTo } = this; - if (redirectTo) { - // reset the value on the controller because it's bound here - this.set('redirectTo', ''); - // here we don't need the namespace because it will be encoded in redirectTo - transition = this.router.transitionTo(redirectTo); - } else { - transition = this.router.transitionTo('vault.cluster', { queryParams: { namespace } }); - } - // returning this w/then because if we keep it - // in the task, it will get cancelled when the component in un-rendered - yield transition.followRedirects().then(() => { - if (isRoot) { - this.flashMessages.warning( - 'You have logged in with a root token. As a security precaution, this root token will not be stored by your browser and you will need to re-authenticate after the window is closed or refreshed.' - ); - } - }); + this.delayAuthMessageReminder.perform(); + const authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data }); + this.onSuccess(authResponse, backendType, data); } catch (e) { - this.handleError(e); + this.set('loading', false); + if (!this.auth.mfaError) { + this.set('error', `Authentication failed: ${this.auth.handleError(e)}`); + } } }) ), @@ -262,9 +225,9 @@ export default Component.extend(DEFAULTS, { if (Ember.testing) { this.showLoading = true; yield timeout(0); - return; + } else { + yield timeout(5000); } - yield timeout(5000); }), actions: { @@ -298,11 +261,10 @@ export default Component.extend(DEFAULTS, { return this.authenticate.unlinked().perform(backend.type, data); }, handleError(e) { - if (e) { - this.handleError(e, false); - } else { - this.set('error', null); - } + this.setProperties({ + loading: false, + error: e ? this.auth.handleError(e) : null, + }); }, }, }); diff --git a/ui/app/components/mfa-error.js b/ui/app/components/mfa-error.js new file mode 100644 index 000000000..ed894a051 --- /dev/null +++ b/ui/app/components/mfa-error.js @@ -0,0 +1,43 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { TOTP_NOT_CONFIGURED } from 'vault/services/auth'; + +const TOTP_NA_MSG = + 'Multi-factor authentication is required, but you have not set it up. In order to do so, please contact your administrator.'; +const MFA_ERROR_MSG = + 'Multi-factor authentication is required, but failed. Go back and try again, or contact your administrator.'; + +export { TOTP_NA_MSG, MFA_ERROR_MSG }; + +/** + * @module MfaError + * MfaError components are used to display mfa errors + * + * @example + * ```js + * + * ``` + */ + +export default class MfaError extends Component { + @service auth; + + get isTotp() { + return this.auth.mfaErrors.includes(TOTP_NOT_CONFIGURED); + } + get title() { + return this.isTotp ? 'TOTP not set up' : 'Unauthorized'; + } + get description() { + return this.isTotp ? TOTP_NA_MSG : MFA_ERROR_MSG; + } + + @action + onClose() { + this.auth.set('mfaErrors', null); + if (this.args.onClose) { + this.args.onClose(); + } + } +} diff --git a/ui/app/components/mfa-form.js b/ui/app/components/mfa-form.js new file mode 100644 index 000000000..6ba69b320 --- /dev/null +++ b/ui/app/components/mfa-form.js @@ -0,0 +1,89 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action, set } from '@ember/object'; +import { task, timeout } from 'ember-concurrency'; +import { numberToWord } from 'vault/helpers/number-to-word'; +/** + * @module MfaForm + * The MfaForm component is used to enter a passcode when mfa is required to login + * + * @example + * ```js + * + * ``` + * @param {string} clusterId - id of selected cluster + * @param {object} authData - data from initial auth request -- { mfa_requirement, backend, data } + * @param {function} onSuccess - fired when passcode passes validation + */ + +export default class MfaForm extends Component { + @service auth; + + @tracked passcode; + @tracked countdown; + @tracked errors; + + get constraints() { + return this.args.authData.mfa_requirement.mfa_constraints; + } + get multiConstraint() { + return this.constraints.length > 1; + } + get singleConstraintMultiMethod() { + return !this.isMultiConstraint && this.constraints[0].methods.length > 1; + } + get singlePasscode() { + return ( + !this.isMultiConstraint && + this.constraints[0].methods.length === 1 && + this.constraints[0].methods[0].uses_passcode + ); + } + get description() { + let base = 'Multi-factor authentication is enabled for your account.'; + if (this.singlePasscode) { + base += ' Enter your authentication code to log in.'; + } + if (this.singleConstraintMultiMethod) { + base += ' Select the MFA method you wish to use.'; + } + if (this.multiConstraint) { + const num = this.constraints.length; + base += ` ${numberToWord(num, true)} methods are required for successful authentication.`; + } + return base; + } + + @task *validate() { + try { + const response = yield this.auth.totpValidate({ + clusterId: this.args.clusterId, + ...this.args.authData, + }); + this.args.onSuccess(response); + } catch (error) { + this.errors = error.errors; + // TODO: update if specific error can be parsed for incorrect passcode + // this.newCodeDelay.perform(); + } + } + + @task *newCodeDelay() { + this.passcode = null; + this.countdown = 30; + while (this.countdown) { + yield timeout(1000); + this.countdown--; + } + } + + @action onSelect(constraint, id) { + set(constraint, 'selectedId', id); + set(constraint, 'selectedMethod', constraint.methods.findBy('id', id)); + } + @action submit(e) { + e.preventDefault(); + this.validate.perform(); + } +} diff --git a/ui/app/controllers/vault/cluster/auth.js b/ui/app/controllers/vault/cluster/auth.js index 103fff827..3e98db58e 100644 --- a/ui/app/controllers/vault/cluster/auth.js +++ b/ui/app/controllers/vault/cluster/auth.js @@ -8,14 +8,19 @@ export default Controller.extend({ clusterController: controller('vault.cluster'), namespaceService: service('namespace'), featureFlagService: service('featureFlag'), - namespaceQueryParam: alias('clusterController.namespaceQueryParam'), + auth: service(), + router: service(), + queryParams: [{ authMethod: 'with', oidcProvider: 'o' }], + + namespaceQueryParam: alias('clusterController.namespaceQueryParam'), wrappedToken: alias('vaultController.wrappedToken'), - authMethod: '', - oidcProvider: '', redirectTo: alias('vaultController.redirectTo'), managedNamespaceRoot: alias('featureFlagService.managedNamespaceRoot'), + authMethod: '', + oidcProvider: '', + get managedNamespaceChild() { let fullParam = this.namespaceQueryParam; let split = fullParam.split('/'); @@ -41,4 +46,39 @@ export default Controller.extend({ this.namespaceService.setNamespace(value, true); this.set('namespaceQueryParam', value); }).restartable(), + + authSuccess({ isRoot, namespace }) { + let transition; + if (this.redirectTo) { + // here we don't need the namespace because it will be encoded in redirectTo + transition = this.router.transitionTo(this.redirectTo); + // reset the value on the controller because it's bound here + this.set('redirectTo', ''); + } else { + transition = this.router.transitionTo('vault.cluster', { queryParams: { namespace } }); + } + transition.followRedirects().then(() => { + if (isRoot) { + this.flashMessages.warning( + 'You have logged in with a root token. As a security precaution, this root token will not be stored by your browser and you will need to re-authenticate after the window is closed or refreshed.' + ); + } + }); + }, + + actions: { + onAuthResponse(authResponse, backend, data) { + const { mfa_requirement } = authResponse; + // mfa methods handled by the backend are validated immediately in the auth service + // if the user must choose between methods or enter passcodes further action is required + if (mfa_requirement) { + this.set('mfaAuthData', { mfa_requirement, backend, data }); + } else { + this.authSuccess(authResponse); + } + }, + onMfaSuccess(authResponse) { + this.authSuccess(authResponse); + }, + }, }); diff --git a/ui/app/helpers/number-to-word.js b/ui/app/helpers/number-to-word.js new file mode 100644 index 000000000..7369ecabd --- /dev/null +++ b/ui/app/helpers/number-to-word.js @@ -0,0 +1,22 @@ +import { helper } from '@ember/component/helper'; + +export function numberToWord(number, capitalize) { + const word = + { + 0: 'zero', + 1: 'one', + 2: 'two', + 3: 'three', + 4: 'four', + 5: 'five', + 6: 'six', + 7: 'seven', + 8: 'eight', + 9: 'nine', + }[number] || number; + return capitalize && typeof word === 'string' ? `${word.charAt(0).toUpperCase()}${word.slice(1)}` : word; +} + +export default helper(function ([number], { capitalize }) { + return numberToWord(number, capitalize); +}); diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index ee1fb727c..b25e50282 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -3,6 +3,7 @@ import { resolve, reject } from 'rsvp'; import { assign } from '@ember/polyfills'; import { isArray } from '@ember/array'; import { computed, get } from '@ember/object'; +import { capitalize } from '@ember/string'; import fetch from 'fetch'; import { getOwner } from '@ember/application'; @@ -14,9 +15,10 @@ import { task, timeout } from 'ember-concurrency'; const TOKEN_SEPARATOR = '☃'; const TOKEN_PREFIX = 'vault-'; const ROOT_PREFIX = '_root_'; +const TOTP_NOT_CONFIGURED = 'TOTP mfa required but not configured'; const BACKENDS = supportedAuthBackends(); -export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX }; +export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX, TOTP_NOT_CONFIGURED }; export default Service.extend({ permissions: service(), @@ -24,6 +26,8 @@ export default Service.extend({ IDLE_TIMEOUT: 3 * 60e3, expirationCalcTS: null, isRenewing: false, + mfaErrors: null, + init() { this._super(...arguments); this.checkForRootToken(); @@ -322,16 +326,98 @@ export default Service.extend({ }); }, + _parseMfaResponse(mfa_requirement) { + // mfa_requirement response comes back in a shape that is not easy to work with + // convert to array of objects and add necessary properties to satisfy the view + if (mfa_requirement) { + const { mfa_request_id, mfa_constraints } = mfa_requirement; + let requiresAction; // if multiple constraints or methods or passcode input is needed further action will be required + const constraints = []; + for (let key in mfa_constraints) { + const methods = mfa_constraints[key].any; + const isMulti = methods.length > 1; + if (isMulti || methods.findBy('uses_passcode')) { + requiresAction = true; + } + // friendly label for display in MfaForm + methods.forEach((m) => { + const typeFormatted = m.type === 'totp' ? m.type.toUpperCase() : capitalize(m.type); + m.label = `${typeFormatted} ${m.uses_passcode ? 'passcode' : 'push notification'}`; + }); + constraints.push({ + name: key, + methods, + selectedMethod: isMulti ? null : methods[0], + }); + } + + return { + mfa_requirement: { mfa_request_id, mfa_constraints: constraints }, + requiresAction, + }; + } + return {}; + }, + async authenticate(/*{clusterId, backend, data}*/) { const [options] = arguments; const adapter = this.clusterAdapter(); + let resp; - let resp = await adapter.authenticate(options); - let authData = await this.persistAuthData(options, resp.auth || resp.data, this.namespaceService.path); + try { + resp = await adapter.authenticate(options); + } catch (e) { + // TODO: check for totp not configured mfa error before throwing + const errors = this.handleError(e); + // stubbing error - verify once API is finalized + if (errors.includes(TOTP_NOT_CONFIGURED)) { + this.set('mfaErrors', errors); + } + throw e; + } + + const { mfa_requirement, requiresAction } = this._parseMfaResponse(resp.auth?.mfa_requirement); + if (mfa_requirement) { + if (requiresAction) { + return { mfa_requirement }; + } + // silently make request to validate endpoint when passcode is not required + try { + resp = await adapter.mfaValidate(mfa_requirement); + } catch (e) { + // it's not clear in the auth-form component whether mfa validation is taking place for non-totp method + // since mfa errors display a screen rather than flash message handle separately + this.set('mfaErrors', this.handleError(e)); + throw e; + } + } + + return this.authSuccess(options, resp.auth || resp.data); + }, + + async totpValidate({ mfa_requirement, ...options }) { + const resp = await this.clusterAdapter().mfaValidate(mfa_requirement); + return this.authSuccess(options, resp.auth || resp.data); + }, + + async authSuccess(options, response) { + const authData = await this.persistAuthData(options, response, this.namespaceService.path); await this.permissions.getPaths.perform(); return authData; }, + handleError(e) { + if (e.errors) { + return e.errors.map((error) => { + if (error.detail) { + return error.detail; + } + return error; + }); + } + return [e]; + }, + getAuthType() { if (!this.authData) return; return this.authData.backend.type; diff --git a/ui/app/styles/components/icon.scss b/ui/app/styles/components/icon.scss index bbb55ec20..c97c63ed6 100644 --- a/ui/app/styles/components/icon.scss +++ b/ui/app/styles/components/icon.scss @@ -51,3 +51,7 @@ margin-right: 4px; } } + +.icon-blue { + color: $blue; +} diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss index 24bb5c391..871de5c9f 100644 --- a/ui/app/styles/core/buttons.scss +++ b/ui/app/styles/core/buttons.scss @@ -237,3 +237,11 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12); padding: $size-8; width: 100%; } + +.icon-button { + background: transparent; + padding: 0; + margin: 0; + border: none; + cursor: pointer; +} diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index 73f31d248..27cb0d244 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -19,6 +19,9 @@ .is-borderless { border: none !important; } +.is-box-shadowless { + box-shadow: none !important; +} .is-relative { position: relative; } @@ -188,6 +191,9 @@ .has-top-margin-xl { margin-top: $spacing-xl; } +.has-top-margin-xxl { + margin-top: $spacing-xxl; +} .has-border-bottom-light { border-radius: 0; border-bottom: 1px solid $grey-light; @@ -204,7 +210,9 @@ ul.bullet { .has-text-semibold { font-weight: $font-weight-semibold; } - +.is-v-centered { + vertical-align: middle; +} .has-text-grey-400 { color: $ui-gray-400; } diff --git a/ui/app/templates/components/mfa-error.hbs b/ui/app/templates/components/mfa-error.hbs new file mode 100644 index 000000000..019c54492 --- /dev/null +++ b/ui/app/templates/components/mfa-error.hbs @@ -0,0 +1,15 @@ +
+ + + +
\ No newline at end of file diff --git a/ui/app/templates/components/mfa-form.hbs b/ui/app/templates/components/mfa-form.hbs new file mode 100644 index 000000000..86baf9901 --- /dev/null +++ b/ui/app/templates/components/mfa-form.hbs @@ -0,0 +1,70 @@ +
+
+

+ {{this.description}} +

+
+ +
+ {{#each this.constraints as |constraint index|}} + {{#if index}} +
+ {{/if}} + {{#if (gt constraint.methods.length 1)}} + +
+ {{/if}} + {{/each}} +
+ {{#if this.newCodeDelay.isRunning}} +
+ +
+ {{/if}} + + {{#if this.newCodeDelay.isRunning}} + + {{this.countdown}} + {{/if}} + +
+ \ No newline at end of file diff --git a/ui/app/templates/components/splash-page.hbs b/ui/app/templates/components/splash-page.hbs index d6970a742..36f115a5d 100644 --- a/ui/app/templates/components/splash-page.hbs +++ b/ui/app/templates/components/splash-page.hbs @@ -10,21 +10,26 @@ - -
-
-
-
- {{yield (hash header=(component "splash-page/splash-header"))}} +{{! bypass UiWizard and container styling }} +{{#if this.hasAltContent}} + {{yield (hash altContent=(component "splash-page/splash-content"))}} +{{else}} + +
+
+
+
+ {{yield (hash header=(component "splash-page/splash-header"))}} +
+
+ {{yield (hash sub-header=(component "splash-page/splash-header"))}} +
+ + {{yield (hash footer=(component "splash-page/splash-content"))}}
-
- {{yield (hash sub-header=(component "splash-page/splash-header"))}} -
- - {{yield (hash footer=(component "splash-page/splash-content"))}}
-
- \ No newline at end of file + +{{/if}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/auth.hbs b/ui/app/templates/vault/cluster/auth.hbs index f37655cae..76dd5f548 100644 --- a/ui/app/templates/vault/cluster/auth.hbs +++ b/ui/app/templates/vault/cluster/auth.hbs @@ -1,84 +1,101 @@ - + + + + {{#if this.oidcProvider}}
{{else}} -

- Sign in to Vault -

+
+ {{#if this.mfaAuthData}} + + {{/if}} +

+ {{if this.mfaAuthData "Authenticate" "Sign in to Vault"}} +

+
{{/if}}
- {{#if this.managedNamespaceRoot}} - - -
-
-
- + {{#unless this.mfaAuthData}} + {{#if this.managedNamespaceRoot}} + + +
+
+
+ +
+
+ /{{this.managedNamespaceRoot}} +
+
+
+
+ +
+
+
-
- /{{this.managedNamespaceRoot}} +
+ + + {{else if (has-feature "Namespaces")}} + + +
+
+
-
-
-
- {{else if (has-feature "Namespaces")}} - - -
-
- -
-
-
-
- -
-
-
-
-
-
- {{/if}} + + + {{/if}} + {{/unless}} - + {{#if this.mfaAuthData}} + + {{else}} + + {{/if}}
diff --git a/ui/lib/core/addon/components/select.js b/ui/lib/core/addon/components/select.js index eb74b4d42..00b4e531c 100644 --- a/ui/lib/core/addon/components/select.js +++ b/ui/lib/core/addon/components/select.js @@ -10,15 +10,16 @@ import layout from '../templates/components/select'; *