Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MFA Config #15200

Merged
merged 31 commits into from
May 21, 2022
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
38c0b61
adds mirage factories for mfa methods and login enforcement
zofskeez Apr 27, 2022
f8bed48
adds mirage handler for mfa config endpoints
zofskeez Apr 27, 2022
7694c06
adds mirage identity manager for uuids
zofskeez Apr 27, 2022
44ff84b
updates mfa test to use renamed mfaLogin mirage handler
zofskeez Apr 27, 2022
c7a19b8
updates mfa login workflow for push methods (#15214)
zofskeez May 2, 2022
c99b6cc
MFA Login Enforcement Model (#15244)
zofskeez May 3, 2022
3732e5d
Model for mfa method (#15218)
arnav28 May 5, 2022
dcca74b
MFA method and enforcement list view (#15353)
arnav28 May 11, 2022
ba81d3a
MFA Login Enforcement Form (#15410)
zofskeez May 13, 2022
9681ad9
MFA Login Enforcement Create and Edit routes (#15422)
zofskeez May 13, 2022
9c0ed26
MFA Login Enforcement Read Views (#15462)
zofskeez May 17, 2022
b8dbd68
MFA method form (#15432)
arnav28 May 17, 2022
4c585c9
Sidebranch: MFA end user setup (#15273)
Monkeychip May 17, 2022
0bc5c3a
MFA Guided Setup Route (#15479)
zofskeez May 17, 2022
e780d1c
MFA Guided Setup Config View (#15486)
zofskeez May 18, 2022
8d774cc
Enforcement view at MFA method level (#15485)
arnav28 May 18, 2022
b0b4d13
MFA Login Enforcement Validations (#15498)
zofskeez May 18, 2022
0e1adf6
Added validations for mfa method model (#15506)
arnav28 May 19, 2022
da24f4b
UI/mfa breadcrumbs and small fixes (#15499)
Monkeychip May 19, 2022
75630ef
UI/mfa small bugs (#15522)
Monkeychip May 19, 2022
e248ddc
Fix label for value on radio-card (#15542)
hashishaw May 20, 2022
cac3f67
MFA Login Enforcement Component Tests (#15539)
zofskeez May 20, 2022
f498c3a
Remove default values from mfa method model (#15540)
arnav28 May 20, 2022
04279d4
UI/mfa small cleanup (#15549)
hashishaw May 20, 2022
c1d661b
updates mfa login enforcement form to only display auth method types …
zofskeez May 20, 2022
e38e69f
remove token type (#15548)
Monkeychip May 20, 2022
ff5146c
removes type from mfa method payload and fixes bug transitioning to m…
zofskeez May 20, 2022
36d4107
removes punctuation from mfa form error message string match
zofskeez May 20, 2022
d62ed0d
updates qr-code component invocation to angle bracket
zofskeez May 20, 2022
ac1cc1a
Re-trigger CI jobs with empty commit
mdeggies May 20, 2022
7f889ba
Merge remote-tracking branch 'origin' into ui/mfa-config
mdeggies May 20, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
29 changes: 29 additions & 0 deletions ui/app/adapters/mfa-login-enforcement.js
Original file line number Diff line number Diff line change
@@ -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
async createRecord() {
zofskeez marked this conversation as resolved.
Show resolved Hide resolved
return this._saveRecord(...arguments);
}
// update record via POST method
updateRecord() {
return this._saveRecord(...arguments);
}

async query(store, type, query) {
const url = this.urlForQuery(query, type.modelName);
return this.ajax(url, 'GET', { data: { list: true } });
}
}
54 changes: 54 additions & 0 deletions ui/app/adapters/mfa-method.js
Original file line number Diff line number Diff line change
@@ -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')}`;
zofskeez marked this conversation as resolved.
Show resolved Hide resolved
return id ? `${url}/${id}` : url;
}
return super.buildURL(...arguments);
}
}
13 changes: 13 additions & 0 deletions ui/app/adapters/mfa-setup.js
Original file line number Diff line number Diff line change
@@ -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 });
}
}
9 changes: 7 additions & 2 deletions ui/app/components/auth-info.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? true : false;
zofskeez marked this conversation as resolved.
Show resolved Hide resolved
}

get isRenewing() {
return this.fakeRenew || this.auth.isRenewing;
Expand Down
38 changes: 29 additions & 9 deletions ui/app/components/mfa-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,29 @@ 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 {
@service auth;

@tracked countdown;
@tracked error;
@tracked codeDelayMessage;

constructor() {
super(...arguments);
// trigger validation immediately when passcode is not required
zofskeez marked this conversation as resolved.
Show resolved Hide resolved
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;
Expand Down Expand Up @@ -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;'));
zofskeez marked this conversation as resolved.
Show resolved Hide resolved
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--;
Expand Down
165 changes: 165 additions & 0 deletions ui/app/components/mfa-login-enforcement-form.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { methods } from 'vault/helpers/mountable-auth-methods';
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' },
];
authMethods = methods();
searchSelectOptions = null;

@tracked name;
@tracked targets = [];
@tracked selectedTargetType = 'accessor';
@tracked selectedTargetValue = null;
@tracked searchSelect = {
options: [],
selected: [],
};
@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();
}

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]],
};
}
}

get selectedTarget() {
return this.targetTypes.findBy('type', this.selectedTargetType);
}
get errors() {
return this.args.modelErrors || this.modelErrors;
}

@task
*save() {
this.modelErrors = {};
// check validity state first and abort if invalid
const { isValid, state } = this.args.model.validate();
if (!isValid) {
this.modelErrors = state;
} else {
try {
yield this.args.model.save();
this.args.onSave();
} catch (error) {
const message = error.errors ? error.errors.join('. ') : error.message;
this.flashMessages.danger(message);
}
}
}

@action
async onMethodChange(selectedIds) {
const methods = await this.args.model.mfa_methods;
// first check for existing methods that have been removed from selection
methods.forEach((method) => {
if (!selectedIds.includes(method.id)) {
methods.removeObject(method);
}
});
// now check for selected items that don't exist and add them to the model
const methodIds = methods.mapBy('id');
selectedIds.forEach((id) => {
if (!methodIds.includes(id)) {
const model = this.store.peekRecord('mfa-method', id);
methods.addObject(model);
}
});
}
@action
onTargetSelect(type) {
this.selectedTargetType = type;
this.resetTargetState();
}
@action
setTargetValue(selected) {
const { type } = this.selectedTarget;
if (type.includes('identity')) {
// for identity groups and entities grab model from store as value
this.selectedTargetValue = this.store.peekRecord(type, selected[0]);
} else {
this.selectedTargetValue = selected;
}
}
@action
addTarget() {
const { label, key } = this.selectedTarget;
const value = this.selectedTargetValue;
this.targets.addObject({ label, value, key });
// add target to appropriate model property
this.args.model[key].addObject(value);
this.selectedTargetValue = null;
this.resetTargetState();
}
@action
removeTarget(target) {
this.targets.removeObject(target);
// remove target from appropriate model property
this.args.model[target.key].removeObject(target.value);
}
@action
cancel() {
// revert model changes
this.args.model.rollbackAttributes();
this.args.onClose();
}
}
52 changes: 52 additions & 0 deletions ui/app/components/mfa-login-enforcement-header.js
Original file line number Diff line number Diff line change
@@ -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));
}
}