diff --git a/ui/app/adapters/mfa-login-enforcement.js b/ui/app/adapters/mfa-login-enforcement.js new file mode 100644 index 000000000..5276df84c --- /dev/null +++ b/ui/app/adapters/mfa-login-enforcement.js @@ -0,0 +1,29 @@ +import ApplicationAdapter from './application'; + +export default class KeymgmtKeyAdapter extends ApplicationAdapter { + namespace = 'v1'; + + pathForType() { + return 'identity/mfa/login-enforcement'; + } + + _saveRecord(store, { modelName }, snapshot) { + const data = store.serializerFor(modelName).serialize(snapshot); + return this.ajax(this.urlForUpdateRecord(snapshot.attr('name'), modelName, snapshot), 'POST', { + data, + }).then(() => data); + } + // create does not return response similar to PUT request + createRecord() { + return this._saveRecord(...arguments); + } + // update record via POST method + updateRecord() { + return this._saveRecord(...arguments); + } + + query(store, type, query) { + const url = this.urlForQuery(query, type.modelName); + return this.ajax(url, 'GET', { data: { list: true } }); + } +} diff --git a/ui/app/adapters/mfa-method.js b/ui/app/adapters/mfa-method.js new file mode 100644 index 000000000..fb5fc310d --- /dev/null +++ b/ui/app/adapters/mfa-method.js @@ -0,0 +1,54 @@ +import ApplicationAdapter from './application'; + +export default class MfaMethodAdapter extends ApplicationAdapter { + namespace = 'v1'; + + pathForType() { + return 'identity/mfa/method'; + } + + createOrUpdate(store, type, snapshot) { + const data = store.serializerFor(type.modelName).serialize(snapshot); + const { id } = snapshot; + return this.ajax(this.buildURL(type.modelName, id, snapshot, 'POST'), 'POST', { + data, + }).then((res) => { + // TODO: Check how 204's are handled by ember + return { + data: { + ...data, + id: res?.data?.method_id || id, + }, + }; + }); + } + + createRecord() { + return this.createOrUpdate(...arguments); + } + + updateRecord() { + return this.createOrUpdate(...arguments); + } + + urlForDeleteRecord(id, modelName, snapshot) { + return this.buildURL(modelName, id, snapshot, 'POST'); + } + + query(store, type, query) { + const url = this.urlForQuery(query, type.modelName); + return this.ajax(url, 'GET', { + data: { + list: true, + }, + }); + } + + buildURL(modelName, id, snapshot, requestType) { + if (requestType === 'POST') { + let url = `${super.buildURL(modelName)}/${snapshot.attr('type')}`; + return id ? `${url}/${id}` : url; + } + return super.buildURL(...arguments); + } +} diff --git a/ui/app/adapters/mfa-setup.js b/ui/app/adapters/mfa-setup.js new file mode 100644 index 000000000..c22561ef2 --- /dev/null +++ b/ui/app/adapters/mfa-setup.js @@ -0,0 +1,13 @@ +import ApplicationAdapter from './application'; + +export default class MfaSetupAdapter extends ApplicationAdapter { + adminGenerate(data) { + let url = `/v1/identity/mfa/method/totp/admin-generate`; + return this.ajax(url, 'POST', { data }); + } + + adminDestroy(data) { + let url = `/v1/identity/mfa/method/totp/admin-destroy`; + return this.ajax(url, 'POST', { data }); + } +} diff --git a/ui/app/components/auth-info.js b/ui/app/components/auth-info.js index 1d52f69ad..1ca7f9cef 100644 --- a/ui/app/components/auth-info.js +++ b/ui/app/components/auth-info.js @@ -20,8 +20,13 @@ export default class AuthInfoComponent extends Component { @service wizard; @service router; - @tracked - fakeRenew = false; + @tracked fakeRenew = false; + + get hasEntityId() { + // root users will not have an entity_id because they are not associated with an entity. + // in order to use the MFA end user setup they need an entity_id + return !!this.auth.authData.entity_id; + } get isRenewing() { return this.fakeRenew || this.auth.isRenewing; diff --git a/ui/app/components/mfa-form.js b/ui/app/components/mfa-form.js index b88410580..e83e835a4 100644 --- a/ui/app/components/mfa-form.js +++ b/ui/app/components/mfa-form.js @@ -15,9 +15,10 @@ import { numberToWord } from 'vault/helpers/number-to-word'; * @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 + * @param {function} onError - fired for multi-method or non-passcode method validation errors */ -export const VALIDATION_ERROR = +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 { @@ -25,6 +26,18 @@ export default class MfaForm extends Component { @tracked countdown; @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.findBy('uses_passcode'); + }); + if (!passcodeOrSelect.length) { + this.validate.perform(); + } + } get constraints() { return this.args.authData.mfa_requirement.mfa_constraints; @@ -66,19 +79,26 @@ export default class MfaForm extends Component { }); this.args.onSuccess(response); } catch (error) { - const codeUsed = (error.errors || []).find((e) => e.includes('code already used;')); - if (codeUsed) { - // parse validity period from error string to initialize countdown - const seconds = parseInt(codeUsed.split('in ')[1].split(' seconds')[0]); - this.newCodeDelay.perform(seconds); + 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.error = VALIDATION_ERROR; + this.args.onError(this.auth.handleError(error)); } } } - @task *newCodeDelay(timePeriod) { - this.countdown = timePeriod; + @task *newCodeDelay(message) { + // parse validity period from error string to initialize countdown + this.countdown = parseInt(message.match(/(\d\w seconds)/)[0].split(' ')[0]); while (this.countdown) { yield timeout(1000); this.countdown--; diff --git a/ui/app/components/mfa-login-enforcement-form.js b/ui/app/components/mfa-login-enforcement-form.js new file mode 100644 index 000000000..ef130f0af --- /dev/null +++ b/ui/app/components/mfa-login-enforcement-form.js @@ -0,0 +1,170 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; + +/** + * @module MfaLoginEnforcementForm + * MfaLoginEnforcementForm components are used to create and edit login enforcements + * + * @example + * ```js + * + * ``` + * @callback onSave + * @callback onClose + * @param {Object} model - login enforcement model + * @param {Object} [isInline] - toggles inline display of form -- method selector and actions are hidden and should be handled externally + * @param {Object} [modelErrors] - model validations state object if handling actions externally when displaying inline + * @param {onSave} [onSave] - triggered on save success + * @param {onClose} [onClose] - triggered on cancel + */ + +export default class MfaLoginEnforcementForm extends Component { + @service store; + @service flashMessages; + + targetTypes = [ + { label: 'Authentication mount', type: 'accessor', key: 'auth_method_accessors' }, + { label: 'Authentication method', type: 'method', key: 'auth_method_types' }, + { label: 'Group', type: 'identity/group', key: 'identity_groups' }, + { label: 'Entity', type: 'identity/entity', key: 'identity_entities' }, + ]; + searchSelectOptions = null; + + @tracked name; + @tracked targets = []; + @tracked selectedTargetType = 'accessor'; + @tracked selectedTargetValue = null; + @tracked searchSelect = { + options: [], + selected: [], + }; + @tracked authMethods = []; + @tracked modelErrors; + + constructor() { + super(...arguments); + // aggregate different target array properties on model into flat list + this.flattenTargets(); + // eagerly fetch identity groups and entities for use as search select options + this.resetTargetState(); + // only auth method types that have mounts can be selected as targets -- fetch from sys/auth and map by type + this.fetchAuthMethods(); + } + + async flattenTargets() { + for (let { label, key } of this.targetTypes) { + const targetArray = await this.args.model[key]; + const targets = targetArray.map((value) => ({ label, key, value })); + this.targets.addObjects(targets); + } + } + async resetTargetState() { + this.selectedTargetValue = null; + const options = this.searchSelectOptions || {}; + if (!this.searchSelectOptions) { + const types = ['identity/group', 'identity/entity']; + for (const type of types) { + try { + options[type] = (await this.store.query(type, {})).toArray(); + } catch (error) { + options[type] = []; + } + } + this.searchSelectOptions = options; + } + if (this.selectedTargetType.includes('identity')) { + this.searchSelect = { + selected: [], + options: [...options[this.selectedTargetType]], + }; + } + } + async fetchAuthMethods() { + const mounts = (await this.store.findAll('auth-method')).toArray(); + this.authMethods = mounts.mapBy('type'); + } + + get selectedTarget() { + return this.targetTypes.findBy('type', this.selectedTargetType); + } + get errors() { + return this.args.modelErrors || this.modelErrors; + } + + @task + *save() { + this.modelErrors = {}; + // check validity state first and abort if invalid + const { isValid, state } = this.args.model.validate(); + if (!isValid) { + this.modelErrors = state; + } else { + try { + yield this.args.model.save(); + this.args.onSave(); + } catch (error) { + const message = error.errors ? error.errors.join('. ') : error.message; + this.flashMessages.danger(message); + } + } + } + + @action + async onMethodChange(selectedIds) { + const methods = await this.args.model.mfa_methods; + // first check for existing methods that have been removed from selection + methods.forEach((method) => { + if (!selectedIds.includes(method.id)) { + methods.removeObject(method); + } + }); + // now check for selected items that don't exist and add them to the model + const methodIds = methods.mapBy('id'); + selectedIds.forEach((id) => { + if (!methodIds.includes(id)) { + const model = this.store.peekRecord('mfa-method', id); + methods.addObject(model); + } + }); + } + @action + onTargetSelect(type) { + this.selectedTargetType = type; + this.resetTargetState(); + } + @action + setTargetValue(selected) { + const { type } = this.selectedTarget; + if (type.includes('identity')) { + // for identity groups and entities grab model from store as value + this.selectedTargetValue = this.store.peekRecord(type, selected[0]); + } else { + this.selectedTargetValue = selected; + } + } + @action + addTarget() { + const { label, key } = this.selectedTarget; + const value = this.selectedTargetValue; + this.targets.addObject({ label, value, key }); + // add target to appropriate model property + this.args.model[key].addObject(value); + this.selectedTargetValue = null; + this.resetTargetState(); + } + @action + removeTarget(target) { + this.targets.removeObject(target); + // remove target from appropriate model property + this.args.model[target.key].removeObject(target.value); + } + @action + cancel() { + // revert model changes + this.args.model.rollbackAttributes(); + this.args.onClose(); + } +} diff --git a/ui/app/components/mfa-login-enforcement-header.js b/ui/app/components/mfa-login-enforcement-header.js new file mode 100644 index 000000000..5b3449cef --- /dev/null +++ b/ui/app/components/mfa-login-enforcement-header.js @@ -0,0 +1,52 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +/** + * @module MfaLoginEnforcementHeader + * MfaLoginEnforcementHeader components are used to display information when creating and editing login enforcements + * + * @example + * ```js + * + * + * ``` + * @callback onRadioCardSelect + * @callback onEnforcementSelect + * @param {boolean} [isInline] - toggle component display when used inline with mfa method form -- overrides heading and shows radio cards and enforcement select + * @param {string} [heading] - page heading to display outside of inline mode + * @param {string} [radioCardGroupValue] - selected value of the radio card group in inline mode -- new, existing or skip are the accepted values + * @param {onRadioCardSelect} [onRadioCardSelect] - change event triggered on radio card select + * @param {onEnforcementSelect} [onEnforcementSelect] - change event triggered on enforcement select when radioCardGroupValue is set to existing + */ + +export default class MfaLoginEnforcementHeaderComponent extends Component { + @service store; + + constructor() { + super(...arguments); + if (this.args.isInline) { + this.fetchEnforcements(); + } + } + + @tracked enforcements = []; + + async fetchEnforcements() { + try { + // cache initial values for lookup in select handler + this._enforcements = (await this.store.query('mfa-login-enforcement', {})).toArray(); + this.enforcements = [...this._enforcements]; + } catch (error) { + this.enforcements = []; + } + } + + @action + onEnforcementSelect([name]) { + // search select returns array of strings, in this case enforcement name + // lookup model and pass to callback + this.args.onEnforcementSelect(this._enforcements.findBy('name', name)); + } +} diff --git a/ui/app/components/mfa-setup-step-one.js b/ui/app/components/mfa-setup-step-one.js new file mode 100644 index 000000000..52b533b8c --- /dev/null +++ b/ui/app/components/mfa-setup-step-one.js @@ -0,0 +1,77 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + +/** + * @module MfaSetupStepOne + * MfaSetupStepOne component is a child component used in the end user setup for MFA. It records the UUID (aka method_id) and sends a admin-generate request. + * + * @param {string} entityId - the entityId of the user. This comes from the auth service which records it on loading of the cluster. A root user does not have an entityId. + * @param {function} isUUIDVerified - a function that consumes a boolean. Is true if the admin-generate is successful and false if it throws a warning or error. + * @param {boolean} restartFlow - a boolean that is true that is true if the user should proceed to step two or false if they should stay on step one. + * @param {function} saveUUIDandQrCode - A function that sends the inputted UUID and return qrCode from step one to the parent. + * @param {boolean} showWarning - whether a warning is returned from the admin-generate query. Needs to be passed to step two. + */ + +export default class MfaSetupStepOne extends Component { + @service store; + @tracked error = ''; + @tracked warning = ''; + @tracked qrCode = ''; + + @action + redirectPreviousPage() { + this.args.restartFlow(); + window.history.back(); + } + + @action + async verifyUUID(evt) { + evt.preventDefault(); + let response = await this.postAdminGenerate(); + + if (response === 'stop_progress') { + this.args.isUUIDVerified(false); + } else if (response === 'reset_method') { + this.args.showWarning(this.warning); + } else { + this.args.isUUIDVerified(true); + } + } + + async postAdminGenerate() { + this.error = ''; + this.warning = ''; + let adapter = this.store.adapterFor('mfa-setup'); + let response; + + try { + response = await adapter.adminGenerate({ + entity_id: this.args.entityId, + method_id: this.UUID, // comes from value on the input + }); + this.args.saveUUIDandQrCode(this.UUID, response.data?.url); + // if there was a warning it won't fail but needs to be handled here and the flow needs to be interrupted + let warnings = response.warnings || []; + if (warnings.length > 0) { + this.UUID = ''; // clear UUID + const alreadyGenerated = warnings.find((w) => + w.includes('Entity already has a secret for MFA method') + ); + if (alreadyGenerated) { + this.warning = + 'A QR code has already been generated, scanned, and MFA set up for this entity. If a new code is required, contact your administrator.'; + return 'reset_method'; + } + this.warning = warnings; // in case other kinds of warnings comes through. + return 'reset_method'; + } + } catch (error) { + this.UUID = ''; // clear the UUID + this.error = error.errors; + return 'stop_progress'; + } + return response; + } +} diff --git a/ui/app/components/mfa-setup-step-two.js b/ui/app/components/mfa-setup-step-two.js new file mode 100644 index 000000000..d81765160 --- /dev/null +++ b/ui/app/components/mfa-setup-step-two.js @@ -0,0 +1,40 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +/** + * @module MfaSetupStepTwo + * MfaSetupStepTwo component is a child component used in the end user setup for MFA. It displays a qrCode or a warning and allows a user to reset the method. + * + * @param {string} entityId - the entityId of the user. This comes from the auth service which records it on loading of the cluster. A root user does not have an entityId. + * @param {string} uuid - the UUID that is entered in the input on step one. + * @param {string} qrCode - the returned url from the admin-generate post. Used to create the qrCode. + * @param {boolean} restartFlow - a boolean that is true that is true if the user should proceed to step two or false if they should stay on step one. + * @param {string} warning - if there is a warning returned from the admin-generate post then it's sent to the step two component in this param. + */ + +export default class MfaSetupStepTwo extends Component { + @service store; + + @action + redirectPreviousPage() { + this.args.restartFlow(); + window.history.back(); + } + + @action + async restartSetup() { + this.error = null; + let adapter = this.store.adapterFor('mfa-setup'); + try { + await adapter.adminDestroy({ + entity_id: this.args.entityId, + method_id: this.args.uuid, + }); + } catch (error) { + this.error = error.errors; + return 'stop_progress'; + } + this.args.restartFlow(); + } +} diff --git a/ui/app/components/mfa/method-form.js b/ui/app/components/mfa/method-form.js new file mode 100644 index 000000000..63cf929b5 --- /dev/null +++ b/ui/app/components/mfa/method-form.js @@ -0,0 +1,52 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; + +/** + * MfaMethodForm component + * + * @example + * ```js + * + * ``` + * @param {Object} model - MFA method model + * @param {boolean} [hasActions] - whether the action buttons will be rendered or not + * @param {onSave} [onSave] - callback when save is successful + * @param {onClose} [onClose] - callback when cancel is triggered + */ +export default class MfaMethodForm extends Component { + @service store; + @service flashMessages; + + @tracked editValidations; + @tracked isEditModalActive = false; + + @task + *save() { + try { + yield this.args.model.save(); + this.args.onSave(); + } catch (e) { + this.flashMessages.danger(e.errors?.join('. ') || e.message); + } + } + + @action + async initSave(e) { + e.preventDefault(); + const { isValid, state } = await this.args.model.validate(); + if (isValid) { + this.isEditModalActive = true; + } else { + this.editValidations = state; + } + } + + @action + cancel() { + this.args.model.rollbackAttributes(); + this.args.onClose(); + } +} diff --git a/ui/app/components/mount-accessor-select.js b/ui/app/components/mount-accessor-select.js index 3e95fd29b..5c57bac0f 100644 --- a/ui/app/components/mount-accessor-select.js +++ b/ui/app/components/mount-accessor-select.js @@ -8,6 +8,8 @@ export default Component.extend({ // Public API //value for the external mount selector value: null, + filterToken: false, + noDefault: false, onChange: () => {}, init() { @@ -17,8 +19,9 @@ export default Component.extend({ authMethods: task(function* () { let methods = yield this.store.findAll('auth-method'); - if (!this.value) { + if (!this.value && !this.noDefault) { this.set('value', methods.get('firstObject.accessor')); + this.onChange(this.value); } return methods; }).drop(), diff --git a/ui/app/components/splash-page.js b/ui/app/components/splash-page.js index a8966ae67..5942d3ab8 100644 --- a/ui/app/components/splash-page.js +++ b/ui/app/components/splash-page.js @@ -7,6 +7,7 @@ export default Component.extend({ auth: service(), store: service(), tagName: '', + showTruncatedNavBar: true, activeCluster: computed('auth.activeCluster', function () { return this.store.peekRecord('cluster', this.auth.activeCluster); diff --git a/ui/app/controllers/vault/cluster/access/mfa/enforcements/enforcement/index.js b/ui/app/controllers/vault/cluster/access/mfa/enforcements/enforcement/index.js new file mode 100644 index 000000000..202c988c3 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/mfa/enforcements/enforcement/index.js @@ -0,0 +1,27 @@ +import Controller from '@ember/controller'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class MfaLoginEnforcementIndexController extends Controller { + @service router; + @service flashMessages; + + queryParams = ['tab']; + tab = 'targets'; + + @tracked showDeleteConfirmation = false; + @tracked deleteError; + + @action + async delete() { + try { + await this.model.destroyRecord(); + this.showDeleteConfirmation = false; + this.flashMessages.success('MFA login enforcement deleted successfully'); + this.router.transitionTo('vault.cluster.access.mfa.enforcements'); + } catch (error) { + this.deleteError = error; + } + } +} diff --git a/ui/app/controllers/vault/cluster/access/mfa/enforcements/index.js b/ui/app/controllers/vault/cluster/access/mfa/enforcements/index.js new file mode 100644 index 000000000..a7fae5de3 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/mfa/enforcements/index.js @@ -0,0 +1,6 @@ +import Controller from '@ember/controller'; + +export default class MfaEnforcementListController extends Controller { + queryParams = ['page']; + page = 1; +} diff --git a/ui/app/controllers/vault/cluster/access/mfa/methods.js b/ui/app/controllers/vault/cluster/access/mfa/methods.js new file mode 100644 index 000000000..b24fe242e --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/mfa/methods.js @@ -0,0 +1,9 @@ +import Controller from '@ember/controller'; + +export default class MfaMethodsListController extends Controller { + queryParams = { + page: 'page', + }; + + page = 1; +} diff --git a/ui/app/controllers/vault/cluster/access/mfa/methods/create.js b/ui/app/controllers/vault/cluster/access/mfa/methods/create.js new file mode 100644 index 000000000..1af4032fb --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/mfa/methods/create.js @@ -0,0 +1,127 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { capitalize } from '@ember/string'; +import { task } from 'ember-concurrency'; + +export default class MfaMethodCreateController extends Controller { + @service flashMessages; + @service router; + + queryParams = ['type']; + methodNames = ['TOTP', 'Duo', 'Okta', 'PingID']; + + @tracked type = null; + @tracked method = null; + @tracked enforcement; + @tracked enforcementPreference = 'new'; + @tracked methodErrors; + @tracked enforcementErrors; + + get description() { + if (this.type === 'totp') { + return `Once set up, TOTP requires a passcode to be presented alongside a Vault token when invoking an API request. + The passcode will be validated against the TOTP key present in the identity of the caller in Vault.`; + } + return `Once set up, the ${this.formattedType} MFA method will require a push confirmation on mobile before login.`; + } + + get formattedType() { + if (!this.type) return ''; + return this.type === 'totp' ? this.type.toUpperCase() : capitalize(this.type); + } + get isTotp() { + return this.type === 'totp'; + } + get showForms() { + return this.type && this.method; + } + + @action + onTypeSelect(type) { + // set any form related properties to default values + this.method = null; + this.enforcement = null; + this.methodErrors = null; + this.enforcementErrors = null; + this.enforcementPreference = 'new'; + this.type = type; + } + @action + createModels() { + if (this.method) { + this.method.unloadRecord(); + } + if (this.enforcement) { + this.enforcement.unloadRecord(); + } + this.method = this.store.createRecord('mfa-method', { type: this.type }); + this.enforcement = this.store.createRecord('mfa-login-enforcement'); + } + @action + onEnforcementPreferenceChange(preference) { + if (preference === 'new') { + this.enforcement = this.store.createRecord('mfa-login-enforcement'); + } else if (this.enforcement) { + this.enforcement.unloadRecord(); + this.enforcement = null; + } + this.enforcementPreference = preference; + } + @action + cancel() { + this.method = null; + this.enforcement = null; + this.enforcementPreference = null; + this.router.transitionTo('vault.cluster.access.mfa.methods'); + } + @task + *save() { + const isValid = this.checkValidityState(); + if (isValid) { + try { + // first save method + yield this.method.save(); + if (this.enforcement) { + this.enforcement.mfa_methods.addObject(this.method); + try { + // now save enforcement and catch error separately + yield this.enforcement.save(); + } catch (error) { + this.handleError( + error, + 'Error saving enforcement. You can still create an enforcement separately and add this method to it.' + ); + } + } + this.router.transitionTo('vault.cluster.access.mfa.methods.method', this.method.id); + } catch (error) { + this.handleError(error, 'Error saving method'); + } + } + } + checkValidityState() { + // block saving models if either is in an invalid state + let isEnforcementValid = true; + const methodValidations = this.method.validate(); + if (!methodValidations.isValid) { + this.methodErrors = methodValidations.state; + } + // only validate enforcement if creating new + if (this.enforcementPreference === 'new') { + const enforcementValidations = this.enforcement.validate(); + // since we are adding the method after it has been saved ignore mfa_methods validation state + const { name, targets } = enforcementValidations.state; + isEnforcementValid = name.isValid && targets.isValid; + if (!enforcementValidations.isValid) { + this.enforcementErrors = enforcementValidations.state; + } + } + return methodValidations.isValid && isEnforcementValid; + } + handleError(error, message) { + const errorMessage = error?.errors ? `${message}: ${error.errors.join(', ')}` : message; + this.flashMessages.danger(errorMessage); + } +} diff --git a/ui/app/controllers/vault/cluster/access/mfa/methods/method/index.js b/ui/app/controllers/vault/cluster/access/mfa/methods/method/index.js new file mode 100644 index 000000000..99c762c4d --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/mfa/methods/method/index.js @@ -0,0 +1,22 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class MfaMethodController extends Controller { + @service router; + @service flashMessages; + + queryParams = ['tab']; + tab = 'config'; + + @action + async deleteMethod() { + try { + await this.model.method.destroyRecord(); + this.flashMessages.success('MFA method deleted successfully deleted.'); + this.router.transitionTo('vault.cluster.access.mfa.methods'); + } catch (error) { + this.flashMessages.danger(`There was an error deleting this MFA method.`); + } + } +} diff --git a/ui/app/controllers/vault/cluster/auth.js b/ui/app/controllers/vault/cluster/auth.js index 0959cbb92..41eb3dcb6 100644 --- a/ui/app/controllers/vault/cluster/auth.js +++ b/ui/app/controllers/vault/cluster/auth.js @@ -69,8 +69,7 @@ export default Controller.extend({ 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 an mfa requirement exists further action is required if (mfa_requirement) { this.set('mfaAuthData', { mfa_requirement, backend, data }); } else { @@ -81,8 +80,10 @@ export default Controller.extend({ this.authSuccess(authResponse); }, onMfaErrorDismiss() { - this.set('mfaAuthData', null); - this.auth.set('mfaErrors', null); + this.setProperties({ + mfaAuthData: null, + mfaErrors: null, + }); }, }, }); diff --git a/ui/app/controllers/vault/cluster/mfa-setup.js b/ui/app/controllers/vault/cluster/mfa-setup.js new file mode 100644 index 000000000..54c250cc1 --- /dev/null +++ b/ui/app/controllers/vault/cluster/mfa-setup.js @@ -0,0 +1,43 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + +export default class VaultClusterMfaSetupController extends Controller { + @service auth; + @tracked onStep = 1; + @tracked warning = ''; + @tracked uuid = ''; + @tracked qrCode = ''; + + get entityId() { + return this.auth.authData.entity_id; + } + + @action isUUIDVerified(verified) { + this.warning = ''; // clear the warning, otherwise it persists. + if (verified) { + this.onStep = 2; + } else { + this.restartFlow(); + } + } + + @action + restartFlow() { + this.onStep = 1; + } + + @action + saveUUIDandQrCode(uuid, qrCode) { + // qrCode could be an empty string if the admin-generate was not successful + this.uuid = uuid; + this.qrCode = qrCode; + } + + @action + showWarning(warning) { + this.warning = warning; + this.onStep = 2; + } +} diff --git a/ui/app/helpers/img-path.js b/ui/app/helpers/img-path.js new file mode 100644 index 000000000..3a806bb20 --- /dev/null +++ b/ui/app/helpers/img-path.js @@ -0,0 +1,6 @@ +import { helper } from '@ember/component/helper'; +import ENV from 'vault/config/environment'; + +export default helper(function ([path]) { + return path.replace(/^~\//, `${ENV.rootURL}images/`); +}); diff --git a/ui/app/models/mfa-login-enforcement.js b/ui/app/models/mfa-login-enforcement.js new file mode 100644 index 000000000..27374bd0e --- /dev/null +++ b/ui/app/models/mfa-login-enforcement.js @@ -0,0 +1,106 @@ +import Model, { attr, hasMany } from '@ember-data/model'; +import ArrayProxy from '@ember/array/proxy'; +import PromiseProxyMixin from '@ember/object/promise-proxy-mixin'; +import { methods } from 'vault/helpers/mountable-auth-methods'; +import { withModelValidations } from 'vault/decorators/model-validations'; +import { isPresent } from '@ember/utils'; + +const validations = { + name: [{ type: 'presence', message: 'Name is required' }], + mfa_methods: [{ type: 'presence', message: 'At least one MFA method is required' }], + targets: [ + { + validator(model) { + // avoid async fetch of records here and access relationship ids to check for presence + const entityIds = model.hasMany('identity_entities').ids(); + const groupIds = model.hasMany('identity_groups').ids(); + return ( + isPresent(model.auth_method_accessors) || + isPresent(model.auth_method_types) || + isPresent(entityIds) || + isPresent(groupIds) + ); + }, + message: + "At least one target is required. If you've selected one, click 'Add' to make sure it's added to this enforcement.", + }, + ], +}; +@withModelValidations(validations) +export default class MfaLoginEnforcementModel extends Model { + @attr('string') name; + @hasMany('mfa-method') mfa_methods; + @attr('string') namespace_id; + @attr('array', { defaultValue: () => [] }) auth_method_accessors; // ["auth_approle_17a552c6"] + @attr('array', { defaultValue: () => [] }) auth_method_types; // ["userpass"] + @hasMany('identity/entity') identity_entities; + @hasMany('identity/group') identity_groups; + + get targets() { + return ArrayProxy.extend(PromiseProxyMixin).create({ + promise: this.prepareTargets(), + }); + } + + async prepareTargets() { + const mountableMethods = methods(); // use for icon lookup + let authMethods; + const targets = []; + + if (this.auth_method_accessors.length || this.auth_method_types.length) { + // fetch all auth methods and lookup by accessor to get mount path and type + try { + const { data } = await this.store.adapterFor('auth-method').findAll(); + authMethods = Object.keys(data).map((key) => ({ path: key, ...data[key] })); + } catch (error) { + // swallow this error + } + } + + if (this.auth_method_accessors.length) { + const selectedAuthMethods = authMethods.filter((model) => { + return this.auth_method_accessors.includes(model.accessor); + }); + targets.addObjects( + selectedAuthMethods.map((method) => { + const mount = mountableMethods.findBy('type', method.type); + const icon = mount.glyph || mount.type; + return { + icon, + link: 'vault.cluster.access.method', + linkModels: [method.path.slice(0, -1)], + title: method.path, + subTitle: method.accessor, + }; + }) + ); + } + + this.auth_method_types.forEach((type) => { + const mount = mountableMethods.findBy('type', type); + const icon = mount.glyph || mount.type; + const mountCount = authMethods.filterBy('type', type).length; + targets.addObject({ + key: 'auth_method_types', + icon, + title: type, + subTitle: `All ${type} mounts (${mountCount})`, + }); + }); + + for (const key of ['identity_entities', 'identity_groups']) { + (await this[key]).forEach((model) => { + targets.addObject({ + key, + icon: 'user', + link: 'vault.cluster.access.identity.show', + linkModels: [key.split('_')[1], model.id, 'details'], + title: model.name, + subTitle: model.id, + }); + }); + } + + return targets; + } +} diff --git a/ui/app/models/mfa-method.js b/ui/app/models/mfa-method.js new file mode 100644 index 000000000..7c4729d22 --- /dev/null +++ b/ui/app/models/mfa-method.js @@ -0,0 +1,168 @@ +import Model, { attr } from '@ember-data/model'; +import { capitalize } from '@ember/string'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; +import { withModelValidations } from 'vault/decorators/model-validations'; +import { isPresent } from '@ember/utils'; + +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'], + pingid: [ + 'username_format', + 'settings_file_base64', + 'use_signature', + 'idp_url', + 'admin_url', + 'authenticator_url', + 'org_alias', + ], +}; + +const REQUIRED_PROPS = { + duo: ['secret_key', 'integration_key', 'api_hostname'], + okta: ['org_name', 'api_token'], + totp: ['issuer'], + pingid: ['settings_file_base64'], +}; + +const validators = Object.keys(REQUIRED_PROPS).reduce((obj, type) => { + REQUIRED_PROPS[type].forEach((prop) => { + obj[`${prop}`] = [ + { + message: `${prop.replace(/_/g, ' ')} is required`, + validator(model) { + return model.type === type ? isPresent(model[prop]) : true; + }, + }, + ]; + }); + return obj; +}, {}); + +@withModelValidations(validators) +export default class MfaMethod extends Model { + // common + @attr('string') type; + @attr('string', { + label: 'Username format', + subText: 'How to map identity names to MFA method names. ', + }) + username_format; + @attr('string', { + label: 'Namespace', + }) + namespace_id; + @attr('string') mount_accessor; + + // PING ID + @attr('string', { + label: 'Settings file', + subText: 'A base-64 encoded third party setting file retrieved from the PingIDs configuration page.', + }) + settings_file_base64; + @attr('boolean') use_signature; + @attr('string') idp_url; + @attr('string') admin_url; + @attr('string') authenticator_url; + @attr('string') org_alias; + + // OKTA + @attr('string', { + label: 'Organization name', + subText: 'Name of the organization to be used in the Okta API.', + }) + org_name; + @attr('string', { + label: 'Okta API key', + }) + api_token; + @attr('string', { + label: 'Base URL', + subText: + 'If set, will be used as the base domain for API requests. Example are okta.com, oktapreview.com and okta-emea.com.', + }) + base_url; + @attr('boolean') primary_email; + + // DUO + @attr('string', { + label: 'Duo secret key', + sensitive: true, + }) + secret_key; + @attr('string', { + label: 'Duo integration key', + sensitive: true, + }) + integration_key; + @attr('string', { + label: 'Duo API hostname', + }) + api_hostname; + @attr('string', { + label: 'Duo push information', + subText: 'Additional information displayed to the user when the push is presented to them.', + }) + push_info; + @attr('boolean', { + label: 'Passcode reminder', + subText: 'If this is turned on, the user is reminded to use the passcode upon MFA validation.', + }) + use_passcode; + + // TOTP + @attr('string', { + label: 'Issuer', + subText: 'The human-readable name of the keys issuing organization.', + }) + issuer; + @attr({ + label: 'Period', + editType: 'ttl', + subText: 'How long each generated TOTP is valid.', + }) + period; + @attr('number', { + label: 'Key size', + subText: 'The size in bytes of the Vault generated key.', + }) + key_size; + @attr('number', { + label: 'QR size', + subText: 'The pixel size of the generated square QR code.', + }) + qr_size; + @attr('string', { + label: 'Algorithm', + possibleValues: ['SHA1', 'SHA256', 'SHA512'], + subText: 'The hashing algorithm used to generate the TOTP code.', + }) + algorithm; + @attr('number', { + label: 'Digits', + possibleValues: [6, 8], + subText: 'The number digits in the generated TOTP code.', + }) + digits; + @attr('number', { + label: 'Skew', + possibleValues: [0, 1], + subText: 'The number of delay periods allowed when validating a TOTP token.', + }) + skew; + @attr('number') max_validation_attempts; + + get name() { + return this.type === 'totp' ? this.type.toUpperCase() : capitalize(this.type); + } + + get formFields() { + return [...METHOD_PROPS.common, ...METHOD_PROPS[this.type]]; + } + + get attrs() { + return expandAttributeMeta(this, this.formFields); + } +} diff --git a/ui/app/router.js b/ui/app/router.js index 641d5d29a..3cd477670 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -17,6 +17,7 @@ Router.map(function () { this.route('logout'); this.mount('open-api-explorer', { path: '/api-explorer' }); this.route('license'); + this.route('mfa-setup'); this.route('clients', function () { this.route('current'); this.route('history'); @@ -58,6 +59,24 @@ Router.map(function () { }); this.route('section', { path: '/:section_name' }); }); + this.route('mfa', function () { + this.route('index', { path: '/' }); + this.route('methods', function () { + this.route('index', { path: '/' }); + this.route('create'); + this.route('method', { path: '/:id' }, function () { + this.route('edit'); + this.route('enforcements'); + }); + }); + this.route('enforcements', function () { + this.route('index', { path: '/' }); + this.route('create'); + this.route('enforcement', { path: '/:name' }, function () { + this.route('edit'); + }); + }); + }); this.route('leases', function () { // lookup this.route('index', { path: '/' }); diff --git a/ui/app/routes/vault/cluster/access/mfa/enforcements/create.js b/ui/app/routes/vault/cluster/access/mfa/enforcements/create.js new file mode 100644 index 000000000..6823de352 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/mfa/enforcements/create.js @@ -0,0 +1,10 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class MfaLoginEnforcementCreateRoute extends Route { + @service store; + + model() { + return this.store.createRecord('mfa-login-enforcement'); + } +} diff --git a/ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement.js b/ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement.js new file mode 100644 index 000000000..b03366b30 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement.js @@ -0,0 +1,10 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class MfaLoginEnforcementRoute extends Route { + @service store; + + model({ name }) { + return this.store.findRecord('mfa-login-enforcement', name); + } +} diff --git a/ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement/edit.js b/ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement/edit.js new file mode 100644 index 000000000..1655c46c7 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement/edit.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class MfaLoginEnforcementEditRoute extends Route {} diff --git a/ui/app/routes/vault/cluster/access/mfa/enforcements/index.js b/ui/app/routes/vault/cluster/access/mfa/enforcements/index.js new file mode 100644 index 000000000..987fb44de --- /dev/null +++ b/ui/app/routes/vault/cluster/access/mfa/enforcements/index.js @@ -0,0 +1,16 @@ +import Route from '@ember/routing/route'; + +export default class MfaEnforcementsRoute extends Route { + model() { + return this.store.query('mfa-login-enforcement', {}).catch((err) => { + if (err.httpStatus === 404) { + return []; + } else { + throw err; + } + }); + } + setupController(controller, model) { + controller.set('model', model); + } +} diff --git a/ui/app/routes/vault/cluster/access/mfa/index.js b/ui/app/routes/vault/cluster/access/mfa/index.js new file mode 100644 index 000000000..cb6054567 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/mfa/index.js @@ -0,0 +1,15 @@ +import Route from '@ember/routing/route'; + +export default class MfaConfigureRoute extends Route { + beforeModel() { + return this.store + .query('mfa-method', {}) + .then(() => { + // if response then they should transition to the methods page instead of staying on the configure page. + this.transitionTo('vault.cluster.access.mfa.methods.index'); + }) + .catch(() => { + // stay on the landing page + }); + } +} diff --git a/ui/app/routes/vault/cluster/access/mfa/methods/create.js b/ui/app/routes/vault/cluster/access/mfa/methods/create.js new file mode 100644 index 000000000..8802e86ab --- /dev/null +++ b/ui/app/routes/vault/cluster/access/mfa/methods/create.js @@ -0,0 +1,20 @@ +import Route from '@ember/routing/route'; + +export default class MfaLoginEnforcementCreateRoute extends Route { + setupController(controller) { + super.setupController(...arguments); + // if route was refreshed after type select recreate method model + const { type } = controller; + if (type) { + // create method and enforcement models for forms if type is selected + controller.createModels(); + } + } + resetController(controller, isExiting) { + if (isExiting) { + // reset type query param when user saves or cancels + // this will not trigger when refreshing the page which preserves intended functionality + controller.set('type', null); + } + } +} diff --git a/ui/app/routes/vault/cluster/access/mfa/methods/index.js b/ui/app/routes/vault/cluster/access/mfa/methods/index.js new file mode 100644 index 000000000..8fa1ffea7 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/mfa/methods/index.js @@ -0,0 +1,25 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class MfaMethodsRoute extends Route { + @service router; + + model() { + return this.store.query('mfa-method', {}).catch((err) => { + if (err.httpStatus === 404) { + return []; + } else { + throw err; + } + }); + } + + afterModel(model) { + if (model.length === 0) { + this.router.transitionTo('vault.cluster.access.mfa'); + } + } + setupController(controller, model) { + controller.set('model', model); + } +} diff --git a/ui/app/routes/vault/cluster/access/mfa/methods/method.js b/ui/app/routes/vault/cluster/access/mfa/methods/method.js new file mode 100644 index 000000000..d0b7dbfad --- /dev/null +++ b/ui/app/routes/vault/cluster/access/mfa/methods/method.js @@ -0,0 +1,24 @@ +import Route from '@ember/routing/route'; +import { hash } from 'rsvp'; +export default class MfaMethodRoute extends Route { + model({ id }) { + return hash({ + method: this.store.findRecord('mfa-method', id).then((data) => data), + enforcements: this.store + .query('mfa-login-enforcement', {}) + .then((data) => { + let filteredEnforcements = data.filter((item) => { + let results = item.hasMany('mfa_methods').ids(); + return results.includes(id); + }); + return filteredEnforcements; + }) + .catch(() => { + // Do nothing + }), + }); + } + setupController(controller, model) { + controller.set('model', model); + } +} diff --git a/ui/app/routes/vault/cluster/access/mfa/methods/method/edit.js b/ui/app/routes/vault/cluster/access/mfa/methods/method/edit.js new file mode 100644 index 000000000..cd3d72b67 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/mfa/methods/method/edit.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class MfaMethodEditRoute extends Route {} diff --git a/ui/app/routes/vault/cluster/mfa-setup.js b/ui/app/routes/vault/cluster/mfa-setup.js new file mode 100644 index 000000000..e8f10fa09 --- /dev/null +++ b/ui/app/routes/vault/cluster/mfa-setup.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class MfaSetupRoute extends Route {} diff --git a/ui/app/serializers/mfa-login-enforcement.js b/ui/app/serializers/mfa-login-enforcement.js new file mode 100644 index 000000000..919bc1c1a --- /dev/null +++ b/ui/app/serializers/mfa-login-enforcement.js @@ -0,0 +1,36 @@ +import ApplicationSerializer from './application'; + +export default class MfaLoginEnforcementSerializer extends ApplicationSerializer { + primaryKey = 'name'; + + // Return data with updated keys for hasMany relationships with ids in the name + transformHasManyKeys(data, destination) { + const keys = { + model: ['mfa_methods', 'identity_entities', 'identity_groups'], + server: ['mfa_method_ids', 'identity_entity_ids', 'identity_group_ids'], + }; + keys[destination].forEach((newKey, index) => { + const oldKey = destination === 'model' ? keys.server[index] : keys.model[index]; + delete Object.assign(data, { [newKey]: data[oldKey] })[oldKey]; + }); + return data; + } + normalize(model, data) { + this.transformHasManyKeys(data, 'model'); + return super.normalize(model, data); + } + normalizeItems(payload) { + if (payload.data) { + if (payload.data?.keys && Array.isArray(payload.data.keys)) { + return payload.data.keys.map((key) => payload.data.key_info[key]); + } + Object.assign(payload, payload.data); + delete payload.data; + } + return payload; + } + serialize() { + const json = super.serialize(...arguments); + return this.transformHasManyKeys(json, 'server'); + } +} diff --git a/ui/app/serializers/mfa-method.js b/ui/app/serializers/mfa-method.js new file mode 100644 index 000000000..af5735c28 --- /dev/null +++ b/ui/app/serializers/mfa-method.js @@ -0,0 +1,22 @@ +import ApplicationSerializer from './application'; + +export default class KeymgmtKeySerializer extends ApplicationSerializer { + normalizeItems(payload) { + if (payload.data.keys && Array.isArray(payload.data.keys)) { + let data = payload.data.keys.map((key) => { + let model = payload.data.key_info[key]; + model.id = key; + return model; + }); + return data; + } + Object.assign(payload, payload.data); + delete payload.data; + return payload; + } + serialize() { + const json = super.serialize(...arguments); + delete json.type; + return json; + } +} diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index 7ee8cf59c..1857d0c14 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -335,14 +335,11 @@ export default Service.extend({ // 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); @@ -357,7 +354,6 @@ export default Service.extend({ return { mfa_requirement: { mfa_request_id, mfa_constraints: constraints }, - requiresAction, }; } return {}; @@ -366,23 +362,10 @@ export default Service.extend({ async authenticate(/*{clusterId, backend, data, selectedAuth}*/) { const [options] = arguments; const adapter = this.clusterAdapter(); + const resp = await adapter.authenticate(options); - let resp = await adapter.authenticate(options); - 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; - } + if (resp.auth?.mfa_requirement) { + return this._parseMfaResponse(resp.auth?.mfa_requirement); } return this.authSuccess(options, resp.auth || resp.data); diff --git a/ui/app/services/permissions.js b/ui/app/services/permissions.js index 6fe090240..6402b7f21 100644 --- a/ui/app/services/permissions.js +++ b/ui/app/services/permissions.js @@ -4,6 +4,7 @@ import { task } from 'ember-concurrency'; const API_PATHS = { access: { methods: 'sys/auth', + mfa: 'identity/mfa/method', entities: 'identity/entity/id', groups: 'identity/group/id', leases: 'sys/leases/lookup', diff --git a/ui/app/styles/components/list-item-row.scss b/ui/app/styles/components/list-item-row.scss index 255835899..75cad84fb 100644 --- a/ui/app/styles/components/list-item-row.scss +++ b/ui/app/styles/components/list-item-row.scss @@ -16,6 +16,12 @@ font-weight: $font-weight-semibold; color: $ui-gray-500; } + + .center-display { + width: 50%; + margin-left: auto; + margin-right: auto; + } } a.list-item-row, diff --git a/ui/app/styles/components/radio-card.scss b/ui/app/styles/components/radio-card.scss index d48932ae4..f65e21e6f 100644 --- a/ui/app/styles/components/radio-card.scss +++ b/ui/app/styles/components/radio-card.scss @@ -19,7 +19,7 @@ opacity: 0; } - input[type='radio'] + label { + input[type='radio'] + span.dot { border: 1px solid $grey-light; border-radius: 50%; cursor: pointer; @@ -30,14 +30,19 @@ flex-grow: 0; } - input[type='radio']:checked + label { + input[type='radio']:checked + span.dot { background: $blue; border: 1px solid $blue; box-shadow: inset 0 0 0 0.15rem $white; } - input[type='radio']:focus + label { + input[type='radio']:focus + span.dot { box-shadow: 0 0 10px 1px rgba($blue, 0.4), inset 0 0 0 0.15rem $white; } + + &.is-disabled { + opacity: 0.6; + box-shadow: none; + } } .radio-card:first-child { margin-left: 0; diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss index 123a0f594..031ee33e9 100644 --- a/ui/app/styles/core/buttons.scss +++ b/ui/app/styles/core/buttons.scss @@ -183,6 +183,10 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12); font-weight: $font-weight-semibold; } + &.has-text-danger { + border: 1px solid $red-500; + } + &.tool-tip-trigger { color: $grey-dark; min-width: auto; diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index ffb4c16a0..72c0423eb 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -81,6 +81,10 @@ .is-flex-start { display: flex !important; justify-content: flex-start; + + &.has-gap { + gap: $spacing-m; + } } .is-flex-full { flex-basis: 100%; @@ -164,6 +168,12 @@ font-size: $size-8; text-transform: lowercase; } +.has-top-padding-s { + padding-top: $spacing-s; +} +.has-top-padding-l { + padding-top: $spacing-l; +} .has-bottom-margin-xs { margin-bottom: $spacing-xs; } @@ -185,6 +195,12 @@ .has-top-margin-s { margin-top: $spacing-s; } +.has-top-margin-xs { + margin-top: $spacing-xs; +} +.has-top-margin-m { + margin-top: $spacing-m; +} .has-top-margin-l { margin-top: $spacing-l; } @@ -212,6 +228,9 @@ .has-left-margin-xl { margin-left: $spacing-xl; } +.has-right-margin-m { + margin-right: $spacing-m; +} .has-right-margin-l { margin-right: $spacing-l; } @@ -241,3 +260,6 @@ ul.bullet { .has-text-grey-400 { color: $ui-gray-400; } +.has-text-align-center { + text-align: center; +} diff --git a/ui/app/templates/components/auth-info.hbs b/ui/app/templates/components/auth-info.hbs index ce7a77c9e..ab9592cca 100644 --- a/ui/app/templates/components/auth-info.hbs +++ b/ui/app/templates/components/auth-info.hbs @@ -16,6 +16,13 @@ /> {{/if}} + {{#if this.hasEntityId}} +
  • + + Multi-factor authentication + +
  • + {{/if}}
  • + + {{/each}} +
    + + {{else}} + + {{/if}} +
    + + + {{#if this.errors.targets.errors}} + + {{/if}} + + {{#unless @isInline}} +
    +
    + + +
    + {{/unless}} + \ No newline at end of file diff --git a/ui/app/templates/components/mfa-login-enforcement-header.hbs b/ui/app/templates/components/mfa-login-enforcement-header.hbs new file mode 100644 index 000000000..089e37df6 --- /dev/null +++ b/ui/app/templates/components/mfa-login-enforcement-header.hbs @@ -0,0 +1,81 @@ +{{#if @isInline}} +

    Enforcement

    +{{else}} + + + + + +

    + + {{@heading}} +

    +
    +
    +{{/if}} +
    +

    + {{#if @isInline}} + An enforcement includes the authentication types, authentication methods, groups, and entities that will require this + MFA method. This is optional and can be added later. + {{else}} + An enforcement will define which auth types, auth mounts, groups, and/or entities will require this MFA method. Keep in + mind that only one of these conditions needs to be satisfied. For example, if an authentication method is added here, + all entities and groups which make use of that authentication method will be subject to an MFA request. + Learn more here. + {{/if}} +

    + {{#if @isInline}} +
    + + + +
    + {{#if (eq @radioCardGroupValue "existing")}} + + {{/if}} + {{/if}} +
    \ No newline at end of file diff --git a/ui/app/templates/components/mfa-setup-step-one.hbs b/ui/app/templates/components/mfa-setup-step-one.hbs new file mode 100644 index 000000000..04276e5e2 --- /dev/null +++ b/ui/app/templates/components/mfa-setup-step-one.hbs @@ -0,0 +1,26 @@ +

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

    +
    + +
    + + + {{! 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 diff --git a/ui/app/templates/components/mfa-setup-step-two.hbs b/ui/app/templates/components/mfa-setup-step-two.hbs new file mode 100644 index 000000000..b5d0e5234 --- /dev/null +++ b/ui/app/templates/components/mfa-setup-step-two.hbs @@ -0,0 +1,34 @@ +

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

    +
    + + {{#if @warning}} + + {{else}} +
    +
    + +
    +
    +
    +
    +
    +

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

    +
    +
    + {{/if}} +
    + + +
    +
    \ No newline at end of file diff --git a/ui/app/templates/components/mfa/login-enforcement-list-item.hbs b/ui/app/templates/components/mfa/login-enforcement-list-item.hbs new file mode 100644 index 000000000..673171433 --- /dev/null +++ b/ui/app/templates/components/mfa/login-enforcement-list-item.hbs @@ -0,0 +1,32 @@ + +
    +
    +
    + + + {{@model.name}} + +
    +
    +
    +
    + + + +
    +
    +
    +
    \ No newline at end of file diff --git a/ui/app/templates/components/mfa/method-form.hbs b/ui/app/templates/components/mfa/method-form.hbs new file mode 100644 index 000000000..546fc539a --- /dev/null +++ b/ui/app/templates/components/mfa/method-form.hbs @@ -0,0 +1,35 @@ +
    + {{#each @model.attrs as |attr|}} + + {{/each}} +
    +{{#if @hasActions}} +
    +
    + + +
    +
    +{{/if}} + + +

    + Editing this configuration will have an impact on all authentication types, methods, groups and entities which make use + of this MFA method. Please make sure you want to make these changes before doing so. +

    +
    \ No newline at end of file diff --git a/ui/app/templates/components/mfa/method-list-item.hbs b/ui/app/templates/components/mfa/method-list-item.hbs new file mode 100644 index 000000000..ebbae24c1 --- /dev/null +++ b/ui/app/templates/components/mfa/method-list-item.hbs @@ -0,0 +1,43 @@ + +
    +
    +
    + +
    + + {{if (eq @model.type "totp") (uppercase @model.type) @model.type}} + + + {{@model.id}} + +
    + + Namespace: + {{@model.namespace_id}} + +
    +
    +
    +
    +
    +
    + + + +
    +
    +
    +
    \ No newline at end of file diff --git a/ui/app/templates/components/mfa/nav.hbs b/ui/app/templates/components/mfa/nav.hbs new file mode 100644 index 000000000..33dde0a66 --- /dev/null +++ b/ui/app/templates/components/mfa/nav.hbs @@ -0,0 +1,12 @@ +
    + +
    \ No newline at end of file diff --git a/ui/app/templates/components/mount-accessor-select.hbs b/ui/app/templates/components/mount-accessor-select.hbs index 04e224b72..6f0a9862d 100644 --- a/ui/app/templates/components/mount-accessor-select.hbs +++ b/ui/app/templates/components/mount-accessor-select.hbs @@ -16,11 +16,24 @@
    diff --git a/ui/app/templates/components/radio-card.hbs b/ui/app/templates/components/radio-card.hbs new file mode 100644 index 000000000..2a4669686 --- /dev/null +++ b/ui/app/templates/components/radio-card.hbs @@ -0,0 +1,35 @@ + \ 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 36f115a5d..9070e5f04 100644 --- a/ui/app/templates/components/splash-page.hbs +++ b/ui/app/templates/components/splash-page.hbs @@ -1,15 +1,17 @@ - - - - - - - - - - +{{#if this.showTruncatedNavBar}} + + + + + + + + + + +{{/if}} {{! bypass UiWizard and container styling }} {{#if this.hasAltContent}} {{yield (hash altContent=(component "splash-page/splash-content"))}} diff --git a/ui/app/templates/vault/cluster/access.hbs b/ui/app/templates/vault/cluster/access.hbs index 0545ba769..d3741e51a 100644 --- a/ui/app/templates/vault/cluster/access.hbs +++ b/ui/app/templates/vault/cluster/access.hbs @@ -11,6 +11,17 @@
  • {{/if}} + {{#if (has-permission "access" routeParams="mfa")}} +
  • + + Multi-factor authentication + +
  • + {{/if}} {{#if (has-permission "access" routeParams="entities")}}
  • diff --git a/ui/app/templates/vault/cluster/access/mfa/enforcements/create.hbs b/ui/app/templates/vault/cluster/access/mfa/enforcements/create.hbs new file mode 100644 index 000000000..b8859c541 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/mfa/enforcements/create.hbs @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/edit.hbs b/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/edit.hbs new file mode 100644 index 000000000..4c76f4d3a --- /dev/null +++ b/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/edit.hbs @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/index.hbs b/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/index.hbs new file mode 100644 index 000000000..d90cdee84 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/index.hbs @@ -0,0 +1,105 @@ + + + + + +

    + + {{this.model.name}} +

    +
    +
    +
    + +
    + + + +
    + + Edit enforcement + +
    +
    + +{{#if (eq this.tab "targets")}} + {{#each @model.targets as |target|}} + +
    +
    +
    + + + {{target.title}} + +
    + + {{target.subTitle}} + +
    +
    +
    + {{#if target.link}} +
    +
    + + + +
    +
    + {{/if}} +
    +
    + {{/each}} +{{else if (eq this.tab "methods")}} + {{#each this.model.mfa_methods as |method|}} + + {{/each}} +{{/if}} + + +

    + Deleting the + {{this.model.name}} + enforcement will mean that the MFA method that depends on it will no longer enforce multi-factor authentication. +

    + Deleting this enforcement cannot be undone; it will have to be recreated. +

    + +
    \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs b/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs new file mode 100644 index 000000000..7ab79aad8 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs @@ -0,0 +1,25 @@ + + +

    + Multi-factor Authentication +

    +
    +
    + + + + + + + New enforcement + + + + +{{#if (gt this.model.length 0)}} + {{#each this.model as |item|}} + + {{/each}} +{{else}} + +{{/if}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/index.hbs b/ui/app/templates/vault/cluster/access/mfa/index.hbs new file mode 100644 index 000000000..2e035d64b --- /dev/null +++ b/ui/app/templates/vault/cluster/access/mfa/index.hbs @@ -0,0 +1,35 @@ + + +

    + Multi-factor authentication +

    +
    +
    + +
    +

    + Configure and enforce multi-factor authentication (MFA) for users logging into Vault, for any +
    + authentication method. + + Learn more + +

    + +
    +
    +

    + Step 1: + Set up an MFA configuration using one of the methods; TOTP, Okta, Duo or Pingid. +

    +

    + Step 2: + Set up an enforcement to map the MFA configuration to your chosen auth method(s). +

    +
    + +
    + MFA configure diagram +
    \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs b/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs new file mode 100644 index 000000000..6d59dc962 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs @@ -0,0 +1,98 @@ + + +

    + {{#if this.method}} + Configure + {{this.method.name}} + MFA + {{else}} + Multi-factor authentication + {{/if}} +

    +
    + + + +
    +
    + {{#if this.showForms}} +

    Settings

    +

    + {{this.description}} + Learn more. +

    + + + {{#if (eq this.enforcementPreference "new")}} + + {{/if}} + {{else}} +

    + Multi-factor authentication (MFA) allows you to set up another layer of security on top of existing authentication + methods. Vault has four available methods. + Learn more. +

    +
    + {{#each this.methodNames as |methodName|}} + +
    +
    + +

    + {{methodName}} +

    +
    +
    +
    + {{/each}} +
    + {{#if this.type}} +

    + {{this.description}} + Learn more. +

    + {{! in a future release cards may be displayed to choose from either template or custom config for TOTP }} + {{! if template is selected a user could choose a predefined config for common authenticators and the values would be populated on the model }} + {{/if}} + {{/if}} + +
    +
    + {{#if this.showForms}} + + + {{else if this.type}} + + {{/if}} +
    +
    +
    \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/methods/index.hbs b/ui/app/templates/vault/cluster/access/mfa/methods/index.hbs new file mode 100644 index 000000000..a75a65537 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/mfa/methods/index.hbs @@ -0,0 +1,25 @@ + + +

    + Multi-factor Authentication +

    +
    +
    + + + + + + + New MFA method + + + + +{{#if (gt this.model.length 0)}} + {{#each this.model as |item|}} + + {{/each}} +{{else}} + +{{/if}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/methods/method/edit.hbs b/ui/app/templates/vault/cluster/access/mfa/methods/method/edit.hbs new file mode 100644 index 000000000..0d2c10411 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/mfa/methods/method/edit.hbs @@ -0,0 +1,16 @@ + + +

    + Configure + {{this.model.method.name}} + MFA +

    +
    +
    + + \ No newline at end of file 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 new file mode 100644 index 000000000..7c70dc0b4 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/mfa/methods/method/index.hbs @@ -0,0 +1,90 @@ + + + + + +

    + + {{this.model.method.name}} +

    +
    +
    + +
    + +
    + +{{#if (eq this.tab "config")}} + + + + Delete + + + Edit + + + +
    + {{#each this.model.method.attrs as |attr|}} + {{#if (eq attr.type "object")}} + + {{else}} + + {{/if}} + {{/each}} +
    +{{else if (eq this.tab "enforcements")}} + + + + New enforcement + + + +
    + {{#if (is-empty this.model.enforcements)}} + + {{else}} + {{#each this.model.enforcements as |item|}} + + {{/each}} + {{/if}} +
    +{{/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 b8214459f..0b6c8e0fd 100644 --- a/ui/app/templates/vault/cluster/auth.hbs +++ b/ui/app/templates/vault/cluster/auth.hbs @@ -1,4 +1,4 @@ - +