mirror of
				https://github.com/optim-enterprises-bv/vault.git
				synced 2025-11-03 20:17:59 +00:00 
			
		
		
		
	MFA Config (#15200)
* adds mirage factories for mfa methods and login enforcement * adds mirage handler for mfa config endpoints * adds mirage identity manager for uuids * updates mfa test to use renamed mfaLogin mirage handler * updates mfa login workflow for push methods (#15214) * MFA Login Enforcement Model (#15244) * adds mfa login enforcement model, adapter and serializer * updates mfa methods to hasMany realtionship and transforms property names * updates login enforcement adapter to use urlForQuery over buildURL * Model for mfa method (#15218) * Model for mfa method * Added adapter and serializer for mfa method - Updated mfa method model - Basic route to handle list view - Added MFA to access nav * Show landing page if methods are not configured * Updated adapter,serializer - Backend is adding new endpoint to list all the mfa methods * Updated landing page - Added MFA diagram - Created helper to resolve full path for assets like images * Remove ember assign * Fixed failing test * MFA method and enforcement list view (#15353) * MFA method and enforcement list view - Added new route for list views - List mfa methods along with id, type and icon - Added client side pagination to list views * Throw error if method id is not present * MFA Login Enforcement Form (#15410) * adds mfa login enforcement form and header components and radio card component * skips login enforcement form tests for now * adds jsdoc annotations for mfa-login-enforcement-header component * adds error handling when fetching identity targets in login enforcement form component * updates radio-card label elements * MFA Login Enforcement Create and Edit routes (#15422) * adds mfa login enforcement form and header components and radio card component * skips login enforcement form tests for now * updates to login enforcement form to fix issues hydrating methods and targets from model when editing * updates to mfa-config mirage handler and login enforcement handler * fixes issue with login enforcement serializer normalizeItems method throwing error on save * updates to mfa route structure * adds login enforcement create and edit routes * MFA Login Enforcement Read Views (#15462) * adds login enforcement read views * skip mfa-method-list-item test for now * MFA method form (#15432) * MFA method form - Updated model for form attributes - Form for editing, creating mfa methods * Added comments * Update model for mfa method * Refactor buildURL in mfa method adapter * Update adapter to handle mfa create * Fixed adapter to handle create mfa response * Sidebranch: MFA end user setup (#15273) * initial setup of components and route * fix navbar * replace parent component with controller * use auth service to return entity id * adapter and some error handling: * clean up adapter and handle warning * wip * use library for qrCode generation * clear warning and QR code display fix * flow for restart setup * add documentation * clean up * fix warning issue * handle root user * remove comment * update copy * fix margin * address comment * MFA Guided Setup Route (#15479) * adds mfa method create route with type selection workflow * updates mfa method create route links to use DocLink component * MFA Guided Setup Config View (#15486) * adds mfa guided setup config view * resets type query param on mfa method create route exit * hide next button if type is not selected in mfa method create route * updates to sure correct state when changing mfa method type in guided setup * Enforcement view at MFA method level (#15485) - List enforcements for each mfa method - Delete MFA method if no enforcements are present - Moved method, enforcement list item component to mfa folder * MFA Login Enforcement Validations (#15498) * adds model and form validations for mfa login enforcements * updates mfa login enforcement validation messages * updates validation message for mfa login enforcement targets * adds transition action to configure mfa button on landing page * unset enforcement on preference change in mfa guided setup workflow * Added validations for mfa method model (#15506) * UI/mfa breadcrumbs and small fixes (#15499) * add active class when on index * breadcrumbs * remove box-shadow to match designs * fix refresh load mfa-method * breadcrumb create * add an empty state the enforcements list view * change to beforeModel * UI/mfa small bugs (#15522) * remove pagintion and fix on methods list view * fix enforcements * Fix label for value on radio-card (#15542) * MFA Login Enforcement Component Tests (#15539) * adds tests for mfa-login-enforcement-header component * adds tests for mfa-login-enforcement-form component * Remove default values from mfa method model (#15540) - use passcode had a default value, as a result it was being sent with all the mfa method types during save and edit flows.. * UI/mfa small cleanup (#15549) * data-test-mleh -> data-test-mfa * Only one label per radio card * Remove unnecessary async * Simplify boolean logic * Make mutation clear * Revert "data-test-mleh -> data-test-mfa" This reverts commit 31430df7bb42580a976d082667cb6ed1f09c3944. * updates mfa login enforcement form to only display auth method types for current mounts as targets (#15547) * remove token type (#15548) * remove token type * conditional param * removes type from mfa method payload and fixes bug transitioning to method route on save success * removes punctuation from mfa form error message string match * updates qr-code component invocation to angle bracket * Re-trigger CI jobs with empty commit Co-authored-by: Arnav Palnitkar <arnav@hashicorp.com> Co-authored-by: Angel Garbarino <Monkeychip@users.noreply.github.com> Co-authored-by: Chelsea Shaw <82459713+hashishaw@users.noreply.github.com> Co-authored-by: Michele Degges <mdeggies@gmail.com>
This commit is contained in:
		
							
								
								
									
										29
									
								
								ui/app/adapters/mfa-login-enforcement.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								ui/app/adapters/mfa-login-enforcement.js
									
									
									
									
									
										Normal file
									
								
							@@ -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 } });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										54
									
								
								ui/app/adapters/mfa-method.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								ui/app/adapters/mfa-method.js
									
									
									
									
									
										Normal file
									
								
							@@ -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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										13
									
								
								ui/app/adapters/mfa-setup.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								ui/app/adapters/mfa-setup.js
									
									
									
									
									
										Normal file
									
								
							@@ -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 });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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--;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										170
									
								
								ui/app/components/mfa-login-enforcement-form.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								ui/app/components/mfa-login-enforcement-form.js
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
 * <MfaLoginEnforcementForm @model={{this.model}} @isInline={{false}} @onSave={{this.onSave}} @onClose={{this.onClose}} />
 | 
			
		||||
 * ```
 | 
			
		||||
 * @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();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										52
									
								
								ui/app/components/mfa-login-enforcement-header.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								ui/app/components/mfa-login-enforcement-header.js
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
 * <MfaLoginEnforcementHeader @heading="New enforcement" />
 | 
			
		||||
 * <MfaLoginEnforcementHeader @radioCardGroupValue={{this.enforcementPreference}} @onRadioCardSelect={{fn (mut this.enforcementPreference)}} @onEnforcementSelect={{fn (mut this.enforcement)}} />
 | 
			
		||||
 * ```
 | 
			
		||||
 * @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));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										77
									
								
								ui/app/components/mfa-setup-step-one.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								ui/app/components/mfa-setup-step-one.js
									
									
									
									
									
										Normal file
									
								
							@@ -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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										40
									
								
								ui/app/components/mfa-setup-step-two.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								ui/app/components/mfa-setup-step-two.js
									
									
									
									
									
										Normal file
									
								
							@@ -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();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										52
									
								
								ui/app/components/mfa/method-form.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								ui/app/components/mfa/method-form.js
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
 * <Mfa::MethodForm @model={{this.model}} @hasActions={{true}} @onSave={{this.onSave}} @onClose={{this.onClose}} />
 | 
			
		||||
 * ```
 | 
			
		||||
 * @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();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -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(),
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,6 @@
 | 
			
		||||
import Controller from '@ember/controller';
 | 
			
		||||
 | 
			
		||||
export default class MfaEnforcementListController extends Controller {
 | 
			
		||||
  queryParams = ['page'];
 | 
			
		||||
  page = 1;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										9
									
								
								ui/app/controllers/vault/cluster/access/mfa/methods.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								ui/app/controllers/vault/cluster/access/mfa/methods.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
import Controller from '@ember/controller';
 | 
			
		||||
 | 
			
		||||
export default class MfaMethodsListController extends Controller {
 | 
			
		||||
  queryParams = {
 | 
			
		||||
    page: 'page',
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  page = 1;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										127
									
								
								ui/app/controllers/vault/cluster/access/mfa/methods/create.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								ui/app/controllers/vault/cluster/access/mfa/methods/create.js
									
									
									
									
									
										Normal file
									
								
							@@ -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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -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.`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -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,
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										43
									
								
								ui/app/controllers/vault/cluster/mfa-setup.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								ui/app/controllers/vault/cluster/mfa-setup.js
									
									
									
									
									
										Normal file
									
								
							@@ -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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										6
									
								
								ui/app/helpers/img-path.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								ui/app/helpers/img-path.js
									
									
									
									
									
										Normal file
									
								
							@@ -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/`);
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										106
									
								
								ui/app/models/mfa-login-enforcement.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								ui/app/models/mfa-login-enforcement.js
									
									
									
									
									
										Normal file
									
								
							@@ -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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										168
									
								
								ui/app/models/mfa-method.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								ui/app/models/mfa-method.js
									
									
									
									
									
										Normal file
									
								
							@@ -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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -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: '/' });
 | 
			
		||||
 
 | 
			
		||||
@@ -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');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,3 @@
 | 
			
		||||
import Route from '@ember/routing/route';
 | 
			
		||||
 | 
			
		||||
export default class MfaLoginEnforcementEditRoute extends Route {}
 | 
			
		||||
							
								
								
									
										16
									
								
								ui/app/routes/vault/cluster/access/mfa/enforcements/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								ui/app/routes/vault/cluster/access/mfa/enforcements/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										15
									
								
								ui/app/routes/vault/cluster/access/mfa/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								ui/app/routes/vault/cluster/access/mfa/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
      });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								ui/app/routes/vault/cluster/access/mfa/methods/create.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								ui/app/routes/vault/cluster/access/mfa/methods/create.js
									
									
									
									
									
										Normal file
									
								
							@@ -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);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										25
									
								
								ui/app/routes/vault/cluster/access/mfa/methods/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								ui/app/routes/vault/cluster/access/mfa/methods/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										24
									
								
								ui/app/routes/vault/cluster/access/mfa/methods/method.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								ui/app/routes/vault/cluster/access/mfa/methods/method.js
									
									
									
									
									
										Normal file
									
								
							@@ -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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,3 @@
 | 
			
		||||
import Route from '@ember/routing/route';
 | 
			
		||||
 | 
			
		||||
export default class MfaMethodEditRoute extends Route {}
 | 
			
		||||
							
								
								
									
										3
									
								
								ui/app/routes/vault/cluster/mfa-setup.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ui/app/routes/vault/cluster/mfa-setup.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
import Route from '@ember/routing/route';
 | 
			
		||||
 | 
			
		||||
export default class MfaSetupRoute extends Route {}
 | 
			
		||||
							
								
								
									
										36
									
								
								ui/app/serializers/mfa-login-enforcement.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								ui/app/serializers/mfa-login-enforcement.js
									
									
									
									
									
										Normal file
									
								
							@@ -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');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										22
									
								
								ui/app/serializers/mfa-method.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								ui/app/serializers/mfa-method.js
									
									
									
									
									
										Normal file
									
								
							@@ -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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -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);
 | 
			
		||||
 
 | 
			
		||||
@@ -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',
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -16,6 +16,13 @@
 | 
			
		||||
              />
 | 
			
		||||
            </li>
 | 
			
		||||
          {{/if}}
 | 
			
		||||
          {{#if this.hasEntityId}}
 | 
			
		||||
            <li class="action">
 | 
			
		||||
              <LinkTo @route="vault.cluster.mfa-setup">
 | 
			
		||||
                Multi-factor authentication
 | 
			
		||||
              </LinkTo>
 | 
			
		||||
            </li>
 | 
			
		||||
          {{/if}}
 | 
			
		||||
          <li class="action">
 | 
			
		||||
            <button type="button" class="link" onclick={{action "restartGuide"}}>
 | 
			
		||||
              Restart guide
 | 
			
		||||
 
 | 
			
		||||
@@ -45,7 +45,7 @@
 | 
			
		||||
              {{! template-lint-enable no-autofocus-attribute}}
 | 
			
		||||
            </div>
 | 
			
		||||
          {{else if (eq constraint.methods.length 1)}}
 | 
			
		||||
            <p class="has-text-grey-400">
 | 
			
		||||
            <p class="has-text-grey-400" data-test-mfa-push-instruction>
 | 
			
		||||
              Check device for push notification
 | 
			
		||||
            </p>
 | 
			
		||||
          {{/if}}
 | 
			
		||||
@@ -53,11 +53,7 @@
 | 
			
		||||
      </div>
 | 
			
		||||
      {{#if this.newCodeDelay.isRunning}}
 | 
			
		||||
        <div>
 | 
			
		||||
          <AlertInline
 | 
			
		||||
            @type="danger"
 | 
			
		||||
            @sizeSmall={{true}}
 | 
			
		||||
            @message="This code is invalid. Please wait until a new code is available."
 | 
			
		||||
          />
 | 
			
		||||
          <AlertInline @type="danger" @sizeSmall={{true}} @message={{this.codeDelayMessage}} />
 | 
			
		||||
        </div>
 | 
			
		||||
      {{/if}}
 | 
			
		||||
      <button
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										154
									
								
								ui/app/templates/components/mfa-login-enforcement-form.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								ui/app/templates/components/mfa-login-enforcement-form.hbs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,154 @@
 | 
			
		||||
<div ...attributes>
 | 
			
		||||
  <FormFieldLabel
 | 
			
		||||
    for="name"
 | 
			
		||||
    @label="Name"
 | 
			
		||||
    @subText="The name for this enforcement. Giving it a name means that you can refer to it again later. This name will not be editable later."
 | 
			
		||||
    data-test-mlef-label="name"
 | 
			
		||||
  />
 | 
			
		||||
  <input
 | 
			
		||||
    autocomplete="off"
 | 
			
		||||
    spellcheck="false"
 | 
			
		||||
    value={{@model.name}}
 | 
			
		||||
    disabled={{not @model.isNew}}
 | 
			
		||||
    class="input field"
 | 
			
		||||
    data-test-mlef-input="name"
 | 
			
		||||
    {{on "input" (pipe (pick "target.value") (fn (mut @model.name)))}}
 | 
			
		||||
  />
 | 
			
		||||
  {{#if this.errors.name.errors}}
 | 
			
		||||
    <AlertInline @type="danger" @message={{join ", " this.errors.name.errors}} />
 | 
			
		||||
  {{/if}}
 | 
			
		||||
 | 
			
		||||
  {{#unless @isInline}}
 | 
			
		||||
    <div class="field">
 | 
			
		||||
      <FormFieldLabel
 | 
			
		||||
        for="methods"
 | 
			
		||||
        @label="MFA methods"
 | 
			
		||||
        @subText="The MFA method(s) that this enforcement will apply to."
 | 
			
		||||
        data-test-mlef-label="methods"
 | 
			
		||||
      />
 | 
			
		||||
      {{! component only computes inputValue on init -- ensure Ember Data hasMany promise has resolved }}
 | 
			
		||||
      {{#if @model.mfa_methods.isFulfilled}}
 | 
			
		||||
        <SearchSelect
 | 
			
		||||
          @placeholder="Type to search for existing MFA methods"
 | 
			
		||||
          @inputValue={{map-by "id" @model.mfa_methods}}
 | 
			
		||||
          @shouldRenderName={{true}}
 | 
			
		||||
          @disallowNewItems={{true}}
 | 
			
		||||
          @models={{array "mfa-method"}}
 | 
			
		||||
          @onChange={{this.onMethodChange}}
 | 
			
		||||
          data-test-mlef-search="methods"
 | 
			
		||||
        />
 | 
			
		||||
      {{/if}}
 | 
			
		||||
      {{#if this.errors.mfa_methods.errors}}
 | 
			
		||||
        <AlertInline @type="danger" @message={{join ", " this.errors.mfa_methods.errors}} />
 | 
			
		||||
      {{/if}}
 | 
			
		||||
    </div>
 | 
			
		||||
  {{/unless}}
 | 
			
		||||
 | 
			
		||||
  <div>
 | 
			
		||||
    <FormFieldLabel
 | 
			
		||||
      for="targets"
 | 
			
		||||
      @label="Targets"
 | 
			
		||||
      @subText="The list of authentication types, authentication mounts, groups, and/or entities that will require this MFA configuration."
 | 
			
		||||
      data-test-mlef-label="targets"
 | 
			
		||||
    />
 | 
			
		||||
    {{#each this.targets as |target|}}
 | 
			
		||||
      <div class="is-flex-center has-border-top-light" data-test-mlef-target={{target.label}}>
 | 
			
		||||
        <InfoTableRow @label={{target.label}} class="is-flex-1 has-no-shadow">
 | 
			
		||||
          {{#if target.value.id}}
 | 
			
		||||
            {{target.value.name}}
 | 
			
		||||
            <span class="tag has-left-margin-s">{{target.value.id}}</span>
 | 
			
		||||
          {{else}}
 | 
			
		||||
            {{target.value}}
 | 
			
		||||
          {{/if}}
 | 
			
		||||
        </InfoTableRow>
 | 
			
		||||
        <button
 | 
			
		||||
          type="button"
 | 
			
		||||
          class="button"
 | 
			
		||||
          data-test-mlef-remove-target={{target.label}}
 | 
			
		||||
          {{on "click" (fn this.removeTarget target)}}
 | 
			
		||||
        >
 | 
			
		||||
          <Icon @name="trash" />
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
    {{/each}}
 | 
			
		||||
    <div class="is-flex-row {{if this.targets 'has-top-padding-s has-border-top-light'}}">
 | 
			
		||||
      <Select
 | 
			
		||||
        @options={{this.targetTypes}}
 | 
			
		||||
        @labelAttribute="label"
 | 
			
		||||
        @valueAttribute="type"
 | 
			
		||||
        @selectedValue={{this.selectedTargetType}}
 | 
			
		||||
        @onChange={{this.onTargetSelect}}
 | 
			
		||||
        data-test-mlef-select="target-type"
 | 
			
		||||
      />
 | 
			
		||||
      <div class="has-left-margin-s is-flex-1">
 | 
			
		||||
        {{#if (eq this.selectedTargetType "accessor")}}
 | 
			
		||||
          <MountAccessorSelect
 | 
			
		||||
            @value={{this.selectedTargetValue}}
 | 
			
		||||
            @showAccessor={{true}}
 | 
			
		||||
            @noDefault={{true}}
 | 
			
		||||
            @onChange={{this.setTargetValue}}
 | 
			
		||||
            @filterToken={{true}}
 | 
			
		||||
            data-test-mlef-select="accessor"
 | 
			
		||||
          />
 | 
			
		||||
        {{else if (eq this.selectedTargetType "method")}}
 | 
			
		||||
          <Select
 | 
			
		||||
            @options={{this.authMethods}}
 | 
			
		||||
            @labelAttribute="displayName"
 | 
			
		||||
            @valueAttribute="value"
 | 
			
		||||
            @isFullwidth={{true}}
 | 
			
		||||
            @noDefault={{true}}
 | 
			
		||||
            @selectedValue={{this.selectedTargetValue}}
 | 
			
		||||
            @onChange={{this.setTargetValue}}
 | 
			
		||||
            data-test-mlef-select="auth-method"
 | 
			
		||||
          />
 | 
			
		||||
        {{else}}
 | 
			
		||||
          <SearchSelect
 | 
			
		||||
            @placeholder="Search for an existing target"
 | 
			
		||||
            @options={{this.searchSelect.options}}
 | 
			
		||||
            {{! workaround since there is no way provided by component to externally clear selected options }}
 | 
			
		||||
            @selectedOptions={{this.searchSelect.selected}}
 | 
			
		||||
            @shouldRenderName={{true}}
 | 
			
		||||
            @selectLimit={{1}}
 | 
			
		||||
            @onChange={{this.setTargetValue}}
 | 
			
		||||
            data-test-mlef-search={{this.selectedTargetType}}
 | 
			
		||||
          />
 | 
			
		||||
        {{/if}}
 | 
			
		||||
      </div>
 | 
			
		||||
      <button
 | 
			
		||||
        type="button"
 | 
			
		||||
        class="button has-left-margin-s"
 | 
			
		||||
        disabled={{not this.selectedTargetValue}}
 | 
			
		||||
        data-test-mlef-add-target
 | 
			
		||||
        {{on "click" this.addTarget}}
 | 
			
		||||
      >
 | 
			
		||||
        Add
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
    {{#if this.errors.targets.errors}}
 | 
			
		||||
      <AlertInline @type="danger" @message={{join ", " this.errors.targets.errors}} />
 | 
			
		||||
    {{/if}}
 | 
			
		||||
  </div>
 | 
			
		||||
  {{#unless @isInline}}
 | 
			
		||||
    <hr />
 | 
			
		||||
    <div class="has-top-padding-s">
 | 
			
		||||
      <button
 | 
			
		||||
        type="button"
 | 
			
		||||
        class="button is-primary {{if this.save.isRunning 'is-loading'}}"
 | 
			
		||||
        disabled={{this.save.isRunning}}
 | 
			
		||||
        data-test-mlef-save
 | 
			
		||||
        {{on "click" (perform this.save)}}
 | 
			
		||||
      >
 | 
			
		||||
        {{if @model.isNew "Create" "Update"}}
 | 
			
		||||
      </button>
 | 
			
		||||
      <button
 | 
			
		||||
        type="button"
 | 
			
		||||
        class="button has-left-margin-s"
 | 
			
		||||
        disabled={{this.save.isRunning}}
 | 
			
		||||
        data-test-mlef-cancel
 | 
			
		||||
        {{on "click" this.cancel}}
 | 
			
		||||
      >
 | 
			
		||||
        Cancel
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
  {{/unless}}
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										81
									
								
								ui/app/templates/components/mfa-login-enforcement-header.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								ui/app/templates/components/mfa-login-enforcement-header.hbs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,81 @@
 | 
			
		||||
{{#if @isInline}}
 | 
			
		||||
  <h3 class="title is-5" data-test-mleh-title>Enforcement</h3>
 | 
			
		||||
{{else}}
 | 
			
		||||
  <PageHeader as |p|>
 | 
			
		||||
    <p.top>
 | 
			
		||||
      <nav class="breadcrumb">
 | 
			
		||||
        <ul>
 | 
			
		||||
          <li>
 | 
			
		||||
            <span class="sep">/</span>
 | 
			
		||||
            <LinkTo @route="vault.cluster.access.mfa.enforcements.index">
 | 
			
		||||
              Enforcements
 | 
			
		||||
            </LinkTo>
 | 
			
		||||
          </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
      </nav>
 | 
			
		||||
    </p.top>
 | 
			
		||||
    <p.levelLeft>
 | 
			
		||||
      <h1 class="title is-3" data-test-mleh-title>
 | 
			
		||||
        <Icon @name="lock" @size="24" />
 | 
			
		||||
        {{@heading}}
 | 
			
		||||
      </h1>
 | 
			
		||||
    </p.levelLeft>
 | 
			
		||||
  </PageHeader>
 | 
			
		||||
{{/if}}
 | 
			
		||||
<div class="has-border-top-light">
 | 
			
		||||
  <p class="has-top-margin-m" data-test-mleh-description>
 | 
			
		||||
    {{#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.
 | 
			
		||||
      <DocLink @path="/docs/auth/login-mfa">Learn more here.</DocLink>
 | 
			
		||||
    {{/if}}
 | 
			
		||||
  </p>
 | 
			
		||||
  {{#if @isInline}}
 | 
			
		||||
    <div class="is-flex-row">
 | 
			
		||||
      <RadioCard
 | 
			
		||||
        @title="Create new"
 | 
			
		||||
        @description="Create a new enforcement for this MFA method."
 | 
			
		||||
        @icon="plus-circle"
 | 
			
		||||
        @value="new"
 | 
			
		||||
        @groupValue={{@radioCardGroupValue}}
 | 
			
		||||
        @onChange={{@onRadioCardSelect}}
 | 
			
		||||
        data-test-mleh-radio="new"
 | 
			
		||||
      />
 | 
			
		||||
      <RadioCard
 | 
			
		||||
        @title="Use existing"
 | 
			
		||||
        @description="Use an existing enforcement configuration."
 | 
			
		||||
        @icon="list"
 | 
			
		||||
        @value="existing"
 | 
			
		||||
        @groupValue={{@radioCardGroupValue}}
 | 
			
		||||
        @disabled={{not this.enforcements.length}}
 | 
			
		||||
        @onChange={{@onRadioCardSelect}}
 | 
			
		||||
        data-test-mleh-radio="existing"
 | 
			
		||||
      />
 | 
			
		||||
      <RadioCard
 | 
			
		||||
        @title="Skip this step"
 | 
			
		||||
        @description="Create MFA without enforcement for now. "
 | 
			
		||||
        @icon="build"
 | 
			
		||||
        @value="skip"
 | 
			
		||||
        @groupValue={{@radioCardGroupValue}}
 | 
			
		||||
        @onChange={{@onRadioCardSelect}}
 | 
			
		||||
        data-test-mleh-radio="skip"
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
    {{#if (eq @radioCardGroupValue "existing")}}
 | 
			
		||||
      <SearchSelect
 | 
			
		||||
        @label="Enforcement"
 | 
			
		||||
        @labelClass="is-label"
 | 
			
		||||
        @subText="Choose the existing enforcement(s) to add to this MFA method."
 | 
			
		||||
        @placeholder="Search for an existing enforcement"
 | 
			
		||||
        @options={{this.enforcements}}
 | 
			
		||||
        @shouldRenderName={{true}}
 | 
			
		||||
        @selectLimit={{1}}
 | 
			
		||||
        @onChange={{this.onEnforcementSelect}}
 | 
			
		||||
      />
 | 
			
		||||
    {{/if}}
 | 
			
		||||
  {{/if}}
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										26
									
								
								ui/app/templates/components/mfa-setup-step-one.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								ui/app/templates/components/mfa-setup-step-one.hbs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
<p>
 | 
			
		||||
  TOTP Multi-factor authentication (MFA) can be enabled here if it is required by your administrator. This will ensure that
 | 
			
		||||
  you are not prevented from logging into Vault in the future, once MFA is fully enforced.
 | 
			
		||||
</p>
 | 
			
		||||
<form id="mfa-setup-step-one" {{on "submit" this.verifyUUID}}>
 | 
			
		||||
  <MessageError @errorMessage={{this.error}} class="has-top-margin-s" />
 | 
			
		||||
  <div class="field has-top-margin-l">
 | 
			
		||||
    <label class="is-label">
 | 
			
		||||
      Method ID
 | 
			
		||||
    </label>
 | 
			
		||||
 | 
			
		||||
    {{! template-lint-disable no-autofocus-attribute}}
 | 
			
		||||
    <p class="sub-text">Enter the UUID for your multi-factor authentication method. This can be provided to you by your
 | 
			
		||||
      administrator.</p>
 | 
			
		||||
    <Input id="uuid" name="uuid" class="input" autocomplete="off" spellcheck="false" autofocus="true" @value={{this.UUID}} />
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="is-flex-start has-gap">
 | 
			
		||||
    <button id="continue" type="submit" class="button is-primary" disabled={{(is-empty-value this.UUID)}}>
 | 
			
		||||
      Verify
 | 
			
		||||
    </button>
 | 
			
		||||
    <button id="cancel" type="button" {{on "click" this.redirectPreviousPage}} class="button">
 | 
			
		||||
      Cancel
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
</form>
 | 
			
		||||
							
								
								
									
										34
									
								
								ui/app/templates/components/mfa-setup-step-two.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								ui/app/templates/components/mfa-setup-step-two.hbs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
			
		||||
<p>
 | 
			
		||||
  TOTP Multi-factor authentication (MFA) can be enabled here if it is required by your administrator. This will ensure that
 | 
			
		||||
  you are not prevented from logging into Vault in the future, once MFA is fully enforced.
 | 
			
		||||
</p>
 | 
			
		||||
<div class="field has-top-margin-l">
 | 
			
		||||
  <MessageError @errorMessage={{this.error}} class="has-top-margin-s" />
 | 
			
		||||
  {{#if @warning}}
 | 
			
		||||
    <AlertBanner @type="info" @title="MFA enabled" @message={{@warning}} class="has-top-margin-l" />
 | 
			
		||||
  {{else}}
 | 
			
		||||
    <div class="list-item-row">
 | 
			
		||||
      <div class="center-display">
 | 
			
		||||
        <QrCode @text={{@qrCode}} @colorLight="#F7F7F7" @width={{155}} @height={{155}} @correctLevel="L" />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="has-top-margin-s">
 | 
			
		||||
      <div class="info-table-row has-no-shadow">
 | 
			
		||||
        <div class="column info-table-row-edit"><Icon @name="alert-triangle-fill" class="has-text-highlight" /></div>
 | 
			
		||||
        <p class="is-size-8">
 | 
			
		||||
          After you leave this page, this QR code will be removed and
 | 
			
		||||
          <strong>cannot</strong>
 | 
			
		||||
          be regenerated.
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  {{/if}}
 | 
			
		||||
  <div class="is-flex-start has-gap has-top-margin-l">
 | 
			
		||||
    <button id="restart" type="button" class="button has-text-danger" {{on "click" this.restartSetup}}>
 | 
			
		||||
      Restart setup
 | 
			
		||||
    </button>
 | 
			
		||||
    <button id="cancel" type="button" {{on "click" this.redirectPreviousPage}} class="button is-primary">
 | 
			
		||||
      Done
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,32 @@
 | 
			
		||||
<LinkedBlock class="list-item-row" @params={{array "vault.cluster.access.mfa.enforcements.enforcement" @model.id}}>
 | 
			
		||||
  <div class="level is-mobile">
 | 
			
		||||
    <div class="level-left">
 | 
			
		||||
      <div>
 | 
			
		||||
        <Icon @name="lock" />
 | 
			
		||||
        <span class="has-text-weight-semibold has-text-black">
 | 
			
		||||
          {{@model.name}}
 | 
			
		||||
        </span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="level-right is-flex is-paddingless is-marginless">
 | 
			
		||||
      <div class="level-item">
 | 
			
		||||
        <PopupMenu>
 | 
			
		||||
          <nav class="menu">
 | 
			
		||||
            <ul class="menu-list">
 | 
			
		||||
              <li>
 | 
			
		||||
                <LinkTo @route="vault.cluster.access.mfa.enforcements.enforcement" @model={{@model.name}}>
 | 
			
		||||
                  Details
 | 
			
		||||
                </LinkTo>
 | 
			
		||||
              </li>
 | 
			
		||||
              <li>
 | 
			
		||||
                <LinkTo @route="vault.cluster.access.mfa.enforcements.enforcement.edit" @model={{@model.name}}>
 | 
			
		||||
                  Edit
 | 
			
		||||
                </LinkTo>
 | 
			
		||||
              </li>
 | 
			
		||||
            </ul>
 | 
			
		||||
          </nav>
 | 
			
		||||
        </PopupMenu>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</LinkedBlock>
 | 
			
		||||
							
								
								
									
										35
									
								
								ui/app/templates/components/mfa/method-form.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								ui/app/templates/components/mfa/method-form.hbs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,35 @@
 | 
			
		||||
<div class="box is-sideless is-fullwidth is-marginless" ...attributes>
 | 
			
		||||
  {{#each @model.attrs as |attr|}}
 | 
			
		||||
    <FormField @attr={{attr}} @model={{@model}} @modelValidations={{or @validations this.editValidations}} />
 | 
			
		||||
  {{/each}}
 | 
			
		||||
</div>
 | 
			
		||||
{{#if @hasActions}}
 | 
			
		||||
  <div class="field is-grouped-split box is-fullwidth is-bottomless">
 | 
			
		||||
    <div class="control">
 | 
			
		||||
      <button
 | 
			
		||||
        type="button"
 | 
			
		||||
        class="button is-primary {{if this.save.isRunning 'is-loading'}}"
 | 
			
		||||
        disabled={{this.save.isRunning}}
 | 
			
		||||
        onclick={{this.initSave}}
 | 
			
		||||
      >
 | 
			
		||||
        Save
 | 
			
		||||
      </button>
 | 
			
		||||
      <button type="button" class="button has-left-margin-s" disabled={{this.save.isRunning}} {{on "click" this.cancel}}>
 | 
			
		||||
        Cancel
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
{{/if}}
 | 
			
		||||
 | 
			
		||||
<ConfirmationModal
 | 
			
		||||
  @title="Edit {{@model.type}} configuration?"
 | 
			
		||||
  @onClose={{action (mut this.isEditModalActive) false}}
 | 
			
		||||
  @isActive={{this.isEditModalActive}}
 | 
			
		||||
  @confirmText={{@model.type}}
 | 
			
		||||
  @onConfirm={{perform this.save}}
 | 
			
		||||
>
 | 
			
		||||
  <p>
 | 
			
		||||
    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.
 | 
			
		||||
  </p>
 | 
			
		||||
</ConfirmationModal>
 | 
			
		||||
							
								
								
									
										43
									
								
								ui/app/templates/components/mfa/method-list-item.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								ui/app/templates/components/mfa/method-list-item.hbs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,43 @@
 | 
			
		||||
<LinkedBlock class="list-item-row" @params={{array "vault.cluster.access.mfa.methods.method" @model.id}}>
 | 
			
		||||
  <div class="level is-mobile">
 | 
			
		||||
    <div class="level-left">
 | 
			
		||||
      <div class="is-flex-row">
 | 
			
		||||
        <Icon @size="24" @name={{@model.type}} class="has-text-grey" />
 | 
			
		||||
        <div>
 | 
			
		||||
          <span class="has-text-weight-semibold has-text-black">
 | 
			
		||||
            {{if (eq @model.type "totp") (uppercase @model.type) @model.type}}
 | 
			
		||||
          </span>
 | 
			
		||||
          <span class="tag has-left-margin-xs">
 | 
			
		||||
            {{@model.id}}
 | 
			
		||||
          </span>
 | 
			
		||||
          <div class="has-top-margin-xs">
 | 
			
		||||
            <code class="is-size-9">
 | 
			
		||||
              Namespace:
 | 
			
		||||
              {{@model.namespace_id}}
 | 
			
		||||
            </code>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="level-right is-flex is-paddingless is-marginless">
 | 
			
		||||
      <div class="level-item">
 | 
			
		||||
        <PopupMenu>
 | 
			
		||||
          <nav class="menu">
 | 
			
		||||
            <ul class="menu-list">
 | 
			
		||||
              <li>
 | 
			
		||||
                <LinkTo @route="vault.cluster.access.mfa.methods.method" @model={{@model.id}}>
 | 
			
		||||
                  Details
 | 
			
		||||
                </LinkTo>
 | 
			
		||||
              </li>
 | 
			
		||||
              <li>
 | 
			
		||||
                <LinkTo @route="vault.cluster.access.mfa.methods.method.edit" @model={{@model.id}}>
 | 
			
		||||
                  Edit
 | 
			
		||||
                </LinkTo>
 | 
			
		||||
              </li>
 | 
			
		||||
            </ul>
 | 
			
		||||
          </nav>
 | 
			
		||||
        </PopupMenu>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</LinkedBlock>
 | 
			
		||||
							
								
								
									
										12
									
								
								ui/app/templates/components/mfa/nav.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								ui/app/templates/components/mfa/nav.hbs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless">
 | 
			
		||||
  <nav class="tabs">
 | 
			
		||||
    <ul>
 | 
			
		||||
      <LinkTo @route="vault.cluster.access.mfa.methods">
 | 
			
		||||
        Methods
 | 
			
		||||
      </LinkTo>
 | 
			
		||||
      <LinkTo @route="vault.cluster.access.mfa.enforcements">
 | 
			
		||||
        Enforcements
 | 
			
		||||
      </LinkTo>
 | 
			
		||||
    </ul>
 | 
			
		||||
  </nav>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -16,11 +16,24 @@
 | 
			
		||||
  <div class="control is-expanded">
 | 
			
		||||
    <div class="select is-fullwidth">
 | 
			
		||||
      <select name={{this.name}} id={{this.name}} onchange={{action "change" value="target.value"}}>
 | 
			
		||||
        {{#if this.noDefault}}
 | 
			
		||||
          <option value="">Select one</option>
 | 
			
		||||
        {{/if}}
 | 
			
		||||
        {{#each this.authMethods.last.value as |method|}}
 | 
			
		||||
          <option selected={{eq this.value method.accessor}} value={{method.accessor}}>
 | 
			
		||||
            {{method.path}}
 | 
			
		||||
            ({{method.type}})
 | 
			
		||||
          </option>
 | 
			
		||||
          {{! token type does not need to be authorized via MFA }}
 | 
			
		||||
          {{#if this.filterToken}}
 | 
			
		||||
            {{#if (not-eq method.id "token")}}
 | 
			
		||||
              <option selected={{eq this.value method.accessor}} value={{method.accessor}}>
 | 
			
		||||
                {{method.path}}
 | 
			
		||||
                ({{if this.showAccessor method.accessor method.type}})
 | 
			
		||||
              </option>
 | 
			
		||||
            {{/if}}
 | 
			
		||||
          {{else}}
 | 
			
		||||
            <option selected={{eq this.value method.accessor}} value={{method.accessor}}>
 | 
			
		||||
              {{method.path}}
 | 
			
		||||
              ({{if this.showAccessor method.accessor method.type}})
 | 
			
		||||
            </option>
 | 
			
		||||
          {{/if}}
 | 
			
		||||
        {{/each}}
 | 
			
		||||
      </select>
 | 
			
		||||
    </div>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										35
									
								
								ui/app/templates/components/radio-card.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								ui/app/templates/components/radio-card.hbs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,35 @@
 | 
			
		||||
<label
 | 
			
		||||
  for={{dasherize @value}}
 | 
			
		||||
  class="radio-card {{if (eq @value @groupValue) 'is-selected'}} {{if @disabled 'is-disabled'}}"
 | 
			
		||||
  ...attributes
 | 
			
		||||
>
 | 
			
		||||
  {{#if (has-block)}}
 | 
			
		||||
    {{yield}}
 | 
			
		||||
  {{else}}
 | 
			
		||||
    <div class="radio-card-row">
 | 
			
		||||
      <div>
 | 
			
		||||
        <Icon @name={{@icon}} @size="24" class="has-text-grey-light" />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="has-left-margin-s">
 | 
			
		||||
        <h5 class="radio-card-message-title">
 | 
			
		||||
          {{@title}}
 | 
			
		||||
        </h5>
 | 
			
		||||
        <p class="radio-card-message-body">
 | 
			
		||||
          {{@description}}
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  {{/if}}
 | 
			
		||||
  <div class="radio-card-radio-row">
 | 
			
		||||
    <RadioButton
 | 
			
		||||
      id={{dasherize @value}}
 | 
			
		||||
      name="config-mode"
 | 
			
		||||
      class="radio"
 | 
			
		||||
      disabled={{@disabled}}
 | 
			
		||||
      @value={{@value}}
 | 
			
		||||
      @groupValue={{@groupValue}}
 | 
			
		||||
      @onChange={{@onChange}}
 | 
			
		||||
    />
 | 
			
		||||
    <span class="dot"></span>
 | 
			
		||||
  </div>
 | 
			
		||||
</label>
 | 
			
		||||
@@ -1,15 +1,17 @@
 | 
			
		||||
<NavHeader as |Nav|>
 | 
			
		||||
  <Nav.home>
 | 
			
		||||
    <HomeLink @class="navbar-item splash-page-logo has-text-white">
 | 
			
		||||
      <LogoEdition />
 | 
			
		||||
    </HomeLink>
 | 
			
		||||
  </Nav.home>
 | 
			
		||||
  <Nav.items>
 | 
			
		||||
    <div class="navbar-item status-indicator-button" data-status={{if this.activeCluster.unsealed "good" "bad"}}>
 | 
			
		||||
      <StatusMenu @label="Status" @onLinkClick={{action Nav.closeDrawer}} />
 | 
			
		||||
    </div>
 | 
			
		||||
  </Nav.items>
 | 
			
		||||
</NavHeader>
 | 
			
		||||
{{#if this.showTruncatedNavBar}}
 | 
			
		||||
  <NavHeader as |Nav|>
 | 
			
		||||
    <Nav.home>
 | 
			
		||||
      <HomeLink @class="navbar-item splash-page-logo has-text-white">
 | 
			
		||||
        <LogoEdition />
 | 
			
		||||
      </HomeLink>
 | 
			
		||||
    </Nav.home>
 | 
			
		||||
    <Nav.items>
 | 
			
		||||
      <div class="navbar-item status-indicator-button" data-status={{if this.activeCluster.unsealed "good" "bad"}}>
 | 
			
		||||
        <StatusMenu @label="Status" @onLinkClick={{action Nav.closeDrawer}} />
 | 
			
		||||
      </div>
 | 
			
		||||
    </Nav.items>
 | 
			
		||||
  </NavHeader>
 | 
			
		||||
{{/if}}
 | 
			
		||||
{{! bypass UiWizard and container styling }}
 | 
			
		||||
{{#if this.hasAltContent}}
 | 
			
		||||
  {{yield (hash altContent=(component "splash-page/splash-content"))}}
 | 
			
		||||
 
 | 
			
		||||
@@ -11,6 +11,17 @@
 | 
			
		||||
        </LinkTo>
 | 
			
		||||
      </li>
 | 
			
		||||
    {{/if}}
 | 
			
		||||
    {{#if (has-permission "access" routeParams="mfa")}}
 | 
			
		||||
      <li>
 | 
			
		||||
        <LinkTo
 | 
			
		||||
          @route="vault.cluster.access.mfa.methods"
 | 
			
		||||
          @current-when="vault.cluster.access.mfa.methods vault.cluster.access.mfa.enforcements vault.cluster.access.mfa.index"
 | 
			
		||||
          data-test-link={{true}}
 | 
			
		||||
        >
 | 
			
		||||
          Multi-factor authentication
 | 
			
		||||
        </LinkTo>
 | 
			
		||||
      </li>
 | 
			
		||||
    {{/if}}
 | 
			
		||||
    {{#if (has-permission "access" routeParams="entities")}}
 | 
			
		||||
      <li>
 | 
			
		||||
        <LinkTo @route="vault.cluster.access.identity" @model="entities" data-test-link={{true}}>
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,7 @@
 | 
			
		||||
<MfaLoginEnforcementHeader @heading="New enforcement" />
 | 
			
		||||
<MfaLoginEnforcementForm
 | 
			
		||||
  @model={{this.model}}
 | 
			
		||||
  @onClose={{transition-to "vault.cluster.access.mfa.enforcements"}}
 | 
			
		||||
  @onSave={{transition-to "vault.cluster.access.mfa.enforcements.enforcement" this.model.name}}
 | 
			
		||||
  class="has-top-margin-l"
 | 
			
		||||
/>
 | 
			
		||||
@@ -0,0 +1,7 @@
 | 
			
		||||
<MfaLoginEnforcementHeader @heading="Update enforcement" />
 | 
			
		||||
<MfaLoginEnforcementForm
 | 
			
		||||
  @model={{this.model}}
 | 
			
		||||
  @onSave={{transition-to "vault.cluster.access.mfa.enforcements.enforcement" this.model.name}}
 | 
			
		||||
  @onClose={{transition-to "vault.cluster.access.mfa.enforcements.enforcement" this.model.name}}
 | 
			
		||||
  class="has-top-margin-l"
 | 
			
		||||
/>
 | 
			
		||||
@@ -0,0 +1,105 @@
 | 
			
		||||
<PageHeader as |p|>
 | 
			
		||||
  <p.top>
 | 
			
		||||
    <nav class="breadcrumb" aria-label="breadcrumb">
 | 
			
		||||
      <ul>
 | 
			
		||||
        <li>
 | 
			
		||||
          <span class="sep">/</span>
 | 
			
		||||
          <LinkTo @route="vault.cluster.access.mfa.enforcements.index">
 | 
			
		||||
            Enforcements
 | 
			
		||||
          </LinkTo>
 | 
			
		||||
        </li>
 | 
			
		||||
      </ul>
 | 
			
		||||
    </nav>
 | 
			
		||||
  </p.top>
 | 
			
		||||
  <p.levelLeft>
 | 
			
		||||
    <h1 class="title is-3">
 | 
			
		||||
      <Icon @name="lock" @size="24" />
 | 
			
		||||
      {{this.model.name}}
 | 
			
		||||
    </h1>
 | 
			
		||||
  </p.levelLeft>
 | 
			
		||||
</PageHeader>
 | 
			
		||||
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless">
 | 
			
		||||
  <nav class="tabs" aria-label="Enforcement tabs">
 | 
			
		||||
    <ul>
 | 
			
		||||
      <LinkTo @route="vault.cluster.access.mfa.enforcements.enforcement" @query={{hash tab="targets"}}>
 | 
			
		||||
        Targets
 | 
			
		||||
      </LinkTo>
 | 
			
		||||
      <LinkTo @route="vault.cluster.access.mfa.enforcements.enforcement" @query={{hash tab="methods"}}>
 | 
			
		||||
        Methods
 | 
			
		||||
      </LinkTo>
 | 
			
		||||
    </ul>
 | 
			
		||||
  </nav>
 | 
			
		||||
</div>
 | 
			
		||||
<Toolbar>
 | 
			
		||||
  <ToolbarActions>
 | 
			
		||||
    <button class="toolbar-link" onclick={{action (mut this.showDeleteConfirmation) true}} type="button">
 | 
			
		||||
      Delete
 | 
			
		||||
    </button>
 | 
			
		||||
    <div class="toolbar-separator"></div>
 | 
			
		||||
    <ToolbarLink @params={{array "vault.cluster.access.mfa.enforcements.enforcement.edit" this.model.id}}>
 | 
			
		||||
      Edit enforcement
 | 
			
		||||
    </ToolbarLink>
 | 
			
		||||
  </ToolbarActions>
 | 
			
		||||
</Toolbar>
 | 
			
		||||
 | 
			
		||||
{{#if (eq this.tab "targets")}}
 | 
			
		||||
  {{#each @model.targets as |target|}}
 | 
			
		||||
    <LinkedBlock class="list-item-row" @disabled={{not target.link}} @params={{union (array target.link) target.linkModels}}>
 | 
			
		||||
      <div class="level is-mobile">
 | 
			
		||||
        <div class="level-left">
 | 
			
		||||
          <div>
 | 
			
		||||
            <Icon @name={{target.icon}} />
 | 
			
		||||
            <span class="has-text-weight-semibold has-text-black">
 | 
			
		||||
              {{target.title}}
 | 
			
		||||
            </span>
 | 
			
		||||
            <div class="has-text-grey is-size-8">
 | 
			
		||||
              <code>
 | 
			
		||||
                {{target.subTitle}}
 | 
			
		||||
              </code>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        {{#if target.link}}
 | 
			
		||||
          <div class="level-right is-flex is-paddingless is-marginless">
 | 
			
		||||
            <div class="level-item">
 | 
			
		||||
              <PopupMenu>
 | 
			
		||||
                <nav class="menu" aria-label="Enforcement target more menu">
 | 
			
		||||
                  <ul class="menu-list">
 | 
			
		||||
                    <li>
 | 
			
		||||
                      <LinkTo @route={{target.link}} @models={{target.linkModels}}>
 | 
			
		||||
                        Details
 | 
			
		||||
                      </LinkTo>
 | 
			
		||||
                    </li>
 | 
			
		||||
                  </ul>
 | 
			
		||||
                </nav>
 | 
			
		||||
              </PopupMenu>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        {{/if}}
 | 
			
		||||
      </div>
 | 
			
		||||
    </LinkedBlock>
 | 
			
		||||
  {{/each}}
 | 
			
		||||
{{else if (eq this.tab "methods")}}
 | 
			
		||||
  {{#each this.model.mfa_methods as |method|}}
 | 
			
		||||
    <Mfa::MethodListItem @model={{method}} />
 | 
			
		||||
  {{/each}}
 | 
			
		||||
{{/if}}
 | 
			
		||||
 | 
			
		||||
<ConfirmationModal
 | 
			
		||||
  @title="Delete enforcement?"
 | 
			
		||||
  @confirmText={{this.model.name}}
 | 
			
		||||
  @toConfirmMsg="deleting the transformation."
 | 
			
		||||
  @buttonText="Delete"
 | 
			
		||||
  @isActive={{this.showDeleteConfirmation}}
 | 
			
		||||
  @onClose={{action (mut this.showDeleteConfirmation) false}}
 | 
			
		||||
  @onConfirm={{this.delete}}
 | 
			
		||||
>
 | 
			
		||||
  <p class="has-bottom-margin-m">
 | 
			
		||||
    Deleting the
 | 
			
		||||
    <strong>{{this.model.name}}</strong>
 | 
			
		||||
    enforcement will mean that the MFA method that depends on it will no longer enforce multi-factor authentication.
 | 
			
		||||
    <br /><br />
 | 
			
		||||
    Deleting this enforcement cannot be undone; it will have to be recreated.
 | 
			
		||||
  </p>
 | 
			
		||||
  <MessageError @model={{this.model}} @errorMessage={{this.deleteError}} />
 | 
			
		||||
</ConfirmationModal>
 | 
			
		||||
@@ -0,0 +1,25 @@
 | 
			
		||||
<PageHeader as |p|>
 | 
			
		||||
  <p.levelLeft>
 | 
			
		||||
    <h1 class="title is-3">
 | 
			
		||||
      Multi-factor Authentication
 | 
			
		||||
    </h1>
 | 
			
		||||
  </p.levelLeft>
 | 
			
		||||
</PageHeader>
 | 
			
		||||
 | 
			
		||||
<Mfa::Nav />
 | 
			
		||||
 | 
			
		||||
<Toolbar>
 | 
			
		||||
  <ToolbarActions>
 | 
			
		||||
    <ToolbarLink @type="add" @params={{array "vault.cluster.access.mfa.enforcements.create"}}>
 | 
			
		||||
      New enforcement
 | 
			
		||||
    </ToolbarLink>
 | 
			
		||||
  </ToolbarActions>
 | 
			
		||||
</Toolbar>
 | 
			
		||||
 | 
			
		||||
{{#if (gt this.model.length 0)}}
 | 
			
		||||
  {{#each this.model as |item|}}
 | 
			
		||||
    <Mfa::LoginEnforcementListItem @model={{item}} />
 | 
			
		||||
  {{/each}}
 | 
			
		||||
{{else}}
 | 
			
		||||
  <EmptyState @title="No enforcements found." @message="Add a new one to get started." />
 | 
			
		||||
{{/if}}
 | 
			
		||||
							
								
								
									
										35
									
								
								ui/app/templates/vault/cluster/access/mfa/index.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								ui/app/templates/vault/cluster/access/mfa/index.hbs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,35 @@
 | 
			
		||||
<PageHeader as |p|>
 | 
			
		||||
  <p.levelLeft>
 | 
			
		||||
    <h1 class="title is-3">
 | 
			
		||||
      Multi-factor authentication
 | 
			
		||||
    </h1>
 | 
			
		||||
  </p.levelLeft>
 | 
			
		||||
</PageHeader>
 | 
			
		||||
 | 
			
		||||
<div class="box is-fullwidth is-sideless is-flex-between is-box-shadowless is-marginless">
 | 
			
		||||
  <p>
 | 
			
		||||
    Configure and enforce multi-factor authentication (MFA) for users logging into Vault, for any
 | 
			
		||||
    <br />
 | 
			
		||||
    authentication method.
 | 
			
		||||
    <LearnLink @path="/tutorials/vault/multi-factor-authentication">
 | 
			
		||||
      Learn more
 | 
			
		||||
    </LearnLink>
 | 
			
		||||
  </p>
 | 
			
		||||
  <button type="submit" class="button is-primary" {{on "click" (transition-to "vault.cluster.access.mfa.methods.create")}}>
 | 
			
		||||
    Configure MFA
 | 
			
		||||
  </button>
 | 
			
		||||
</div>
 | 
			
		||||
<div class="box is-fullwidth is-shadowless">
 | 
			
		||||
  <p>
 | 
			
		||||
    <b>Step 1:</b>
 | 
			
		||||
    Set up an MFA configuration using one of the methods; TOTP, Okta, Duo or Pingid.
 | 
			
		||||
  </p>
 | 
			
		||||
  <p>
 | 
			
		||||
    <b>Step 2:</b>
 | 
			
		||||
    Set up an enforcement to map the MFA configuration to your chosen auth method(s).
 | 
			
		||||
  </p>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="box is-fullwidth is-shadowless">
 | 
			
		||||
  <img src={{img-path "~/mfa-landing.png"}} alt="MFA configure diagram" />
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										98
									
								
								ui/app/templates/vault/cluster/access/mfa/methods/create.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								ui/app/templates/vault/cluster/access/mfa/methods/create.hbs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,98 @@
 | 
			
		||||
<PageHeader as |p|>
 | 
			
		||||
  <p.levelLeft class="has-border-bottom-light">
 | 
			
		||||
    <h1 class="title is-3">
 | 
			
		||||
      {{#if this.method}}
 | 
			
		||||
        Configure
 | 
			
		||||
        {{this.method.name}}
 | 
			
		||||
        MFA
 | 
			
		||||
      {{else}}
 | 
			
		||||
        Multi-factor authentication
 | 
			
		||||
      {{/if}}
 | 
			
		||||
    </h1>
 | 
			
		||||
  </p.levelLeft>
 | 
			
		||||
  <p.top>
 | 
			
		||||
    <nav class="breadcrumb">
 | 
			
		||||
      <ul>
 | 
			
		||||
        <li>
 | 
			
		||||
          <span class="sep">/</span>
 | 
			
		||||
          <LinkTo @route="vault.cluster.access.mfa.methods.index">
 | 
			
		||||
            Methods
 | 
			
		||||
          </LinkTo>
 | 
			
		||||
        </li>
 | 
			
		||||
      </ul>
 | 
			
		||||
    </nav>
 | 
			
		||||
  </p.top>
 | 
			
		||||
</PageHeader>
 | 
			
		||||
<div class="has-border-top-light has-top-padding-l">
 | 
			
		||||
  {{#if this.showForms}}
 | 
			
		||||
    <h3 class="is-size-4 has-text-semibold">Settings</h3>
 | 
			
		||||
    <p class="has-border-top-light has-top-padding-l">
 | 
			
		||||
      {{this.description}}
 | 
			
		||||
      <DocLink @path={{concat "/api-docs/secret/identity/mfa/" this.type}}>Learn more.</DocLink>
 | 
			
		||||
    </p>
 | 
			
		||||
    <Mfa::MethodForm @model={{this.method}} @validations={{this.methodErrors}} class="is-box-shadowless" />
 | 
			
		||||
    <MfaLoginEnforcementHeader
 | 
			
		||||
      @isInline={{true}}
 | 
			
		||||
      @radioCardGroupValue={{this.enforcementPreference}}
 | 
			
		||||
      @onRadioCardSelect={{this.onEnforcementPreferenceChange}}
 | 
			
		||||
      @onEnforcementSelect={{fn (mut this.enforcement)}}
 | 
			
		||||
    />
 | 
			
		||||
    {{#if (eq this.enforcementPreference "new")}}
 | 
			
		||||
      <MfaLoginEnforcementForm
 | 
			
		||||
        @model={{this.enforcement}}
 | 
			
		||||
        @isInline={{true}}
 | 
			
		||||
        @modelErrors={{this.enforcementErrors}}
 | 
			
		||||
        class="has-top-margin-l"
 | 
			
		||||
      />
 | 
			
		||||
    {{/if}}
 | 
			
		||||
  {{else}}
 | 
			
		||||
    <p>
 | 
			
		||||
      Multi-factor authentication (MFA) allows you to set up another layer of security on top of existing authentication
 | 
			
		||||
      methods. Vault has four available methods.
 | 
			
		||||
      <DocLink @path="/api-docs/secret/identity/mfa">Learn more.</DocLink>
 | 
			
		||||
    </p>
 | 
			
		||||
    <div class="is-flex-row has-top-margin-xl">
 | 
			
		||||
      {{#each this.methodNames as |methodName|}}
 | 
			
		||||
        <RadioCard @value={{lowercase methodName}} @groupValue={{this.type}} @onChange={{this.onTypeSelect}}>
 | 
			
		||||
          <div class="radio-card-row is-flex-v-centered">
 | 
			
		||||
            <div>
 | 
			
		||||
              <Icon
 | 
			
		||||
                @name={{if (eq methodName "Okta") "okta-color" (lowercase methodName)}}
 | 
			
		||||
                @size="24"
 | 
			
		||||
                class={{if (eq methodName "TOTP") "has-text-grey"}}
 | 
			
		||||
              />
 | 
			
		||||
              <p class="has-text-semibold has-text-align-center {{if (eq methodName 'Okta') 'has-top-margin-xs'}}">
 | 
			
		||||
                {{methodName}}
 | 
			
		||||
              </p>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </RadioCard>
 | 
			
		||||
      {{/each}}
 | 
			
		||||
    </div>
 | 
			
		||||
    {{#if this.type}}
 | 
			
		||||
      <p class="has-top-margin-l">
 | 
			
		||||
        {{this.description}}
 | 
			
		||||
        <DocLink @path={{concat "/api-docs/secret/identity/mfa/" this.type}}>Learn more.</DocLink>
 | 
			
		||||
      </p>
 | 
			
		||||
      {{! 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}}
 | 
			
		||||
 | 
			
		||||
  <div class="has-top-margin-l has-border-top-light">
 | 
			
		||||
    <div class="has-top-margin-l has-bottom-margin-l">
 | 
			
		||||
      {{#if this.showForms}}
 | 
			
		||||
        <button class="button is-primary" type="button" {{on "click" (perform this.save)}}>
 | 
			
		||||
          Continue
 | 
			
		||||
        </button>
 | 
			
		||||
        <button class="button has-left-margin-xs" type="button" {{on "click" this.cancel}}>
 | 
			
		||||
          Cancel
 | 
			
		||||
        </button>
 | 
			
		||||
      {{else if this.type}}
 | 
			
		||||
        <button class="button is-primary" type="button" {{on "click" this.createModels}}>
 | 
			
		||||
          Next
 | 
			
		||||
        </button>
 | 
			
		||||
      {{/if}}
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										25
									
								
								ui/app/templates/vault/cluster/access/mfa/methods/index.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								ui/app/templates/vault/cluster/access/mfa/methods/index.hbs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
<PageHeader as |p|>
 | 
			
		||||
  <p.levelLeft>
 | 
			
		||||
    <h1 class="title is-3">
 | 
			
		||||
      Multi-factor Authentication
 | 
			
		||||
    </h1>
 | 
			
		||||
  </p.levelLeft>
 | 
			
		||||
</PageHeader>
 | 
			
		||||
 | 
			
		||||
<Mfa::Nav />
 | 
			
		||||
 | 
			
		||||
<Toolbar>
 | 
			
		||||
  <ToolbarActions>
 | 
			
		||||
    <ToolbarLink @type="add" @params={{array "vault.cluster.access.mfa.methods.create"}}>
 | 
			
		||||
      New MFA method
 | 
			
		||||
    </ToolbarLink>
 | 
			
		||||
  </ToolbarActions>
 | 
			
		||||
</Toolbar>
 | 
			
		||||
 | 
			
		||||
{{#if (gt this.model.length 0)}}
 | 
			
		||||
  {{#each this.model as |item|}}
 | 
			
		||||
    <Mfa::MethodListItem @model={{item}} />
 | 
			
		||||
  {{/each}}
 | 
			
		||||
{{else}}
 | 
			
		||||
  <EmptyState @title="No methods found." @message="Add a new one to get started." />
 | 
			
		||||
{{/if}}
 | 
			
		||||
@@ -0,0 +1,16 @@
 | 
			
		||||
<PageHeader as |p|>
 | 
			
		||||
  <p.levelLeft>
 | 
			
		||||
    <h1 class="title is-3">
 | 
			
		||||
      Configure
 | 
			
		||||
      {{this.model.method.name}}
 | 
			
		||||
      MFA
 | 
			
		||||
    </h1>
 | 
			
		||||
  </p.levelLeft>
 | 
			
		||||
</PageHeader>
 | 
			
		||||
 | 
			
		||||
<Mfa::MethodForm
 | 
			
		||||
  @model={{this.model.method}}
 | 
			
		||||
  @hasActions={{true}}
 | 
			
		||||
  @onSave={{transition-to "vault.cluster.access.mfa.methods.method" this.model.method.id}}
 | 
			
		||||
  @onClose={{transition-to "vault.cluster.access.mfa.methods.method" this.model.method.id}}
 | 
			
		||||
/>
 | 
			
		||||
@@ -0,0 +1,90 @@
 | 
			
		||||
<PageHeader as |p|>
 | 
			
		||||
  <p.top>
 | 
			
		||||
    <nav class="breadcrumb">
 | 
			
		||||
      <ul>
 | 
			
		||||
        <li>
 | 
			
		||||
          <span class="sep">/</span>
 | 
			
		||||
          <LinkTo @route="vault.cluster.access.mfa.methods.index">
 | 
			
		||||
            Methods
 | 
			
		||||
          </LinkTo>
 | 
			
		||||
        </li>
 | 
			
		||||
      </ul>
 | 
			
		||||
    </nav>
 | 
			
		||||
  </p.top>
 | 
			
		||||
  <p.levelLeft>
 | 
			
		||||
    <h1 class="title is-3">
 | 
			
		||||
      <Icon @size="24" @name={{this.model.method.type}} />
 | 
			
		||||
      {{this.model.method.name}}
 | 
			
		||||
    </h1>
 | 
			
		||||
  </p.levelLeft>
 | 
			
		||||
</PageHeader>
 | 
			
		||||
 | 
			
		||||
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless">
 | 
			
		||||
  <nav class="tabs">
 | 
			
		||||
    <ul>
 | 
			
		||||
      <LinkTo @route="vault.cluster.access.mfa.methods.method" @query={{hash tab="config"}}>
 | 
			
		||||
        Configuration
 | 
			
		||||
      </LinkTo>
 | 
			
		||||
      <LinkTo @route="vault.cluster.access.mfa.methods.method" @query={{hash tab="enforcements"}}>
 | 
			
		||||
        Enforcements
 | 
			
		||||
      </LinkTo>
 | 
			
		||||
    </ul>
 | 
			
		||||
  </nav>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
{{#if (eq this.tab "config")}}
 | 
			
		||||
  <Toolbar>
 | 
			
		||||
    <ToolbarActions>
 | 
			
		||||
      <ConfirmAction
 | 
			
		||||
        @buttonClasses="toolbar-link"
 | 
			
		||||
        @disabled={{not (is-empty this.model.enforcements)}}
 | 
			
		||||
        @onConfirmAction={{this.deleteMethod}}
 | 
			
		||||
        @confirmTitle="Are you sure?"
 | 
			
		||||
        @confirmMessage="Deleting this MFA configuration is permanent, and it will no longer be available."
 | 
			
		||||
        @confirmButtonText="Delete"
 | 
			
		||||
      >
 | 
			
		||||
        Delete
 | 
			
		||||
      </ConfirmAction>
 | 
			
		||||
      <ToolbarLink @params={{array "vault.cluster.access.mfa.methods.method.edit" this.model.method.id}}>
 | 
			
		||||
        Edit
 | 
			
		||||
      </ToolbarLink>
 | 
			
		||||
    </ToolbarActions>
 | 
			
		||||
  </Toolbar>
 | 
			
		||||
  <div class="box is-fullwidth is-sideless is-paddingless is-marginless">
 | 
			
		||||
    {{#each this.model.method.attrs as |attr|}}
 | 
			
		||||
      {{#if (eq attr.type "object")}}
 | 
			
		||||
        <InfoTableRow
 | 
			
		||||
          @alwaysRender={{not (is-empty-value (get this.model.method attr.name))}}
 | 
			
		||||
          @label={{or attr.options.label (to-label attr.name)}}
 | 
			
		||||
          @value={{stringify (get this.model.method attr.name)}}
 | 
			
		||||
        />
 | 
			
		||||
      {{else}}
 | 
			
		||||
        <InfoTableRow
 | 
			
		||||
          @alwaysRender={{not (is-empty-value (get this.model.method attr.name))}}
 | 
			
		||||
          @label={{or attr.options.label (to-label attr.name)}}
 | 
			
		||||
          @value={{get this.model.method attr.name}}
 | 
			
		||||
        />
 | 
			
		||||
      {{/if}}
 | 
			
		||||
    {{/each}}
 | 
			
		||||
  </div>
 | 
			
		||||
{{else if (eq this.tab "enforcements")}}
 | 
			
		||||
  <Toolbar>
 | 
			
		||||
    <ToolbarActions>
 | 
			
		||||
      <ToolbarLink @type="add" @params={{array "vault.cluster.access.mfa.enforcements.create"}}>
 | 
			
		||||
        New enforcement
 | 
			
		||||
      </ToolbarLink>
 | 
			
		||||
    </ToolbarActions>
 | 
			
		||||
  </Toolbar>
 | 
			
		||||
  <div class="box is-fullwidth is-sideless is-paddingless is-marginless">
 | 
			
		||||
    {{#if (is-empty this.model.enforcements)}}
 | 
			
		||||
      <EmptyState
 | 
			
		||||
        @title="No enforcements found."
 | 
			
		||||
        @message="No enforcements are applied to this MFA method. Edit an existing enforcement or add a new one to get started."
 | 
			
		||||
      />
 | 
			
		||||
    {{else}}
 | 
			
		||||
      {{#each this.model.enforcements as |item|}}
 | 
			
		||||
        <Mfa::LoginEnforcementListItem @model={{item}} />
 | 
			
		||||
      {{/each}}
 | 
			
		||||
    {{/if}}
 | 
			
		||||
  </div>
 | 
			
		||||
{{/if}}
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
<SplashPage @hasAltContent={{this.auth.mfaErrors}} as |Page|>
 | 
			
		||||
<SplashPage @hasAltContent={{this.mfaErrors}} as |Page|>
 | 
			
		||||
  <Page.altContent>
 | 
			
		||||
    <div class="has-top-margin-xxl" data-test-mfa-error>
 | 
			
		||||
      <EmptyState
 | 
			
		||||
@@ -6,7 +6,7 @@
 | 
			
		||||
        @message="Multi-factor authentication is required, but failed. Go back and try again, or contact your administrator."
 | 
			
		||||
        @icon="alert-circle"
 | 
			
		||||
        @bottomBorder={{true}}
 | 
			
		||||
        @subTitle={{join ". " this.auth.mfaErrors}}
 | 
			
		||||
        @subTitle={{join ". " this.mfaErrors}}
 | 
			
		||||
        class="is-box-shadowless"
 | 
			
		||||
      >
 | 
			
		||||
        <button type="button" class="button is-ghost is-transparent" {{on "click" (action "onMfaErrorDismiss")}}>
 | 
			
		||||
@@ -99,7 +99,12 @@
 | 
			
		||||
  {{/unless}}
 | 
			
		||||
  <Page.content>
 | 
			
		||||
    {{#if this.mfaAuthData}}
 | 
			
		||||
      <MfaForm @clusterId={{this.model.id}} @authData={{this.mfaAuthData}} @onSuccess={{action "onMfaSuccess"}} />
 | 
			
		||||
      <MfaForm
 | 
			
		||||
        @clusterId={{this.model.id}}
 | 
			
		||||
        @authData={{this.mfaAuthData}}
 | 
			
		||||
        @onSuccess={{action "onMfaSuccess"}}
 | 
			
		||||
        @onError={{fn (mut this.mfaErrors)}}
 | 
			
		||||
      />
 | 
			
		||||
    {{else}}
 | 
			
		||||
      <AuthForm
 | 
			
		||||
        @wrappedToken={{this.wrappedToken}}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										29
									
								
								ui/app/templates/vault/cluster/mfa-setup.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								ui/app/templates/vault/cluster/mfa-setup.hbs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
			
		||||
<SplashPage @showTruncatedNavBar={{false}} as |Page|>
 | 
			
		||||
  <Page.header>
 | 
			
		||||
    <h1 class="title is-4">MFA setup</h1>
 | 
			
		||||
  </Page.header>
 | 
			
		||||
  <Page.content>
 | 
			
		||||
    <div class="auth-form" data-test-mfa-form>
 | 
			
		||||
      <div class="box">
 | 
			
		||||
        {{#if (eq this.onStep 1)}}
 | 
			
		||||
          <MfaSetupStepOne
 | 
			
		||||
            @entityId={{this.entityId}}
 | 
			
		||||
            @isUUIDVerified={{this.isUUIDVerified}}
 | 
			
		||||
            @restartFlow={{this.restartFlow}}
 | 
			
		||||
            @saveUUIDandQrCode={{this.saveUUIDandQrCode}}
 | 
			
		||||
            @showWarning={{this.showWarning}}
 | 
			
		||||
          />
 | 
			
		||||
        {{/if}}
 | 
			
		||||
        {{#if (eq this.onStep 2)}}
 | 
			
		||||
          <MfaSetupStepTwo
 | 
			
		||||
            @entityId={{this.entityId}}
 | 
			
		||||
            @uuid={{this.uuid}}
 | 
			
		||||
            @qrCode={{this.qrCode}}
 | 
			
		||||
            @restartFlow={{this.restartFlow}}
 | 
			
		||||
            @warning={{this.warning}}
 | 
			
		||||
          />
 | 
			
		||||
        {{/if}}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </Page.content>
 | 
			
		||||
</SplashPage>
 | 
			
		||||
@@ -1,3 +1,3 @@
 | 
			
		||||
<div class="linked-block" role="link" {{on "click" this.onClick}} ...attributes>
 | 
			
		||||
<div class={{unless @disabled "linked-block"}} role="link" {{on "click" this.onClick}} ...attributes>
 | 
			
		||||
  {{yield}}
 | 
			
		||||
</div>
 | 
			
		||||
@@ -23,6 +23,7 @@ import { encodePath } from 'vault/utils/path-encoding-helpers';
 | 
			
		||||
 * @param {Object} [queryParams=null] - queryParams can be passed via this property. It needs to be an object.
 | 
			
		||||
 * @param {String} [linkPrefix=null] - Overwrite the params with custom route.  See KMIP.
 | 
			
		||||
 * @param {Boolean} [encode=false] - Encode the path.
 | 
			
		||||
 * @param {boolean} [disabled] - disable the link -- prevents on click and removes linked-block hover styling
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
export default class LinkedBlockComponent extends Component {
 | 
			
		||||
@@ -30,32 +31,34 @@ export default class LinkedBlockComponent extends Component {
 | 
			
		||||
 | 
			
		||||
  @action
 | 
			
		||||
  onClick(event) {
 | 
			
		||||
    const $target = event.target;
 | 
			
		||||
    const isAnchorOrButton =
 | 
			
		||||
      $target.tagName === 'A' ||
 | 
			
		||||
      $target.tagName === 'BUTTON' ||
 | 
			
		||||
      $target.closest('button') ||
 | 
			
		||||
      $target.closest('a');
 | 
			
		||||
    if (!isAnchorOrButton) {
 | 
			
		||||
      let params = this.args.params;
 | 
			
		||||
      if (this.args.encode) {
 | 
			
		||||
        params = params.map((param, index) => {
 | 
			
		||||
          if (index === 0 || typeof param !== 'string') {
 | 
			
		||||
            return param;
 | 
			
		||||
          }
 | 
			
		||||
          return encodePath(param);
 | 
			
		||||
        });
 | 
			
		||||
    if (!this.args.disabled) {
 | 
			
		||||
      const $target = event.target;
 | 
			
		||||
      const isAnchorOrButton =
 | 
			
		||||
        $target.tagName === 'A' ||
 | 
			
		||||
        $target.tagName === 'BUTTON' ||
 | 
			
		||||
        $target.closest('button') ||
 | 
			
		||||
        $target.closest('a');
 | 
			
		||||
      if (!isAnchorOrButton) {
 | 
			
		||||
        let params = this.args.params;
 | 
			
		||||
        if (this.args.encode) {
 | 
			
		||||
          params = params.map((param, index) => {
 | 
			
		||||
            if (index === 0 || typeof param !== 'string') {
 | 
			
		||||
              return param;
 | 
			
		||||
            }
 | 
			
		||||
            return encodePath(param);
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
        const queryParams = this.args.queryParams;
 | 
			
		||||
        if (queryParams) {
 | 
			
		||||
          params.push({ queryParams });
 | 
			
		||||
        }
 | 
			
		||||
        if (this.args.linkPrefix) {
 | 
			
		||||
          let targetRoute = this.args.params[0];
 | 
			
		||||
          targetRoute = `${this.args.linkPrefix}.${targetRoute}`;
 | 
			
		||||
          this.args.params[0] = targetRoute;
 | 
			
		||||
        }
 | 
			
		||||
        this.router.transitionTo(...params);
 | 
			
		||||
      }
 | 
			
		||||
      const queryParams = this.args.queryParams;
 | 
			
		||||
      if (queryParams) {
 | 
			
		||||
        params.push({ queryParams });
 | 
			
		||||
      }
 | 
			
		||||
      if (this.args.linkPrefix) {
 | 
			
		||||
        let targetRoute = this.args.params[0];
 | 
			
		||||
        targetRoute = `${this.args.linkPrefix}.${targetRoute}`;
 | 
			
		||||
        this.args.params[0] = targetRoute;
 | 
			
		||||
      }
 | 
			
		||||
      this.router.transitionTo(...params);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -16,7 +16,7 @@ import layout from '../templates/components/search-select';
 | 
			
		||||
 * @param {string} id - The name of the form field
 | 
			
		||||
 * @param {Array} models - An array of model types to fetch from the API.
 | 
			
		||||
 * @param {function} onChange - The onchange action for this form field.
 | 
			
		||||
 * @param {string | Array} inputValue -  A comma-separated string or an array of strings.
 | 
			
		||||
 * @param {string | Array} inputValue -  A comma-separated string or an array of strings -- array of ids for models.
 | 
			
		||||
 * @param {string} label - Label for this form field
 | 
			
		||||
 * @param {string} fallbackComponent - name of component to be rendered if the API call 403s
 | 
			
		||||
 * @param {string} [backend] - name of the backend if the query for options needs additional information (eg. secret backend)
 | 
			
		||||
 
 | 
			
		||||
@@ -10,12 +10,14 @@
 | 
			
		||||
    id=this.id
 | 
			
		||||
  }}
 | 
			
		||||
{{else}}
 | 
			
		||||
  <label class={{if this.labelClass this.labelClass "title is-4"}} data-test-field-label>
 | 
			
		||||
    {{this.label}}
 | 
			
		||||
    {{#if this.helpText}}
 | 
			
		||||
      <InfoTooltip>{{this.helpText}}</InfoTooltip>
 | 
			
		||||
    {{/if}}
 | 
			
		||||
  </label>
 | 
			
		||||
  {{#if this.label}}
 | 
			
		||||
    <label class={{if this.labelClass this.labelClass "title is-4"}} data-test-field-label>
 | 
			
		||||
      {{this.label}}
 | 
			
		||||
      {{#if this.helpText}}
 | 
			
		||||
        <InfoTooltip>{{this.helpText}}</InfoTooltip>
 | 
			
		||||
      {{/if}}
 | 
			
		||||
    </label>
 | 
			
		||||
  {{/if}}
 | 
			
		||||
  {{#if this.subLabel}}
 | 
			
		||||
    <p class="is-label">{{this.subLabel}}</p>
 | 
			
		||||
  {{/if}}
 | 
			
		||||
 
 | 
			
		||||
@@ -21,6 +21,8 @@ export const localIconMap = {
 | 
			
		||||
  radius: 'user',
 | 
			
		||||
  ssh: 'terminal-screen',
 | 
			
		||||
  totp: 'history',
 | 
			
		||||
  duo: null,
 | 
			
		||||
  pingid: null,
 | 
			
		||||
  transit: 'swap-horizontal',
 | 
			
		||||
  userpass: 'identity-user',
 | 
			
		||||
  stopwatch: 'clock',
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										19
									
								
								ui/mirage/factories/mfa-duo-method.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								ui/mirage/factories/mfa-duo-method.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
import { Factory } from 'ember-cli-mirage';
 | 
			
		||||
 | 
			
		||||
export default Factory.extend({
 | 
			
		||||
  api_hostname: 'api-foobar.duosecurity.com',
 | 
			
		||||
  mount_accessor: '',
 | 
			
		||||
  name: '', // returned but cannot be set at this time
 | 
			
		||||
  namespace_id: 'root',
 | 
			
		||||
  pushinfo: '',
 | 
			
		||||
  type: 'duo',
 | 
			
		||||
  use_passcode: false,
 | 
			
		||||
  username_template: '',
 | 
			
		||||
 | 
			
		||||
  afterCreate(record) {
 | 
			
		||||
    if (record.name) {
 | 
			
		||||
      console.warn('Endpoint ignored these unrecognized parameters: [name]'); // eslint-disable-line
 | 
			
		||||
      record.name = '';
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										41
									
								
								ui/mirage/factories/mfa-login-enforcement.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								ui/mirage/factories/mfa-login-enforcement.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,41 @@
 | 
			
		||||
import { Factory } from 'ember-cli-mirage';
 | 
			
		||||
 | 
			
		||||
export default Factory.extend({
 | 
			
		||||
  auth_method_accessors: null,
 | 
			
		||||
  auth_method_types: null,
 | 
			
		||||
  identity_entity_ids: null,
 | 
			
		||||
  identity_group_ids: null,
 | 
			
		||||
  mfa_method_ids: null,
 | 
			
		||||
  name: null,
 | 
			
		||||
  namespace_id: 'root',
 | 
			
		||||
 | 
			
		||||
  afterCreate(record, server) {
 | 
			
		||||
    // initialize arrays and stub some data if not provided
 | 
			
		||||
    if (!record.name) {
 | 
			
		||||
      // use random string for generated name
 | 
			
		||||
      record.update('name', (Math.random() + 1).toString(36).substring(2));
 | 
			
		||||
    }
 | 
			
		||||
    if (!record.mfa_method_ids) {
 | 
			
		||||
      // aggregate all existing methods and choose a random one
 | 
			
		||||
      const methods = ['Totp', 'Duo', 'Okta', 'Pingid'].reduce((methods, type) => {
 | 
			
		||||
        const records = server.schema.db[`mfa${type}Methods`].where({});
 | 
			
		||||
        if (records.length) {
 | 
			
		||||
          methods.push(...records);
 | 
			
		||||
        }
 | 
			
		||||
        return methods;
 | 
			
		||||
      }, []);
 | 
			
		||||
      // if no methods were found create one since it is a required for login enforcements
 | 
			
		||||
      if (!methods.length) {
 | 
			
		||||
        methods.push(server.create('mfa-totp-method'));
 | 
			
		||||
      }
 | 
			
		||||
      const method = methods.length ? methods[Math.floor(Math.random() * methods.length)] : null;
 | 
			
		||||
      record.update('mfa_method_ids', method ? [method.id] : []);
 | 
			
		||||
    }
 | 
			
		||||
    const keys = ['auth_method_accessors', 'auth_method_types', 'identity_group_ids', 'identity_entity_ids'];
 | 
			
		||||
    keys.forEach((key) => {
 | 
			
		||||
      if (!record[key]) {
 | 
			
		||||
        record.update(key, key === 'auth_method_types' ? ['userpass'] : []);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										18
									
								
								ui/mirage/factories/mfa-okta-method.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								ui/mirage/factories/mfa-okta-method.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
import { Factory } from 'ember-cli-mirage';
 | 
			
		||||
 | 
			
		||||
export default Factory.extend({
 | 
			
		||||
  base_url: 'okta.com',
 | 
			
		||||
  mount_accessor: '',
 | 
			
		||||
  name: '', // returned but cannot be set at this time
 | 
			
		||||
  namespace_id: 'root',
 | 
			
		||||
  org_name: 'dev-foobar',
 | 
			
		||||
  type: 'okta',
 | 
			
		||||
  username_template: '', // returned but cannot be set at this time
 | 
			
		||||
 | 
			
		||||
  afterCreate(record) {
 | 
			
		||||
    if (record.name) {
 | 
			
		||||
      console.warn('Endpoint ignored these unrecognized parameters: [name]'); // eslint-disable-line
 | 
			
		||||
      record.name = '';
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										11
									
								
								ui/mirage/factories/mfa-pingid-method.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								ui/mirage/factories/mfa-pingid-method.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
import { Factory } from 'ember-cli-mirage';
 | 
			
		||||
 | 
			
		||||
export default Factory.extend({
 | 
			
		||||
  use_signature: true,
 | 
			
		||||
  idp_url: 'https://foobar.pingidentity.com/pingid',
 | 
			
		||||
  admin_url: 'https://foobar.pingidentity.com/pingid',
 | 
			
		||||
  authenticator_url: 'https://authenticator.pingone.com/pingid/ppm',
 | 
			
		||||
  org_alias: 'foobarbaz',
 | 
			
		||||
  type: 'pingid',
 | 
			
		||||
  username_template: '',
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										22
									
								
								ui/mirage/factories/mfa-totp-method.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								ui/mirage/factories/mfa-totp-method.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
import { Factory } from 'ember-cli-mirage';
 | 
			
		||||
 | 
			
		||||
export default Factory.extend({
 | 
			
		||||
  algorithm: 'SHA1',
 | 
			
		||||
  digits: 6,
 | 
			
		||||
  issuer: 'Vault',
 | 
			
		||||
  key_size: 20,
 | 
			
		||||
  max_validation_attempts: 5,
 | 
			
		||||
  name: '', // returned but cannot be set at this time
 | 
			
		||||
  namespace_id: 'root',
 | 
			
		||||
  period: 30,
 | 
			
		||||
  qr_size: 200,
 | 
			
		||||
  skew: 1,
 | 
			
		||||
  type: 'totp',
 | 
			
		||||
 | 
			
		||||
  afterCreate(record) {
 | 
			
		||||
    if (record.name) {
 | 
			
		||||
      console.warn('Endpoint ignored these unrecognized parameters: [name]'); // eslint-disable-line
 | 
			
		||||
      record.name = '';
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
@@ -1,10 +1,11 @@
 | 
			
		||||
// add all handlers here
 | 
			
		||||
// individual lookup done in mirage config
 | 
			
		||||
import base from './base';
 | 
			
		||||
import mfa from './mfa';
 | 
			
		||||
import mfaLogin from './mfa-login';
 | 
			
		||||
import activity from './activity';
 | 
			
		||||
import clients from './clients';
 | 
			
		||||
import db from './db';
 | 
			
		||||
import kms from './kms';
 | 
			
		||||
import mfaConfig from './mfa-config';
 | 
			
		||||
 | 
			
		||||
export { base, activity, mfa, clients, db, kms };
 | 
			
		||||
export { base, activity, mfaLogin, mfaConfig, clients, db, kms };
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										171
									
								
								ui/mirage/handlers/mfa-config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								ui/mirage/handlers/mfa-config.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,171 @@
 | 
			
		||||
import { Response } from 'miragejs';
 | 
			
		||||
 | 
			
		||||
export default function (server) {
 | 
			
		||||
  const methods = ['totp', 'duo', 'okta', 'pingid'];
 | 
			
		||||
  const required = {
 | 
			
		||||
    totp: ['issuer'],
 | 
			
		||||
    duo: ['secret_key', 'integration_key', 'api_hostname'],
 | 
			
		||||
    okta: ['org_name', 'api_token'],
 | 
			
		||||
    pingid: ['settings_file_base64'],
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const validate = (type, data, cb) => {
 | 
			
		||||
    if (!methods.includes(type)) {
 | 
			
		||||
      return new Response(400, {}, { errors: [`Method ${type} not found`] });
 | 
			
		||||
    }
 | 
			
		||||
    if (data) {
 | 
			
		||||
      const missing = required[type].reduce((params, key) => {
 | 
			
		||||
        if (!data[key]) {
 | 
			
		||||
          params.push(key);
 | 
			
		||||
        }
 | 
			
		||||
        return params;
 | 
			
		||||
      }, []);
 | 
			
		||||
      if (missing.length) {
 | 
			
		||||
        return new Response(400, {}, { errors: [`Missing required parameters: [${missing.join(', ')}]`] });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return cb();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const dbKeyFromType = (type) => `mfa${type.charAt(0).toUpperCase()}${type.slice(1)}Methods`;
 | 
			
		||||
 | 
			
		||||
  const generateListResponse = (schema, isMethod) => {
 | 
			
		||||
    let records = [];
 | 
			
		||||
    if (isMethod) {
 | 
			
		||||
      methods.forEach((method) => {
 | 
			
		||||
        records.addObjects(schema.db[dbKeyFromType(method)].where({}));
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      records = schema.db.mfaLoginEnforcements.where({});
 | 
			
		||||
    }
 | 
			
		||||
    // seed the db with a few records if none exist
 | 
			
		||||
    if (!records.length) {
 | 
			
		||||
      if (isMethod) {
 | 
			
		||||
        methods.forEach((type) => {
 | 
			
		||||
          records.push(server.create(`mfa-${type}-method`));
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        records = server.createList('mfa-login-enforcement', 4).toArray();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    const dataKey = isMethod ? 'id' : 'name';
 | 
			
		||||
    const data = records.reduce(
 | 
			
		||||
      (resp, record) => {
 | 
			
		||||
        resp.key_info[record[dataKey]] = record;
 | 
			
		||||
        resp.keys.push(record[dataKey]);
 | 
			
		||||
        return resp;
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key_info: {},
 | 
			
		||||
        keys: [],
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
    return { data };
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // list methods
 | 
			
		||||
  server.get('/identity/mfa/method/', (schema) => {
 | 
			
		||||
    return generateListResponse(schema, true);
 | 
			
		||||
  });
 | 
			
		||||
  // fetch method by id
 | 
			
		||||
  server.get('/identity/mfa/method/:id', (schema, { params: { id } }) => {
 | 
			
		||||
    let record;
 | 
			
		||||
    for (const method of methods) {
 | 
			
		||||
      record = schema.db[dbKeyFromType(method)].find(id);
 | 
			
		||||
      if (record) {
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    // inconvenient when testing edit route to return a 404 on refresh since mirage memory is cleared
 | 
			
		||||
    // flip this variable to test 404 state if needed
 | 
			
		||||
    const shouldError = false;
 | 
			
		||||
    // create a new record so data is always returned
 | 
			
		||||
    if (!record && !shouldError) {
 | 
			
		||||
      return { data: server.create('mfa-totp-method') };
 | 
			
		||||
    }
 | 
			
		||||
    return !record ? new Response(404, {}, { errors: [] }) : { data: record };
 | 
			
		||||
  });
 | 
			
		||||
  // create method
 | 
			
		||||
  server.post('/identity/mfa/method/:type', (schema, { params: { type }, requestBody }) => {
 | 
			
		||||
    const data = JSON.parse(requestBody);
 | 
			
		||||
    return validate(type, data, () => {
 | 
			
		||||
      const record = server.create(`mfa-${type}-method`, data);
 | 
			
		||||
      return { data: { method_id: record.id } };
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
  // update method
 | 
			
		||||
  server.put('/identity/mfa/method/:type/:id', (schema, { params: { type, id }, requestBody }) => {
 | 
			
		||||
    const data = JSON.parse(requestBody);
 | 
			
		||||
    return validate(type, data, () => {
 | 
			
		||||
      schema.db[dbKeyFromType(type)].update(id, data);
 | 
			
		||||
      return {};
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
  // delete method
 | 
			
		||||
  server.delete('/identity/mfa/method/:type/:id', (schema, { params: { type, id } }) => {
 | 
			
		||||
    return validate(type, null, () => {
 | 
			
		||||
      schema.db[dbKeyFromType(type)].remove(id);
 | 
			
		||||
      return {};
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
  // list enforcements
 | 
			
		||||
  server.get('/identity/mfa/login-enforcement', (schema) => {
 | 
			
		||||
    return generateListResponse(schema);
 | 
			
		||||
  });
 | 
			
		||||
  // fetch enforcement by name
 | 
			
		||||
  server.get('/identity/mfa/login-enforcement/:name', (schema, { params: { name } }) => {
 | 
			
		||||
    const record = schema.db.mfaLoginEnforcements.findBy({ name });
 | 
			
		||||
    // inconvenient when testing edit route to return a 404 on refresh since mirage memory is cleared
 | 
			
		||||
    // flip this variable to test 404 state if needed
 | 
			
		||||
    const shouldError = false;
 | 
			
		||||
    // create a new record so data is always returned
 | 
			
		||||
    if (!record && !shouldError) {
 | 
			
		||||
      return { data: server.create('mfa-login-enforcement', { name }) };
 | 
			
		||||
    }
 | 
			
		||||
    return !record ? new Response(404, {}, { errors: [] }) : { data: record };
 | 
			
		||||
  });
 | 
			
		||||
  // create/update enforcement
 | 
			
		||||
  server.post('/identity/mfa/login-enforcement/:name', (schema, { params: { name }, requestBody }) => {
 | 
			
		||||
    const data = JSON.parse(requestBody);
 | 
			
		||||
    // at least one method id is required
 | 
			
		||||
    if (!data.mfa_method_ids?.length) {
 | 
			
		||||
      return new Response(400, {}, { errors: ['missing method ids'] });
 | 
			
		||||
    }
 | 
			
		||||
    // at least one of the following targets is required
 | 
			
		||||
    const required = [
 | 
			
		||||
      'auth_method_accessors',
 | 
			
		||||
      'auth_method_types',
 | 
			
		||||
      'identity_group_ids',
 | 
			
		||||
      'identity_entity_ids',
 | 
			
		||||
    ];
 | 
			
		||||
    let hasRequired = false;
 | 
			
		||||
    for (let key of required) {
 | 
			
		||||
      if (data[key]?.length) {
 | 
			
		||||
        hasRequired = true;
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (!hasRequired) {
 | 
			
		||||
      return new Response(
 | 
			
		||||
        400,
 | 
			
		||||
        {},
 | 
			
		||||
        {
 | 
			
		||||
          errors: [
 | 
			
		||||
            'One of auth_method_accessors, auth_method_types, identity_group_ids, identity_entity_ids must be specified',
 | 
			
		||||
          ],
 | 
			
		||||
        }
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    if (schema.db.mfaLoginEnforcements.findBy({ name })) {
 | 
			
		||||
      schema.db.mfaLoginEnforcements.update({ name }, data);
 | 
			
		||||
    } else {
 | 
			
		||||
      schema.db.mfaLoginEnforcements.insert(data);
 | 
			
		||||
    }
 | 
			
		||||
    return { ...data, id: data.name };
 | 
			
		||||
  });
 | 
			
		||||
  // delete enforcement
 | 
			
		||||
  server.delete('/identity/mfa/login-enforcement/:name', (schema, { params: { name } }) => {
 | 
			
		||||
    schema.db.mfaLoginEnforcements.remove({ name });
 | 
			
		||||
    return {};
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
@@ -2,11 +2,58 @@ import { Response } from 'miragejs';
 | 
			
		||||
import Ember from 'ember';
 | 
			
		||||
import fetch from 'fetch';
 | 
			
		||||
 | 
			
		||||
// initial auth response cache -- lookup by mfa_request_id key
 | 
			
		||||
const authResponses = {};
 | 
			
		||||
// mfa requirement cache -- lookup by mfa_request_id key
 | 
			
		||||
const mfaRequirement = {};
 | 
			
		||||
 | 
			
		||||
// may be imported in tests when the validation request needs to be intercepted to make assertions prior to returning a response
 | 
			
		||||
// in that case it may be helpful to still use this validation logic to ensure to payload is as expected
 | 
			
		||||
export const validationHandler = (schema, req) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { mfa_request_id, mfa_payload } = JSON.parse(req.requestBody);
 | 
			
		||||
    const mfaRequest = mfaRequirement[mfa_request_id];
 | 
			
		||||
 | 
			
		||||
    if (!mfaRequest) {
 | 
			
		||||
      return new Response(404, {}, { errors: ['MFA Request ID not found'] });
 | 
			
		||||
    }
 | 
			
		||||
    // validate request body
 | 
			
		||||
    for (let constraintId in mfa_payload) {
 | 
			
		||||
      // ensure ids were passed in map
 | 
			
		||||
      const method = mfaRequest.methods.find(({ id }) => id === constraintId);
 | 
			
		||||
      if (!method) {
 | 
			
		||||
        return new Response(400, {}, { errors: [`Invalid MFA constraint id ${constraintId} passed in map`] });
 | 
			
		||||
      }
 | 
			
		||||
      // test non-totp validation by rejecting all pingid requests
 | 
			
		||||
      if (method.type === 'pingid') {
 | 
			
		||||
        return new Response(403, {}, { errors: ['PingId MFA validation failed'] });
 | 
			
		||||
      }
 | 
			
		||||
      // validate totp passcode
 | 
			
		||||
      const passcode = mfa_payload[constraintId][0];
 | 
			
		||||
      if (method.uses_passcode) {
 | 
			
		||||
        if (passcode !== 'test') {
 | 
			
		||||
          const error =
 | 
			
		||||
            {
 | 
			
		||||
              used: 'code already used; new code is available in 30 seconds',
 | 
			
		||||
              limit:
 | 
			
		||||
                'maximum TOTP validation attempts 4 exceeded the allowed attempts 3. Please try again in 15 seconds',
 | 
			
		||||
            }[passcode] || 'failed to validate';
 | 
			
		||||
          console.log(error);
 | 
			
		||||
          return new Response(403, {}, { errors: [error] });
 | 
			
		||||
        }
 | 
			
		||||
      } else if (passcode) {
 | 
			
		||||
        // for okta and duo, reject if a passcode was provided
 | 
			
		||||
        return new Response(400, {}, { errors: ['Passcode should only be provided for TOTP MFA type'] });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return authResponses[mfa_request_id];
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.log(error);
 | 
			
		||||
    return new Response(500, {}, { errors: ['Mirage Handler Error: /sys/mfa/validate'] });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function (server) {
 | 
			
		||||
  // initial auth response cache -- lookup by mfa_request_id key
 | 
			
		||||
  const authResponses = {};
 | 
			
		||||
  // mfa requirement cache -- lookup by mfa_request_id key
 | 
			
		||||
  const mfaRequirement = {};
 | 
			
		||||
  // generate different constraint scenarios and return mfa_requirement object
 | 
			
		||||
  const generateMfaRequirement = (req, res) => {
 | 
			
		||||
    const { user } = req.params;
 | 
			
		||||
@@ -104,48 +151,5 @@ export default function (server) {
 | 
			
		||||
  };
 | 
			
		||||
  server.post('/auth/:method/login/:user', passthroughLogin);
 | 
			
		||||
 | 
			
		||||
  server.post('/sys/mfa/validate', (schema, req) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const { mfa_request_id, mfa_payload } = JSON.parse(req.requestBody);
 | 
			
		||||
      const mfaRequest = mfaRequirement[mfa_request_id];
 | 
			
		||||
 | 
			
		||||
      if (!mfaRequest) {
 | 
			
		||||
        return new Response(404, {}, { errors: ['MFA Request ID not found'] });
 | 
			
		||||
      }
 | 
			
		||||
      // validate request body
 | 
			
		||||
      for (let constraintId in mfa_payload) {
 | 
			
		||||
        // ensure ids were passed in map
 | 
			
		||||
        const method = mfaRequest.methods.find(({ id }) => id === constraintId);
 | 
			
		||||
        if (!method) {
 | 
			
		||||
          return new Response(
 | 
			
		||||
            400,
 | 
			
		||||
            {},
 | 
			
		||||
            { errors: [`Invalid MFA constraint id ${constraintId} passed in map`] }
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
        // test non-totp validation by rejecting all pingid requests
 | 
			
		||||
        if (method.type === 'pingid') {
 | 
			
		||||
          return new Response(403, {}, { errors: ['PingId MFA validation failed'] });
 | 
			
		||||
        }
 | 
			
		||||
        // validate totp passcode
 | 
			
		||||
        const passcode = mfa_payload[constraintId][0];
 | 
			
		||||
        if (method.uses_passcode) {
 | 
			
		||||
          if (passcode !== 'test') {
 | 
			
		||||
            const error =
 | 
			
		||||
              passcode === 'used'
 | 
			
		||||
                ? 'code already used; new code is available in 30 seconds'
 | 
			
		||||
                : 'failed to validate';
 | 
			
		||||
            return new Response(403, {}, { errors: [error] });
 | 
			
		||||
          }
 | 
			
		||||
        } else if (passcode) {
 | 
			
		||||
          // for okta and duo, reject if a passcode was provided
 | 
			
		||||
          return new Response(400, {}, { errors: ['Passcode should only be provided for TOTP MFA type'] });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      return authResponses[mfa_request_id];
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.log(error);
 | 
			
		||||
      return new Response(500, {}, { errors: ['Mirage Handler Error: /sys/mfa/validate'] });
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
  server.post('/sys/mfa/validate', validationHandler);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										47
									
								
								ui/mirage/identity-managers/application.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								ui/mirage/identity-managers/application.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
			
		||||
// to more closely match the Vault backend this will return UUIDs as identifiers for records in mirage
 | 
			
		||||
export default class {
 | 
			
		||||
  constructor() {
 | 
			
		||||
    this.ids = new Set();
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * Returns a unique identifier.
 | 
			
		||||
   *
 | 
			
		||||
   * @method fetch
 | 
			
		||||
   * @param {Object} data Records attributes hash
 | 
			
		||||
   * @return {String} Unique identifier
 | 
			
		||||
   * @public
 | 
			
		||||
   */
 | 
			
		||||
  fetch() {
 | 
			
		||||
    let uuid = crypto.randomUUID();
 | 
			
		||||
    // odds are incredibly low that we'll run into a duplicate using crypto.randomUUID()
 | 
			
		||||
    // but just to be safe...
 | 
			
		||||
    while (this.ids.has(uuid)) {
 | 
			
		||||
      uuid = crypto.randomUUID();
 | 
			
		||||
    }
 | 
			
		||||
    this.ids.add(uuid);
 | 
			
		||||
    return uuid;
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * Register an identifier.
 | 
			
		||||
   * Must throw if identifier is already used.
 | 
			
		||||
   *
 | 
			
		||||
   * @method set
 | 
			
		||||
   * @param {String|Number} id
 | 
			
		||||
   * @public
 | 
			
		||||
   */
 | 
			
		||||
  set(id) {
 | 
			
		||||
    if (this.ids.has(id)) {
 | 
			
		||||
      throw new Error(`ID ${id} is in use.`);
 | 
			
		||||
    }
 | 
			
		||||
    this.ids.add(id);
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * Reset identity manager.
 | 
			
		||||
   *
 | 
			
		||||
   * @method reset
 | 
			
		||||
   * @public
 | 
			
		||||
   */
 | 
			
		||||
  reset() {
 | 
			
		||||
    this.ids.clear();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -124,6 +124,7 @@
 | 
			
		||||
    "ember-modifier": "^3.1.0",
 | 
			
		||||
    "ember-page-title": "^6.2.2",
 | 
			
		||||
    "ember-power-select": "^5.0.3",
 | 
			
		||||
    "ember-qrcode-shim": "^0.4.0",
 | 
			
		||||
    "ember-qunit": "^5.1.5",
 | 
			
		||||
    "ember-resolver": "^8.0.3",
 | 
			
		||||
    "ember-responsive": "^3.0.0-beta.3",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										5
									
								
								ui/public/duo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								ui/public/duo.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
			
		||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
<path d="M23.988 11.994C23.988 18.618 18.618 23.988 11.994 23.988C5.37 23.988 0 18.618 0 11.994C0 5.37 5.37 0 11.994 0C18.618 0 23.988 5.37 23.988 11.994Z" fill="#59B734"/>
 | 
			
		||||
<path d="M20.4045 11.9505C20.3153 13.3849 19.1259 14.5026 17.6888 14.5026C16.2516 14.5026 15.0622 13.3849 14.973 11.9505H20.4045ZM9.01951 11.9505C8.93647 13.3893 7.7457 14.5136 6.30451 14.514H3.58276V11.9505H9.01876H9.01951ZM14.7173 9.07275V11.793C14.7173 11.8462 14.715 11.898 14.712 11.9505V14.5095H12.153V9.072H14.7173V9.07275Z" fill="#E6F3D8"/>
 | 
			
		||||
<path d="M11.8395 9.07275V14.5095C10.4 14.4264 9.27525 13.2349 9.27525 11.793V9.07275H11.8402H11.8395ZM17.6887 9.07275C19.1301 9.07311 20.3211 10.1973 20.4045 11.6362H14.973C15.0564 10.1976 16.247 9.07351 17.688 9.07275H17.6887ZM6.30375 9.07275C7.74507 9.07311 8.93607 10.1973 9.0195 11.6362H3.5835V9.07275H6.30375Z" fill="white"/>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 957 B  | 
							
								
								
									
										
											BIN
										
									
								
								ui/public/images/mfa-landing.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								ui/public/images/mfa-landing.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 62 KiB  | 
							
								
								
									
										3
									
								
								ui/public/okta.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ui/public/okta.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
<path d="M12 2C6.49063 2 2 6.45844 2 12C2 17.5416 6.45875 22 12 22C17.5413 22 22 17.5413 22 12C22 6.45875 17.5094 2 12 2ZM12 17C9.22937 17 7 14.7706 7 12C7 9.22937 9.22937 7 12 7C14.7706 7 17 9.22937 17 12C17 14.7706 14.7706 17 12 17Z" fill="#007DC1"/>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 356 B  | 
							
								
								
									
										11
									
								
								ui/public/pingid.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								ui/public/pingid.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| 
		 After Width: | Height: | Size: 6.4 KiB  | 
@@ -1,15 +1,16 @@
 | 
			
		||||
import { module, test } from 'qunit';
 | 
			
		||||
import { setupApplicationTest } from 'ember-qunit';
 | 
			
		||||
import { click, currentRouteName, fillIn, visit } from '@ember/test-helpers';
 | 
			
		||||
import { click, currentRouteName, fillIn, visit, waitUntil, find } from '@ember/test-helpers';
 | 
			
		||||
import { setupMirage } from 'ember-cli-mirage/test-support';
 | 
			
		||||
import ENV from 'vault/config/environment';
 | 
			
		||||
import { validationHandler } from '../../mirage/handlers/mfa-login';
 | 
			
		||||
 | 
			
		||||
module('Acceptance | mfa', function (hooks) {
 | 
			
		||||
module('Acceptance | mfa-login', function (hooks) {
 | 
			
		||||
  setupApplicationTest(hooks);
 | 
			
		||||
  setupMirage(hooks);
 | 
			
		||||
 | 
			
		||||
  hooks.before(function () {
 | 
			
		||||
    ENV['ember-cli-mirage'].handler = 'mfa';
 | 
			
		||||
    ENV['ember-cli-mirage'].handler = 'mfaLogin';
 | 
			
		||||
  });
 | 
			
		||||
  hooks.beforeEach(function () {
 | 
			
		||||
    this.select = async (select = 0, option = 1) => {
 | 
			
		||||
@@ -56,7 +57,27 @@ module('Acceptance | mfa', function (hooks) {
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  test('it should handle single mfa constraint with push method', async function (assert) {
 | 
			
		||||
    assert.expect(1);
 | 
			
		||||
    assert.expect(6);
 | 
			
		||||
 | 
			
		||||
    server.post('/sys/mfa/validate', async (schema, req) => {
 | 
			
		||||
      await waitUntil(() => find('[data-test-mfa-description]'));
 | 
			
		||||
      assert
 | 
			
		||||
        .dom('[data-test-mfa-description]')
 | 
			
		||||
        .hasText(
 | 
			
		||||
          'Multi-factor authentication is enabled for your account.',
 | 
			
		||||
          'Mfa form displays with correct description'
 | 
			
		||||
        );
 | 
			
		||||
      assert.dom('[data-test-mfa-label]').hasText('Okta push notification', 'Correct method renders');
 | 
			
		||||
      assert
 | 
			
		||||
        .dom('[data-test-mfa-push-instruction]')
 | 
			
		||||
        .hasText('Check device for push notification', 'Push notification instruction renders');
 | 
			
		||||
      assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled while validating');
 | 
			
		||||
      assert
 | 
			
		||||
        .dom('[data-test-mfa-validate]')
 | 
			
		||||
        .hasClass('is-loading', 'Loading class applied to button while validating');
 | 
			
		||||
      return validationHandler(schema, req);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await login('mfa-b');
 | 
			
		||||
    didLogin(assert);
 | 
			
		||||
  });
 | 
			
		||||
@@ -5,7 +5,7 @@ import { hbs } from 'ember-cli-htmlbars';
 | 
			
		||||
import { setupMirage } from 'ember-cli-mirage/test-support';
 | 
			
		||||
import { fillIn, click, waitUntil } from '@ember/test-helpers';
 | 
			
		||||
import { _cancelTimers as cancelTimers, later } from '@ember/runloop';
 | 
			
		||||
import { VALIDATION_ERROR } from 'vault/components/mfa-form';
 | 
			
		||||
import { TOTP_VALIDATION_ERROR } from 'vault/components/mfa-form';
 | 
			
		||||
 | 
			
		||||
module('Integration | Component | mfa-form', function (hooks) {
 | 
			
		||||
  setupRenderingTest(hooks);
 | 
			
		||||
@@ -38,7 +38,9 @@ module('Integration | Component | mfa-form', function (hooks) {
 | 
			
		||||
      mfa_constraints: { test_mfa_1: { any: [totpConstraint] } },
 | 
			
		||||
    }).mfa_requirement;
 | 
			
		||||
 | 
			
		||||
    await render(hbs`<MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} />`);
 | 
			
		||||
    await render(
 | 
			
		||||
      hbs`<MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} @onError={{fn (mut this.error)}} />`
 | 
			
		||||
    );
 | 
			
		||||
    assert
 | 
			
		||||
      .dom('[data-test-mfa-description]')
 | 
			
		||||
      .includesText(
 | 
			
		||||
@@ -51,7 +53,9 @@ module('Integration | Component | mfa-form', function (hooks) {
 | 
			
		||||
      mfa_constraints: { test_mfa_1: { any: [duoConstraint, oktaConstraint] } },
 | 
			
		||||
    }).mfa_requirement;
 | 
			
		||||
 | 
			
		||||
    await render(hbs`<MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} />`);
 | 
			
		||||
    await render(
 | 
			
		||||
      hbs`<MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} @onError={{fn (mut this.error)}} />`
 | 
			
		||||
    );
 | 
			
		||||
    assert
 | 
			
		||||
      .dom('[data-test-mfa-description]')
 | 
			
		||||
      .includesText(
 | 
			
		||||
@@ -64,7 +68,9 @@ module('Integration | Component | mfa-form', function (hooks) {
 | 
			
		||||
      mfa_constraints: { test_mfa_1: { any: [oktaConstraint] }, test_mfa_2: { any: [duoConstraint] } },
 | 
			
		||||
    }).mfa_requirement;
 | 
			
		||||
 | 
			
		||||
    await render(hbs`<MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} />`);
 | 
			
		||||
    await render(
 | 
			
		||||
      hbs`<MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} @onError={{fn (mut this.error)}} />`
 | 
			
		||||
    );
 | 
			
		||||
    assert
 | 
			
		||||
      .dom('[data-test-mfa-description]')
 | 
			
		||||
      .includesText(
 | 
			
		||||
@@ -164,28 +170,39 @@ module('Integration | Component | mfa-form', function (hooks) {
 | 
			
		||||
    await click('[data-test-mfa-validate]');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  test('it should show countdown on passcode already used error', async function (assert) {
 | 
			
		||||
    this.owner.lookup('service:auth').reopen({
 | 
			
		||||
      totpValidate() {
 | 
			
		||||
        throw { errors: ['code already used; new code is available in 45 seconds'] };
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    await render(hbs`
 | 
			
		||||
      <MfaForm
 | 
			
		||||
        @clusterId={{this.clusterId}}
 | 
			
		||||
        @authData={{this.mfaAuthData}}
 | 
			
		||||
      />
 | 
			
		||||
    `);
 | 
			
		||||
  test('it should show countdown on passcode already used and rate limit errors', async function (assert) {
 | 
			
		||||
    const messages = {
 | 
			
		||||
      used: 'code already used; new code is available in 45 seconds',
 | 
			
		||||
      limit:
 | 
			
		||||
        'maximum TOTP validation attempts 4 exceeded the allowed attempts 3. Please try again in 15 seconds',
 | 
			
		||||
    };
 | 
			
		||||
    const codes = ['used', 'limit'];
 | 
			
		||||
    for (let code of codes) {
 | 
			
		||||
      this.owner.lookup('service:auth').reopen({
 | 
			
		||||
        totpValidate() {
 | 
			
		||||
          throw { errors: [messages[code]] };
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
      await render(hbs`
 | 
			
		||||
        <MfaForm
 | 
			
		||||
          @clusterId={{this.clusterId}}
 | 
			
		||||
          @authData={{this.mfaAuthData}}
 | 
			
		||||
        />
 | 
			
		||||
      `);
 | 
			
		||||
 | 
			
		||||
    await fillIn('[data-test-mfa-passcode]', 'test-code');
 | 
			
		||||
    later(() => cancelTimers(), 50);
 | 
			
		||||
    await click('[data-test-mfa-validate]');
 | 
			
		||||
    assert
 | 
			
		||||
      .dom('[data-test-mfa-countdown]')
 | 
			
		||||
      .hasText('45', 'countdown renders with correct initial value from error response');
 | 
			
		||||
    assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled during countdown');
 | 
			
		||||
    assert.dom('[data-test-mfa-passcode]').isDisabled('Input is disabled during countdown');
 | 
			
		||||
    assert.dom('[data-test-inline-error-message]').exists('Alert message renders');
 | 
			
		||||
      await fillIn('[data-test-mfa-passcode]', code);
 | 
			
		||||
      later(() => cancelTimers(), 50);
 | 
			
		||||
      await click('[data-test-mfa-validate]');
 | 
			
		||||
      assert
 | 
			
		||||
        .dom('[data-test-mfa-countdown]')
 | 
			
		||||
        .hasText(
 | 
			
		||||
          code === 'used' ? '45' : '15',
 | 
			
		||||
          'countdown renders with correct initial value from error response'
 | 
			
		||||
        );
 | 
			
		||||
      assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled during countdown');
 | 
			
		||||
      assert.dom('[data-test-mfa-passcode]').isDisabled('Input is disabled during countdown');
 | 
			
		||||
      assert.dom('[data-test-inline-error-message]').exists('Alert message renders');
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  test('it should show error message for passcode invalid error', async function (assert) {
 | 
			
		||||
@@ -206,6 +223,6 @@ module('Integration | Component | mfa-form', function (hooks) {
 | 
			
		||||
    await click('[data-test-mfa-validate]');
 | 
			
		||||
    assert
 | 
			
		||||
      .dom('[data-test-error]')
 | 
			
		||||
      .includesText(VALIDATION_ERROR, 'Generic error message renders for passcode validation error');
 | 
			
		||||
      .includesText(TOTP_VALIDATION_ERROR, 'Generic error message renders for passcode validation error');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,251 @@
 | 
			
		||||
import { module, test } from 'qunit';
 | 
			
		||||
import { setupRenderingTest } from 'ember-qunit';
 | 
			
		||||
import { render, click, fillIn } from '@ember/test-helpers';
 | 
			
		||||
import { hbs } from 'ember-cli-htmlbars';
 | 
			
		||||
import { setupMirage } from 'ember-cli-mirage/test-support';
 | 
			
		||||
 | 
			
		||||
module('Integration | Component | mfa-login-enforcement-form', function (hooks) {
 | 
			
		||||
  setupRenderingTest(hooks);
 | 
			
		||||
  setupMirage(hooks);
 | 
			
		||||
 | 
			
		||||
  hooks.beforeEach(function () {
 | 
			
		||||
    this.store = this.owner.lookup('service:store');
 | 
			
		||||
    this.model = this.store.createRecord('mfa-login-enforcement');
 | 
			
		||||
    this.server.get('/sys/auth', () => ({
 | 
			
		||||
      data: { 'userpass/': { type: 'userpass', accessor: 'auth_userpass_1234' } },
 | 
			
		||||
    }));
 | 
			
		||||
    this.server.get('/identity/mfa/method', () => ({
 | 
			
		||||
      data: {
 | 
			
		||||
        key_info: {
 | 
			
		||||
          123456: { type: 'totp' },
 | 
			
		||||
        },
 | 
			
		||||
        keys: ['123456'],
 | 
			
		||||
      },
 | 
			
		||||
    }));
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  test('it should render correct fields', async function (assert) {
 | 
			
		||||
    await render(hbs`
 | 
			
		||||
      <MfaLoginEnforcementForm
 | 
			
		||||
        @model={{this.model}}
 | 
			
		||||
        @onClose={{fn (mut this.didClose)}}
 | 
			
		||||
        @onSave={{fn (mut this.didSave)}}
 | 
			
		||||
      />
 | 
			
		||||
    `);
 | 
			
		||||
 | 
			
		||||
    const fields = {
 | 
			
		||||
      name: {
 | 
			
		||||
        label: 'Name',
 | 
			
		||||
        subText:
 | 
			
		||||
          'The name for this enforcement. Giving it a name means that you can refer to it again later. This name will not be editable later.',
 | 
			
		||||
      },
 | 
			
		||||
      methods: {
 | 
			
		||||
        label: 'MFA methods',
 | 
			
		||||
        subText: 'The MFA method(s) that this enforcement will apply to.',
 | 
			
		||||
      },
 | 
			
		||||
      targets: {
 | 
			
		||||
        label: 'Targets',
 | 
			
		||||
        subText:
 | 
			
		||||
          'The list of authentication types, authentication mounts, groups, and/or entities that will require this MFA configuration.',
 | 
			
		||||
      },
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const subTexts = this.element.querySelectorAll('[data-test-label-subtext]');
 | 
			
		||||
    Object.keys(fields).forEach((field, index) => {
 | 
			
		||||
      const { label, subText } = fields[field];
 | 
			
		||||
      assert.dom(`[data-test-mlef-label="${field}"]`).hasText(label, `${field} field label renders`);
 | 
			
		||||
      assert.dom(subTexts[index]).hasText(subText, `${subText} field label sub text renders`);
 | 
			
		||||
    });
 | 
			
		||||
    assert.dom('[data-test-mlef-input="name"]').exists(`Name field input renders`);
 | 
			
		||||
    assert.dom('[data-test-mlef-search="methods"]').exists('MFA method search select renders');
 | 
			
		||||
    assert.dom('[data-test-mlef-select="target-type"]').exists('Target type selector renders');
 | 
			
		||||
    assert.dom('[data-test-mlef-select="accessor"]').exists('Auth mount target selector renders by default');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  test('it should render inline', async function (assert) {
 | 
			
		||||
    this.errors = this.model.validate().state;
 | 
			
		||||
    await render(hbs`
 | 
			
		||||
      <MfaLoginEnforcementForm
 | 
			
		||||
        @model={{this.model}}
 | 
			
		||||
        @isInline={{true}}
 | 
			
		||||
        @modelErrors={{this.errors}}
 | 
			
		||||
      />
 | 
			
		||||
    `);
 | 
			
		||||
 | 
			
		||||
    assert.dom('[data-test-mlef-input="name"]').exists(`Name field input renders`);
 | 
			
		||||
    assert.dom('[data-test-mlef-search="methods"]').doesNotExist('MFA method search select does not render');
 | 
			
		||||
    assert.dom('[data-test-mlef-select="target-type"]').exists('Target type selector renders');
 | 
			
		||||
    assert
 | 
			
		||||
      .dom('[data-test-inline-error-message]')
 | 
			
		||||
      .exists({ count: 2 }, 'External validation errors are displayed');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  test('it should display field validation errors on save', async function (assert) {
 | 
			
		||||
    await render(hbs`
 | 
			
		||||
      <MfaLoginEnforcementForm
 | 
			
		||||
        @model={{this.model}}
 | 
			
		||||
        @onClose={{fn (mut this.didClose)}}
 | 
			
		||||
        @onSave={{fn (mut this.didSave)}}
 | 
			
		||||
      />
 | 
			
		||||
    `);
 | 
			
		||||
 | 
			
		||||
    await click('[data-test-mlef-save]');
 | 
			
		||||
    const errors = this.element.querySelectorAll('[data-test-inline-error-message]');
 | 
			
		||||
    assert.dom(errors[0]).hasText('Name is required', 'Name error message renders');
 | 
			
		||||
    assert.dom(errors[1]).hasText('At least one MFA method is required', 'Methods error message renders');
 | 
			
		||||
    assert
 | 
			
		||||
      .dom(errors[2])
 | 
			
		||||
      .hasText(
 | 
			
		||||
        "At least one target is required. If you've selected one, click 'Add' to make sure it's added to this enforcement.",
 | 
			
		||||
        'Targets error message renders'
 | 
			
		||||
      );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  test('it should save new enforcement', async function (assert) {
 | 
			
		||||
    assert.expect(5);
 | 
			
		||||
 | 
			
		||||
    this.server.post('/identity/mfa/login-enforcement/bar', () => {
 | 
			
		||||
      assert.ok(true, 'save request sent to server');
 | 
			
		||||
      return {};
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await render(hbs`
 | 
			
		||||
      <MfaLoginEnforcementForm
 | 
			
		||||
        @model={{this.model}}
 | 
			
		||||
        @onClose={{fn (mut this.didClose)}}
 | 
			
		||||
        @onSave={{fn (mut this.didSave) true}}
 | 
			
		||||
      />
 | 
			
		||||
    `);
 | 
			
		||||
 | 
			
		||||
    await fillIn('[data-test-mlef-input="name"]', 'bar');
 | 
			
		||||
    await click('.ember-basic-dropdown-trigger');
 | 
			
		||||
    await click('.ember-power-select-option');
 | 
			
		||||
    await fillIn('[data-test-mlef-select="accessor"] select', 'auth_userpass_1234');
 | 
			
		||||
    await click('[data-test-mlef-add-target]');
 | 
			
		||||
    await click('[data-test-mlef-save]');
 | 
			
		||||
    assert.true(this.didSave, 'onSave callback triggered');
 | 
			
		||||
    assert.equal(this.model.name, 'bar', 'Name property set on model');
 | 
			
		||||
    assert.equal(this.model.mfa_methods.firstObject.id, '123456', 'Mfa method added to model');
 | 
			
		||||
    assert.equal(
 | 
			
		||||
      this.model.auth_method_accessors.firstObject,
 | 
			
		||||
      'auth_userpass_1234',
 | 
			
		||||
      'Target saved to correct model property'
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  test('it should populate fields with model data', async function (assert) {
 | 
			
		||||
    this.model.name = 'foo';
 | 
			
		||||
    const [method] = (await this.store.query('mfa-method', {})).toArray();
 | 
			
		||||
    this.model.mfa_methods.addObject(method);
 | 
			
		||||
    this.model.auth_method_accessors.addObject('auth_userpass_1234');
 | 
			
		||||
 | 
			
		||||
    await render(hbs`
 | 
			
		||||
      <MfaLoginEnforcementForm
 | 
			
		||||
        @model={{this.model}}
 | 
			
		||||
        @onClose={{fn (mut this.didClose)}}
 | 
			
		||||
        @onSave={{fn (mut this.didSave) true}}
 | 
			
		||||
      />
 | 
			
		||||
    `);
 | 
			
		||||
 | 
			
		||||
    assert.dom('[data-test-mlef-input="name"]').hasValue('foo', 'Name input is populated');
 | 
			
		||||
    assert.dom('.search-select-list-item').includesText('TOTP', 'MFA method type renders in selected option');
 | 
			
		||||
    assert
 | 
			
		||||
      .dom('.search-select-list-item small')
 | 
			
		||||
      .hasText('123456', 'MFA method id renders in selected option');
 | 
			
		||||
    assert
 | 
			
		||||
      .dom('[data-test-row-label="Authentication mount"]')
 | 
			
		||||
      .hasText('Authentication mount', 'Selected target type renders');
 | 
			
		||||
    assert
 | 
			
		||||
      .dom('[data-test-value-div="Authentication mount"]')
 | 
			
		||||
      .hasText('auth_userpass_1234', 'Selected target value renders');
 | 
			
		||||
 | 
			
		||||
    await click('[data-test-mlef-remove-target]');
 | 
			
		||||
    await click('[data-test-mlef-save]');
 | 
			
		||||
    assert
 | 
			
		||||
      .dom('[data-test-inline-error-message]')
 | 
			
		||||
      .includesText('At least one target is required', 'Target is removed');
 | 
			
		||||
    assert.notOk(this.model.auth_method_accessors.length, 'Target is removed from appropriate model prop');
 | 
			
		||||
 | 
			
		||||
    await fillIn('[data-test-mlef-select="accessor"] select', 'auth_userpass_1234');
 | 
			
		||||
    await click('[data-test-mlef-add-target]');
 | 
			
		||||
    await click('[data-test-selected-list-button="delete"]');
 | 
			
		||||
    await click('[data-test-mlef-save]');
 | 
			
		||||
    assert
 | 
			
		||||
      .dom('[data-test-inline-error-message]')
 | 
			
		||||
      .hasText('At least one MFA method is required', 'Target is removed');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  test('it should add and remove targets', async function (assert) {
 | 
			
		||||
    assert.expect();
 | 
			
		||||
 | 
			
		||||
    this.server.get('/identity/entity/id', () => ({
 | 
			
		||||
      data: {
 | 
			
		||||
        key_info: { 1234: { name: 'foo entity' } },
 | 
			
		||||
        keys: ['1234'],
 | 
			
		||||
      },
 | 
			
		||||
    }));
 | 
			
		||||
    this.server.get('/identity/group/id', () => ({
 | 
			
		||||
      data: {
 | 
			
		||||
        key_info: { 1234: { name: 'bar group' } },
 | 
			
		||||
        keys: ['1234'],
 | 
			
		||||
      },
 | 
			
		||||
    }));
 | 
			
		||||
    this.model.auth_method_accessors.addObject('auth_userpass_1234');
 | 
			
		||||
    this.model.auth_method_types.addObject('userpass');
 | 
			
		||||
    const [entity] = (await this.store.query('identity/entity', {})).toArray();
 | 
			
		||||
    this.model.identity_entities.addObject(entity);
 | 
			
		||||
    const [group] = (await this.store.query('identity/group', {})).toArray();
 | 
			
		||||
    this.model.identity_groups.addObject(group);
 | 
			
		||||
 | 
			
		||||
    await render(hbs`
 | 
			
		||||
      <MfaLoginEnforcementForm
 | 
			
		||||
        @model={{this.model}}
 | 
			
		||||
        @onClose={{fn (mut this.didClose)}}
 | 
			
		||||
        @onSave={{fn (mut this.didSave) true}}
 | 
			
		||||
      />
 | 
			
		||||
    `);
 | 
			
		||||
 | 
			
		||||
    const targets = [
 | 
			
		||||
      {
 | 
			
		||||
        label: 'Authentication mount',
 | 
			
		||||
        value: 'auth_userpass_1234',
 | 
			
		||||
        key: 'auth_method_accessors',
 | 
			
		||||
        type: 'accessor',
 | 
			
		||||
      },
 | 
			
		||||
      { label: 'Authentication method', value: 'userpass', key: 'auth_method_types', type: 'method' },
 | 
			
		||||
      { label: 'Group', value: 'bar group 1234', key: 'identity_groups', type: 'identity/group' },
 | 
			
		||||
      { label: 'Entity', value: 'foo entity 1234', key: 'identity_entities', type: 'identity/entity' },
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    for (const [index, target] of targets.entries()) {
 | 
			
		||||
      // target populated from model
 | 
			
		||||
      assert
 | 
			
		||||
        .dom(`[data-test-row-label="${target.label}"]`)
 | 
			
		||||
        .hasText(target.label, `${target.label} target populated with correct type label`);
 | 
			
		||||
      assert
 | 
			
		||||
        .dom(`[data-test-value-div="${target.label}"]`)
 | 
			
		||||
        .hasText(target.value, `${target.label} target populated with correct value`);
 | 
			
		||||
      // remove target
 | 
			
		||||
      await click(`[data-test-mlef-remove-target="${target.label}"]`);
 | 
			
		||||
      assert
 | 
			
		||||
        .dom('[data-test-mlef-target]')
 | 
			
		||||
        .exists({ count: targets.length - (index + 1) }, `${target.label} target removed`);
 | 
			
		||||
      assert.notOk(this.model[target.key].length, `${target.label} removed from correct model prop`);
 | 
			
		||||
    }
 | 
			
		||||
    // add targets
 | 
			
		||||
    for (const target of targets) {
 | 
			
		||||
      await fillIn('[data-test-mlef-select="target-type"] select', target.type);
 | 
			
		||||
      if (['Group', 'Entity'].includes(target.label)) {
 | 
			
		||||
        await click(`[data-test-mlef-search="${target.type}"] .ember-basic-dropdown-trigger`);
 | 
			
		||||
        await click('.ember-power-select-option');
 | 
			
		||||
      } else {
 | 
			
		||||
        const key = target.label === 'Authentication method' ? 'auth-method' : 'accessor';
 | 
			
		||||
        const value = target.label === 'Authentication method' ? 'userpass' : 'auth_userpass_1234';
 | 
			
		||||
        await fillIn(`[data-test-mlef-select="${key}"] select`, value);
 | 
			
		||||
      }
 | 
			
		||||
      await click('[data-test-mlef-add-target]');
 | 
			
		||||
      assert.ok(this.model[target.key].length, `${target.label} added to correct model prop`);
 | 
			
		||||
    }
 | 
			
		||||
    assert.dom('[data-test-mlef-target]').exists({ count: 4 }, 'All targets were added back');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -0,0 +1,65 @@
 | 
			
		||||
import { module, test } from 'qunit';
 | 
			
		||||
import { setupRenderingTest } from 'ember-qunit';
 | 
			
		||||
import { render, click } from '@ember/test-helpers';
 | 
			
		||||
import { hbs } from 'ember-cli-htmlbars';
 | 
			
		||||
import { setupMirage } from 'ember-cli-mirage/test-support';
 | 
			
		||||
 | 
			
		||||
module('Integration | Component | mfa-login-enforcement-header', function (hooks) {
 | 
			
		||||
  setupRenderingTest(hooks);
 | 
			
		||||
  setupMirage(hooks);
 | 
			
		||||
 | 
			
		||||
  test('it renders heading', async function (assert) {
 | 
			
		||||
    await render(hbs`<MfaLoginEnforcementHeader @heading="New enforcement" />`);
 | 
			
		||||
 | 
			
		||||
    assert.dom('[data-test-mleh-title]').includesText('New enforcement');
 | 
			
		||||
    assert.dom('[data-test-mleh-title] svg').hasClass('flight-icon-lock', 'Lock icon renders');
 | 
			
		||||
    assert
 | 
			
		||||
      .dom('[data-test-mleh-description]')
 | 
			
		||||
      .includesText('An enforcement will define which auth types', 'Description renders');
 | 
			
		||||
    assert.dom('[data-test-mleh-radio]').doesNotExist('Radio cards are hidden when not inline display mode');
 | 
			
		||||
    assert
 | 
			
		||||
      .dom('[data-test-component="search-select"]')
 | 
			
		||||
      .doesNotExist('Search select is hidden when not inline display mode');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  test('it renders inline', async function (assert) {
 | 
			
		||||
    assert.expect(7);
 | 
			
		||||
 | 
			
		||||
    this.server.get('/identity/mfa/login-enforcement', () => {
 | 
			
		||||
      assert.ok(true, 'Request made to fetch enforcements');
 | 
			
		||||
      return {
 | 
			
		||||
        data: {
 | 
			
		||||
          key_info: {
 | 
			
		||||
            foo: { name: 'foo' },
 | 
			
		||||
          },
 | 
			
		||||
          keys: ['foo'],
 | 
			
		||||
        },
 | 
			
		||||
      };
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await render(hbs`
 | 
			
		||||
      <MfaLoginEnforcementHeader
 | 
			
		||||
        @isInline={{true}}
 | 
			
		||||
        @radioCardGroupValue={{this.value}}
 | 
			
		||||
        @onRadioCardSelect={{fn (mut this.value)}}
 | 
			
		||||
        @onEnforcementSelect={{fn (mut this.enforcement)}}
 | 
			
		||||
      />
 | 
			
		||||
    `);
 | 
			
		||||
 | 
			
		||||
    assert.dom('[data-test-mleh-title]').includesText('Enforcement');
 | 
			
		||||
    assert
 | 
			
		||||
      .dom('[data-test-mleh-description]')
 | 
			
		||||
      .includesText('An enforcement includes the authentication types', 'Description renders');
 | 
			
		||||
 | 
			
		||||
    for (const option of ['new', 'existing', 'skip']) {
 | 
			
		||||
      await click(`[data-test-mleh-radio="${option}"] input`);
 | 
			
		||||
      assert.equal(this.value, option, 'Value is updated on radio select');
 | 
			
		||||
      if (option === 'existing') {
 | 
			
		||||
        await click('.ember-basic-dropdown-trigger');
 | 
			
		||||
        await click('.ember-power-select-option');
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    assert.equal(this.enforcement.name, 'foo', 'Existing enforcement is selected');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										26
									
								
								ui/tests/integration/components/mfa-method-list-item-test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								ui/tests/integration/components/mfa-method-list-item-test.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
import { module, skip } from 'qunit';
 | 
			
		||||
import { setupRenderingTest } from 'ember-qunit';
 | 
			
		||||
import { render } from '@ember/test-helpers';
 | 
			
		||||
import { hbs } from 'ember-cli-htmlbars';
 | 
			
		||||
 | 
			
		||||
module('Integration | Component | mfa-method-list-item', function (hooks) {
 | 
			
		||||
  setupRenderingTest(hooks);
 | 
			
		||||
 | 
			
		||||
  skip('it renders', async function (assert) {
 | 
			
		||||
    // Set any properties with this.set('myProperty', 'value');
 | 
			
		||||
    // Handle any actions with this.set('myAction', function(val) { ... });
 | 
			
		||||
 | 
			
		||||
    await render(hbs`<MfaMethodListItem />`);
 | 
			
		||||
 | 
			
		||||
    assert.dom(this.element).hasText('');
 | 
			
		||||
 | 
			
		||||
    // Template block usage:
 | 
			
		||||
    await render(hbs`
 | 
			
		||||
      <MfaMethodListItem>
 | 
			
		||||
        template block text
 | 
			
		||||
      </MfaMethodListItem>
 | 
			
		||||
    `);
 | 
			
		||||
 | 
			
		||||
    assert.dom(this.element).hasText('template block text');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										33
									
								
								ui/tests/unit/serializers/mfa-login-enforcement-test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								ui/tests/unit/serializers/mfa-login-enforcement-test.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,33 @@
 | 
			
		||||
import { module, test } from 'qunit';
 | 
			
		||||
import { setupTest } from 'ember-qunit';
 | 
			
		||||
 | 
			
		||||
module('Unit | Serializer | mfa-login-enforcement', function (hooks) {
 | 
			
		||||
  setupTest(hooks);
 | 
			
		||||
 | 
			
		||||
  test('it should transform property names for hasMany relationships', function (assert) {
 | 
			
		||||
    const serverData = {
 | 
			
		||||
      name: 'foo',
 | 
			
		||||
      mfa_method_ids: ['1'],
 | 
			
		||||
      auth_method_types: ['userpass'],
 | 
			
		||||
      auth_method_accessors: ['auth_approle_17a552c6'],
 | 
			
		||||
      identity_entity_ids: ['2', '3'],
 | 
			
		||||
      identity_group_ids: ['4', '5', '6'],
 | 
			
		||||
    };
 | 
			
		||||
    const tranformedData = {
 | 
			
		||||
      name: 'foo',
 | 
			
		||||
      mfa_methods: ['1'],
 | 
			
		||||
      auth_method_types: ['userpass'],
 | 
			
		||||
      auth_method_accessors: ['auth_approle_17a552c6'],
 | 
			
		||||
      identity_entities: ['2', '3'],
 | 
			
		||||
      identity_groups: ['4', '5', '6'],
 | 
			
		||||
    };
 | 
			
		||||
    const mutableData = { ...serverData };
 | 
			
		||||
    const serializer = this.owner.lookup('serializer:mfa-login-enforcement');
 | 
			
		||||
 | 
			
		||||
    serializer.transformHasManyKeys(mutableData, 'model');
 | 
			
		||||
    assert.deepEqual(mutableData, tranformedData, 'hasMany property names are transformed for model');
 | 
			
		||||
 | 
			
		||||
    serializer.transformHasManyKeys(mutableData, 'server');
 | 
			
		||||
    assert.deepEqual(mutableData, serverData, 'hasMany property names are transformed for server');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -8327,6 +8327,13 @@ ember-power-select@^5.0.3:
 | 
			
		||||
    ember-text-measurer "^0.6.0"
 | 
			
		||||
    ember-truth-helpers "^2.1.0 || ^3.0.0"
 | 
			
		||||
 | 
			
		||||
ember-qrcode-shim@^0.4.0:
 | 
			
		||||
  version "0.4.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/ember-qrcode-shim/-/ember-qrcode-shim-0.4.0.tgz#bc4c61e8c33c7e731e98d68780a772d59eec4fc6"
 | 
			
		||||
  integrity sha512-tmdxr7mqfeG5vK6Lb553qmFlhnZipZyGBPQIBh5TbRQozPH5ATVS7zq77eV//d9y3997R7hGIYTNbsGZ718lOw==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    ember-cli-babel "^7.1.2"
 | 
			
		||||
 | 
			
		||||
ember-qunit@^5.1.5:
 | 
			
		||||
  version "5.1.5"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/ember-qunit/-/ember-qunit-5.1.5.tgz#24a7850f052be24189ff597dfc31b923e684c444"
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user