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:
parent
ebbb828b80
commit
7da2085fa3
|
@ -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 } });
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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--;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import Controller from '@ember/controller';
|
||||
|
||||
export default class MfaMethodsListController extends Controller {
|
||||
queryParams = {
|
||||
page: 'page',
|
||||
};
|
||||
|
||||
page = 1;
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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/`);
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -0,0 +1,3 @@
|
|||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class MfaSetupRoute extends Route {}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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}}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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}}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 = '';
|
||||
}
|
||||
},
|
||||
});
|
|
@ -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'] : []);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
|
@ -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 = '';
|
||||
}
|
||||
},
|
||||
});
|
|
@ -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: '',
|
||||
});
|
|
@ -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 };
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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 |
Binary file not shown.
|
@ -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 |
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');
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue