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:
Jordan Reimer 2022-05-20 18:40:16 -06:00 committed by GitHub
parent ebbb828b80
commit 7da2085fa3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
92 changed files with 3143 additions and 178 deletions

View File

@ -0,0 +1,29 @@
import ApplicationAdapter from './application';
export default class KeymgmtKeyAdapter extends ApplicationAdapter {
namespace = 'v1';
pathForType() {
return 'identity/mfa/login-enforcement';
}
_saveRecord(store, { modelName }, snapshot) {
const data = store.serializerFor(modelName).serialize(snapshot);
return this.ajax(this.urlForUpdateRecord(snapshot.attr('name'), modelName, snapshot), 'POST', {
data,
}).then(() => data);
}
// create does not return response similar to PUT request
createRecord() {
return this._saveRecord(...arguments);
}
// update record via POST method
updateRecord() {
return this._saveRecord(...arguments);
}
query(store, type, query) {
const url = this.urlForQuery(query, type.modelName);
return this.ajax(url, 'GET', { data: { list: true } });
}
}

View File

@ -0,0 +1,54 @@
import ApplicationAdapter from './application';
export default class MfaMethodAdapter extends ApplicationAdapter {
namespace = 'v1';
pathForType() {
return 'identity/mfa/method';
}
createOrUpdate(store, type, snapshot) {
const data = store.serializerFor(type.modelName).serialize(snapshot);
const { id } = snapshot;
return this.ajax(this.buildURL(type.modelName, id, snapshot, 'POST'), 'POST', {
data,
}).then((res) => {
// TODO: Check how 204's are handled by ember
return {
data: {
...data,
id: res?.data?.method_id || id,
},
};
});
}
createRecord() {
return this.createOrUpdate(...arguments);
}
updateRecord() {
return this.createOrUpdate(...arguments);
}
urlForDeleteRecord(id, modelName, snapshot) {
return this.buildURL(modelName, id, snapshot, 'POST');
}
query(store, type, query) {
const url = this.urlForQuery(query, type.modelName);
return this.ajax(url, 'GET', {
data: {
list: true,
},
});
}
buildURL(modelName, id, snapshot, requestType) {
if (requestType === 'POST') {
let url = `${super.buildURL(modelName)}/${snapshot.attr('type')}`;
return id ? `${url}/${id}` : url;
}
return super.buildURL(...arguments);
}
}

View File

@ -0,0 +1,13 @@
import ApplicationAdapter from './application';
export default class MfaSetupAdapter extends ApplicationAdapter {
adminGenerate(data) {
let url = `/v1/identity/mfa/method/totp/admin-generate`;
return this.ajax(url, 'POST', { data });
}
adminDestroy(data) {
let url = `/v1/identity/mfa/method/totp/admin-destroy`;
return this.ajax(url, 'POST', { data });
}
}

View File

@ -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;

View File

@ -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--;

View File

@ -0,0 +1,170 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';
/**
* @module MfaLoginEnforcementForm
* MfaLoginEnforcementForm components are used to create and edit login enforcements
*
* @example
* ```js
* <MfaLoginEnforcementForm @model={{this.model}} @isInline={{false}} @onSave={{this.onSave}} @onClose={{this.onClose}} />
* ```
* @callback onSave
* @callback onClose
* @param {Object} model - login enforcement model
* @param {Object} [isInline] - toggles inline display of form -- method selector and actions are hidden and should be handled externally
* @param {Object} [modelErrors] - model validations state object if handling actions externally when displaying inline
* @param {onSave} [onSave] - triggered on save success
* @param {onClose} [onClose] - triggered on cancel
*/
export default class MfaLoginEnforcementForm extends Component {
@service store;
@service flashMessages;
targetTypes = [
{ label: 'Authentication mount', type: 'accessor', key: 'auth_method_accessors' },
{ label: 'Authentication method', type: 'method', key: 'auth_method_types' },
{ label: 'Group', type: 'identity/group', key: 'identity_groups' },
{ label: 'Entity', type: 'identity/entity', key: 'identity_entities' },
];
searchSelectOptions = null;
@tracked name;
@tracked targets = [];
@tracked selectedTargetType = 'accessor';
@tracked selectedTargetValue = null;
@tracked searchSelect = {
options: [],
selected: [],
};
@tracked authMethods = [];
@tracked modelErrors;
constructor() {
super(...arguments);
// aggregate different target array properties on model into flat list
this.flattenTargets();
// eagerly fetch identity groups and entities for use as search select options
this.resetTargetState();
// only auth method types that have mounts can be selected as targets -- fetch from sys/auth and map by type
this.fetchAuthMethods();
}
async flattenTargets() {
for (let { label, key } of this.targetTypes) {
const targetArray = await this.args.model[key];
const targets = targetArray.map((value) => ({ label, key, value }));
this.targets.addObjects(targets);
}
}
async resetTargetState() {
this.selectedTargetValue = null;
const options = this.searchSelectOptions || {};
if (!this.searchSelectOptions) {
const types = ['identity/group', 'identity/entity'];
for (const type of types) {
try {
options[type] = (await this.store.query(type, {})).toArray();
} catch (error) {
options[type] = [];
}
}
this.searchSelectOptions = options;
}
if (this.selectedTargetType.includes('identity')) {
this.searchSelect = {
selected: [],
options: [...options[this.selectedTargetType]],
};
}
}
async fetchAuthMethods() {
const mounts = (await this.store.findAll('auth-method')).toArray();
this.authMethods = mounts.mapBy('type');
}
get selectedTarget() {
return this.targetTypes.findBy('type', this.selectedTargetType);
}
get errors() {
return this.args.modelErrors || this.modelErrors;
}
@task
*save() {
this.modelErrors = {};
// check validity state first and abort if invalid
const { isValid, state } = this.args.model.validate();
if (!isValid) {
this.modelErrors = state;
} else {
try {
yield this.args.model.save();
this.args.onSave();
} catch (error) {
const message = error.errors ? error.errors.join('. ') : error.message;
this.flashMessages.danger(message);
}
}
}
@action
async onMethodChange(selectedIds) {
const methods = await this.args.model.mfa_methods;
// first check for existing methods that have been removed from selection
methods.forEach((method) => {
if (!selectedIds.includes(method.id)) {
methods.removeObject(method);
}
});
// now check for selected items that don't exist and add them to the model
const methodIds = methods.mapBy('id');
selectedIds.forEach((id) => {
if (!methodIds.includes(id)) {
const model = this.store.peekRecord('mfa-method', id);
methods.addObject(model);
}
});
}
@action
onTargetSelect(type) {
this.selectedTargetType = type;
this.resetTargetState();
}
@action
setTargetValue(selected) {
const { type } = this.selectedTarget;
if (type.includes('identity')) {
// for identity groups and entities grab model from store as value
this.selectedTargetValue = this.store.peekRecord(type, selected[0]);
} else {
this.selectedTargetValue = selected;
}
}
@action
addTarget() {
const { label, key } = this.selectedTarget;
const value = this.selectedTargetValue;
this.targets.addObject({ label, value, key });
// add target to appropriate model property
this.args.model[key].addObject(value);
this.selectedTargetValue = null;
this.resetTargetState();
}
@action
removeTarget(target) {
this.targets.removeObject(target);
// remove target from appropriate model property
this.args.model[target.key].removeObject(target.value);
}
@action
cancel() {
// revert model changes
this.args.model.rollbackAttributes();
this.args.onClose();
}
}

View File

@ -0,0 +1,52 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
/**
* @module MfaLoginEnforcementHeader
* MfaLoginEnforcementHeader components are used to display information when creating and editing login enforcements
*
* @example
* ```js
* <MfaLoginEnforcementHeader @heading="New enforcement" />
* <MfaLoginEnforcementHeader @radioCardGroupValue={{this.enforcementPreference}} @onRadioCardSelect={{fn (mut this.enforcementPreference)}} @onEnforcementSelect={{fn (mut this.enforcement)}} />
* ```
* @callback onRadioCardSelect
* @callback onEnforcementSelect
* @param {boolean} [isInline] - toggle component display when used inline with mfa method form -- overrides heading and shows radio cards and enforcement select
* @param {string} [heading] - page heading to display outside of inline mode
* @param {string} [radioCardGroupValue] - selected value of the radio card group in inline mode -- new, existing or skip are the accepted values
* @param {onRadioCardSelect} [onRadioCardSelect] - change event triggered on radio card select
* @param {onEnforcementSelect} [onEnforcementSelect] - change event triggered on enforcement select when radioCardGroupValue is set to existing
*/
export default class MfaLoginEnforcementHeaderComponent extends Component {
@service store;
constructor() {
super(...arguments);
if (this.args.isInline) {
this.fetchEnforcements();
}
}
@tracked enforcements = [];
async fetchEnforcements() {
try {
// cache initial values for lookup in select handler
this._enforcements = (await this.store.query('mfa-login-enforcement', {})).toArray();
this.enforcements = [...this._enforcements];
} catch (error) {
this.enforcements = [];
}
}
@action
onEnforcementSelect([name]) {
// search select returns array of strings, in this case enforcement name
// lookup model and pass to callback
this.args.onEnforcementSelect(this._enforcements.findBy('name', name));
}
}

View File

@ -0,0 +1,77 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
/**
* @module MfaSetupStepOne
* MfaSetupStepOne component is a child component used in the end user setup for MFA. It records the UUID (aka method_id) and sends a admin-generate request.
*
* @param {string} entityId - the entityId of the user. This comes from the auth service which records it on loading of the cluster. A root user does not have an entityId.
* @param {function} isUUIDVerified - a function that consumes a boolean. Is true if the admin-generate is successful and false if it throws a warning or error.
* @param {boolean} restartFlow - a boolean that is true that is true if the user should proceed to step two or false if they should stay on step one.
* @param {function} saveUUIDandQrCode - A function that sends the inputted UUID and return qrCode from step one to the parent.
* @param {boolean} showWarning - whether a warning is returned from the admin-generate query. Needs to be passed to step two.
*/
export default class MfaSetupStepOne extends Component {
@service store;
@tracked error = '';
@tracked warning = '';
@tracked qrCode = '';
@action
redirectPreviousPage() {
this.args.restartFlow();
window.history.back();
}
@action
async verifyUUID(evt) {
evt.preventDefault();
let response = await this.postAdminGenerate();
if (response === 'stop_progress') {
this.args.isUUIDVerified(false);
} else if (response === 'reset_method') {
this.args.showWarning(this.warning);
} else {
this.args.isUUIDVerified(true);
}
}
async postAdminGenerate() {
this.error = '';
this.warning = '';
let adapter = this.store.adapterFor('mfa-setup');
let response;
try {
response = await adapter.adminGenerate({
entity_id: this.args.entityId,
method_id: this.UUID, // comes from value on the input
});
this.args.saveUUIDandQrCode(this.UUID, response.data?.url);
// if there was a warning it won't fail but needs to be handled here and the flow needs to be interrupted
let warnings = response.warnings || [];
if (warnings.length > 0) {
this.UUID = ''; // clear UUID
const alreadyGenerated = warnings.find((w) =>
w.includes('Entity already has a secret for MFA method')
);
if (alreadyGenerated) {
this.warning =
'A QR code has already been generated, scanned, and MFA set up for this entity. If a new code is required, contact your administrator.';
return 'reset_method';
}
this.warning = warnings; // in case other kinds of warnings comes through.
return 'reset_method';
}
} catch (error) {
this.UUID = ''; // clear the UUID
this.error = error.errors;
return 'stop_progress';
}
return response;
}
}

View File

@ -0,0 +1,40 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
/**
* @module MfaSetupStepTwo
* MfaSetupStepTwo component is a child component used in the end user setup for MFA. It displays a qrCode or a warning and allows a user to reset the method.
*
* @param {string} entityId - the entityId of the user. This comes from the auth service which records it on loading of the cluster. A root user does not have an entityId.
* @param {string} uuid - the UUID that is entered in the input on step one.
* @param {string} qrCode - the returned url from the admin-generate post. Used to create the qrCode.
* @param {boolean} restartFlow - a boolean that is true that is true if the user should proceed to step two or false if they should stay on step one.
* @param {string} warning - if there is a warning returned from the admin-generate post then it's sent to the step two component in this param.
*/
export default class MfaSetupStepTwo extends Component {
@service store;
@action
redirectPreviousPage() {
this.args.restartFlow();
window.history.back();
}
@action
async restartSetup() {
this.error = null;
let adapter = this.store.adapterFor('mfa-setup');
try {
await adapter.adminDestroy({
entity_id: this.args.entityId,
method_id: this.args.uuid,
});
} catch (error) {
this.error = error.errors;
return 'stop_progress';
}
this.args.restartFlow();
}
}

View File

@ -0,0 +1,52 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';
/**
* MfaMethodForm component
*
* @example
* ```js
* <Mfa::MethodForm @model={{this.model}} @hasActions={{true}} @onSave={{this.onSave}} @onClose={{this.onClose}} />
* ```
* @param {Object} model - MFA method model
* @param {boolean} [hasActions] - whether the action buttons will be rendered or not
* @param {onSave} [onSave] - callback when save is successful
* @param {onClose} [onClose] - callback when cancel is triggered
*/
export default class MfaMethodForm extends Component {
@service store;
@service flashMessages;
@tracked editValidations;
@tracked isEditModalActive = false;
@task
*save() {
try {
yield this.args.model.save();
this.args.onSave();
} catch (e) {
this.flashMessages.danger(e.errors?.join('. ') || e.message);
}
}
@action
async initSave(e) {
e.preventDefault();
const { isValid, state } = await this.args.model.validate();
if (isValid) {
this.isEditModalActive = true;
} else {
this.editValidations = state;
}
}
@action
cancel() {
this.args.model.rollbackAttributes();
this.args.onClose();
}
}

View File

@ -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(),

View File

@ -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);

View File

@ -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;
}
}
}

View File

@ -0,0 +1,6 @@
import Controller from '@ember/controller';
export default class MfaEnforcementListController extends Controller {
queryParams = ['page'];
page = 1;
}

View File

@ -0,0 +1,9 @@
import Controller from '@ember/controller';
export default class MfaMethodsListController extends Controller {
queryParams = {
page: 'page',
};
page = 1;
}

View File

@ -0,0 +1,127 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { capitalize } from '@ember/string';
import { task } from 'ember-concurrency';
export default class MfaMethodCreateController extends Controller {
@service flashMessages;
@service router;
queryParams = ['type'];
methodNames = ['TOTP', 'Duo', 'Okta', 'PingID'];
@tracked type = null;
@tracked method = null;
@tracked enforcement;
@tracked enforcementPreference = 'new';
@tracked methodErrors;
@tracked enforcementErrors;
get description() {
if (this.type === 'totp') {
return `Once set up, TOTP requires a passcode to be presented alongside a Vault token when invoking an API request.
The passcode will be validated against the TOTP key present in the identity of the caller in Vault.`;
}
return `Once set up, the ${this.formattedType} MFA method will require a push confirmation on mobile before login.`;
}
get formattedType() {
if (!this.type) return '';
return this.type === 'totp' ? this.type.toUpperCase() : capitalize(this.type);
}
get isTotp() {
return this.type === 'totp';
}
get showForms() {
return this.type && this.method;
}
@action
onTypeSelect(type) {
// set any form related properties to default values
this.method = null;
this.enforcement = null;
this.methodErrors = null;
this.enforcementErrors = null;
this.enforcementPreference = 'new';
this.type = type;
}
@action
createModels() {
if (this.method) {
this.method.unloadRecord();
}
if (this.enforcement) {
this.enforcement.unloadRecord();
}
this.method = this.store.createRecord('mfa-method', { type: this.type });
this.enforcement = this.store.createRecord('mfa-login-enforcement');
}
@action
onEnforcementPreferenceChange(preference) {
if (preference === 'new') {
this.enforcement = this.store.createRecord('mfa-login-enforcement');
} else if (this.enforcement) {
this.enforcement.unloadRecord();
this.enforcement = null;
}
this.enforcementPreference = preference;
}
@action
cancel() {
this.method = null;
this.enforcement = null;
this.enforcementPreference = null;
this.router.transitionTo('vault.cluster.access.mfa.methods');
}
@task
*save() {
const isValid = this.checkValidityState();
if (isValid) {
try {
// first save method
yield this.method.save();
if (this.enforcement) {
this.enforcement.mfa_methods.addObject(this.method);
try {
// now save enforcement and catch error separately
yield this.enforcement.save();
} catch (error) {
this.handleError(
error,
'Error saving enforcement. You can still create an enforcement separately and add this method to it.'
);
}
}
this.router.transitionTo('vault.cluster.access.mfa.methods.method', this.method.id);
} catch (error) {
this.handleError(error, 'Error saving method');
}
}
}
checkValidityState() {
// block saving models if either is in an invalid state
let isEnforcementValid = true;
const methodValidations = this.method.validate();
if (!methodValidations.isValid) {
this.methodErrors = methodValidations.state;
}
// only validate enforcement if creating new
if (this.enforcementPreference === 'new') {
const enforcementValidations = this.enforcement.validate();
// since we are adding the method after it has been saved ignore mfa_methods validation state
const { name, targets } = enforcementValidations.state;
isEnforcementValid = name.isValid && targets.isValid;
if (!enforcementValidations.isValid) {
this.enforcementErrors = enforcementValidations.state;
}
}
return methodValidations.isValid && isEnforcementValid;
}
handleError(error, message) {
const errorMessage = error?.errors ? `${message}: ${error.errors.join(', ')}` : message;
this.flashMessages.danger(errorMessage);
}
}

View File

@ -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.`);
}
}
}

View File

@ -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,
});
},
},
});

View File

@ -0,0 +1,43 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
export default class VaultClusterMfaSetupController extends Controller {
@service auth;
@tracked onStep = 1;
@tracked warning = '';
@tracked uuid = '';
@tracked qrCode = '';
get entityId() {
return this.auth.authData.entity_id;
}
@action isUUIDVerified(verified) {
this.warning = ''; // clear the warning, otherwise it persists.
if (verified) {
this.onStep = 2;
} else {
this.restartFlow();
}
}
@action
restartFlow() {
this.onStep = 1;
}
@action
saveUUIDandQrCode(uuid, qrCode) {
// qrCode could be an empty string if the admin-generate was not successful
this.uuid = uuid;
this.qrCode = qrCode;
}
@action
showWarning(warning) {
this.warning = warning;
this.onStep = 2;
}
}

View File

@ -0,0 +1,6 @@
import { helper } from '@ember/component/helper';
import ENV from 'vault/config/environment';
export default helper(function ([path]) {
return path.replace(/^~\//, `${ENV.rootURL}images/`);
});

View File

@ -0,0 +1,106 @@
import Model, { attr, hasMany } from '@ember-data/model';
import ArrayProxy from '@ember/array/proxy';
import PromiseProxyMixin from '@ember/object/promise-proxy-mixin';
import { methods } from 'vault/helpers/mountable-auth-methods';
import { withModelValidations } from 'vault/decorators/model-validations';
import { isPresent } from '@ember/utils';
const validations = {
name: [{ type: 'presence', message: 'Name is required' }],
mfa_methods: [{ type: 'presence', message: 'At least one MFA method is required' }],
targets: [
{
validator(model) {
// avoid async fetch of records here and access relationship ids to check for presence
const entityIds = model.hasMany('identity_entities').ids();
const groupIds = model.hasMany('identity_groups').ids();
return (
isPresent(model.auth_method_accessors) ||
isPresent(model.auth_method_types) ||
isPresent(entityIds) ||
isPresent(groupIds)
);
},
message:
"At least one target is required. If you've selected one, click 'Add' to make sure it's added to this enforcement.",
},
],
};
@withModelValidations(validations)
export default class MfaLoginEnforcementModel extends Model {
@attr('string') name;
@hasMany('mfa-method') mfa_methods;
@attr('string') namespace_id;
@attr('array', { defaultValue: () => [] }) auth_method_accessors; // ["auth_approle_17a552c6"]
@attr('array', { defaultValue: () => [] }) auth_method_types; // ["userpass"]
@hasMany('identity/entity') identity_entities;
@hasMany('identity/group') identity_groups;
get targets() {
return ArrayProxy.extend(PromiseProxyMixin).create({
promise: this.prepareTargets(),
});
}
async prepareTargets() {
const mountableMethods = methods(); // use for icon lookup
let authMethods;
const targets = [];
if (this.auth_method_accessors.length || this.auth_method_types.length) {
// fetch all auth methods and lookup by accessor to get mount path and type
try {
const { data } = await this.store.adapterFor('auth-method').findAll();
authMethods = Object.keys(data).map((key) => ({ path: key, ...data[key] }));
} catch (error) {
// swallow this error
}
}
if (this.auth_method_accessors.length) {
const selectedAuthMethods = authMethods.filter((model) => {
return this.auth_method_accessors.includes(model.accessor);
});
targets.addObjects(
selectedAuthMethods.map((method) => {
const mount = mountableMethods.findBy('type', method.type);
const icon = mount.glyph || mount.type;
return {
icon,
link: 'vault.cluster.access.method',
linkModels: [method.path.slice(0, -1)],
title: method.path,
subTitle: method.accessor,
};
})
);
}
this.auth_method_types.forEach((type) => {
const mount = mountableMethods.findBy('type', type);
const icon = mount.glyph || mount.type;
const mountCount = authMethods.filterBy('type', type).length;
targets.addObject({
key: 'auth_method_types',
icon,
title: type,
subTitle: `All ${type} mounts (${mountCount})`,
});
});
for (const key of ['identity_entities', 'identity_groups']) {
(await this[key]).forEach((model) => {
targets.addObject({
key,
icon: 'user',
link: 'vault.cluster.access.identity.show',
linkModels: [key.split('_')[1], model.id, 'details'],
title: model.name,
subTitle: model.id,
});
});
}
return targets;
}
}

168
ui/app/models/mfa-method.js Normal file
View File

@ -0,0 +1,168 @@
import Model, { attr } from '@ember-data/model';
import { capitalize } from '@ember/string';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import { withModelValidations } from 'vault/decorators/model-validations';
import { isPresent } from '@ember/utils';
const METHOD_PROPS = {
common: [],
duo: ['username_format', 'secret_key', 'integration_key', 'api_hostname', 'push_info', 'use_passcode'],
okta: ['username_format', 'mount_accessor', 'org_name', 'api_token', 'base_url', 'primary_email'],
totp: ['issuer', 'period', 'key_size', 'qr_size', 'algorithm', 'digits', 'skew', 'max_validation_attempts'],
pingid: [
'username_format',
'settings_file_base64',
'use_signature',
'idp_url',
'admin_url',
'authenticator_url',
'org_alias',
],
};
const REQUIRED_PROPS = {
duo: ['secret_key', 'integration_key', 'api_hostname'],
okta: ['org_name', 'api_token'],
totp: ['issuer'],
pingid: ['settings_file_base64'],
};
const validators = Object.keys(REQUIRED_PROPS).reduce((obj, type) => {
REQUIRED_PROPS[type].forEach((prop) => {
obj[`${prop}`] = [
{
message: `${prop.replace(/_/g, ' ')} is required`,
validator(model) {
return model.type === type ? isPresent(model[prop]) : true;
},
},
];
});
return obj;
}, {});
@withModelValidations(validators)
export default class MfaMethod extends Model {
// common
@attr('string') type;
@attr('string', {
label: 'Username format',
subText: 'How to map identity names to MFA method names. ',
})
username_format;
@attr('string', {
label: 'Namespace',
})
namespace_id;
@attr('string') mount_accessor;
// PING ID
@attr('string', {
label: 'Settings file',
subText: 'A base-64 encoded third party setting file retrieved from the PingIDs configuration page.',
})
settings_file_base64;
@attr('boolean') use_signature;
@attr('string') idp_url;
@attr('string') admin_url;
@attr('string') authenticator_url;
@attr('string') org_alias;
// OKTA
@attr('string', {
label: 'Organization name',
subText: 'Name of the organization to be used in the Okta API.',
})
org_name;
@attr('string', {
label: 'Okta API key',
})
api_token;
@attr('string', {
label: 'Base URL',
subText:
'If set, will be used as the base domain for API requests. Example are okta.com, oktapreview.com and okta-emea.com.',
})
base_url;
@attr('boolean') primary_email;
// DUO
@attr('string', {
label: 'Duo secret key',
sensitive: true,
})
secret_key;
@attr('string', {
label: 'Duo integration key',
sensitive: true,
})
integration_key;
@attr('string', {
label: 'Duo API hostname',
})
api_hostname;
@attr('string', {
label: 'Duo push information',
subText: 'Additional information displayed to the user when the push is presented to them.',
})
push_info;
@attr('boolean', {
label: 'Passcode reminder',
subText: 'If this is turned on, the user is reminded to use the passcode upon MFA validation.',
})
use_passcode;
// TOTP
@attr('string', {
label: 'Issuer',
subText: 'The human-readable name of the keys issuing organization.',
})
issuer;
@attr({
label: 'Period',
editType: 'ttl',
subText: 'How long each generated TOTP is valid.',
})
period;
@attr('number', {
label: 'Key size',
subText: 'The size in bytes of the Vault generated key.',
})
key_size;
@attr('number', {
label: 'QR size',
subText: 'The pixel size of the generated square QR code.',
})
qr_size;
@attr('string', {
label: 'Algorithm',
possibleValues: ['SHA1', 'SHA256', 'SHA512'],
subText: 'The hashing algorithm used to generate the TOTP code.',
})
algorithm;
@attr('number', {
label: 'Digits',
possibleValues: [6, 8],
subText: 'The number digits in the generated TOTP code.',
})
digits;
@attr('number', {
label: 'Skew',
possibleValues: [0, 1],
subText: 'The number of delay periods allowed when validating a TOTP token.',
})
skew;
@attr('number') max_validation_attempts;
get name() {
return this.type === 'totp' ? this.type.toUpperCase() : capitalize(this.type);
}
get formFields() {
return [...METHOD_PROPS.common, ...METHOD_PROPS[this.type]];
}
get attrs() {
return expandAttributeMeta(this, this.formFields);
}
}

View File

@ -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: '/' });

View File

@ -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');
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class MfaLoginEnforcementEditRoute extends Route {}

View File

@ -0,0 +1,16 @@
import Route from '@ember/routing/route';
export default class MfaEnforcementsRoute extends Route {
model() {
return this.store.query('mfa-login-enforcement', {}).catch((err) => {
if (err.httpStatus === 404) {
return [];
} else {
throw err;
}
});
}
setupController(controller, model) {
controller.set('model', model);
}
}

View File

@ -0,0 +1,15 @@
import Route from '@ember/routing/route';
export default class MfaConfigureRoute extends Route {
beforeModel() {
return this.store
.query('mfa-method', {})
.then(() => {
// if response then they should transition to the methods page instead of staying on the configure page.
this.transitionTo('vault.cluster.access.mfa.methods.index');
})
.catch(() => {
// stay on the landing page
});
}
}

View File

@ -0,0 +1,20 @@
import Route from '@ember/routing/route';
export default class MfaLoginEnforcementCreateRoute extends Route {
setupController(controller) {
super.setupController(...arguments);
// if route was refreshed after type select recreate method model
const { type } = controller;
if (type) {
// create method and enforcement models for forms if type is selected
controller.createModels();
}
}
resetController(controller, isExiting) {
if (isExiting) {
// reset type query param when user saves or cancels
// this will not trigger when refreshing the page which preserves intended functionality
controller.set('type', null);
}
}
}

View File

@ -0,0 +1,25 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class MfaMethodsRoute extends Route {
@service router;
model() {
return this.store.query('mfa-method', {}).catch((err) => {
if (err.httpStatus === 404) {
return [];
} else {
throw err;
}
});
}
afterModel(model) {
if (model.length === 0) {
this.router.transitionTo('vault.cluster.access.mfa');
}
}
setupController(controller, model) {
controller.set('model', model);
}
}

View File

@ -0,0 +1,24 @@
import Route from '@ember/routing/route';
import { hash } from 'rsvp';
export default class MfaMethodRoute extends Route {
model({ id }) {
return hash({
method: this.store.findRecord('mfa-method', id).then((data) => data),
enforcements: this.store
.query('mfa-login-enforcement', {})
.then((data) => {
let filteredEnforcements = data.filter((item) => {
let results = item.hasMany('mfa_methods').ids();
return results.includes(id);
});
return filteredEnforcements;
})
.catch(() => {
// Do nothing
}),
});
}
setupController(controller, model) {
controller.set('model', model);
}
}

View File

@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class MfaMethodEditRoute extends Route {}

View File

@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class MfaSetupRoute extends Route {}

View File

@ -0,0 +1,36 @@
import ApplicationSerializer from './application';
export default class MfaLoginEnforcementSerializer extends ApplicationSerializer {
primaryKey = 'name';
// Return data with updated keys for hasMany relationships with ids in the name
transformHasManyKeys(data, destination) {
const keys = {
model: ['mfa_methods', 'identity_entities', 'identity_groups'],
server: ['mfa_method_ids', 'identity_entity_ids', 'identity_group_ids'],
};
keys[destination].forEach((newKey, index) => {
const oldKey = destination === 'model' ? keys.server[index] : keys.model[index];
delete Object.assign(data, { [newKey]: data[oldKey] })[oldKey];
});
return data;
}
normalize(model, data) {
this.transformHasManyKeys(data, 'model');
return super.normalize(model, data);
}
normalizeItems(payload) {
if (payload.data) {
if (payload.data?.keys && Array.isArray(payload.data.keys)) {
return payload.data.keys.map((key) => payload.data.key_info[key]);
}
Object.assign(payload, payload.data);
delete payload.data;
}
return payload;
}
serialize() {
const json = super.serialize(...arguments);
return this.transformHasManyKeys(json, 'server');
}
}

View File

@ -0,0 +1,22 @@
import ApplicationSerializer from './application';
export default class KeymgmtKeySerializer extends ApplicationSerializer {
normalizeItems(payload) {
if (payload.data.keys && Array.isArray(payload.data.keys)) {
let data = payload.data.keys.map((key) => {
let model = payload.data.key_info[key];
model.id = key;
return model;
});
return data;
}
Object.assign(payload, payload.data);
delete payload.data;
return payload;
}
serialize() {
const json = super.serialize(...arguments);
delete json.type;
return json;
}
}

View File

@ -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);

View File

@ -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',

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

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

View File

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

View File

@ -0,0 +1,154 @@
<div ...attributes>
<FormFieldLabel
for="name"
@label="Name"
@subText="The name for this enforcement. Giving it a name means that you can refer to it again later. This name will not be editable later."
data-test-mlef-label="name"
/>
<input
autocomplete="off"
spellcheck="false"
value={{@model.name}}
disabled={{not @model.isNew}}
class="input field"
data-test-mlef-input="name"
{{on "input" (pipe (pick "target.value") (fn (mut @model.name)))}}
/>
{{#if this.errors.name.errors}}
<AlertInline @type="danger" @message={{join ", " this.errors.name.errors}} />
{{/if}}
{{#unless @isInline}}
<div class="field">
<FormFieldLabel
for="methods"
@label="MFA methods"
@subText="The MFA method(s) that this enforcement will apply to."
data-test-mlef-label="methods"
/>
{{! component only computes inputValue on init -- ensure Ember Data hasMany promise has resolved }}
{{#if @model.mfa_methods.isFulfilled}}
<SearchSelect
@placeholder="Type to search for existing MFA methods"
@inputValue={{map-by "id" @model.mfa_methods}}
@shouldRenderName={{true}}
@disallowNewItems={{true}}
@models={{array "mfa-method"}}
@onChange={{this.onMethodChange}}
data-test-mlef-search="methods"
/>
{{/if}}
{{#if this.errors.mfa_methods.errors}}
<AlertInline @type="danger" @message={{join ", " this.errors.mfa_methods.errors}} />
{{/if}}
</div>
{{/unless}}
<div>
<FormFieldLabel
for="targets"
@label="Targets"
@subText="The list of authentication types, authentication mounts, groups, and/or entities that will require this MFA configuration."
data-test-mlef-label="targets"
/>
{{#each this.targets as |target|}}
<div class="is-flex-center has-border-top-light" data-test-mlef-target={{target.label}}>
<InfoTableRow @label={{target.label}} class="is-flex-1 has-no-shadow">
{{#if target.value.id}}
{{target.value.name}}
<span class="tag has-left-margin-s">{{target.value.id}}</span>
{{else}}
{{target.value}}
{{/if}}
</InfoTableRow>
<button
type="button"
class="button"
data-test-mlef-remove-target={{target.label}}
{{on "click" (fn this.removeTarget target)}}
>
<Icon @name="trash" />
</button>
</div>
{{/each}}
<div class="is-flex-row {{if this.targets 'has-top-padding-s has-border-top-light'}}">
<Select
@options={{this.targetTypes}}
@labelAttribute="label"
@valueAttribute="type"
@selectedValue={{this.selectedTargetType}}
@onChange={{this.onTargetSelect}}
data-test-mlef-select="target-type"
/>
<div class="has-left-margin-s is-flex-1">
{{#if (eq this.selectedTargetType "accessor")}}
<MountAccessorSelect
@value={{this.selectedTargetValue}}
@showAccessor={{true}}
@noDefault={{true}}
@onChange={{this.setTargetValue}}
@filterToken={{true}}
data-test-mlef-select="accessor"
/>
{{else if (eq this.selectedTargetType "method")}}
<Select
@options={{this.authMethods}}
@labelAttribute="displayName"
@valueAttribute="value"
@isFullwidth={{true}}
@noDefault={{true}}
@selectedValue={{this.selectedTargetValue}}
@onChange={{this.setTargetValue}}
data-test-mlef-select="auth-method"
/>
{{else}}
<SearchSelect
@placeholder="Search for an existing target"
@options={{this.searchSelect.options}}
{{! workaround since there is no way provided by component to externally clear selected options }}
@selectedOptions={{this.searchSelect.selected}}
@shouldRenderName={{true}}
@selectLimit={{1}}
@onChange={{this.setTargetValue}}
data-test-mlef-search={{this.selectedTargetType}}
/>
{{/if}}
</div>
<button
type="button"
class="button has-left-margin-s"
disabled={{not this.selectedTargetValue}}
data-test-mlef-add-target
{{on "click" this.addTarget}}
>
Add
</button>
</div>
{{#if this.errors.targets.errors}}
<AlertInline @type="danger" @message={{join ", " this.errors.targets.errors}} />
{{/if}}
</div>
{{#unless @isInline}}
<hr />
<div class="has-top-padding-s">
<button
type="button"
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
disabled={{this.save.isRunning}}
data-test-mlef-save
{{on "click" (perform this.save)}}
>
{{if @model.isNew "Create" "Update"}}
</button>
<button
type="button"
class="button has-left-margin-s"
disabled={{this.save.isRunning}}
data-test-mlef-cancel
{{on "click" this.cancel}}
>
Cancel
</button>
</div>
{{/unless}}
</div>

View File

@ -0,0 +1,81 @@
{{#if @isInline}}
<h3 class="title is-5" data-test-mleh-title>Enforcement</h3>
{{else}}
<PageHeader as |p|>
<p.top>
<nav class="breadcrumb">
<ul>
<li>
<span class="sep">&#x0002f;</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>

View File

@ -0,0 +1,26 @@
<p>
TOTP Multi-factor authentication (MFA) can be enabled here if it is required by your administrator. This will ensure that
you are not prevented from logging into Vault in the future, once MFA is fully enforced.
</p>
<form id="mfa-setup-step-one" {{on "submit" this.verifyUUID}}>
<MessageError @errorMessage={{this.error}} class="has-top-margin-s" />
<div class="field has-top-margin-l">
<label class="is-label">
Method ID
</label>
{{! template-lint-disable no-autofocus-attribute}}
<p class="sub-text">Enter the UUID for your multi-factor authentication method. This can be provided to you by your
administrator.</p>
<Input id="uuid" name="uuid" class="input" autocomplete="off" spellcheck="false" autofocus="true" @value={{this.UUID}} />
</div>
<div class="is-flex-start has-gap">
<button id="continue" type="submit" class="button is-primary" disabled={{(is-empty-value this.UUID)}}>
Verify
</button>
<button id="cancel" type="button" {{on "click" this.redirectPreviousPage}} class="button">
Cancel
</button>
</div>
</form>

View File

@ -0,0 +1,34 @@
<p>
TOTP Multi-factor authentication (MFA) can be enabled here if it is required by your administrator. This will ensure that
you are not prevented from logging into Vault in the future, once MFA is fully enforced.
</p>
<div class="field has-top-margin-l">
<MessageError @errorMessage={{this.error}} class="has-top-margin-s" />
{{#if @warning}}
<AlertBanner @type="info" @title="MFA enabled" @message={{@warning}} class="has-top-margin-l" />
{{else}}
<div class="list-item-row">
<div class="center-display">
<QrCode @text={{@qrCode}} @colorLight="#F7F7F7" @width={{155}} @height={{155}} @correctLevel="L" />
</div>
</div>
<div class="has-top-margin-s">
<div class="info-table-row has-no-shadow">
<div class="column info-table-row-edit"><Icon @name="alert-triangle-fill" class="has-text-highlight" /></div>
<p class="is-size-8">
After you leave this page, this QR code will be removed and
<strong>cannot</strong>
be regenerated.
</p>
</div>
</div>
{{/if}}
<div class="is-flex-start has-gap has-top-margin-l">
<button id="restart" type="button" class="button has-text-danger" {{on "click" this.restartSetup}}>
Restart setup
</button>
<button id="cancel" type="button" {{on "click" this.redirectPreviousPage}} class="button is-primary">
Done
</button>
</div>
</div>

View File

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

View File

@ -0,0 +1,35 @@
<div class="box is-sideless is-fullwidth is-marginless" ...attributes>
{{#each @model.attrs as |attr|}}
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{or @validations this.editValidations}} />
{{/each}}
</div>
{{#if @hasActions}}
<div class="field is-grouped-split box is-fullwidth is-bottomless">
<div class="control">
<button
type="button"
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
disabled={{this.save.isRunning}}
onclick={{this.initSave}}
>
Save
</button>
<button type="button" class="button has-left-margin-s" disabled={{this.save.isRunning}} {{on "click" this.cancel}}>
Cancel
</button>
</div>
</div>
{{/if}}
<ConfirmationModal
@title="Edit {{@model.type}} configuration?"
@onClose={{action (mut this.isEditModalActive) false}}
@isActive={{this.isEditModalActive}}
@confirmText={{@model.type}}
@onConfirm={{perform this.save}}
>
<p>
Editing this configuration will have an impact on all authentication types, methods, groups and entities which make use
of this MFA method. Please make sure you want to make these changes before doing so.
</p>
</ConfirmationModal>

View File

@ -0,0 +1,43 @@
<LinkedBlock class="list-item-row" @params={{array "vault.cluster.access.mfa.methods.method" @model.id}}>
<div class="level is-mobile">
<div class="level-left">
<div class="is-flex-row">
<Icon @size="24" @name={{@model.type}} class="has-text-grey" />
<div>
<span class="has-text-weight-semibold has-text-black">
{{if (eq @model.type "totp") (uppercase @model.type) @model.type}}
</span>
<span class="tag has-left-margin-xs">
{{@model.id}}
</span>
<div class="has-top-margin-xs">
<code class="is-size-9">
Namespace:
{{@model.namespace_id}}
</code>
</div>
</div>
</div>
</div>
<div class="level-right is-flex is-paddingless is-marginless">
<div class="level-item">
<PopupMenu>
<nav class="menu">
<ul class="menu-list">
<li>
<LinkTo @route="vault.cluster.access.mfa.methods.method" @model={{@model.id}}>
Details
</LinkTo>
</li>
<li>
<LinkTo @route="vault.cluster.access.mfa.methods.method.edit" @model={{@model.id}}>
Edit
</LinkTo>
</li>
</ul>
</nav>
</PopupMenu>
</div>
</div>
</div>
</LinkedBlock>

View File

@ -0,0 +1,12 @@
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless">
<nav class="tabs">
<ul>
<LinkTo @route="vault.cluster.access.mfa.methods">
Methods
</LinkTo>
<LinkTo @route="vault.cluster.access.mfa.enforcements">
Enforcements
</LinkTo>
</ul>
</nav>
</div>

View File

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

View File

@ -0,0 +1,35 @@
<label
for={{dasherize @value}}
class="radio-card {{if (eq @value @groupValue) 'is-selected'}} {{if @disabled 'is-disabled'}}"
...attributes
>
{{#if (has-block)}}
{{yield}}
{{else}}
<div class="radio-card-row">
<div>
<Icon @name={{@icon}} @size="24" class="has-text-grey-light" />
</div>
<div class="has-left-margin-s">
<h5 class="radio-card-message-title">
{{@title}}
</h5>
<p class="radio-card-message-body">
{{@description}}
</p>
</div>
</div>
{{/if}}
<div class="radio-card-radio-row">
<RadioButton
id={{dasherize @value}}
name="config-mode"
class="radio"
disabled={{@disabled}}
@value={{@value}}
@groupValue={{@groupValue}}
@onChange={{@onChange}}
/>
<span class="dot"></span>
</div>
</label>

View File

@ -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"))}}

View File

@ -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}}>

View File

@ -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"
/>

View File

@ -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"
/>

View File

@ -0,0 +1,105 @@
<PageHeader as |p|>
<p.top>
<nav class="breadcrumb" aria-label="breadcrumb">
<ul>
<li>
<span class="sep">&#x0002f;</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>

View File

@ -0,0 +1,25 @@
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3">
Multi-factor Authentication
</h1>
</p.levelLeft>
</PageHeader>
<Mfa::Nav />
<Toolbar>
<ToolbarActions>
<ToolbarLink @type="add" @params={{array "vault.cluster.access.mfa.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}}

View File

@ -0,0 +1,35 @@
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3">
Multi-factor authentication
</h1>
</p.levelLeft>
</PageHeader>
<div class="box is-fullwidth is-sideless is-flex-between is-box-shadowless is-marginless">
<p>
Configure and enforce multi-factor authentication (MFA) for users logging into Vault, for any
<br />
authentication method.
<LearnLink @path="/tutorials/vault/multi-factor-authentication">
Learn more
</LearnLink>
</p>
<button type="submit" class="button is-primary" {{on "click" (transition-to "vault.cluster.access.mfa.methods.create")}}>
Configure MFA
</button>
</div>
<div class="box is-fullwidth is-shadowless">
<p>
<b>Step 1:</b>
Set up an MFA configuration using one of the methods; TOTP, Okta, Duo or Pingid.
</p>
<p>
<b>Step 2:</b>
Set up an enforcement to map the MFA configuration to your chosen auth method(s).
</p>
</div>
<div class="box is-fullwidth is-shadowless">
<img src={{img-path "~/mfa-landing.png"}} alt="MFA configure diagram" />
</div>

View File

@ -0,0 +1,98 @@
<PageHeader as |p|>
<p.levelLeft class="has-border-bottom-light">
<h1 class="title is-3">
{{#if this.method}}
Configure
{{this.method.name}}
MFA
{{else}}
Multi-factor authentication
{{/if}}
</h1>
</p.levelLeft>
<p.top>
<nav class="breadcrumb">
<ul>
<li>
<span class="sep">&#x0002f;</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>

View File

@ -0,0 +1,25 @@
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3">
Multi-factor Authentication
</h1>
</p.levelLeft>
</PageHeader>
<Mfa::Nav />
<Toolbar>
<ToolbarActions>
<ToolbarLink @type="add" @params={{array "vault.cluster.access.mfa.methods.create"}}>
New MFA method
</ToolbarLink>
</ToolbarActions>
</Toolbar>
{{#if (gt this.model.length 0)}}
{{#each this.model as |item|}}
<Mfa::MethodListItem @model={{item}} />
{{/each}}
{{else}}
<EmptyState @title="No methods found." @message="Add a new one to get started." />
{{/if}}

View File

@ -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}}
/>

View File

@ -0,0 +1,90 @@
<PageHeader as |p|>
<p.top>
<nav class="breadcrumb">
<ul>
<li>
<span class="sep">&#x0002f;</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}}

View File

@ -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}}

View File

@ -0,0 +1,29 @@
<SplashPage @showTruncatedNavBar={{false}} as |Page|>
<Page.header>
<h1 class="title is-4">MFA setup</h1>
</Page.header>
<Page.content>
<div class="auth-form" data-test-mfa-form>
<div class="box">
{{#if (eq this.onStep 1)}}
<MfaSetupStepOne
@entityId={{this.entityId}}
@isUUIDVerified={{this.isUUIDVerified}}
@restartFlow={{this.restartFlow}}
@saveUUIDandQrCode={{this.saveUUIDandQrCode}}
@showWarning={{this.showWarning}}
/>
{{/if}}
{{#if (eq this.onStep 2)}}
<MfaSetupStepTwo
@entityId={{this.entityId}}
@uuid={{this.uuid}}
@qrCode={{this.qrCode}}
@restartFlow={{this.restartFlow}}
@warning={{this.warning}}
/>
{{/if}}
</div>
</div>
</Page.content>
</SplashPage>

View File

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

View File

@ -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);
}
}
}

View File

@ -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)

View File

@ -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}}

View File

@ -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',

View File

@ -0,0 +1,19 @@
import { Factory } from 'ember-cli-mirage';
export default Factory.extend({
api_hostname: 'api-foobar.duosecurity.com',
mount_accessor: '',
name: '', // returned but cannot be set at this time
namespace_id: 'root',
pushinfo: '',
type: 'duo',
use_passcode: false,
username_template: '',
afterCreate(record) {
if (record.name) {
console.warn('Endpoint ignored these unrecognized parameters: [name]'); // eslint-disable-line
record.name = '';
}
},
});

View File

@ -0,0 +1,41 @@
import { Factory } from 'ember-cli-mirage';
export default Factory.extend({
auth_method_accessors: null,
auth_method_types: null,
identity_entity_ids: null,
identity_group_ids: null,
mfa_method_ids: null,
name: null,
namespace_id: 'root',
afterCreate(record, server) {
// initialize arrays and stub some data if not provided
if (!record.name) {
// use random string for generated name
record.update('name', (Math.random() + 1).toString(36).substring(2));
}
if (!record.mfa_method_ids) {
// aggregate all existing methods and choose a random one
const methods = ['Totp', 'Duo', 'Okta', 'Pingid'].reduce((methods, type) => {
const records = server.schema.db[`mfa${type}Methods`].where({});
if (records.length) {
methods.push(...records);
}
return methods;
}, []);
// if no methods were found create one since it is a required for login enforcements
if (!methods.length) {
methods.push(server.create('mfa-totp-method'));
}
const method = methods.length ? methods[Math.floor(Math.random() * methods.length)] : null;
record.update('mfa_method_ids', method ? [method.id] : []);
}
const keys = ['auth_method_accessors', 'auth_method_types', 'identity_group_ids', 'identity_entity_ids'];
keys.forEach((key) => {
if (!record[key]) {
record.update(key, key === 'auth_method_types' ? ['userpass'] : []);
}
});
},
});

View File

@ -0,0 +1,18 @@
import { Factory } from 'ember-cli-mirage';
export default Factory.extend({
base_url: 'okta.com',
mount_accessor: '',
name: '', // returned but cannot be set at this time
namespace_id: 'root',
org_name: 'dev-foobar',
type: 'okta',
username_template: '', // returned but cannot be set at this time
afterCreate(record) {
if (record.name) {
console.warn('Endpoint ignored these unrecognized parameters: [name]'); // eslint-disable-line
record.name = '';
}
},
});

View File

@ -0,0 +1,11 @@
import { Factory } from 'ember-cli-mirage';
export default Factory.extend({
use_signature: true,
idp_url: 'https://foobar.pingidentity.com/pingid',
admin_url: 'https://foobar.pingidentity.com/pingid',
authenticator_url: 'https://authenticator.pingone.com/pingid/ppm',
org_alias: 'foobarbaz',
type: 'pingid',
username_template: '',
});

View File

@ -0,0 +1,22 @@
import { Factory } from 'ember-cli-mirage';
export default Factory.extend({
algorithm: 'SHA1',
digits: 6,
issuer: 'Vault',
key_size: 20,
max_validation_attempts: 5,
name: '', // returned but cannot be set at this time
namespace_id: 'root',
period: 30,
qr_size: 200,
skew: 1,
type: 'totp',
afterCreate(record) {
if (record.name) {
console.warn('Endpoint ignored these unrecognized parameters: [name]'); // eslint-disable-line
record.name = '';
}
},
});

View File

@ -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 };

View File

@ -0,0 +1,171 @@
import { Response } from 'miragejs';
export default function (server) {
const methods = ['totp', 'duo', 'okta', 'pingid'];
const required = {
totp: ['issuer'],
duo: ['secret_key', 'integration_key', 'api_hostname'],
okta: ['org_name', 'api_token'],
pingid: ['settings_file_base64'],
};
const validate = (type, data, cb) => {
if (!methods.includes(type)) {
return new Response(400, {}, { errors: [`Method ${type} not found`] });
}
if (data) {
const missing = required[type].reduce((params, key) => {
if (!data[key]) {
params.push(key);
}
return params;
}, []);
if (missing.length) {
return new Response(400, {}, { errors: [`Missing required parameters: [${missing.join(', ')}]`] });
}
}
return cb();
};
const dbKeyFromType = (type) => `mfa${type.charAt(0).toUpperCase()}${type.slice(1)}Methods`;
const generateListResponse = (schema, isMethod) => {
let records = [];
if (isMethod) {
methods.forEach((method) => {
records.addObjects(schema.db[dbKeyFromType(method)].where({}));
});
} else {
records = schema.db.mfaLoginEnforcements.where({});
}
// seed the db with a few records if none exist
if (!records.length) {
if (isMethod) {
methods.forEach((type) => {
records.push(server.create(`mfa-${type}-method`));
});
} else {
records = server.createList('mfa-login-enforcement', 4).toArray();
}
}
const dataKey = isMethod ? 'id' : 'name';
const data = records.reduce(
(resp, record) => {
resp.key_info[record[dataKey]] = record;
resp.keys.push(record[dataKey]);
return resp;
},
{
key_info: {},
keys: [],
}
);
return { data };
};
// list methods
server.get('/identity/mfa/method/', (schema) => {
return generateListResponse(schema, true);
});
// fetch method by id
server.get('/identity/mfa/method/:id', (schema, { params: { id } }) => {
let record;
for (const method of methods) {
record = schema.db[dbKeyFromType(method)].find(id);
if (record) {
break;
}
}
// inconvenient when testing edit route to return a 404 on refresh since mirage memory is cleared
// flip this variable to test 404 state if needed
const shouldError = false;
// create a new record so data is always returned
if (!record && !shouldError) {
return { data: server.create('mfa-totp-method') };
}
return !record ? new Response(404, {}, { errors: [] }) : { data: record };
});
// create method
server.post('/identity/mfa/method/:type', (schema, { params: { type }, requestBody }) => {
const data = JSON.parse(requestBody);
return validate(type, data, () => {
const record = server.create(`mfa-${type}-method`, data);
return { data: { method_id: record.id } };
});
});
// update method
server.put('/identity/mfa/method/:type/:id', (schema, { params: { type, id }, requestBody }) => {
const data = JSON.parse(requestBody);
return validate(type, data, () => {
schema.db[dbKeyFromType(type)].update(id, data);
return {};
});
});
// delete method
server.delete('/identity/mfa/method/:type/:id', (schema, { params: { type, id } }) => {
return validate(type, null, () => {
schema.db[dbKeyFromType(type)].remove(id);
return {};
});
});
// list enforcements
server.get('/identity/mfa/login-enforcement', (schema) => {
return generateListResponse(schema);
});
// fetch enforcement by name
server.get('/identity/mfa/login-enforcement/:name', (schema, { params: { name } }) => {
const record = schema.db.mfaLoginEnforcements.findBy({ name });
// inconvenient when testing edit route to return a 404 on refresh since mirage memory is cleared
// flip this variable to test 404 state if needed
const shouldError = false;
// create a new record so data is always returned
if (!record && !shouldError) {
return { data: server.create('mfa-login-enforcement', { name }) };
}
return !record ? new Response(404, {}, { errors: [] }) : { data: record };
});
// create/update enforcement
server.post('/identity/mfa/login-enforcement/:name', (schema, { params: { name }, requestBody }) => {
const data = JSON.parse(requestBody);
// at least one method id is required
if (!data.mfa_method_ids?.length) {
return new Response(400, {}, { errors: ['missing method ids'] });
}
// at least one of the following targets is required
const required = [
'auth_method_accessors',
'auth_method_types',
'identity_group_ids',
'identity_entity_ids',
];
let hasRequired = false;
for (let key of required) {
if (data[key]?.length) {
hasRequired = true;
break;
}
}
if (!hasRequired) {
return new Response(
400,
{},
{
errors: [
'One of auth_method_accessors, auth_method_types, identity_group_ids, identity_entity_ids must be specified',
],
}
);
}
if (schema.db.mfaLoginEnforcements.findBy({ name })) {
schema.db.mfaLoginEnforcements.update({ name }, data);
} else {
schema.db.mfaLoginEnforcements.insert(data);
}
return { ...data, id: data.name };
});
// delete enforcement
server.delete('/identity/mfa/login-enforcement/:name', (schema, { params: { name } }) => {
schema.db.mfaLoginEnforcements.remove({ name });
return {};
});
}

View File

@ -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);
}

View File

@ -0,0 +1,47 @@
// to more closely match the Vault backend this will return UUIDs as identifiers for records in mirage
export default class {
constructor() {
this.ids = new Set();
}
/**
* Returns a unique identifier.
*
* @method fetch
* @param {Object} data Records attributes hash
* @return {String} Unique identifier
* @public
*/
fetch() {
let uuid = crypto.randomUUID();
// odds are incredibly low that we'll run into a duplicate using crypto.randomUUID()
// but just to be safe...
while (this.ids.has(uuid)) {
uuid = crypto.randomUUID();
}
this.ids.add(uuid);
return uuid;
}
/**
* Register an identifier.
* Must throw if identifier is already used.
*
* @method set
* @param {String|Number} id
* @public
*/
set(id) {
if (this.ids.has(id)) {
throw new Error(`ID ${id} is in use.`);
}
this.ids.add(id);
}
/**
* Reset identity manager.
*
* @method reset
* @public
*/
reset() {
this.ids.clear();
}
}

View File

@ -124,6 +124,7 @@
"ember-modifier": "^3.1.0",
"ember-page-title": "^6.2.2",
"ember-power-select": "^5.0.3",
"ember-qrcode-shim": "^0.4.0",
"ember-qunit": "^5.1.5",
"ember-resolver": "^8.0.3",
"ember-responsive": "^3.0.0-beta.3",

5
ui/public/duo.svg Normal file
View File

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.988 11.994C23.988 18.618 18.618 23.988 11.994 23.988C5.37 23.988 0 18.618 0 11.994C0 5.37 5.37 0 11.994 0C18.618 0 23.988 5.37 23.988 11.994Z" fill="#59B734"/>
<path d="M20.4045 11.9505C20.3153 13.3849 19.1259 14.5026 17.6888 14.5026C16.2516 14.5026 15.0622 13.3849 14.973 11.9505H20.4045ZM9.01951 11.9505C8.93647 13.3893 7.7457 14.5136 6.30451 14.514H3.58276V11.9505H9.01876H9.01951ZM14.7173 9.07275V11.793C14.7173 11.8462 14.715 11.898 14.712 11.9505V14.5095H12.153V9.072H14.7173V9.07275Z" fill="#E6F3D8"/>
<path d="M11.8395 9.07275V14.5095C10.4 14.4264 9.27525 13.2349 9.27525 11.793V9.07275H11.8402H11.8395ZM17.6887 9.07275C19.1301 9.07311 20.3211 10.1973 20.4045 11.6362H14.973C15.0564 10.1976 16.247 9.07351 17.688 9.07275H17.6887ZM6.30375 9.07275C7.74507 9.07311 8.93607 10.1973 9.0195 11.6362H3.5835V9.07275H6.30375Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 957 B

BIN
ui/public/images/mfa-landing.png (Stored with Git LFS) Normal file

Binary file not shown.

3
ui/public/okta.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.49063 2 2 6.45844 2 12C2 17.5416 6.45875 22 12 22C17.5413 22 22 17.5413 22 12C22 6.45875 17.5094 2 12 2ZM12 17C9.22937 17 7 14.7706 7 12C7 9.22937 9.22937 7 12 7C14.7706 7 17 9.22937 17 12C17 14.7706 14.7706 17 12 17Z" fill="#007DC1"/>
</svg>

After

Width:  |  Height:  |  Size: 356 B

11
ui/public/pingid.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@ -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);
});

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -0,0 +1,26 @@
import { module, skip } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | mfa-method-list-item', function (hooks) {
setupRenderingTest(hooks);
skip('it renders', async function (assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<MfaMethodListItem />`);
assert.dom(this.element).hasText('');
// Template block usage:
await render(hbs`
<MfaMethodListItem>
template block text
</MfaMethodListItem>
`);
assert.dom(this.element).hasText('template block text');
});
});

View File

@ -0,0 +1,33 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Serializer | mfa-login-enforcement', function (hooks) {
setupTest(hooks);
test('it should transform property names for hasMany relationships', function (assert) {
const serverData = {
name: 'foo',
mfa_method_ids: ['1'],
auth_method_types: ['userpass'],
auth_method_accessors: ['auth_approle_17a552c6'],
identity_entity_ids: ['2', '3'],
identity_group_ids: ['4', '5', '6'],
};
const tranformedData = {
name: 'foo',
mfa_methods: ['1'],
auth_method_types: ['userpass'],
auth_method_accessors: ['auth_approle_17a552c6'],
identity_entities: ['2', '3'],
identity_groups: ['4', '5', '6'],
};
const mutableData = { ...serverData };
const serializer = this.owner.lookup('serializer:mfa-login-enforcement');
serializer.transformHasManyKeys(mutableData, 'model');
assert.deepEqual(mutableData, tranformedData, 'hasMany property names are transformed for model');
serializer.transformHasManyKeys(mutableData, 'server');
assert.deepEqual(mutableData, serverData, 'hasMany property names are transformed for server');
});
});

View File

@ -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"