From 38c0b614c0286ae6b109bc6c6e62313da51e0beb Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Wed, 27 Apr 2022 12:04:54 -0600 Subject: [PATCH 01/30] adds mirage factories for mfa methods and login enforcement --- ui/mirage/factories/mfa-duo-method.js | 19 ++++++++++ ui/mirage/factories/mfa-login-enforcement.js | 37 ++++++++++++++++++++ ui/mirage/factories/mfa-okta-method.js | 18 ++++++++++ ui/mirage/factories/mfa-pingid-method.js | 11 ++++++ ui/mirage/factories/mfa-totp-method.js | 22 ++++++++++++ 5 files changed, 107 insertions(+) create mode 100644 ui/mirage/factories/mfa-duo-method.js create mode 100644 ui/mirage/factories/mfa-login-enforcement.js create mode 100644 ui/mirage/factories/mfa-okta-method.js create mode 100644 ui/mirage/factories/mfa-pingid-method.js create mode 100644 ui/mirage/factories/mfa-totp-method.js diff --git a/ui/mirage/factories/mfa-duo-method.js b/ui/mirage/factories/mfa-duo-method.js new file mode 100644 index 0000000000000..9f8d132c27c64 --- /dev/null +++ b/ui/mirage/factories/mfa-duo-method.js @@ -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 = ''; + } + }, +}); diff --git a/ui/mirage/factories/mfa-login-enforcement.js b/ui/mirage/factories/mfa-login-enforcement.js new file mode 100644 index 0000000000000..a4522bbbdf4ff --- /dev/null +++ b/ui/mirage/factories/mfa-login-enforcement.js @@ -0,0 +1,37 @@ +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.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; + }, []); + const method = methods.length ? methods[Math.floor(Math.random() * methods.length)] : null; + record.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[key] = key === 'auth_method_types' ? ['userpass'] : []; + } + }); + }, +}); diff --git a/ui/mirage/factories/mfa-okta-method.js b/ui/mirage/factories/mfa-okta-method.js new file mode 100644 index 0000000000000..2bf495066e559 --- /dev/null +++ b/ui/mirage/factories/mfa-okta-method.js @@ -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 = ''; + } + }, +}); diff --git a/ui/mirage/factories/mfa-pingid-method.js b/ui/mirage/factories/mfa-pingid-method.js new file mode 100644 index 0000000000000..f95481219f48d --- /dev/null +++ b/ui/mirage/factories/mfa-pingid-method.js @@ -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: '', +}); diff --git a/ui/mirage/factories/mfa-totp-method.js b/ui/mirage/factories/mfa-totp-method.js new file mode 100644 index 0000000000000..9b82a2b316c5e --- /dev/null +++ b/ui/mirage/factories/mfa-totp-method.js @@ -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 = ''; + } + }, +}); From f8bed481d43b65de46b865cd5c733945c1059deb Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Wed, 27 Apr 2022 12:05:37 -0600 Subject: [PATCH 02/30] adds mirage handler for mfa config endpoints --- ui/mirage/handlers/index.js | 5 +- ui/mirage/handlers/mfa-config.js | 138 ++++++++++++++++++++ ui/mirage/handlers/{mfa.js => mfa-login.js} | 0 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 ui/mirage/handlers/mfa-config.js rename ui/mirage/handlers/{mfa.js => mfa-login.js} (100%) diff --git a/ui/mirage/handlers/index.js b/ui/mirage/handlers/index.js index edf6bfb1a8314..cd134b58230de 100644 --- a/ui/mirage/handlers/index.js +++ b/ui/mirage/handlers/index.js @@ -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 }; diff --git a/ui/mirage/handlers/mfa-config.js b/ui/mirage/handlers/mfa-config.js new file mode 100644 index 0000000000000..ddcb3226191cd --- /dev/null +++ b/ui/mirage/handlers/mfa-config.js @@ -0,0 +1,138 @@ +import { Response } from 'miragejs'; +import { dasherize } from '@ember/string'; + +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, key) => { + let records = schema.db[key].where({}); + // seed the db with a few records if none exist + if (!records.length) { + records = server.createList(dasherize(key).slice(0, -1), 3).toArray(); + } + const dataKey = key === 'mfaLoginEnforcements' ? 'name' : 'id'; + 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/:type', (schema, { params: { type } }) => { + return validate(type, null, () => generateListResponse(schema, dbKeyFromType(type))); + }); + // fetch method by id + server.get('/identity/mfa/method/:type/:id', (schema, { params: { type, id } }) => { + return validate(type, null, () => { + const record = schema.db[dbKeyFromType(type)].find(id); + 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, 'mfaLoginEnforcements'); + }); + // fetch enforcement by name + server.get('/identity/mfa/login-enforcement/:name', (schema, { params: { name } }) => { + const record = schema.db.mfaLoginEnforcements.findBy({ 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, + {}, + 'One of auth_method_accessors, auth_method_types, identity_group_ids, identity_entity_ids must be specified' + ); + } + data.name = name; + if (schema.db.mfaLoginEnforcements.findBy({ name })) { + schema.db.mfaLoginEnforcements.update({ name }, data); + } else { + schema.db.mfaLoginEnforcements.insert(data); + } + return {}; + }); + // delete enforcement + server.delete('/identity/mfa/login-enforcement/:name', (schema, { params: { name } }) => { + schema.db.mfaLoginEnforcements.remove({ name }); + return {}; + }); +} diff --git a/ui/mirage/handlers/mfa.js b/ui/mirage/handlers/mfa-login.js similarity index 100% rename from ui/mirage/handlers/mfa.js rename to ui/mirage/handlers/mfa-login.js From 7694c067cdeb21f0bb7251905b3102662fbbcf3c Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Wed, 27 Apr 2022 12:06:01 -0600 Subject: [PATCH 03/30] adds mirage identity manager for uuids --- ui/mirage/identity-managers/application.js | 47 ++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 ui/mirage/identity-managers/application.js diff --git a/ui/mirage/identity-managers/application.js b/ui/mirage/identity-managers/application.js new file mode 100644 index 0000000000000..4c5c951d9f2dd --- /dev/null +++ b/ui/mirage/identity-managers/application.js @@ -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(); + } +} From 44ff84b01c8625acbb716279e666ce0247bd7da3 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Wed, 27 Apr 2022 12:22:40 -0600 Subject: [PATCH 04/30] updates mfa test to use renamed mfaLogin mirage handler --- ui/tests/acceptance/mfa-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/tests/acceptance/mfa-test.js b/ui/tests/acceptance/mfa-test.js index 94eab2310eb1e..370b85b528300 100644 --- a/ui/tests/acceptance/mfa-test.js +++ b/ui/tests/acceptance/mfa-test.js @@ -9,7 +9,7 @@ module('Acceptance | mfa', function (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) => { From c7a19b876041616417b1885f9f864956922a2300 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Mon, 2 May 2022 10:17:21 -0600 Subject: [PATCH 05/30] updates mfa login workflow for push methods (#15214) --- ui/app/components/mfa-form.js | 38 +++++-- ui/app/controllers/vault/cluster/auth.js | 9 +- ui/app/services/auth.js | 25 +---- ui/app/templates/components/mfa-form.hbs | 8 +- ui/app/templates/vault/cluster/auth.hbs | 11 +- ui/mirage/handlers/mfa-login.js | 100 +++++++++--------- .../{mfa-test.js => mfa-login-test.js} | 27 ++++- .../integration/components/mfa-form-test.js | 71 ++++++++----- 8 files changed, 168 insertions(+), 121 deletions(-) rename ui/tests/acceptance/{mfa-test.js => mfa-login-test.js} (84%) diff --git a/ui/app/components/mfa-form.js b/ui/app/components/mfa-form.js index b884105800415..ddbb257f0cd2b 100644 --- a/ui/app/components/mfa-form.js +++ b/ui/app/components/mfa-form.js @@ -15,9 +15,10 @@ import { numberToWord } from 'vault/helpers/number-to-word'; * @param {string} clusterId - id of selected cluster * @param {object} authData - data from initial auth request -- { mfa_requirement, backend, data } * @param {function} onSuccess - fired when passcode passes validation + * @param {function} onError - fired for multi-method or non-passcode method validation errors */ -export const VALIDATION_ERROR = +export const TOTP_VALIDATION_ERROR = 'The passcode failed to validate. If you entered the correct passcode, contact your administrator.'; export default class MfaForm extends Component { @@ -25,6 +26,18 @@ export default class MfaForm extends Component { @tracked countdown; @tracked error; + @tracked codeDelayMessage; + + constructor() { + super(...arguments); + // trigger validation immediately when passcode is not required + const passcodeOrSelect = this.constraints.filter((constraint) => { + return constraint.methods.length > 1 || constraint.methods.findBy('uses_passcode'); + }); + if (!passcodeOrSelect.length) { + this.validate.perform(); + } + } get constraints() { return this.args.authData.mfa_requirement.mfa_constraints; @@ -66,19 +79,26 @@ export default class MfaForm extends Component { }); this.args.onSuccess(response); } catch (error) { - const codeUsed = (error.errors || []).find((e) => e.includes('code already used;')); - if (codeUsed) { - // parse validity period from error string to initialize countdown - const seconds = parseInt(codeUsed.split('in ')[1].split(' seconds')[0]); - this.newCodeDelay.perform(seconds); + const errors = error.errors || []; + const codeUsed = errors.find((e) => e.includes('code already used;')); + const rateLimit = errors.find((e) => e.includes('maximum TOTP validation attempts')); + const delayMessage = codeUsed || rateLimit; + + if (delayMessage) { + const reason = codeUsed ? 'This code has already been used' : 'Maximum validation attempts exceeded'; + this.codeDelayMessage = `${reason}. Please wait until a new code is available.`; + this.newCodeDelay.perform(delayMessage); + } else if (this.singlePasscode) { + this.error = TOTP_VALIDATION_ERROR; } else { - this.error = VALIDATION_ERROR; + this.args.onError(this.auth.handleError(error)); } } } - @task *newCodeDelay(timePeriod) { - this.countdown = timePeriod; + @task *newCodeDelay(message) { + // parse validity period from error string to initialize countdown + this.countdown = parseInt(message.match(/(\d\w seconds)/)[0].split(' ')[0]); while (this.countdown) { yield timeout(1000); this.countdown--; diff --git a/ui/app/controllers/vault/cluster/auth.js b/ui/app/controllers/vault/cluster/auth.js index 0959cbb920ce4..41eb3dcb6ec39 100644 --- a/ui/app/controllers/vault/cluster/auth.js +++ b/ui/app/controllers/vault/cluster/auth.js @@ -69,8 +69,7 @@ export default Controller.extend({ actions: { onAuthResponse(authResponse, backend, data) { const { mfa_requirement } = authResponse; - // mfa methods handled by the backend are validated immediately in the auth service - // if the user must choose between methods or enter passcodes further action is required + // if an mfa requirement exists further action is required if (mfa_requirement) { this.set('mfaAuthData', { mfa_requirement, backend, data }); } else { @@ -81,8 +80,10 @@ export default Controller.extend({ this.authSuccess(authResponse); }, onMfaErrorDismiss() { - this.set('mfaAuthData', null); - this.auth.set('mfaErrors', null); + this.setProperties({ + mfaAuthData: null, + mfaErrors: null, + }); }, }, }); diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index 7ee8cf59c91fe..1857d0c1414ff 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -335,14 +335,11 @@ export default Service.extend({ // convert to array of objects and add necessary properties to satisfy the view if (mfa_requirement) { const { mfa_request_id, mfa_constraints } = mfa_requirement; - let requiresAction; // if multiple constraints or methods or passcode input is needed further action will be required const constraints = []; for (let key in mfa_constraints) { const methods = mfa_constraints[key].any; const isMulti = methods.length > 1; - if (isMulti || methods.findBy('uses_passcode')) { - requiresAction = true; - } + // friendly label for display in MfaForm methods.forEach((m) => { const typeFormatted = m.type === 'totp' ? m.type.toUpperCase() : capitalize(m.type); @@ -357,7 +354,6 @@ export default Service.extend({ return { mfa_requirement: { mfa_request_id, mfa_constraints: constraints }, - requiresAction, }; } return {}; @@ -366,23 +362,10 @@ export default Service.extend({ async authenticate(/*{clusterId, backend, data, selectedAuth}*/) { const [options] = arguments; const adapter = this.clusterAdapter(); + const resp = await adapter.authenticate(options); - let resp = await adapter.authenticate(options); - const { mfa_requirement, requiresAction } = this._parseMfaResponse(resp.auth?.mfa_requirement); - - if (mfa_requirement) { - if (requiresAction) { - return { mfa_requirement }; - } - // silently make request to validate endpoint when passcode is not required - try { - resp = await adapter.mfaValidate(mfa_requirement); - } catch (e) { - // it's not clear in the auth-form component whether mfa validation is taking place for non-totp method - // since mfa errors display a screen rather than flash message handle separately - this.set('mfaErrors', this.handleError(e)); - throw e; - } + if (resp.auth?.mfa_requirement) { + return this._parseMfaResponse(resp.auth?.mfa_requirement); } return this.authSuccess(options, resp.auth || resp.data); diff --git a/ui/app/templates/components/mfa-form.hbs b/ui/app/templates/components/mfa-form.hbs index 4f1e63faac8f8..f7b08c08f0009 100644 --- a/ui/app/templates/components/mfa-form.hbs +++ b/ui/app/templates/components/mfa-form.hbs @@ -45,7 +45,7 @@ {{! template-lint-enable no-autofocus-attribute}} {{else if (eq constraint.methods.length 1)}} -

+

Check device for push notification

{{/if}} @@ -53,11 +53,7 @@ {{#if this.newCodeDelay.isRunning}}
- +
{{/if}} + +
+

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

+

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

+
+ +
+ MFA configure diagram +
\ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/index.hbs b/ui/app/templates/vault/cluster/access/mfa/index.hbs new file mode 100644 index 0000000000000..02d6a610f9885 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/mfa/index.hbs @@ -0,0 +1,9 @@ + + +

+ Multi-factor Authentication +

+
+
+ +List view \ No newline at end of file diff --git a/ui/public/images/mfa-landing.png b/ui/public/images/mfa-landing.png new file mode 100644 index 0000000000000000000000000000000000000000..7848f72a8d01660443ca61b4d40273bded39c35f GIT binary patch literal 63398 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 C&-`Hk literal 0 HcmV?d00001 From dcca74baea47999434422d2c2f383c11ddbd0801 Mon Sep 17 00:00:00 2001 From: Arnav Palnitkar Date: Wed, 11 May 2022 09:37:08 -0700 Subject: [PATCH 08/30] 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 --- ui/app/adapters/mfa-method.js | 22 ++++----- .../vault/cluster/access/mfa/enforcements.js | 9 ++++ .../vault/cluster/access/mfa/index.js | 3 -- .../vault/cluster/access/mfa/methods.js | 9 ++++ ui/app/router.js | 8 +++- .../vault/cluster/access/mfa/configure.js | 2 +- .../vault/cluster/access/mfa/enforcements.js | 27 +++++++++++ .../routes/vault/cluster/access/mfa/method.js | 10 +++++ .../access/mfa/{index.js => methods.js} | 2 +- ui/app/templates/components/mfa/nav.hbs | 12 +++++ .../vault/cluster/access/mfa/enforcements.hbs | 40 +++++++++++++++++ .../cluster/access/mfa/{ => method}/index.hbs | 8 +++- .../vault/cluster/access/mfa/methods.hbs | 45 +++++++++++++++++++ ui/lib/core/icon-mappings.js | 2 + ui/public/duo.svg | 5 +++ ui/public/okta.svg | 3 ++ ui/public/pingid.svg | 11 +++++ 17 files changed, 199 insertions(+), 19 deletions(-) create mode 100644 ui/app/controllers/vault/cluster/access/mfa/enforcements.js delete mode 100644 ui/app/controllers/vault/cluster/access/mfa/index.js create mode 100644 ui/app/controllers/vault/cluster/access/mfa/methods.js create mode 100644 ui/app/routes/vault/cluster/access/mfa/enforcements.js create mode 100644 ui/app/routes/vault/cluster/access/mfa/method.js rename ui/app/routes/vault/cluster/access/mfa/{index.js => methods.js} (92%) create mode 100644 ui/app/templates/components/mfa/nav.hbs create mode 100644 ui/app/templates/vault/cluster/access/mfa/enforcements.hbs rename ui/app/templates/vault/cluster/access/mfa/{ => method}/index.hbs (55%) create mode 100644 ui/app/templates/vault/cluster/access/mfa/methods.hbs create mode 100644 ui/public/duo.svg create mode 100644 ui/public/okta.svg create mode 100644 ui/public/pingid.svg diff --git a/ui/app/adapters/mfa-method.js b/ui/app/adapters/mfa-method.js index 5c465ec87313e..0f5a8fc83cca4 100644 --- a/ui/app/adapters/mfa-method.js +++ b/ui/app/adapters/mfa-method.js @@ -3,24 +3,26 @@ import ApplicationAdapter from './application'; export default class MfaMethodAdapter extends ApplicationAdapter { namespace = 'v1'; - urlForQuery(methodType) { - let baseUrl = this.buildURL() + '/identity/mfa/method'; - if (methodType) { - return `${baseUrl}/${methodType}`; - } - return baseUrl; + pathForType() { + return 'identity/mfa/method'; } - queryRecord(type, id) { - return this.ajax(this.urlForQuery(type), 'POST', { + queryRecord(store, type, query) { + const { id } = query; + if (!id) { + throw new Error('MFA method ID is required to fetch the details.'); + } + const url = this.urlForQuery(query, type.modelName); + return this.ajax(url, 'POST', { data: { id, }, }); } - query() { - return this.ajax(this.urlForQuery(), 'GET', { + query(store, type, query) { + const url = this.urlForQuery(query, type.modelName); + return this.ajax(url, 'GET', { data: { list: true, }, diff --git a/ui/app/controllers/vault/cluster/access/mfa/enforcements.js b/ui/app/controllers/vault/cluster/access/mfa/enforcements.js new file mode 100644 index 0000000000000..81488ac7f6c9d --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/mfa/enforcements.js @@ -0,0 +1,9 @@ +import Controller from '@ember/controller'; + +export default class MfaEnforcementListController extends Controller { + queryParams = { + page: 'page', + }; + + page = 1; +} diff --git a/ui/app/controllers/vault/cluster/access/mfa/index.js b/ui/app/controllers/vault/cluster/access/mfa/index.js deleted file mode 100644 index b6b14ac823ac5..0000000000000 --- a/ui/app/controllers/vault/cluster/access/mfa/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import Controller from '@ember/controller'; - -export default class MfaListController extends Controller {} diff --git a/ui/app/controllers/vault/cluster/access/mfa/methods.js b/ui/app/controllers/vault/cluster/access/mfa/methods.js new file mode 100644 index 0000000000000..b24fe242eb6fc --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/mfa/methods.js @@ -0,0 +1,9 @@ +import Controller from '@ember/controller'; + +export default class MfaMethodsListController extends Controller { + queryParams = { + page: 'page', + }; + + page = 1; +} diff --git a/ui/app/router.js b/ui/app/router.js index 8ba886c3d9dcd..91c9115b9aa3b 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -59,9 +59,13 @@ Router.map(function () { this.route('section', { path: '/:section_name' }); }); this.route('mfa', function () { - // lookup - this.route('index', { path: '/' }); this.route('configure', { path: '/landing' }); + this.route('methods', { path: '/' }); + this.route('method', { path: '/method/:method_id' }, function () { + this.route('index', { path: '/' }); + }); + this.route('enforcements', { path: '/enforcement-list' }); + this.route('enforcement', { path: '/enforcement/:id' }); }); this.route('leases', function () { // lookup diff --git a/ui/app/routes/vault/cluster/access/mfa/configure.js b/ui/app/routes/vault/cluster/access/mfa/configure.js index 3bf8a3ce31737..a1986fd4e7463 100644 --- a/ui/app/routes/vault/cluster/access/mfa/configure.js +++ b/ui/app/routes/vault/cluster/access/mfa/configure.js @@ -1,6 +1,6 @@ import Route from '@ember/routing/route'; -export default class ConfigureRoute extends Route { +export default class MfaConfigureRoute extends Route { model() { return {}; } diff --git a/ui/app/routes/vault/cluster/access/mfa/enforcements.js b/ui/app/routes/vault/cluster/access/mfa/enforcements.js new file mode 100644 index 0000000000000..158c41753a0bc --- /dev/null +++ b/ui/app/routes/vault/cluster/access/mfa/enforcements.js @@ -0,0 +1,27 @@ +import Route from '@ember/routing/route'; + +export default class MfaEnforcementsRoute extends Route { + queryParams = { + page: { + refreshModel: true, + }, + }; + + model(params) { + return this.store + .lazyPaginatedQuery('mfa-login-enforcement', { + responsePath: 'data.keys', + page: params.page || 1, + }) + .catch((err) => { + if (err.httpStatus === 404) { + return []; + } else { + throw err; + } + }); + } + setupController(controller, model) { + controller.set('model', model); + } +} diff --git a/ui/app/routes/vault/cluster/access/mfa/method.js b/ui/app/routes/vault/cluster/access/mfa/method.js new file mode 100644 index 0000000000000..a7671c8453a07 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/mfa/method.js @@ -0,0 +1,10 @@ +import Route from '@ember/routing/route'; + +export default class MfaMethodRoute extends Route { + model(params) { + return this.store.findRecord('mfa-method', params['method_id']); + } + setupController(controller, model) { + controller.set('model', model); + } +} diff --git a/ui/app/routes/vault/cluster/access/mfa/index.js b/ui/app/routes/vault/cluster/access/mfa/methods.js similarity index 92% rename from ui/app/routes/vault/cluster/access/mfa/index.js rename to ui/app/routes/vault/cluster/access/mfa/methods.js index a7db06b280e2e..eb3d6b89f73c5 100644 --- a/ui/app/routes/vault/cluster/access/mfa/index.js +++ b/ui/app/routes/vault/cluster/access/mfa/methods.js @@ -1,7 +1,7 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; -export default class MfaRoute extends Route { +export default class MfaMethodsRoute extends Route { @service router; queryParams = { diff --git a/ui/app/templates/components/mfa/nav.hbs b/ui/app/templates/components/mfa/nav.hbs new file mode 100644 index 0000000000000..33dde0a660ff8 --- /dev/null +++ b/ui/app/templates/components/mfa/nav.hbs @@ -0,0 +1,12 @@ +
+ +
\ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/enforcements.hbs b/ui/app/templates/vault/cluster/access/mfa/enforcements.hbs new file mode 100644 index 0000000000000..cd32e175f7514 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/mfa/enforcements.hbs @@ -0,0 +1,40 @@ + + +

+ Multi-factor Authentication +

+
+
+ + + + + + + New enforcement + + + + +{{#if this.model.meta.total}} + {{#each this.model as |item|}} + +
+
+
+ + {{item.name}} + +
+
+
+
+ {{/each}} +{{/if}} +{{#if (gt this.model.meta.lastPage 1)}} + +{{/if}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/index.hbs b/ui/app/templates/vault/cluster/access/mfa/method/index.hbs similarity index 55% rename from ui/app/templates/vault/cluster/access/mfa/index.hbs rename to ui/app/templates/vault/cluster/access/mfa/method/index.hbs index 02d6a610f9885..58cc37ae4b3ce 100644 --- a/ui/app/templates/vault/cluster/access/mfa/index.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/method/index.hbs @@ -1,9 +1,13 @@

- Multi-factor Authentication + {{this.model.type}}

-List view \ No newline at end of file + + + + +{{this.model.id}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/methods.hbs b/ui/app/templates/vault/cluster/access/mfa/methods.hbs new file mode 100644 index 0000000000000..b9147019b2698 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/mfa/methods.hbs @@ -0,0 +1,45 @@ + + +

+ Multi-factor Authentication +

+
+
+ + + + + + + New MFA method + + + + +{{#if this.model.meta.total}} + {{#each this.model as |item|}} + +
+
+
+ + + {{item.type}} + +
+ + {{item.id}} + +
+
+
+
+ {{/each}} +{{/if}} +{{#if (gt this.model.meta.lastPage 1)}} + +{{/if}} \ No newline at end of file diff --git a/ui/lib/core/icon-mappings.js b/ui/lib/core/icon-mappings.js index 021cbb30b7e83..3d11821f42e56 100644 --- a/ui/lib/core/icon-mappings.js +++ b/ui/lib/core/icon-mappings.js @@ -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', diff --git a/ui/public/duo.svg b/ui/public/duo.svg new file mode 100644 index 0000000000000..72a97e5d139ff --- /dev/null +++ b/ui/public/duo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/public/okta.svg b/ui/public/okta.svg new file mode 100644 index 0000000000000..6b6e8906e4759 --- /dev/null +++ b/ui/public/okta.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/public/pingid.svg b/ui/public/pingid.svg new file mode 100644 index 0000000000000..99b33fefc3dcb --- /dev/null +++ b/ui/public/pingid.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + From ba81d3a9486bebe0a6945a6e59684813dd73a828 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Fri, 13 May 2022 13:32:50 -0600 Subject: [PATCH 09/30] 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 --- ui/app/adapters/mfa-login-enforcement.js | 15 +- .../components/mfa-login-enforcement-form.js | 152 ++++++++++++++++++ .../mfa-login-enforcement-header.js | 41 +++++ ui/app/components/mount-accessor-select.js | 4 +- ui/app/models/mfa-login-enforcement.js | 27 +--- ui/app/models/mfa-method.js | 5 + ui/app/styles/components/radio-card.scss | 5 + ui/app/styles/core/helpers.scss | 9 ++ .../components/mfa-login-enforcement-form.hbs | 106 ++++++++++++ .../mfa-login-enforcement-header.hbs | 68 ++++++++ .../components/mount-accessor-select.hbs | 5 +- ui/app/templates/components/radio-card.hbs | 30 ++++ .../templates/components/search-select.hbs | 14 +- .../mfa-login-enforcement-form-test.js | 26 +++ 14 files changed, 476 insertions(+), 31 deletions(-) create mode 100644 ui/app/components/mfa-login-enforcement-form.js create mode 100644 ui/app/components/mfa-login-enforcement-header.js create mode 100644 ui/app/templates/components/mfa-login-enforcement-form.hbs create mode 100644 ui/app/templates/components/mfa-login-enforcement-header.hbs create mode 100644 ui/app/templates/components/radio-card.hbs create mode 100644 ui/tests/integration/components/mfa-login-enforcement-form-test.js diff --git a/ui/app/adapters/mfa-login-enforcement.js b/ui/app/adapters/mfa-login-enforcement.js index a983e0a87dc11..7f776ff299f8b 100644 --- a/ui/app/adapters/mfa-login-enforcement.js +++ b/ui/app/adapters/mfa-login-enforcement.js @@ -7,8 +7,21 @@ export default class KeymgmtKeyAdapter extends ApplicationAdapter { return 'identity/mfa/login-enforcement'; } + _saveRecord(store, { modelName }, snapshot) { + const data = store.serializerFor(modelName).serialize(snapshot); + return this.ajax(this.urlForUpdateRecord(modelName, snapshot), 'POST', { data }).then(() => data); + } + // create does not return response similar to PUT request + async createRecord() { + 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 } }).then((resp) => resp.data); + return this.ajax(url, 'GET', { data: { list: true } }); } } diff --git a/ui/app/components/mfa-login-enforcement-form.js b/ui/app/components/mfa-login-enforcement-form.js new file mode 100644 index 0000000000000..1092895c9d1f3 --- /dev/null +++ b/ui/app/components/mfa-login-enforcement-form.js @@ -0,0 +1,152 @@ +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 + * + * ``` + * @callback onSave + * @callback onClose + * @param {Object} model - login enforcement model + * @param {Object} [mfaMethod] - provide when creating a login enforcement for a selected method -- otherwise search selector is displayed + * @param {boolean} [hasActions] - whether the save and cancel actions will be displayed and handled internally or not + * @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 mfaMethods = []; + @tracked targets = []; + @tracked selectedTargetType = 'accessor'; + @tracked selectedTargetValue = null; + @tracked searchSelect = { + options: [], + selected: [], + }; + + 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 target of this.targetTypes) { + const targetArray = await this.args.model[target.key]; + this.targets.addObjects(targetArray); + } + } + async resetTargetState() { + this.selectedTargetValue = null; + this.searchSelect.selected = []; + 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.options = [...options[this.selectedTargetType]]; + } + } + + get selectedTarget() { + return this.targetTypes.findBy('type', this.selectedTargetType); + } + + @task + *save() { + 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].addObject(target.value); + } + @action + cancel() { + // revert model changes + this.args.model.rollbackAttributes(); + this.args.onClose(); + } +} diff --git a/ui/app/components/mfa-login-enforcement-header.js b/ui/app/components/mfa-login-enforcement-header.js new file mode 100644 index 0000000000000..8fb451659008f --- /dev/null +++ b/ui/app/components/mfa-login-enforcement-header.js @@ -0,0 +1,41 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; + +/** + * @module MfaLoginEnforcementHeader + * MfaLoginEnforcementHeader components are used to display information when creating and editing login enforcements + * + * @example + * ```js + * + * + * ``` + * @callback onRadioCardSelect + * @callback onEnforcementSelect + * @param {string} [heading] - displays page heading and more verbose description used in create/edit routes -- if not provided the component will render in inline form with radio cards + * @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.heading) { + this.fetchEnforcements(); + } + } + + @tracked enforcements = []; + + async fetchEnforcements() { + try { + this.enforcements = (await this.store.query('mfa-login-enforcement', {})).toArray(); + } catch (error) { + this.enforcements = []; + } + } +} diff --git a/ui/app/components/mount-accessor-select.js b/ui/app/components/mount-accessor-select.js index 3e95fd29b26b3..d47fef4bf2220 100644 --- a/ui/app/components/mount-accessor-select.js +++ b/ui/app/components/mount-accessor-select.js @@ -8,6 +8,7 @@ export default Component.extend({ // Public API //value for the external mount selector value: null, + noDefault: false, onChange: () => {}, init() { @@ -17,8 +18,9 @@ export default Component.extend({ authMethods: task(function* () { let methods = yield this.store.findAll('auth-method'); - if (!this.value) { + if (!this.value && !this.noDefault) { this.set('value', methods.get('firstObject.accessor')); + this.onChange(this.value); } return methods; }).drop(), diff --git a/ui/app/models/mfa-login-enforcement.js b/ui/app/models/mfa-login-enforcement.js index ed6dd1cfd14d0..1e366b2e71664 100644 --- a/ui/app/models/mfa-login-enforcement.js +++ b/ui/app/models/mfa-login-enforcement.js @@ -1,28 +1,11 @@ import Model, { attr, hasMany } from '@ember-data/model'; -import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; -export default class mfaLoginEnforcementModel extends Model { - @attr('string', { - label: 'Enforcement 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.', - }) - name; - @hasMany('mfa-method', { - label: 'MFA methods', - subText: 'The MFA method(s) that this enforcement will apply to.', - editType: 'searchSelect', - models: ['mfa-method'], - }) - mfa_methods; +export default class MfaLoginEnforcementModel extends Model { + @attr('string') name; + @hasMany('mfa-method') mfa_methods; @attr('string') namespace_id; - @attr('array') auth_method_accessors; // ["auth_approle_17a552c6"] - @attr('array') auth_method_types; // ["userpass"] + @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 formFields() { - // handle the targets field directly in the template since it is an aggregate of 4 attributes - return expandAttributeMeta(this, ['name', 'mfa_methods']); - } } diff --git a/ui/app/models/mfa-method.js b/ui/app/models/mfa-method.js index 59802716c4b85..b7ed8eddf5291 100644 --- a/ui/app/models/mfa-method.js +++ b/ui/app/models/mfa-method.js @@ -1,4 +1,5 @@ import Model, { attr } from '@ember-data/model'; +import { capitalize } from '@ember/string'; export default class MfaMethod extends Model { // common @@ -34,4 +35,8 @@ export default class MfaMethod extends Model { @attr('number') digits; @attr('number') skew; @attr('number') max_validation_attempts; + + get name() { + return this.type === 'totp' ? this.type.toUpperCase() : capitalize(this.type); + } } diff --git a/ui/app/styles/components/radio-card.scss b/ui/app/styles/components/radio-card.scss index d48932ae4dd98..3f4f38768c37c 100644 --- a/ui/app/styles/components/radio-card.scss +++ b/ui/app/styles/components/radio-card.scss @@ -38,6 +38,11 @@ input[type='radio']:focus + label { box-shadow: 0 0 10px 1px rgba($blue, 0.4), inset 0 0 0 0.15rem $white; } + + &.is-disabled { + opacity: 0.6; + box-shadow: none; + } } .radio-card:first-child { margin-left: 0; diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index ffb4c16a01de7..a399a410dd735 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -164,6 +164,9 @@ font-size: $size-8; text-transform: lowercase; } +.has-top-padding-s { + padding-top: $spacing-s; +} .has-bottom-margin-xs { margin-bottom: $spacing-xs; } @@ -185,6 +188,9 @@ .has-top-margin-s { margin-top: $spacing-s; } +.has-top-margin-m { + margin-top: $spacing-m; +} .has-top-margin-l { margin-top: $spacing-l; } @@ -212,6 +218,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; } diff --git a/ui/app/templates/components/mfa-login-enforcement-form.hbs b/ui/app/templates/components/mfa-login-enforcement-form.hbs new file mode 100644 index 0000000000000..b4c17c208699f --- /dev/null +++ b/ui/app/templates/components/mfa-login-enforcement-form.hbs @@ -0,0 +1,106 @@ +
+ + +
+ + +
+ +
+ + {{#each this.targets as |target|}} +
+ + {{#if target.value.id}} + {{target.value.name}} + {{target.value.id}} + {{else}} + {{target.value}} + {{/if}} + + +
+ {{/each}} +
+ + {{else}} + + {{/if}} +
+ +
+
+ {{#if @hasActions}} +
+
+ + +
+ {{/if}} + \ No newline at end of file diff --git a/ui/app/templates/components/mfa-login-enforcement-header.hbs b/ui/app/templates/components/mfa-login-enforcement-header.hbs new file mode 100644 index 0000000000000..56a992cad8e81 --- /dev/null +++ b/ui/app/templates/components/mfa-login-enforcement-header.hbs @@ -0,0 +1,68 @@ + + +

+ {{#if @heading}} + + {{@heading}} + {{else}} + Enforcement + {{/if}} +

+
+
+
+

+ {{#if @heading}} + An enforcement will define which auth types, auth mounts, groups, and/or entities will require this MFA method. Keep in + mind that only one of these conditions needs to be satisfied. For example, if an authentication method is added here, + all entities and groups which make use of that authentication method will be subject to an MFA request. + + Learn more here. + + {{else}} + 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. + {{/if}} +

+ {{#unless @heading}} +
+ + + +
+ {{#if (eq @radioCardGroupValue "existing")}} + + {{/if}} + {{/unless}} +
\ No newline at end of file diff --git a/ui/app/templates/components/mount-accessor-select.hbs b/ui/app/templates/components/mount-accessor-select.hbs index 04e224b72b50d..13807a0a03ab3 100644 --- a/ui/app/templates/components/mount-accessor-select.hbs +++ b/ui/app/templates/components/mount-accessor-select.hbs @@ -16,10 +16,13 @@
diff --git a/ui/app/templates/components/radio-card.hbs b/ui/app/templates/components/radio-card.hbs new file mode 100644 index 0000000000000..345da2618ebf4 --- /dev/null +++ b/ui/app/templates/components/radio-card.hbs @@ -0,0 +1,30 @@ + \ No newline at end of file diff --git a/ui/lib/core/addon/templates/components/search-select.hbs b/ui/lib/core/addon/templates/components/search-select.hbs index dbbc5a666ebb4..6b0f12b3d8552 100644 --- a/ui/lib/core/addon/templates/components/search-select.hbs +++ b/ui/lib/core/addon/templates/components/search-select.hbs @@ -8,12 +8,14 @@ placeHolder=this.placeHolder }} {{else}} - + {{#if this.label}} + + {{/if}} {{#if this.subLabel}}

{{this.subLabel}}

{{/if}} diff --git a/ui/tests/integration/components/mfa-login-enforcement-form-test.js b/ui/tests/integration/components/mfa-login-enforcement-form-test.js new file mode 100644 index 0000000000000..a7be9d439c48b --- /dev/null +++ b/ui/tests/integration/components/mfa-login-enforcement-form-test.js @@ -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-login-enforcement-form', 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``); + + assert.dom(this.element).hasText(''); + + // Template block usage: + await render(hbs` + + template block text + + `); + + assert.dom(this.element).hasText('template block text'); + }); +}); From 9681ad9556abf324dbfd07a50fb0a9ff6f7ff447 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Fri, 13 May 2022 13:36:17 -0600 Subject: [PATCH 10/30] 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 --- .../components/mfa-login-enforcement-form.js | 10 +-- ui/app/router.js | 18 +++-- .../vault/cluster/access/mfa/configure.js | 7 -- .../cluster/access/mfa/enforcements/create.js | 10 +++ .../access/mfa/enforcements/enforcement.js | 10 +++ .../mfa/enforcements/enforcement/edit.js | 3 + .../index.js} | 0 .../routes/vault/cluster/access/mfa/index.js | 3 + .../cluster/access/mfa/methods/create.js | 3 + .../mfa/{methods.js => methods/index.js} | 2 +- .../access/mfa/{ => methods}/method.js | 4 +- ui/app/serializers/mfa-login-enforcement.js | 12 +++- .../components/mfa-login-enforcement-form.hbs | 27 +++++--- ui/app/templates/vault/cluster/access.hbs | 6 +- .../vault/cluster/access/mfa/enforcements.hbs | 40 ------------ .../access/mfa/enforcements/create.hbs | 8 +++ .../mfa/enforcements/enforcement/edit.hbs | 8 +++ .../cluster/access/mfa/enforcements/index.hbs | 61 +++++++++++++++++ .../access/mfa/{configure.hbs => index.hbs} | 0 .../mfa/{methods.hbs => methods/index.hbs} | 4 +- .../access/mfa/{ => methods}/method/index.hbs | 0 ui/lib/core/addon/components/search-select.js | 2 +- ui/mirage/factories/mfa-login-enforcement.js | 10 ++- ui/mirage/handlers/mfa-config.js | 65 ++++++++++++++----- 24 files changed, 219 insertions(+), 94 deletions(-) delete mode 100644 ui/app/routes/vault/cluster/access/mfa/configure.js create mode 100644 ui/app/routes/vault/cluster/access/mfa/enforcements/create.js create mode 100644 ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement.js create mode 100644 ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement/edit.js rename ui/app/routes/vault/cluster/access/mfa/{enforcements.js => enforcements/index.js} (100%) create mode 100644 ui/app/routes/vault/cluster/access/mfa/index.js create mode 100644 ui/app/routes/vault/cluster/access/mfa/methods/create.js rename ui/app/routes/vault/cluster/access/mfa/{methods.js => methods/index.js} (90%) rename ui/app/routes/vault/cluster/access/mfa/{ => methods}/method.js (67%) delete mode 100644 ui/app/templates/vault/cluster/access/mfa/enforcements.hbs create mode 100644 ui/app/templates/vault/cluster/access/mfa/enforcements/create.hbs create mode 100644 ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/edit.hbs create mode 100644 ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs rename ui/app/templates/vault/cluster/access/mfa/{configure.hbs => index.hbs} (100%) rename ui/app/templates/vault/cluster/access/mfa/{methods.hbs => methods/index.hbs} (93%) rename ui/app/templates/vault/cluster/access/mfa/{ => methods}/method/index.hbs (100%) diff --git a/ui/app/components/mfa-login-enforcement-form.js b/ui/app/components/mfa-login-enforcement-form.js index 1092895c9d1f3..e7dd229a0ae1c 100644 --- a/ui/app/components/mfa-login-enforcement-form.js +++ b/ui/app/components/mfa-login-enforcement-form.js @@ -11,7 +11,7 @@ import { task } from 'ember-concurrency'; * * @example * ```js - * + * * ``` * @callback onSave * @callback onClose @@ -36,7 +36,6 @@ export default class MfaLoginEnforcementForm extends Component { searchSelectOptions = null; @tracked name; - @tracked mfaMethods = []; @tracked targets = []; @tracked selectedTargetType = 'accessor'; @tracked selectedTargetValue = null; @@ -54,9 +53,10 @@ export default class MfaLoginEnforcementForm extends Component { } async flattenTargets() { - for (let target of this.targetTypes) { - const targetArray = await this.args.model[target.key]; - this.targets.addObjects(targetArray); + 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() { diff --git a/ui/app/router.js b/ui/app/router.js index 91c9115b9aa3b..dde36b5a56be7 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -59,13 +59,21 @@ Router.map(function () { this.route('section', { path: '/:section_name' }); }); this.route('mfa', function () { - this.route('configure', { path: '/landing' }); - this.route('methods', { path: '/' }); - this.route('method', { path: '/method/:method_id' }, 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', function () { + this.route('index', { path: '/' }); + this.route('create'); + this.route('enforcement', { path: '/:name' }, function () { + this.route('edit'); + }); }); - this.route('enforcements', { path: '/enforcement-list' }); - this.route('enforcement', { path: '/enforcement/:id' }); }); this.route('leases', function () { // lookup diff --git a/ui/app/routes/vault/cluster/access/mfa/configure.js b/ui/app/routes/vault/cluster/access/mfa/configure.js deleted file mode 100644 index a1986fd4e7463..0000000000000 --- a/ui/app/routes/vault/cluster/access/mfa/configure.js +++ /dev/null @@ -1,7 +0,0 @@ -import Route from '@ember/routing/route'; - -export default class MfaConfigureRoute extends Route { - model() { - return {}; - } -} diff --git a/ui/app/routes/vault/cluster/access/mfa/enforcements/create.js b/ui/app/routes/vault/cluster/access/mfa/enforcements/create.js new file mode 100644 index 0000000000000..6823de3527d28 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/mfa/enforcements/create.js @@ -0,0 +1,10 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class MfaLoginEnforcementCreateRoute extends Route { + @service store; + + model() { + return this.store.createRecord('mfa-login-enforcement'); + } +} diff --git a/ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement.js b/ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement.js new file mode 100644 index 0000000000000..f638f6a37c6fc --- /dev/null +++ b/ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement.js @@ -0,0 +1,10 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class MfaLoginEnforcementReadRoute extends Route { + @service store; + + model({ name }) { + return this.store.findRecord('mfa-login-enforcement', name); + } +} diff --git a/ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement/edit.js b/ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement/edit.js new file mode 100644 index 0000000000000..1655c46c71ae4 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement/edit.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class MfaLoginEnforcementEditRoute extends Route {} diff --git a/ui/app/routes/vault/cluster/access/mfa/enforcements.js b/ui/app/routes/vault/cluster/access/mfa/enforcements/index.js similarity index 100% rename from ui/app/routes/vault/cluster/access/mfa/enforcements.js rename to ui/app/routes/vault/cluster/access/mfa/enforcements/index.js diff --git a/ui/app/routes/vault/cluster/access/mfa/index.js b/ui/app/routes/vault/cluster/access/mfa/index.js new file mode 100644 index 0000000000000..b26756b979bfa --- /dev/null +++ b/ui/app/routes/vault/cluster/access/mfa/index.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class MfaConfigureRoute extends Route {} diff --git a/ui/app/routes/vault/cluster/access/mfa/methods/create.js b/ui/app/routes/vault/cluster/access/mfa/methods/create.js new file mode 100644 index 0000000000000..ca69de3f86271 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/mfa/methods/create.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class MfaLoginEnforcementCreateRoute extends Route {} diff --git a/ui/app/routes/vault/cluster/access/mfa/methods.js b/ui/app/routes/vault/cluster/access/mfa/methods/index.js similarity index 90% rename from ui/app/routes/vault/cluster/access/mfa/methods.js rename to ui/app/routes/vault/cluster/access/mfa/methods/index.js index eb3d6b89f73c5..6299e29e71c7b 100644 --- a/ui/app/routes/vault/cluster/access/mfa/methods.js +++ b/ui/app/routes/vault/cluster/access/mfa/methods/index.js @@ -26,7 +26,7 @@ export default class MfaMethodsRoute extends Route { } afterModel(model) { if (model.length === 0) { - this.router.transitionTo('vault.cluster.access.mfa.configure'); + this.router.transitionTo('vault.cluster.access.mfa'); } } setupController(controller, model) { diff --git a/ui/app/routes/vault/cluster/access/mfa/method.js b/ui/app/routes/vault/cluster/access/mfa/methods/method.js similarity index 67% rename from ui/app/routes/vault/cluster/access/mfa/method.js rename to ui/app/routes/vault/cluster/access/mfa/methods/method.js index a7671c8453a07..69be5b9114b6c 100644 --- a/ui/app/routes/vault/cluster/access/mfa/method.js +++ b/ui/app/routes/vault/cluster/access/mfa/methods/method.js @@ -1,8 +1,8 @@ import Route from '@ember/routing/route'; export default class MfaMethodRoute extends Route { - model(params) { - return this.store.findRecord('mfa-method', params['method_id']); + model({ id }) { + return this.store.findRecord('mfa-method', id); } setupController(controller, model) { controller.set('model', model); diff --git a/ui/app/serializers/mfa-login-enforcement.js b/ui/app/serializers/mfa-login-enforcement.js index f0600421cf234..bfd7fe3cf12e7 100644 --- a/ui/app/serializers/mfa-login-enforcement.js +++ b/ui/app/serializers/mfa-login-enforcement.js @@ -1,6 +1,6 @@ import ApplicationSerializer from './application'; -export default class KeymgmtProviderSerializer extends ApplicationSerializer { +export default class MfaLoginEnforcementSerializer extends ApplicationSerializer { primaryKey = 'name'; // change keys for hasMany relationships with ids in the name @@ -18,6 +18,16 @@ export default class KeymgmtProviderSerializer extends ApplicationSerializer { 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); this.transformHasManyKeys(json, 'server'); diff --git a/ui/app/templates/components/mfa-login-enforcement-form.hbs b/ui/app/templates/components/mfa-login-enforcement-form.hbs index b4c17c208699f..46ca1fb50c892 100644 --- a/ui/app/templates/components/mfa-login-enforcement-form.hbs +++ b/ui/app/templates/components/mfa-login-enforcement-form.hbs @@ -12,16 +12,23 @@ class="input field" {{on "input" (pipe (pick "target.value") (fn (mut @model.name)))}} /> -
- - -
+ + {{#unless @mfaMethod}} +
+ + {{! component only computes inputValue on init -- ensure Ember Data hasMany promise has resolved }} + {{#if @model.mfa_methods.isFulfilled}} + + {{/if}} +
+ {{/unless}}
- + Multi-factor authentication diff --git a/ui/app/templates/vault/cluster/access/mfa/enforcements.hbs b/ui/app/templates/vault/cluster/access/mfa/enforcements.hbs deleted file mode 100644 index cd32e175f7514..0000000000000 --- a/ui/app/templates/vault/cluster/access/mfa/enforcements.hbs +++ /dev/null @@ -1,40 +0,0 @@ - - -

- Multi-factor Authentication -

-
-
- - - - - - - New enforcement - - - - -{{#if this.model.meta.total}} - {{#each this.model as |item|}} - -
-
-
- - {{item.name}} - -
-
-
-
- {{/each}} -{{/if}} -{{#if (gt this.model.meta.lastPage 1)}} - -{{/if}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/enforcements/create.hbs b/ui/app/templates/vault/cluster/access/mfa/enforcements/create.hbs new file mode 100644 index 0000000000000..1eb096175ef26 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/mfa/enforcements/create.hbs @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/edit.hbs b/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/edit.hbs new file mode 100644 index 0000000000000..af26ad98feb23 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/edit.hbs @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs b/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs new file mode 100644 index 0000000000000..3dc5af6070e41 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs @@ -0,0 +1,61 @@ + + +

+ Multi-factor Authentication +

+
+
+ + + + + + + New enforcement + + + + +{{#if this.model.meta.total}} + {{#each this.model as |item|}} + +
+
+
+ + + {{item.name}} + +
+
+
+
+ + + +
+
+
+
+ {{/each}} +{{/if}} +{{#if (gt this.model.meta.lastPage 1)}} + +{{/if}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/configure.hbs b/ui/app/templates/vault/cluster/access/mfa/index.hbs similarity index 100% rename from ui/app/templates/vault/cluster/access/mfa/configure.hbs rename to ui/app/templates/vault/cluster/access/mfa/index.hbs diff --git a/ui/app/templates/vault/cluster/access/mfa/methods.hbs b/ui/app/templates/vault/cluster/access/mfa/methods/index.hbs similarity index 93% rename from ui/app/templates/vault/cluster/access/mfa/methods.hbs rename to ui/app/templates/vault/cluster/access/mfa/methods/index.hbs index b9147019b2698..6e59d9b51b75f 100644 --- a/ui/app/templates/vault/cluster/access/mfa/methods.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/methods/index.hbs @@ -10,7 +10,7 @@ - + New MFA method @@ -18,7 +18,7 @@ {{#if this.model.meta.total}} {{#each this.model as |item|}} - +
diff --git a/ui/app/templates/vault/cluster/access/mfa/method/index.hbs b/ui/app/templates/vault/cluster/access/mfa/methods/method/index.hbs similarity index 100% rename from ui/app/templates/vault/cluster/access/mfa/method/index.hbs rename to ui/app/templates/vault/cluster/access/mfa/methods/method/index.hbs diff --git a/ui/lib/core/addon/components/search-select.js b/ui/lib/core/addon/components/search-select.js index 5e8403a5de55f..fa0f2dacad21e 100644 --- a/ui/lib/core/addon/components/search-select.js +++ b/ui/lib/core/addon/components/search-select.js @@ -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) diff --git a/ui/mirage/factories/mfa-login-enforcement.js b/ui/mirage/factories/mfa-login-enforcement.js index a4522bbbdf4ff..f4a077750b352 100644 --- a/ui/mirage/factories/mfa-login-enforcement.js +++ b/ui/mirage/factories/mfa-login-enforcement.js @@ -13,7 +13,7 @@ export default Factory.extend({ // initialize arrays and stub some data if not provided if (!record.name) { // use random string for generated name - record.name = (Math.random() + 1).toString(36).substring(2); + record.update('name', (Math.random() + 1).toString(36).substring(2)); } if (!record.mfa_method_ids) { // aggregate all existing methods and choose a random one @@ -24,13 +24,17 @@ export default Factory.extend({ } 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.mfa_method_ids = method ? [method.id] : []; + 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[key] = key === 'auth_method_types' ? ['userpass'] : []; + record.update(key, key === 'auth_method_types' ? ['userpass'] : []); } }); }, diff --git a/ui/mirage/handlers/mfa-config.js b/ui/mirage/handlers/mfa-config.js index ddcb3226191cd..a28d304fdebd9 100644 --- a/ui/mirage/handlers/mfa-config.js +++ b/ui/mirage/handlers/mfa-config.js @@ -1,5 +1,4 @@ import { Response } from 'miragejs'; -import { dasherize } from '@ember/string'; export default function (server) { const methods = ['totp', 'duo', 'okta', 'pingid']; @@ -30,13 +29,26 @@ export default function (server) { const dbKeyFromType = (type) => `mfa${type.charAt(0).toUpperCase()}${type.slice(1)}Methods`; - const generateListResponse = (schema, key) => { - let records = schema.db[key].where({}); + 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) { - records = server.createList(dasherize(key).slice(0, -1), 3).toArray(); + if (isMethod) { + methods.forEach((type) => { + records.push(server.create(`mfa-${type}-method`)); + }); + } else { + records = server.createList('mfa-login-enforcement', 4).toArray(); + } } - const dataKey = key === 'mfaLoginEnforcements' ? 'name' : 'id'; + const dataKey = isMethod ? 'id' : 'name'; const data = records.reduce( (resp, record) => { resp.key_info[record[dataKey]] = record; @@ -52,15 +64,26 @@ export default function (server) { }; // list methods - server.get('/identity/mfa/method/:type', (schema, { params: { type } }) => { - return validate(type, null, () => generateListResponse(schema, dbKeyFromType(type))); + server.get('/identity/mfa/method/', (schema) => { + return generateListResponse(schema, true); }); // fetch method by id - server.get('/identity/mfa/method/:type/:id', (schema, { params: { type, id } }) => { - return validate(type, null, () => { - const record = schema.db[dbKeyFromType(type)].find(id); - return !record ? new Response(404, {}, { errors: [] }) : { data: record }; - }); + 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 }) => { @@ -87,11 +110,18 @@ export default function (server) { }); // list enforcements server.get('/identity/mfa/login-enforcement', (schema) => { - return generateListResponse(schema, 'mfaLoginEnforcements'); + 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 @@ -119,16 +149,19 @@ export default function (server) { return new Response( 400, {}, - 'One of auth_method_accessors, auth_method_types, identity_group_ids, identity_entity_ids must be specified' + { + errors: [ + 'One of auth_method_accessors, auth_method_types, identity_group_ids, identity_entity_ids must be specified', + ], + } ); } - data.name = name; if (schema.db.mfaLoginEnforcements.findBy({ name })) { schema.db.mfaLoginEnforcements.update({ name }, data); } else { schema.db.mfaLoginEnforcements.insert(data); } - return {}; + return { ...data, id: data.name }; }); // delete enforcement server.delete('/identity/mfa/login-enforcement/:name', (schema, { params: { name } }) => { From 9c0ed267a60c6fc39e60841725608f84bd30ed47 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Tue, 17 May 2022 11:29:59 -0600 Subject: [PATCH 11/30] MFA Login Enforcement Read Views (#15462) * adds login enforcement read views * skip mfa-method-list-item test for now --- ui/app/adapters/mfa-login-enforcement.js | 4 +- .../components/mfa-login-enforcement-form.js | 6 +- .../mfa/enforcements/enforcement/index.js | 27 ++++++ .../index.js} | 5 +- ui/app/models/mfa-login-enforcement.js | 71 ++++++++++++++ .../access/mfa/enforcements/enforcement.js | 2 +- ui/app/styles/core/helpers.scss | 3 + .../components/mfa-login-enforcement-form.hbs | 1 + .../components/mfa-method-list-item.hbs | 43 +++++++++ .../mfa/enforcements/enforcement/index.hbs | 93 +++++++++++++++++++ .../cluster/access/mfa/enforcements/index.hbs | 2 +- .../cluster/access/mfa/methods/index.hbs | 17 +--- ui/lib/core/addon/components/linked-block.hbs | 2 +- ui/lib/core/addon/components/linked-block.js | 53 ++++++----- .../components/mfa-method-list-item-test.js | 26 ++++++ 15 files changed, 304 insertions(+), 51 deletions(-) create mode 100644 ui/app/controllers/vault/cluster/access/mfa/enforcements/enforcement/index.js rename ui/app/controllers/vault/cluster/access/mfa/{enforcements.js => enforcements/index.js} (75%) create mode 100644 ui/app/templates/components/mfa-method-list-item.hbs create mode 100644 ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/index.hbs create mode 100644 ui/tests/integration/components/mfa-method-list-item-test.js diff --git a/ui/app/adapters/mfa-login-enforcement.js b/ui/app/adapters/mfa-login-enforcement.js index 7f776ff299f8b..9c292d275900b 100644 --- a/ui/app/adapters/mfa-login-enforcement.js +++ b/ui/app/adapters/mfa-login-enforcement.js @@ -9,7 +9,9 @@ export default class KeymgmtKeyAdapter extends ApplicationAdapter { _saveRecord(store, { modelName }, snapshot) { const data = store.serializerFor(modelName).serialize(snapshot); - return this.ajax(this.urlForUpdateRecord(modelName, snapshot), 'POST', { data }).then(() => data); + 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() { diff --git a/ui/app/components/mfa-login-enforcement-form.js b/ui/app/components/mfa-login-enforcement-form.js index e7dd229a0ae1c..815a9be01fab5 100644 --- a/ui/app/components/mfa-login-enforcement-form.js +++ b/ui/app/components/mfa-login-enforcement-form.js @@ -61,7 +61,6 @@ export default class MfaLoginEnforcementForm extends Component { } async resetTargetState() { this.selectedTargetValue = null; - this.searchSelect.selected = []; const options = this.searchSelectOptions || {}; if (!this.searchSelectOptions) { const types = ['identity/group', 'identity/entity']; @@ -75,7 +74,10 @@ export default class MfaLoginEnforcementForm extends Component { this.searchSelectOptions = options; } if (this.selectedTargetType.includes('identity')) { - this.searchSelect.options = [...options[this.selectedTargetType]]; + this.searchSelect = { + selected: [], + options: [...options[this.selectedTargetType]], + }; } } diff --git a/ui/app/controllers/vault/cluster/access/mfa/enforcements/enforcement/index.js b/ui/app/controllers/vault/cluster/access/mfa/enforcements/enforcement/index.js new file mode 100644 index 0000000000000..202c988c390ee --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/mfa/enforcements/enforcement/index.js @@ -0,0 +1,27 @@ +import Controller from '@ember/controller'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class MfaLoginEnforcementIndexController extends Controller { + @service router; + @service flashMessages; + + queryParams = ['tab']; + tab = 'targets'; + + @tracked showDeleteConfirmation = false; + @tracked deleteError; + + @action + async delete() { + try { + await this.model.destroyRecord(); + this.showDeleteConfirmation = false; + this.flashMessages.success('MFA login enforcement deleted successfully'); + this.router.transitionTo('vault.cluster.access.mfa.enforcements'); + } catch (error) { + this.deleteError = error; + } + } +} diff --git a/ui/app/controllers/vault/cluster/access/mfa/enforcements.js b/ui/app/controllers/vault/cluster/access/mfa/enforcements/index.js similarity index 75% rename from ui/app/controllers/vault/cluster/access/mfa/enforcements.js rename to ui/app/controllers/vault/cluster/access/mfa/enforcements/index.js index 81488ac7f6c9d..a7fae5de3c3d5 100644 --- a/ui/app/controllers/vault/cluster/access/mfa/enforcements.js +++ b/ui/app/controllers/vault/cluster/access/mfa/enforcements/index.js @@ -1,9 +1,6 @@ import Controller from '@ember/controller'; export default class MfaEnforcementListController extends Controller { - queryParams = { - page: 'page', - }; - + queryParams = ['page']; page = 1; } diff --git a/ui/app/models/mfa-login-enforcement.js b/ui/app/models/mfa-login-enforcement.js index 1e366b2e71664..877bda47331b1 100644 --- a/ui/app/models/mfa-login-enforcement.js +++ b/ui/app/models/mfa-login-enforcement.js @@ -1,4 +1,7 @@ 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'; export default class MfaLoginEnforcementModel extends Model { @attr('string') name; @@ -8,4 +11,72 @@ export default class MfaLoginEnforcementModel extends Model { @attr('array', { defaultValue: () => [] }) auth_method_types; // ["userpass"] @hasMany('identity/entity') identity_entities; @hasMany('identity/group') identity_groups; + + get targets() { + return ArrayProxy.extend(PromiseProxyMixin).create({ + promise: this.prepareTargets(), + }); + } + + async prepareTargets() { + const mountableMethods = methods(); // use for icon lookup + let authMethods; + const targets = []; + + if (this.auth_method_accessors.length || this.auth_method_types.length) { + // fetch all auth methods and lookup by accessor to get mount path and type + try { + const { data } = await this.store.adapterFor('auth-method').findAll(); + authMethods = Object.keys(data).map((key) => ({ path: key, ...data[key] })); + } catch (error) { + // swallow this error + } + } + + if (this.auth_method_accessors.length) { + const selectedAuthMethods = authMethods.filter((model) => { + return this.auth_method_accessors.includes(model.accessor); + }); + targets.addObjects( + selectedAuthMethods.map((method) => { + const mount = mountableMethods.findBy('type', method.type); + const icon = mount.glyph || mount.type; + return { + icon, + link: 'vault.cluster.access.method', + linkModels: [method.path.slice(0, -1)], + title: method.path, + subTitle: method.accessor, + }; + }) + ); + } + + this.auth_method_types.forEach((type) => { + const mount = mountableMethods.findBy('type', type); + const icon = mount.glyph || mount.type; + const mountCount = authMethods.filterBy('type', type).length; + targets.addObject({ + key: 'auth_method_types', + icon, + title: type, + subTitle: `All ${type} mounts (${mountCount})`, + }); + }); + + for (const key of ['identity_entities', 'identity_groups']) { + (await this[key]).forEach((model) => { + targets.addObject({ + key, + icon: 'user', + link: 'vault.cluster.access.identity.show', + linkModels: [key.split('_')[1], model.id, 'details'], + title: model.name, + subTitle: model.id, + }); + }); + } + + return targets; + } } diff --git a/ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement.js b/ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement.js index f638f6a37c6fc..b03366b30cd8a 100644 --- a/ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement.js +++ b/ui/app/routes/vault/cluster/access/mfa/enforcements/enforcement.js @@ -1,7 +1,7 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; -export default class MfaLoginEnforcementReadRoute extends Route { +export default class MfaLoginEnforcementRoute extends Route { @service store; model({ name }) { diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index a399a410dd735..f74971d66720a 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -188,6 +188,9 @@ .has-top-margin-s { margin-top: $spacing-s; } +.has-top-margin-xs { + margin-top: $spacing-xs; +} .has-top-margin-m { margin-top: $spacing-m; } diff --git a/ui/app/templates/components/mfa-login-enforcement-form.hbs b/ui/app/templates/components/mfa-login-enforcement-form.hbs index 46ca1fb50c892..bd12af3f9e0a0 100644 --- a/ui/app/templates/components/mfa-login-enforcement-form.hbs +++ b/ui/app/templates/components/mfa-login-enforcement-form.hbs @@ -9,6 +9,7 @@ autocomplete="off" spellcheck="false" value={{@model.name}} + disabled={{not @model.isNew}} class="input field" {{on "input" (pipe (pick "target.value") (fn (mut @model.name)))}} /> diff --git a/ui/app/templates/components/mfa-method-list-item.hbs b/ui/app/templates/components/mfa-method-list-item.hbs new file mode 100644 index 0000000000000..ebbae24c19f0b --- /dev/null +++ b/ui/app/templates/components/mfa-method-list-item.hbs @@ -0,0 +1,43 @@ + +
+
+
+ +
+ + {{if (eq @model.type "totp") (uppercase @model.type) @model.type}} + + + {{@model.id}} + +
+ + Namespace: + {{@model.namespace_id}} + +
+
+
+
+
+
+ + + +
+
+
+
\ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/index.hbs b/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/index.hbs new file mode 100644 index 0000000000000..816eb4426abd7 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/index.hbs @@ -0,0 +1,93 @@ + + +

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

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

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

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

+ +
\ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs b/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs index 3dc5af6070e41..d3d9d8c520e5c 100644 --- a/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs @@ -30,7 +30,7 @@
- +
+ \ No newline at end of file diff --git a/ui/app/templates/components/mfa-setup-step-two.hbs b/ui/app/templates/components/mfa-setup-step-two.hbs new file mode 100644 index 0000000000000..d6168bf6650b7 --- /dev/null +++ b/ui/app/templates/components/mfa-setup-step-two.hbs @@ -0,0 +1,35 @@ +

+ TOTP Multi-factor authentication (MFA) can be enabled here if it is required by your administrator. This will ensure that + you are not prevented from logging into Vault in the future, once MFA is fully enforced. +

+
+ + {{#if @warning}} + + {{else}} +
+
+ {{! template-lint-disable no-curly-component-invocation }} + {{qr-code text=@qrCode colorLight="#F7F7F7" width=155 height=155 correctLevel="L"}} +
+
+
+
+
+

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

+
+
+ {{/if}} +
+ + +
+
\ No newline at end of file diff --git a/ui/app/templates/components/splash-page.hbs b/ui/app/templates/components/splash-page.hbs index 36f115a5d62b6..9070e5f04e21d 100644 --- a/ui/app/templates/components/splash-page.hbs +++ b/ui/app/templates/components/splash-page.hbs @@ -1,15 +1,17 @@ - - - - - - - - - - +{{#if this.showTruncatedNavBar}} + + + + + + + + + + +{{/if}} {{! bypass UiWizard and container styling }} {{#if this.hasAltContent}} {{yield (hash altContent=(component "splash-page/splash-content"))}} diff --git a/ui/app/templates/vault/cluster/mfa-setup.hbs b/ui/app/templates/vault/cluster/mfa-setup.hbs new file mode 100644 index 0000000000000..a91cc93d47cde --- /dev/null +++ b/ui/app/templates/vault/cluster/mfa-setup.hbs @@ -0,0 +1,29 @@ + + +

MFA setup

+
+ +
+
+ {{#if (eq this.onStep 1)}} + + {{/if}} + {{#if (eq this.onStep 2)}} + + {{/if}} +
+
+
+
\ No newline at end of file diff --git a/ui/package.json b/ui/package.json index 5ea7fae101ba1..a3f14fe3ca5c5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/yarn.lock b/ui/yarn.lock index 4b18eade92ca0..cda6465c1ecac 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -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" From 0bc5c3aa466e891de263109086837a5b9fd8adca Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Tue, 17 May 2022 14:47:03 -0600 Subject: [PATCH 14/30] MFA Guided Setup Route (#15479) * adds mfa method create route with type selection workflow * updates mfa method create route links to use DocLink component --- .../cluster/access/mfa/methods/create.js | 27 ++++++++ ui/app/styles/core/helpers.scss | 3 + ui/app/templates/components/radio-card.hbs | 28 ++++---- .../cluster/access/mfa/methods/create.hbs | 67 +++++++++++++++++++ 4 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 ui/app/controllers/vault/cluster/access/mfa/methods/create.js create mode 100644 ui/app/templates/vault/cluster/access/mfa/methods/create.hbs diff --git a/ui/app/controllers/vault/cluster/access/mfa/methods/create.js b/ui/app/controllers/vault/cluster/access/mfa/methods/create.js new file mode 100644 index 0000000000000..73793096347b1 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/mfa/methods/create.js @@ -0,0 +1,27 @@ +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'; + +export default class MfaMethodCreateController extends Controller { + @service store; + + queryParams = ['type']; + methodNames = ['TOTP', 'Duo', 'Okta', 'PingID']; + + @tracked type = null; + @tracked selectedType; + @tracked method; + + get formattedSelectedType() { + if (!this.selectedType) return ''; + return this.selectedType === 'totp' ? this.selectedType.toUpperCase() : capitalize(this.selectedType); + } + + @action + createMethod() { + this.method = this.store.createRecord('mfa-method', { type: this.selectedType }); + this.type = this.selectedType; // set selectedType to query param for state tracking + } +} diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index 8e836582bde25..b76e98fef57fd 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -257,3 +257,6 @@ ul.bullet { .has-text-grey-400 { color: $ui-gray-400; } +.has-text-align-center { + text-align: center; +} diff --git a/ui/app/templates/components/radio-card.hbs b/ui/app/templates/components/radio-card.hbs index 345da2618ebf4..528817ad185c2 100644 --- a/ui/app/templates/components/radio-card.hbs +++ b/ui/app/templates/components/radio-card.hbs @@ -3,19 +3,23 @@ class="radio-card {{if (eq @value @groupValue) 'is-selected'}} {{if @disabled 'is-disabled'}}" ...attributes > -
-
- + {{#if (has-block)}} + {{yield}} + {{else}} +
+
+ +
+
+
+ {{@title}} +
+

+ {{@description}} +

+
-
-
- {{@title}} -
-

- {{@description}} -

-
-
+ {{/if}}
+ +

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

+
+ + +
+ {{#if this.method}} + {{! display forms }} + {{else}} +

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

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

+ {{methodName}} +

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

+ {{#if (eq this.selectedType "totp")}} + 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. + {{else}} + Once set up, the + {{this.formattedSelectedType}} + MFA method will require a push confirmation on mobile before login. + {{/if}} + Learn more. +

+ {{! in a future release cards may be displayed to choose from either template or custom config for TOTP }} + {{! if template is selected a user could choose a predefined config for common authenticators and the values would be populated on the model }} +
+ +
+ {{/if}} + {{/if}} +
\ No newline at end of file From e780d1c43e4dfbfa2e8f20d79f9850090e2c0c86 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Wed, 18 May 2022 09:14:31 -0600 Subject: [PATCH 15/30] 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 --- .../components/mfa-login-enforcement-form.js | 5 +- .../mfa-login-enforcement-header.js | 3 +- ui/app/components/mfa/method-form.js | 1 - .../cluster/access/mfa/methods/create.js | 78 +++++++++++++++++-- .../cluster/access/mfa/methods/create.js | 18 ++++- ui/app/styles/core/helpers.scss | 3 + .../components/mfa-login-enforcement-form.hbs | 6 +- .../mfa-login-enforcement-header.hbs | 36 ++++----- .../templates/components/mfa/method-form.hbs | 2 +- .../cluster/access/mfa/methods/create.hbs | 64 +++++++++------ .../access/mfa/methods/method/edit.hbs | 1 - 11 files changed, 154 insertions(+), 63 deletions(-) diff --git a/ui/app/components/mfa-login-enforcement-form.js b/ui/app/components/mfa-login-enforcement-form.js index 815a9be01fab5..5030423ec23c0 100644 --- a/ui/app/components/mfa-login-enforcement-form.js +++ b/ui/app/components/mfa-login-enforcement-form.js @@ -11,13 +11,12 @@ import { task } from 'ember-concurrency'; * * @example * ```js - * + * * ``` * @callback onSave * @callback onClose * @param {Object} model - login enforcement model - * @param {Object} [mfaMethod] - provide when creating a login enforcement for a selected method -- otherwise search selector is displayed - * @param {boolean} [hasActions] - whether the save and cancel actions will be displayed and handled internally or not + * @param {Object} [isInline] - toggles inline display of form -- method selector and actions are hidden and should be handled externally * @param {onSave} [onSave] - triggered on save success * @param {onClose} [onClose] - triggered on cancel */ diff --git a/ui/app/components/mfa-login-enforcement-header.js b/ui/app/components/mfa-login-enforcement-header.js index 8fb451659008f..4af2e7d3a83c2 100644 --- a/ui/app/components/mfa-login-enforcement-header.js +++ b/ui/app/components/mfa-login-enforcement-header.js @@ -13,7 +13,8 @@ import { inject as service } from '@ember/service'; * ``` * @callback onRadioCardSelect * @callback onEnforcementSelect - * @param {string} [heading] - displays page heading and more verbose description used in create/edit routes -- if not provided the component will render in inline form with radio cards + * @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 diff --git a/ui/app/components/mfa/method-form.js b/ui/app/components/mfa/method-form.js index f0b0bf4ea4701..55ae9c2dd3a8c 100644 --- a/ui/app/components/mfa/method-form.js +++ b/ui/app/components/mfa/method-form.js @@ -11,7 +11,6 @@ import { task } from 'ember-concurrency'; * * ``` * @param {Object} model - MFA method model - * @param {boolean} [isEditMode] - whether the form is used for edit flow or not * @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 diff --git a/ui/app/controllers/vault/cluster/access/mfa/methods/create.js b/ui/app/controllers/vault/cluster/access/mfa/methods/create.js index 73793096347b1..138e302446f5e 100644 --- a/ui/app/controllers/vault/cluster/access/mfa/methods/create.js +++ b/ui/app/controllers/vault/cluster/access/mfa/methods/create.js @@ -3,25 +3,87 @@ 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 store; + @service flashMessages; + @service router; queryParams = ['type']; methodNames = ['TOTP', 'Duo', 'Okta', 'PingID']; @tracked type = null; - @tracked selectedType; - @tracked method; + @tracked method = null; + @tracked enforcement; + @tracked enforcementPreference; - get formattedSelectedType() { - if (!this.selectedType) return ''; - return this.selectedType === 'totp' ? this.selectedType.toUpperCase() : capitalize(this.selectedType); + 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) { + this.method = null; + this.type = type; + } @action createMethod() { - this.method = this.store.createRecord('mfa-method', { type: this.selectedType }); - this.type = this.selectedType; // set selectedType to query param for state tracking + if (this.method) { + this.method.unloadRecord(); + } + this.method = this.store.createRecord('mfa-method', { type: this.type }); + } + @action + onEnforcementPreferenceChange(preference) { + if (preference === 'new') { + this.enforcement = this.store.createRecord('mfa-login-enforcement'); + } else if (this.enforcement) { + this.enforcement.unloadRecord(); + } + this.enforcementPreference = preference; + } + @action + cancel() { + this.method = null; + this.enforcement = null; + this.enforcementPreference = null; + this.router.transitionTo('vault.cluster.access.mfa.methods'); + } + @task + *save() { + // JLR TODO: add model validations and check them first before proceeding + try { + // first save method + yield this.method.save(); + this.enforcement.mfa_methods.addObject(this.method); + try { + // now save enforcement and catch error separately + yield this.enforcement.save(); + this.router.transitionTo('vault.cluster.access.mfa.methods.method', this.method.id); + } catch (error) { + this.handleError(error, 'Error saving enforcement'); + } + } catch (error) { + this.handleError(error, 'Error saving method'); + } + } + handleError(error, message) { + const errorMessage = error?.errors ? `${message}: ${error.errors.join(', ')}` : message; + this.flashMessages.danger(errorMessage); } } diff --git a/ui/app/routes/vault/cluster/access/mfa/methods/create.js b/ui/app/routes/vault/cluster/access/mfa/methods/create.js index ca69de3f86271..ac4c418a9ac37 100644 --- a/ui/app/routes/vault/cluster/access/mfa/methods/create.js +++ b/ui/app/routes/vault/cluster/access/mfa/methods/create.js @@ -1,3 +1,19 @@ import Route from '@ember/routing/route'; -export default class MfaLoginEnforcementCreateRoute extends 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) { + controller.set('method', this.store.createRecord('mfa-method', { type })); + } + } + resetController(controller, isExiting) { + if (isExiting) { + // reset type query param when user saves or cancels + // this will not trigger when refreshing the page which preserves intended functionality + controller.set('type', null); + } + } +} diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index b76e98fef57fd..72c0423ebeddf 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -171,6 +171,9 @@ .has-top-padding-s { padding-top: $spacing-s; } +.has-top-padding-l { + padding-top: $spacing-l; +} .has-bottom-margin-xs { margin-bottom: $spacing-xs; } diff --git a/ui/app/templates/components/mfa-login-enforcement-form.hbs b/ui/app/templates/components/mfa-login-enforcement-form.hbs index bd12af3f9e0a0..6fbb404774b42 100644 --- a/ui/app/templates/components/mfa-login-enforcement-form.hbs +++ b/ui/app/templates/components/mfa-login-enforcement-form.hbs @@ -14,7 +14,7 @@ {{on "input" (pipe (pick "target.value") (fn (mut @model.name)))}} /> - {{#unless @mfaMethod}} + {{#unless @isInline}}
{{! component only computes inputValue on init -- ensure Ember Data hasMany promise has resolved }} @@ -95,7 +95,7 @@
- {{#if @hasActions}} + {{#unless @isInline}}
- {{/if}} + {{/unless}}
\ No newline at end of file diff --git a/ui/app/templates/components/mfa-login-enforcement-header.hbs b/ui/app/templates/components/mfa-login-enforcement-header.hbs index 56a992cad8e81..4f52409f420a3 100644 --- a/ui/app/templates/components/mfa-login-enforcement-header.hbs +++ b/ui/app/templates/components/mfa-login-enforcement-header.hbs @@ -1,30 +1,28 @@ - - -

- {{#if @heading}} +{{#if @isInline}} +

Enforcement

+{{else}} + + +

{{@heading}} - {{else}} - Enforcement - {{/if}} -

-
-
+ +
+
+{{/if}}

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

- {{#unless @heading}} + {{#if @isInline}}
{{/if}} - {{/unless}} + {{/if}}
\ No newline at end of file diff --git a/ui/app/templates/components/mfa/method-form.hbs b/ui/app/templates/components/mfa/method-form.hbs index 6cd62efc23219..8f13c0eeae083 100644 --- a/ui/app/templates/components/mfa/method-form.hbs +++ b/ui/app/templates/components/mfa/method-form.hbs @@ -1,4 +1,4 @@ -
+
{{#each @model.attrs as |attr|}} {{/each}} diff --git a/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs b/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs index da3a9e0be2dd9..dfe4d2477b81f 100644 --- a/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs @@ -11,23 +11,32 @@ - -
- {{#if this.method}} - {{! display forms }} +
+ {{#if this.showForms}} +

Settings

+

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

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

+

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

{{#each this.methodNames as |methodName|}} - +
{{/each}}
- {{#if this.selectedType}} + {{#if this.type}}

- {{#if (eq this.selectedType "totp")}} - 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. - {{else}} - Once set up, the - {{this.formattedSelectedType}} - MFA method will require a push confirmation on mobile before login. - {{/if}} - Learn more. + {{this.description}} + Learn more.

{{! in a future release cards may be displayed to choose from either template or custom config for TOTP }} {{! if template is selected a user could choose a predefined config for common authenticators and the values would be populated on the model }} -
- -
{{/if}} {{/if}} + +
+
+ {{#if this.showForms}} + + + {{else if this.type}} + + {{/if}} +
+
\ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/methods/method/edit.hbs b/ui/app/templates/vault/cluster/access/mfa/methods/method/edit.hbs index 84de78084833c..d03a75251e4bf 100644 --- a/ui/app/templates/vault/cluster/access/mfa/methods/method/edit.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/methods/method/edit.hbs @@ -10,7 +10,6 @@ Date: Wed, 18 May 2022 10:24:38 -0700 Subject: [PATCH 16/30] 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 --- ui/app/adapters/mfa-method.js | 4 + .../access/mfa/methods/method/index.js | 22 +++++ .../cluster/access/mfa/methods/method.js | 18 +++- .../mfa/login-enforcement-list-item.hbs | 32 +++++++ .../method-list-item.hbs} | 0 .../mfa/enforcements/enforcement/index.hbs | 2 +- .../cluster/access/mfa/enforcements/index.hbs | 33 +------ .../cluster/access/mfa/methods/index.hbs | 2 +- .../access/mfa/methods/method/edit.hbs | 8 +- .../access/mfa/methods/method/index.hbs | 90 ++++++++++++++----- 10 files changed, 148 insertions(+), 63 deletions(-) create mode 100644 ui/app/controllers/vault/cluster/access/mfa/methods/method/index.js create mode 100644 ui/app/templates/components/mfa/login-enforcement-list-item.hbs rename ui/app/templates/components/{mfa-method-list-item.hbs => mfa/method-list-item.hbs} (100%) diff --git a/ui/app/adapters/mfa-method.js b/ui/app/adapters/mfa-method.js index 6704f2c690905..fb5fc310de9a5 100644 --- a/ui/app/adapters/mfa-method.js +++ b/ui/app/adapters/mfa-method.js @@ -31,6 +31,10 @@ export default class MfaMethodAdapter extends ApplicationAdapter { 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', { diff --git a/ui/app/controllers/vault/cluster/access/mfa/methods/method/index.js b/ui/app/controllers/vault/cluster/access/mfa/methods/method/index.js new file mode 100644 index 0000000000000..99c762c4d459f --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/mfa/methods/method/index.js @@ -0,0 +1,22 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class MfaMethodController extends Controller { + @service router; + @service flashMessages; + + queryParams = ['tab']; + tab = 'config'; + + @action + async deleteMethod() { + try { + await this.model.method.destroyRecord(); + this.flashMessages.success('MFA method deleted successfully deleted.'); + this.router.transitionTo('vault.cluster.access.mfa.methods'); + } catch (error) { + this.flashMessages.danger(`There was an error deleting this MFA method.`); + } + } +} diff --git a/ui/app/routes/vault/cluster/access/mfa/methods/method.js b/ui/app/routes/vault/cluster/access/mfa/methods/method.js index 69be5b9114b6c..d0b7dbfad5071 100644 --- a/ui/app/routes/vault/cluster/access/mfa/methods/method.js +++ b/ui/app/routes/vault/cluster/access/mfa/methods/method.js @@ -1,8 +1,22 @@ import Route from '@ember/routing/route'; - +import { hash } from 'rsvp'; export default class MfaMethodRoute extends Route { model({ id }) { - return this.store.findRecord('mfa-method', id); + return hash({ + method: this.store.findRecord('mfa-method', id).then((data) => data), + enforcements: this.store + .query('mfa-login-enforcement', {}) + .then((data) => { + let filteredEnforcements = data.filter((item) => { + let results = item.hasMany('mfa_methods').ids(); + return results.includes(id); + }); + return filteredEnforcements; + }) + .catch(() => { + // Do nothing + }), + }); } setupController(controller, model) { controller.set('model', model); diff --git a/ui/app/templates/components/mfa/login-enforcement-list-item.hbs b/ui/app/templates/components/mfa/login-enforcement-list-item.hbs new file mode 100644 index 0000000000000..673171433951b --- /dev/null +++ b/ui/app/templates/components/mfa/login-enforcement-list-item.hbs @@ -0,0 +1,32 @@ + +
+
+
+ + + {{@model.name}} + +
+
+
+
+ + + +
+
+
+
\ No newline at end of file diff --git a/ui/app/templates/components/mfa-method-list-item.hbs b/ui/app/templates/components/mfa/method-list-item.hbs similarity index 100% rename from ui/app/templates/components/mfa-method-list-item.hbs rename to ui/app/templates/components/mfa/method-list-item.hbs diff --git a/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/index.hbs b/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/index.hbs index 816eb4426abd7..1f198bba88aec 100644 --- a/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/index.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/index.hbs @@ -69,7 +69,7 @@ {{/each}} {{else if (eq this.tab "methods")}} {{#each this.model.mfa_methods as |method|}} - + {{/each}} {{/if}} diff --git a/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs b/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs index d3d9d8c520e5c..ef1383a259cd3 100644 --- a/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs @@ -18,38 +18,7 @@ {{#if this.model.meta.total}} {{#each this.model as |item|}} - -
-
-
- - - {{item.name}} - -
-
-
-
- - - -
-
-
-
+ {{/each}} {{/if}} {{#if (gt this.model.meta.lastPage 1)}} diff --git a/ui/app/templates/vault/cluster/access/mfa/methods/index.hbs b/ui/app/templates/vault/cluster/access/mfa/methods/index.hbs index 3edf3a629d3fe..e654cdacb62f2 100644 --- a/ui/app/templates/vault/cluster/access/mfa/methods/index.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/methods/index.hbs @@ -18,7 +18,7 @@ {{#if this.model.meta.total}} {{#each this.model as |item|}} - + {{/each}} {{/if}} {{#if (gt this.model.meta.lastPage 1)}} diff --git a/ui/app/templates/vault/cluster/access/mfa/methods/method/edit.hbs b/ui/app/templates/vault/cluster/access/mfa/methods/method/edit.hbs index d03a75251e4bf..0d2c1041183e1 100644 --- a/ui/app/templates/vault/cluster/access/mfa/methods/method/edit.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/methods/method/edit.hbs @@ -2,15 +2,15 @@

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

\ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/methods/method/index.hbs b/ui/app/templates/vault/cluster/access/mfa/methods/method/index.hbs index b8855413e2f2f..e62b352ae4482 100644 --- a/ui/app/templates/vault/cluster/access/mfa/methods/method/index.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/methods/method/index.hbs @@ -1,34 +1,78 @@

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

- - - - Edit - - - +
+ +
-
- {{#each this.model.attrs as |attr|}} - {{#if (eq attr.type "object")}} - + + + Delete + + + Edit + + + +
+ {{#each this.model.method.attrs as |attr|}} + {{#if (eq attr.type "object")}} + + {{else}} + + {{/if}} + {{/each}} +
+{{else if (eq this.tab "enforcements")}} + + + + New enforcement + + + +
+ {{#if (is-empty this.model.enforcements)}} + {{else}} - + {{#each this.model.enforcements as |item|}} + + {{/each}} {{/if}} - {{/each}} -
\ No newline at end of file +
+{{/if}} \ No newline at end of file From b0b4d134e447de05ea0ae47f44bca6b021318071 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Wed, 18 May 2022 14:29:41 -0600 Subject: [PATCH 17/30] 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 --- .../components/mfa-login-enforcement-form.js | 26 +++++--- .../cluster/access/mfa/methods/create.js | 61 +++++++++++++++---- ui/app/models/mfa-login-enforcement.js | 24 ++++++++ .../cluster/access/mfa/methods/create.js | 3 +- .../components/mfa-login-enforcement-form.hbs | 9 +++ ui/app/templates/components/radio-card.hbs | 2 +- .../vault/cluster/access/mfa/index.hbs | 2 +- .../cluster/access/mfa/methods/create.hbs | 9 ++- 8 files changed, 111 insertions(+), 25 deletions(-) diff --git a/ui/app/components/mfa-login-enforcement-form.js b/ui/app/components/mfa-login-enforcement-form.js index 5030423ec23c0..0d23243b87fd9 100644 --- a/ui/app/components/mfa-login-enforcement-form.js +++ b/ui/app/components/mfa-login-enforcement-form.js @@ -17,6 +17,7 @@ import { task } from 'ember-concurrency'; * @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 */ @@ -42,6 +43,7 @@ export default class MfaLoginEnforcementForm extends Component { options: [], selected: [], }; + @tracked modelErrors; constructor() { super(...arguments); @@ -83,15 +85,25 @@ export default class MfaLoginEnforcementForm extends Component { get selectedTarget() { return this.targetTypes.findBy('type', this.selectedTargetType); } + get errors() { + return this.args.modelErrors || this.modelErrors; + } @task *save() { - try { - yield this.args.model.save(); - this.args.onSave(); - } catch (error) { - const message = error.errors ? error.errors.join('. ') : error.message; - this.flashMessages.danger(message); + 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); + } } } @@ -142,7 +154,7 @@ export default class MfaLoginEnforcementForm extends Component { removeTarget(target) { this.targets.removeObject(target); // remove target from appropriate model property - this.args.model[target.key].addObject(target.value); + this.args.model[target.key].removeObject(target.value); } @action cancel() { diff --git a/ui/app/controllers/vault/cluster/access/mfa/methods/create.js b/ui/app/controllers/vault/cluster/access/mfa/methods/create.js index 138e302446f5e..af49cc364f3bd 100644 --- a/ui/app/controllers/vault/cluster/access/mfa/methods/create.js +++ b/ui/app/controllers/vault/cluster/access/mfa/methods/create.js @@ -15,7 +15,9 @@ export default class MfaMethodCreateController extends Controller { @tracked type = null; @tracked method = null; @tracked enforcement; - @tracked enforcementPreference; + @tracked enforcementPreference = 'new'; + @tracked methodErrors; + @tracked enforcementErrors; get description() { if (this.type === 'totp') { @@ -38,15 +40,24 @@ export default class MfaMethodCreateController extends Controller { @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 - createMethod() { + 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) { @@ -54,6 +65,7 @@ export default class MfaMethodCreateController extends Controller { this.enforcement = this.store.createRecord('mfa-login-enforcement'); } else if (this.enforcement) { this.enforcement.unloadRecord(); + this.enforcement = null; } this.enforcementPreference = preference; } @@ -66,21 +78,44 @@ export default class MfaMethodCreateController extends Controller { } @task *save() { - // JLR TODO: add model validations and check them first before proceeding - try { - // first save method - yield this.method.save(); - this.enforcement.mfa_methods.addObject(this.method); + const isValid = this.checkValidityState(); + if (isValid) { try { - // now save enforcement and catch error separately - yield this.enforcement.save(); - this.router.transitionTo('vault.cluster.access.mfa.methods.method', this.method.id); + // 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(); + this.router.transitionTo('vault.cluster.access.mfa.methods.method', this.method.id); + } catch (error) { + this.handleError(error, 'Error saving enforcement'); + } + } } catch (error) { - this.handleError(error, 'Error saving enforcement'); + this.handleError(error, 'Error saving method'); + } + } + } + checkValidityState() { + // block saving models if either is in an invalid state + let isEnforcementValid = true; + const methodValidations = { isValid: true }; // roughing in for now until validations are created -- replace with 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; } - } catch (error) { - this.handleError(error, 'Error saving method'); } + return methodValidations.isValid && isEnforcementValid; } handleError(error, message) { const errorMessage = error?.errors ? `${message}: ${error.errors.join(', ')}` : message; diff --git a/ui/app/models/mfa-login-enforcement.js b/ui/app/models/mfa-login-enforcement.js index 877bda47331b1..27374bd0e7574 100644 --- a/ui/app/models/mfa-login-enforcement.js +++ b/ui/app/models/mfa-login-enforcement.js @@ -2,7 +2,31 @@ 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; diff --git a/ui/app/routes/vault/cluster/access/mfa/methods/create.js b/ui/app/routes/vault/cluster/access/mfa/methods/create.js index ac4c418a9ac37..8802e86ab10c1 100644 --- a/ui/app/routes/vault/cluster/access/mfa/methods/create.js +++ b/ui/app/routes/vault/cluster/access/mfa/methods/create.js @@ -6,7 +6,8 @@ export default class MfaLoginEnforcementCreateRoute extends Route { // if route was refreshed after type select recreate method model const { type } = controller; if (type) { - controller.set('method', this.store.createRecord('mfa-method', { type })); + // create method and enforcement models for forms if type is selected + controller.createModels(); } } resetController(controller, isExiting) { diff --git a/ui/app/templates/components/mfa-login-enforcement-form.hbs b/ui/app/templates/components/mfa-login-enforcement-form.hbs index 6fbb404774b42..82226d533d026 100644 --- a/ui/app/templates/components/mfa-login-enforcement-form.hbs +++ b/ui/app/templates/components/mfa-login-enforcement-form.hbs @@ -13,6 +13,9 @@ class="input field" {{on "input" (pipe (pick "target.value") (fn (mut @model.name)))}} /> + {{#if this.errors.name.errors}} + + {{/if}} {{#unless @isInline}}
@@ -28,6 +31,9 @@ @onChange={{this.onMethodChange}} /> {{/if}} + {{#if this.errors.mfa_methods.errors}} + + {{/if}}
{{/unless}} @@ -94,6 +100,9 @@ Add
+ {{#if this.errors.targets.errors}} + + {{/if}}
{{#unless @isInline}}
diff --git a/ui/app/templates/components/radio-card.hbs b/ui/app/templates/components/radio-card.hbs index 528817ad185c2..6321a5a3ab65e 100644 --- a/ui/app/templates/components/radio-card.hbs +++ b/ui/app/templates/components/radio-card.hbs @@ -26,7 +26,7 @@ class="radio" disabled={{@disabled}} @value={{@value}} - @groupValue={{this.config.mode}} + @groupValue={{@groupValue}} @onChange={{@onChange}} /> diff --git a/ui/app/templates/vault/cluster/access/mfa/index.hbs b/ui/app/templates/vault/cluster/access/mfa/index.hbs index 7cd3a7c42d8e4..8bcec6debd03e 100644 --- a/ui/app/templates/vault/cluster/access/mfa/index.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/index.hbs @@ -15,7 +15,7 @@ Learn more

-
diff --git a/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs b/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs index dfe4d2477b81f..8f43bbc57625a 100644 --- a/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs @@ -26,7 +26,12 @@ @onEnforcementSelect={{fn (mut this.enforcement)}} /> {{#if (eq this.enforcementPreference "new")}} - + {{/if}} {{else}}

@@ -72,7 +77,7 @@ Cancel {{else if this.type}} - {{/if}} From 0e1adf670ebd4b6a0eedc6cda4908a9f4d5cda42 Mon Sep 17 00:00:00 2001 From: Arnav Palnitkar Date: Thu, 19 May 2022 09:22:00 -0700 Subject: [PATCH 18/30] Added validations for mfa method model (#15506) --- ui/app/components/mfa/method-form.js | 15 ++++++++ .../cluster/access/mfa/methods/create.js | 2 +- ui/app/models/mfa-method.js | 34 ++++++++++++++++--- .../templates/components/mfa/method-form.hbs | 6 ++-- .../cluster/access/mfa/methods/create.hbs | 2 +- 5 files changed, 49 insertions(+), 10 deletions(-) diff --git a/ui/app/components/mfa/method-form.js b/ui/app/components/mfa/method-form.js index 55ae9c2dd3a8c..63cf929b5e9ba 100644 --- a/ui/app/components/mfa/method-form.js +++ b/ui/app/components/mfa/method-form.js @@ -1,5 +1,6 @@ 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'; @@ -19,6 +20,9 @@ export default class MfaMethodForm extends Component { @service store; @service flashMessages; + @tracked editValidations; + @tracked isEditModalActive = false; + @task *save() { try { @@ -29,6 +33,17 @@ export default class MfaMethodForm extends Component { } } + @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(); diff --git a/ui/app/controllers/vault/cluster/access/mfa/methods/create.js b/ui/app/controllers/vault/cluster/access/mfa/methods/create.js index af49cc364f3bd..336885be442d1 100644 --- a/ui/app/controllers/vault/cluster/access/mfa/methods/create.js +++ b/ui/app/controllers/vault/cluster/access/mfa/methods/create.js @@ -101,7 +101,7 @@ export default class MfaMethodCreateController extends Controller { checkValidityState() { // block saving models if either is in an invalid state let isEnforcementValid = true; - const methodValidations = { isValid: true }; // roughing in for now until validations are created -- replace with this.method.validate(); + const methodValidations = this.method.validate(); if (!methodValidations.isValid) { this.methodErrors = methodValidations.state; } diff --git a/ui/app/models/mfa-method.js b/ui/app/models/mfa-method.js index 3ae1baf1f8130..c355c28a26403 100644 --- a/ui/app/models/mfa-method.js +++ b/ui/app/models/mfa-method.js @@ -1,14 +1,16 @@ 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_template', 'secret_key', 'integration_key', 'api_hostname', 'push_info', 'use_passcode'], - okta: ['mount_accessor', 'org_name', 'api_token', 'base_url', 'primary_email'], + 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_template', + 'username_format', 'settings_file_base64', 'use_signature', 'idp_url', @@ -18,14 +20,36 @@ const METHOD_PROPS = { ], }; +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 template', + label: 'Username format', subText: 'How to map identity names to MFA method names. ', }) - username_template; + username_format; @attr('string', { label: 'Namespace', }) diff --git a/ui/app/templates/components/mfa/method-form.hbs b/ui/app/templates/components/mfa/method-form.hbs index 8f13c0eeae083..546fc539a0e1c 100644 --- a/ui/app/templates/components/mfa/method-form.hbs +++ b/ui/app/templates/components/mfa/method-form.hbs @@ -1,6 +1,6 @@

{{#each @model.attrs as |attr|}} - + {{/each}}
{{#if @hasActions}} @@ -10,7 +10,7 @@ type="button" class="button is-primary {{if this.save.isRunning 'is-loading'}}" disabled={{this.save.isRunning}} - onclick={{action (mut this.isEditModalActive) true}} + onclick={{this.initSave}} > Save @@ -25,7 +25,7 @@ @title="Edit {{@model.type}} configuration?" @onClose={{action (mut this.isEditModalActive) false}} @isActive={{this.isEditModalActive}} - @confirmText="edit" + @confirmText={{@model.type}} @onConfirm={{perform this.save}} >

diff --git a/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs b/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs index 8f43bbc57625a..4a52326e597bc 100644 --- a/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs @@ -18,7 +18,7 @@ {{this.description}} Learn more.

- + Date: Thu, 19 May 2022 12:38:14 -0700 Subject: [PATCH 19/30] 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/app/routes/vault/cluster/access/mfa/index.js | 14 +++++++++++++- .../components/mfa-login-enforcement-header.hbs | 12 ++++++++++++ ui/app/templates/vault/cluster/access.hbs | 2 +- .../access/mfa/enforcements/enforcement/index.hbs | 12 ++++++++++++ .../cluster/access/mfa/enforcements/index.hbs | 2 ++ .../templates/vault/cluster/access/mfa/index.hbs | 2 +- .../vault/cluster/access/mfa/methods/create.hbs | 12 ++++++++++++ .../cluster/access/mfa/methods/method/index.hbs | 12 ++++++++++++ 8 files changed, 65 insertions(+), 3 deletions(-) diff --git a/ui/app/routes/vault/cluster/access/mfa/index.js b/ui/app/routes/vault/cluster/access/mfa/index.js index b26756b979bfa..cb6054567c420 100644 --- a/ui/app/routes/vault/cluster/access/mfa/index.js +++ b/ui/app/routes/vault/cluster/access/mfa/index.js @@ -1,3 +1,15 @@ import Route from '@ember/routing/route'; -export default class MfaConfigureRoute extends Route {} +export default class MfaConfigureRoute extends Route { + beforeModel() { + return this.store + .query('mfa-method', {}) + .then(() => { + // if response then they should transition to the methods page instead of staying on the configure page. + this.transitionTo('vault.cluster.access.mfa.methods.index'); + }) + .catch(() => { + // stay on the landing page + }); + } +} diff --git a/ui/app/templates/components/mfa-login-enforcement-header.hbs b/ui/app/templates/components/mfa-login-enforcement-header.hbs index 4f52409f420a3..db18ddcb69c1a 100644 --- a/ui/app/templates/components/mfa-login-enforcement-header.hbs +++ b/ui/app/templates/components/mfa-login-enforcement-header.hbs @@ -2,6 +2,18 @@

Enforcement

{{else}} + + +

diff --git a/ui/app/templates/vault/cluster/access.hbs b/ui/app/templates/vault/cluster/access.hbs index 3b4f98cdfa993..d3741e51a34f7 100644 --- a/ui/app/templates/vault/cluster/access.hbs +++ b/ui/app/templates/vault/cluster/access.hbs @@ -15,7 +15,7 @@
  • Multi-factor authentication diff --git a/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/index.hbs b/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/index.hbs index 1f198bba88aec..d90cdee8404b6 100644 --- a/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/index.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/enforcements/enforcement/index.hbs @@ -1,4 +1,16 @@ + + +

    diff --git a/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs b/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs index ef1383a259cd3..ee66050f20e1e 100644 --- a/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs @@ -20,6 +20,8 @@ {{#each this.model as |item|}} {{/each}} +{{else}} + {{/if}} {{#if (gt this.model.meta.lastPage 1)}} -
    +

    Configure and enforce multi-factor authentication (MFA) for users logging into Vault, for any
    diff --git a/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs b/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs index 4a52326e597bc..6d59dc962b0a1 100644 --- a/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/methods/create.hbs @@ -10,6 +10,18 @@ {{/if}}

    + + +
    {{#if this.showForms}} diff --git a/ui/app/templates/vault/cluster/access/mfa/methods/method/index.hbs b/ui/app/templates/vault/cluster/access/mfa/methods/method/index.hbs index e62b352ae4482..7c70dc0b4ea26 100644 --- a/ui/app/templates/vault/cluster/access/mfa/methods/method/index.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/methods/method/index.hbs @@ -1,4 +1,16 @@ + + +

    From 75630ef2b4608724fdbafa809b45c5ee387bc537 Mon Sep 17 00:00:00 2001 From: Angel Garbarino Date: Thu, 19 May 2022 14:32:40 -0700 Subject: [PATCH 20/30] UI/mfa small bugs (#15522) * remove pagintion and fix on methods list view * fix enforcements --- .../cluster/access/mfa/enforcements/index.js | 27 ++++++------------ .../vault/cluster/access/mfa/methods/index.js | 28 ++++++------------- .../cluster/access/mfa/enforcements/index.hbs | 9 +----- .../cluster/access/mfa/methods/index.hbs | 11 ++------ 4 files changed, 21 insertions(+), 54 deletions(-) diff --git a/ui/app/routes/vault/cluster/access/mfa/enforcements/index.js b/ui/app/routes/vault/cluster/access/mfa/enforcements/index.js index 158c41753a0bc..987fb44dea746 100644 --- a/ui/app/routes/vault/cluster/access/mfa/enforcements/index.js +++ b/ui/app/routes/vault/cluster/access/mfa/enforcements/index.js @@ -1,25 +1,14 @@ import Route from '@ember/routing/route'; export default class MfaEnforcementsRoute extends Route { - queryParams = { - page: { - refreshModel: true, - }, - }; - - model(params) { - return this.store - .lazyPaginatedQuery('mfa-login-enforcement', { - responsePath: 'data.keys', - page: params.page || 1, - }) - .catch((err) => { - if (err.httpStatus === 404) { - return []; - } else { - throw err; - } - }); + model() { + return this.store.query('mfa-login-enforcement', {}).catch((err) => { + if (err.httpStatus === 404) { + return []; + } else { + throw err; + } + }); } setupController(controller, model) { controller.set('model', model); diff --git a/ui/app/routes/vault/cluster/access/mfa/methods/index.js b/ui/app/routes/vault/cluster/access/mfa/methods/index.js index 6299e29e71c7b..8fa1ffea7ad64 100644 --- a/ui/app/routes/vault/cluster/access/mfa/methods/index.js +++ b/ui/app/routes/vault/cluster/access/mfa/methods/index.js @@ -4,26 +4,16 @@ import { inject as service } from '@ember/service'; export default class MfaMethodsRoute extends Route { @service router; - queryParams = { - page: { - refreshModel: true, - }, - }; - - model(params) { - return this.store - .lazyPaginatedQuery('mfa-method', { - responsePath: 'data.keys', - page: params.page || 1, - }) - .catch((err) => { - if (err.httpStatus === 404) { - return []; - } else { - throw err; - } - }); + 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'); diff --git a/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs b/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs index ee66050f20e1e..7ab79aad85ed1 100644 --- a/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/enforcements/index.hbs @@ -16,17 +16,10 @@ -{{#if this.model.meta.total}} +{{#if (gt this.model.length 0)}} {{#each this.model as |item|}} {{/each}} {{else}} -{{/if}} -{{#if (gt this.model.meta.lastPage 1)}} - {{/if}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/mfa/methods/index.hbs b/ui/app/templates/vault/cluster/access/mfa/methods/index.hbs index e654cdacb62f2..a75a65537bd30 100644 --- a/ui/app/templates/vault/cluster/access/mfa/methods/index.hbs +++ b/ui/app/templates/vault/cluster/access/mfa/methods/index.hbs @@ -16,15 +16,10 @@ -{{#if this.model.meta.total}} +{{#if (gt this.model.length 0)}} {{#each this.model as |item|}} {{/each}} -{{/if}} -{{#if (gt this.model.meta.lastPage 1)}} - +{{else}} + {{/if}} \ No newline at end of file From e248ddc7d2dd2bad3e466aefd0c180beee7eb071 Mon Sep 17 00:00:00 2001 From: Chelsea Shaw <82459713+hashishaw@users.noreply.github.com> Date: Fri, 20 May 2022 12:27:38 -0500 Subject: [PATCH 21/30] Fix label for value on radio-card (#15542) --- ui/app/templates/components/radio-card.hbs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/app/templates/components/radio-card.hbs b/ui/app/templates/components/radio-card.hbs index 6321a5a3ab65e..5b91a1b55f715 100644 --- a/ui/app/templates/components/radio-card.hbs +++ b/ui/app/templates/components/radio-card.hbs @@ -1,5 +1,5 @@