From d9e35b96c0a7757e383165ec515a34152fd1ce01 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Mon, 20 Dec 2021 12:15:28 -0700 Subject: [PATCH 01/25] adds development workflow to mirage config --- ui/config/environment.js | 9 +- ui/mirage/config.js | 550 ++++++++++++++++++++------------------- ui/package.json | 1 + 3 files changed, 289 insertions(+), 271 deletions(-) diff --git a/ui/config/environment.js b/ui/config/environment.js index 5cf098ddfe9d..bc33c4d81ef4 100644 --- a/ui/config/environment.js +++ b/ui/config/environment.js @@ -45,9 +45,12 @@ module.exports = function (environment) { ENV.APP.LOG_TRANSITIONS = true; // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; // ENV.APP.LOG_VIEW_LOOKUPS = true; - // ENV['ember-cli-mirage'] = { - // enabled: true, - // }; + if (process.env.MIRAGE_DEV_HANDLER !== undefined) { + ENV['ember-cli-mirage'] = { + enabled: true, + handler: process.env.MIRAGE_DEV_HANDLER, + }; + } } if (environment === 'test') { diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 4b55678bb584..d4ad5728946b 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -1,303 +1,317 @@ +import ENV from 'vault/config/environment'; +import handlers from './handlers'; + const EXPIRY_DATE = '2021-05-12T23:20:50.52Z'; export default function () { this.namespace = 'v1'; - this.get('sys/internal/counters/activity', function (db) { - let data = {}; - const firstRecord = db['clients/activities'].first(); - if (firstRecord) { - data = firstRecord; - } - return { - data, - request_id: '0001', - }; - }); + // start ember in development running mirage -> yarn start:mirage handlerName + // if handler is not provided, general config will be used + // this is useful for feature development when a specific and limited config is required + const { handler } = ENV['ember-cli-mirage']; + if (handler && handlers[handler]) { + handlers[handler](this); + this.logging = false; // disables passthrough logging which spams the console + console.log(`⚙ Using ${handler} Mirage handler ⚙`); // eslint-disable-line + } else { + console.log('⚙ Using default Mirage config ⚙'); // eslint-disable-line + this.get('sys/internal/counters/activity', function (db) { + let data = {}; + const firstRecord = db['clients/activities'].first(); + if (firstRecord) { + data = firstRecord; + } + return { + data, + request_id: '0001', + }; + }); - this.get('sys/internal/counters/config', function (db) { - return { - request_id: '00001', - data: db['clients/configs'].first(), - }; - }); + this.get('sys/internal/counters/config', function (db) { + return { + request_id: '00001', + data: db['clients/configs'].first(), + }; + }); - this.get('/sys/internal/ui/feature-flags', (db) => { - const featuresResponse = db.features.first(); - return { - data: { - feature_flags: featuresResponse ? featuresResponse.feature_flags : null, - }, - }; - }); + this.get('/sys/internal/ui/feature-flags', (db) => { + const featuresResponse = db.features.first(); + return { + data: { + feature_flags: featuresResponse ? featuresResponse.feature_flags : null, + }, + }; + }); - this.get('/sys/internal/counters/activity/monthly', function () { - return { - data: { - by_namespace: [ - { - namespace_id: 'Z4Rzh', - namespace_path: 'namespace1/', - counts: { - distinct_entities: 867, - non_entity_tokens: 939, - clients: 1806, + this.get('/sys/internal/counters/activity/monthly', function () { + return { + data: { + by_namespace: [ + { + namespace_id: 'Z4Rzh', + namespace_path: 'namespace1/', + counts: { + distinct_entities: 867, + non_entity_tokens: 939, + clients: 1806, + }, }, - }, - { - namespace_id: 'DcgzU', - namespace_path: 'namespace17/', - counts: { - distinct_entities: 966, - non_entity_tokens: 550, - clients: 1516, + { + namespace_id: 'DcgzU', + namespace_path: 'namespace17/', + counts: { + distinct_entities: 966, + non_entity_tokens: 550, + clients: 1516, + }, }, - }, - { - namespace_id: '5SWT8', - namespace_path: 'namespacelonglonglong4/', - counts: { - distinct_entities: 996, - non_entity_tokens: 417, - clients: 1413, + { + namespace_id: '5SWT8', + namespace_path: 'namespacelonglonglong4/', + counts: { + distinct_entities: 996, + non_entity_tokens: 417, + clients: 1413, + }, }, - }, - { - namespace_id: 'XGu7R', - namespace_path: 'namespace12/', - counts: { - distinct_entities: 829, - non_entity_tokens: 540, - clients: 1369, + { + namespace_id: 'XGu7R', + namespace_path: 'namespace12/', + counts: { + distinct_entities: 829, + non_entity_tokens: 540, + clients: 1369, + }, }, - }, - { - namespace_id: 'yHcL9', - namespace_path: 'namespace11/', - counts: { - distinct_entities: 563, - non_entity_tokens: 705, - clients: 1268, + { + namespace_id: 'yHcL9', + namespace_path: 'namespace11/', + counts: { + distinct_entities: 563, + non_entity_tokens: 705, + clients: 1268, + }, }, - }, - { - namespace_id: 'F0xGm', - namespace_path: 'namespace10/', - counts: { - distinct_entities: 925, - non_entity_tokens: 255, - clients: 1180, + { + namespace_id: 'F0xGm', + namespace_path: 'namespace10/', + counts: { + distinct_entities: 925, + non_entity_tokens: 255, + clients: 1180, + }, }, - }, - { - namespace_id: 'aJuQG', - namespace_path: 'namespace9/', - counts: { - distinct_entities: 935, - non_entity_tokens: 239, - clients: 1174, + { + namespace_id: 'aJuQG', + namespace_path: 'namespace9/', + counts: { + distinct_entities: 935, + non_entity_tokens: 239, + clients: 1174, + }, }, - }, - { - namespace_id: 'bw5UO', - namespace_path: 'namespace6/', - counts: { - distinct_entities: 810, - non_entity_tokens: 363, - clients: 1173, + { + namespace_id: 'bw5UO', + namespace_path: 'namespace6/', + counts: { + distinct_entities: 810, + non_entity_tokens: 363, + clients: 1173, + }, }, - }, - { - namespace_id: 'IeyJp', - namespace_path: 'namespace14/', - counts: { - distinct_entities: 774, - non_entity_tokens: 392, - clients: 1166, + { + namespace_id: 'IeyJp', + namespace_path: 'namespace14/', + counts: { + distinct_entities: 774, + non_entity_tokens: 392, + clients: 1166, + }, }, - }, - { - namespace_id: 'Uc0o8', - namespace_path: 'namespace16/', - counts: { - distinct_entities: 408, - non_entity_tokens: 743, - clients: 1151, + { + namespace_id: 'Uc0o8', + namespace_path: 'namespace16/', + counts: { + distinct_entities: 408, + non_entity_tokens: 743, + clients: 1151, + }, }, - }, - { - namespace_id: 'R6L40', - namespace_path: 'namespace2/', - counts: { - distinct_entities: 292, - non_entity_tokens: 736, - clients: 1028, + { + namespace_id: 'R6L40', + namespace_path: 'namespace2/', + counts: { + distinct_entities: 292, + non_entity_tokens: 736, + clients: 1028, + }, }, - }, - { - namespace_id: 'Rqa3W', - namespace_path: 'namespace13/', - counts: { - distinct_entities: 160, - non_entity_tokens: 803, - clients: 963, + { + namespace_id: 'Rqa3W', + namespace_path: 'namespace13/', + counts: { + distinct_entities: 160, + non_entity_tokens: 803, + clients: 963, + }, }, - }, - { - namespace_id: 'MSgZE', - namespace_path: 'namespace7/', - counts: { - distinct_entities: 201, - non_entity_tokens: 657, - clients: 858, + { + namespace_id: 'MSgZE', + namespace_path: 'namespace7/', + counts: { + distinct_entities: 201, + non_entity_tokens: 657, + clients: 858, + }, }, - }, - { - namespace_id: 'kxU4t', - namespace_path: 'namespacelonglonglong3/', - counts: { - distinct_entities: 742, - non_entity_tokens: 26, - clients: 768, + { + namespace_id: 'kxU4t', + namespace_path: 'namespacelonglonglong3/', + counts: { + distinct_entities: 742, + non_entity_tokens: 26, + clients: 768, + }, }, - }, - { - namespace_id: '5xKya', - namespace_path: 'namespace15/', - counts: { - distinct_entities: 663, - non_entity_tokens: 19, - clients: 682, + { + namespace_id: '5xKya', + namespace_path: 'namespace15/', + counts: { + distinct_entities: 663, + non_entity_tokens: 19, + clients: 682, + }, }, - }, - { - namespace_id: '5KxXA', - namespace_path: 'namespace18anotherlong/', - counts: { - distinct_entities: 470, - non_entity_tokens: 196, - clients: 666, + { + namespace_id: '5KxXA', + namespace_path: 'namespace18anotherlong/', + counts: { + distinct_entities: 470, + non_entity_tokens: 196, + clients: 666, + }, }, - }, - { - namespace_id: 'AAidI', - namespace_path: 'namespace20/', - counts: { - distinct_entities: 429, - non_entity_tokens: 60, - clients: 489, + { + namespace_id: 'AAidI', + namespace_path: 'namespace20/', + counts: { + distinct_entities: 429, + non_entity_tokens: 60, + clients: 489, + }, }, - }, - { - namespace_id: 'BCl56', - namespace_path: 'namespace8/', - counts: { - distinct_entities: 61, - non_entity_tokens: 201, - clients: 262, + { + namespace_id: 'BCl56', + namespace_path: 'namespace8/', + counts: { + distinct_entities: 61, + non_entity_tokens: 201, + clients: 262, + }, }, - }, - { - namespace_id: 'yYNw2', - namespace_path: 'namespace19/', - counts: { - distinct_entities: 165, - non_entity_tokens: 85, - clients: 250, + { + namespace_id: 'yYNw2', + namespace_path: 'namespace19/', + counts: { + distinct_entities: 165, + non_entity_tokens: 85, + clients: 250, + }, }, - }, - { - namespace_id: 'root', - namespace_path: '', - counts: { - distinct_entities: 67, - non_entity_tokens: 9, - clients: 76, + { + namespace_id: 'root', + namespace_path: '', + counts: { + distinct_entities: 67, + non_entity_tokens: 9, + clients: 76, + }, }, - }, - ], - distinct_entities: 11323, - non_entity_tokens: 7935, - clients: 19258, - }, - }; - }); - - this.get('/sys/health', function () { - return { - initialized: true, - sealed: false, - standby: false, - license: { - expiry: '2021-05-12T23:20:50.52Z', - state: 'stored', - }, - performance_standby: false, - replication_performance_mode: 'disabled', - replication_dr_mode: 'disabled', - server_time_utc: 1622562585, - version: '1.9.0+ent', - cluster_name: 'vault-cluster-e779cd7c', - cluster_id: '5f20f5ab-acea-0481-787e-71ec2ff5a60b', - last_wal: 121, - }; - }); - - this.get('/sys/license/status', function () { - return { - data: { - autoloading_used: false, - stored: { - expiration_time: EXPIRY_DATE, - features: ['DR Replication', 'Namespaces', 'Lease Count Quotas', 'Automated Snapshots'], - license_id: '0eca7ef8-ebc0-f875-315e-3cc94a7870cf', - performance_standby_count: 0, - start_time: '2020-04-28T00:00:00Z', + ], + distinct_entities: 11323, + non_entity_tokens: 7935, + clients: 19258, }, - persisted_autoload: { - expiration_time: EXPIRY_DATE, - features: ['DR Replication', 'Namespaces', 'Lease Count Quotas', 'Automated Snapshots'], - license_id: '0eca7ef8-ebc0-f875-315e-3cc94a7870cf', - performance_standby_count: 0, - start_time: '2020-04-28T00:00:00Z', + }; + }); + + this.get('/sys/health', function () { + return { + initialized: true, + sealed: false, + standby: false, + license: { + expiry: '2021-05-12T23:20:50.52Z', + state: 'stored', }, - autoloaded: { - expiration_time: EXPIRY_DATE, - features: ['DR Replication', 'Namespaces', 'Lease Count Quotas', 'Automated Snapshots'], - license_id: '0eca7ef8-ebc0-f875-315e-3cc94a7870cf', - performance_standby_count: 0, - start_time: '2020-04-28T00:00:00Z', + performance_standby: false, + replication_performance_mode: 'disabled', + replication_dr_mode: 'disabled', + server_time_utc: 1622562585, + version: '1.9.0+ent', + cluster_name: 'vault-cluster-e779cd7c', + cluster_id: '5f20f5ab-acea-0481-787e-71ec2ff5a60b', + last_wal: 121, + }; + }); + + this.get('/sys/license/status', function () { + return { + data: { + autoloading_used: false, + stored: { + expiration_time: EXPIRY_DATE, + features: ['DR Replication', 'Namespaces', 'Lease Count Quotas', 'Automated Snapshots'], + license_id: '0eca7ef8-ebc0-f875-315e-3cc94a7870cf', + performance_standby_count: 0, + start_time: '2020-04-28T00:00:00Z', + }, + persisted_autoload: { + expiration_time: EXPIRY_DATE, + features: ['DR Replication', 'Namespaces', 'Lease Count Quotas', 'Automated Snapshots'], + license_id: '0eca7ef8-ebc0-f875-315e-3cc94a7870cf', + performance_standby_count: 0, + start_time: '2020-04-28T00:00:00Z', + }, + autoloaded: { + expiration_time: EXPIRY_DATE, + features: ['DR Replication', 'Namespaces', 'Lease Count Quotas', 'Automated Snapshots'], + license_id: '0eca7ef8-ebc0-f875-315e-3cc94a7870cf', + performance_standby_count: 0, + start_time: '2020-04-28T00:00:00Z', + }, }, - }, - }; - }); + }; + }); - this.get('sys/namespaces', function () { - return { - data: { - keys: [ - 'ns1/', - 'ns2/', - 'ns3/', - 'ns4/', - 'ns5/', - 'ns6/', - 'ns7/', - 'ns8/', - 'ns9/', - 'ns10/', - 'ns11/', - 'ns12/', - 'ns13/', - 'ns14/', - 'ns15/', - 'ns16/', - 'ns17/', - 'ns18/', - ], - }, - }; - }); + this.get('sys/namespaces', function () { + return { + data: { + keys: [ + 'ns1/', + 'ns2/', + 'ns3/', + 'ns4/', + 'ns5/', + 'ns6/', + 'ns7/', + 'ns8/', + 'ns9/', + 'ns10/', + 'ns11/', + 'ns12/', + 'ns13/', + 'ns14/', + 'ns15/', + 'ns16/', + 'ns17/', + 'ns18/', + ], + }, + }; + }); + } this.passthrough(); } diff --git a/ui/package.json b/ui/package.json index b70ac0bd028a..cb6dbd6321c4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -24,6 +24,7 @@ "fmt:styles": "prettier --write app/styles/**/*.*", "start": "export VAULT_ADDR=http://localhost:8200; ember server --proxy=$VAULT_ADDR", "start2": "ember server --proxy=http://localhost:8202 --port=4202", + "start:mirage": "start () { MIRAGE_DEV_HANDLER=$1 yarn run start; }; start", "test": "npm-run-all lint:js:quiet lint:hbs:quiet && node scripts/start-vault.js", "test:oss": "yarn run test -f='!enterprise'", "test:browserstack": "export CI=true; node scripts/start-vault.js --browserstack", From b7a8a66938eecd9add6be4306378a0eac1d95c7d Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Tue, 21 Dec 2021 16:55:28 -0700 Subject: [PATCH 02/25] adds mirage handler and factory for mfa workflow --- ui/mirage/factories/mfa-method.js | 13 +++++ ui/mirage/handlers/index.js | 5 ++ ui/mirage/handlers/mfa.js | 88 +++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 ui/mirage/factories/mfa-method.js create mode 100644 ui/mirage/handlers/index.js create mode 100644 ui/mirage/handlers/mfa.js diff --git a/ui/mirage/factories/mfa-method.js b/ui/mirage/factories/mfa-method.js new file mode 100644 index 000000000000..912eeb472d26 --- /dev/null +++ b/ui/mirage/factories/mfa-method.js @@ -0,0 +1,13 @@ +import { Factory } from 'ember-cli-mirage'; +import faker from 'faker'; + +export default Factory.extend({ + type: () => faker.random.arrayElement('duo', 'okta', 'pingid', 'totp'), + uses_passcode: false, + + afterCreate(mfaMethod) { + if (mfaMethod.type === 'totp') { + mfaMethod.uses_passcode = true; + } + }, +}); diff --git a/ui/mirage/handlers/index.js b/ui/mirage/handlers/index.js new file mode 100644 index 000000000000..8938b5f6e704 --- /dev/null +++ b/ui/mirage/handlers/index.js @@ -0,0 +1,5 @@ +// add all handlers here +// individual lookup done in mirage config +import mfa from './mfa'; + +export { mfa }; diff --git a/ui/mirage/handlers/mfa.js b/ui/mirage/handlers/mfa.js new file mode 100644 index 000000000000..3364af823f02 --- /dev/null +++ b/ui/mirage/handlers/mfa.js @@ -0,0 +1,88 @@ +import faker from 'faker'; +import { Response } from 'miragejs'; + +export default function (server) { + // initial auth response cache -- lookup by mfa_request_id key + const authResponses = {}; + // mfa enforcement cache -- lookup by mfa_request_id key + const mfaEnforcement = {}; + // passthrough original request, cache response and return mfa stub + const passthroughLogin = (schema, req) => { + const xhr = req.passthrough(); + xhr.onreadystatechange = () => { + if (xhr.readyState === 4 && xhr.status < 300) { + const type = ['duo', 'okta', 'pingid', 'totp'].includes(req.params.user) ? req.params.user : null; + // bypass mfa for users that do not match type + if (type) { + const res = JSON.parse(xhr.responseText); + const mfa_request_id = faker.datatype.uuid(); + // cache auth response to be returned later by sys/mfa/validate + authResponses[mfa_request_id] = { ...res }; + // unsure of final response shape when mfa is enabled + // it looks like the new object will be added under the auth key so return only that for now + const mfa_enforcement = { + mfa_request_id, + mfa_constraints: [server.create('mfa-method', { type })], + }; + // cache mfa requests to test different validation scenarios + mfaEnforcement[mfa_request_id] = mfa_enforcement; + // XMLHttpRequest response prop only has a getter -- redefine as writable and set value + Object.defineProperty(xhr, 'response', { + writable: true, + value: JSON.stringify({ auth: { mfa_enforcement } }), + }); + } + } + }; + }; + server.post('/auth/:method/login/:user', passthroughLogin); + + // unsure if the token method will utilize mfa + // server.get('/auth/token/lookup-self', passthroughLogin); + + server.post( + '/sys/mfa/validate', + (schema, req) => { + try { + const { mfa_request_id, mfa_payload } = JSON.parse(req.requestBody); + const mfaRequest = mfaEnforcement[mfa_request_id]; + + if (!mfaRequest) { + return new Response(404, {}, { errors: ['MFA Request ID not found'] }); + } + // validate request body + for (let constraintId in mfa_payload) { + // ensure ids were passed in map + const mfaConstraint = mfaRequest.mfa_constraints.find(({ id }) => id === constraintId); + if (!mfaConstraint) { + return new Response( + 400, + {}, + { errors: [`Invalid MFA constraint id ${constraintId} passed in map`] } + ); + } + // test non-totp validation by rejecting all pingid requests + if (mfaConstraint.type === 'pingid') { + return new Response(403, {}, { errors: ['PingId MFA validation failed'] }); + } + // validate totp passcode + const passcode = mfa_payload[constraintId]; + if (mfaConstraint.type === 'totp') { + if (passcode !== 'test') { + const error = !passcode ? 'TOTP passcode not provided' : 'Incorrect TOTP passcode provided'; + return new Response(403, {}, { errors: [error] }); + } + } else if (passcode) { + // for okta and duo, reject if a passcode was provided + return new Response(400, {}, { errors: ['Passcode should only be provided for TOTP MFA type'] }); + } + } + return authResponses[mfa_request_id]; + } catch (error) { + console.log(error); + return new Response(500, {}, { errors: ['Mirage Handler Error: /sys/mfa/validate'] }); + } + }, + { timing: 3000 } + ); +} From f9ea459f269d33dbfeb37ddcd209bc933b05ee82 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Tue, 21 Dec 2021 16:56:37 -0700 Subject: [PATCH 03/25] adds mfa handling to auth service and cluster adapter --- ui/app/adapters/cluster.js | 13 +++++++++++++ ui/app/services/auth.js | 22 +++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/ui/app/adapters/cluster.js b/ui/app/adapters/cluster.js index 4b52350b1992..df95d3cb34a2 100644 --- a/ui/app/adapters/cluster.js +++ b/ui/app/adapters/cluster.js @@ -126,6 +126,19 @@ export default ApplicationAdapter.extend({ return this.ajax(url, verb, options); }, + mfaValidate({ mfa_request_id, mfa_constraints }, passcode) { + const options = { + data: { + mfa_request_id, + mfa_payload: mfa_constraints.reduce((obj, constraint) => { + obj[constraint.id] = constraint.type === 'totp' ? passcode : ''; + return obj; + }, {}), + }, + }; + return this.ajax('/v1/sys/mfa/validate', 'POST', options); + }, + urlFor(endpoint) { if (!ENDPOINTS.includes(endpoint)) { throw new Error( diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index b2a1a99ff87e..c83c49593ca5 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -326,7 +326,27 @@ export default Service.extend({ const adapter = this.clusterAdapter(); let resp = await adapter.authenticate(options); - let authData = await this.persistAuthData(options, resp.auth || resp.data, this.namespaceService.path); + const mfa_enforcement = resp.auth?.mfa_enforcement; + + if (mfa_enforcement) { + const usesPasscode = mfa_enforcement.mfa_constraints.findBy('uses_passcode'); + if (usesPasscode) { + return { mfa_enforcement }; + } + // silently make request to validate endpoint when passcode is not required + resp = await adapter.mfaValidate(mfa_enforcement); + } + + return this.authSuccess(options, resp.auth || resp.data); + }, + + async totpValidate({ mfa_enforcement, ...options }, passcode) { + const resp = await this.clusterAdapter().mfaValidate(mfa_enforcement, passcode); + return this.authSuccess(options, resp.auth || resp.data); + }, + + async authSuccess(options, response) { + const authData = await this.persistAuthData(options, response, this.namespaceService.path); await this.permissions.getPaths.perform(); return authData; }, From 7a7f64d5cf8a2de8bb4a9248623a298731a9e09f Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Tue, 21 Dec 2021 17:00:15 -0700 Subject: [PATCH 04/25] moves auth success logic from form to controller --- ui/app/components/auth-form.js | 52 ++++------------------- ui/app/controllers/vault/cluster/auth.js | 47 ++++++++++++++++++-- ui/app/templates/components/auth-form.hbs | 2 +- ui/stories/auth-form.md | 24 +++++------ 4 files changed, 64 insertions(+), 61 deletions(-) diff --git a/ui/app/components/auth-form.js b/ui/app/components/auth-form.js index 5e7f8a7fc3c1..1daf98a4f256 100644 --- a/ui/app/components/auth-form.js +++ b/ui/app/components/auth-form.js @@ -1,4 +1,3 @@ -import Ember from 'ember'; import { next } from '@ember/runloop'; import { inject as service } from '@ember/service'; import { match, alias, or } from '@ember/object/computed'; @@ -7,7 +6,7 @@ import { dasherize } from '@ember/string'; import Component from '@ember/component'; import { computed } from '@ember/object'; import { supportedAuthBackends } from 'vault/helpers/supported-auth-backends'; -import { task, timeout } from 'ember-concurrency'; +import { task } from 'ember-concurrency'; import { waitFor } from '@ember/test-waiters'; const BACKENDS = supportedAuthBackends(); @@ -18,13 +17,13 @@ const BACKENDS = supportedAuthBackends(); * * @example ```js * // All properties are passed in via query params. - * ``` + * ``` * - * @param wrappedToken=null {String} - The auth method that is currently selected in the dropdown. - * @param cluster=null {Object} - The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. - * @param namespace=null {String} - The currently active namespace. - * @param redirectTo=null {String} - The name of the route to redirect to. - * @param selectedAuth=null {String} - The auth method that is currently selected in the dropdown. + * @param {string} wrappedToken - The auth method that is currently selected in the dropdown. + * @param {object} cluster - The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. + * @param {string} namespace- The currently active namespace. + * @param {string} selectedAuth - The auth method that is currently selected in the dropdown. + * @param {function} onSuccess - Fired on auth success */ const DEFAULTS = { @@ -45,7 +44,6 @@ export default Component.extend(DEFAULTS, { selectedAuth: null, methods: null, cluster: null, - redirectTo: null, namespace: null, wrappedToken: null, // internal @@ -227,46 +225,14 @@ export default Component.extend(DEFAULTS, { waitFor(function* (backendType, data) { let clusterId = this.cluster.id; try { - if (backendType === 'okta') { - this.delayAuthMessageReminder.perform(); - } - let authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data }); - - let { isRoot, namespace } = authResponse; - let transition; - let { redirectTo } = this; - if (redirectTo) { - // reset the value on the controller because it's bound here - this.set('redirectTo', ''); - // here we don't need the namespace because it will be encoded in redirectTo - transition = this.router.transitionTo(redirectTo); - } else { - transition = this.router.transitionTo('vault.cluster', { queryParams: { namespace } }); - } - // returning this w/then because if we keep it - // in the task, it will get cancelled when the component in un-rendered - yield transition.followRedirects().then(() => { - if (isRoot) { - this.flashMessages.warning( - 'You have logged in with a root token. As a security precaution, this root token will not be stored by your browser and you will need to re-authenticate after the window is closed or refreshed.' - ); - } - }); + const authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data }); + this.onSuccess(authResponse, backendType, data); } catch (e) { this.handleError(e); } }) ), - delayAuthMessageReminder: task(function* () { - if (Ember.testing) { - this.showLoading = true; - yield timeout(0); - return; - } - yield timeout(5000); - }), - actions: { doSubmit() { let passedData, e; diff --git a/ui/app/controllers/vault/cluster/auth.js b/ui/app/controllers/vault/cluster/auth.js index 103fff827554..9aed0f4d78ee 100644 --- a/ui/app/controllers/vault/cluster/auth.js +++ b/ui/app/controllers/vault/cluster/auth.js @@ -8,14 +8,19 @@ export default Controller.extend({ clusterController: controller('vault.cluster'), namespaceService: service('namespace'), featureFlagService: service('featureFlag'), - namespaceQueryParam: alias('clusterController.namespaceQueryParam'), + auth: service(), + router: service(), + queryParams: [{ authMethod: 'with', oidcProvider: 'o' }], + + namespaceQueryParam: alias('clusterController.namespaceQueryParam'), wrappedToken: alias('vaultController.wrappedToken'), - authMethod: '', - oidcProvider: '', redirectTo: alias('vaultController.redirectTo'), managedNamespaceRoot: alias('featureFlagService.managedNamespaceRoot'), + authMethod: '', + oidcProvider: '', + get managedNamespaceChild() { let fullParam = this.namespaceQueryParam; let split = fullParam.split('/'); @@ -41,4 +46,40 @@ export default Controller.extend({ this.namespaceService.setNamespace(value, true); this.set('namespaceQueryParam', value); }).restartable(), + + authSuccess({ isRoot, namespace }) { + let transition; + if (this.redirectTo) { + // here we don't need the namespace because it will be encoded in redirectTo + transition = this.router.transitionTo(this.redirectTo); + // reset the value on the controller because it's bound here + this.set('redirectTo', ''); + } else { + transition = this.router.transitionTo('vault.cluster', { queryParams: { namespace } }); + } + transition.followRedirects().then(() => { + if (isRoot) { + this.flashMessages.warning( + 'You have logged in with a root token. As a security precaution, this root token will not be stored by your browser and you will need to re-authenticate after the window is closed or refreshed.' + ); + } + }); + }, + + actions: { + onAuthResponse(authResponse, backend, data) { + console.log(authResponse); + const { mfa_enforcement } = authResponse; + // mfa methods handled by the backend are validated immediately in the auth service + // mfa_enforcement returned for totp only + if (mfa_enforcement) { + this.set('mfaAuthData', { mfa_enforcement, backend, data }); + } else { + this.authSuccess(authResponse); + } + }, + onMfaSuccess(authResponse) { + this.authSuccess(authResponse); + }, + }, }); diff --git a/ui/app/templates/components/auth-form.hbs b/ui/app/templates/components/auth-form.hbs index 654a6b43fd1d..b9bd9426c16a 100644 --- a/ui/app/templates/components/auth-form.hbs +++ b/ui/app/templates/components/auth-form.hbs @@ -162,7 +162,7 @@ > Sign In - {{#if (and this.delayAuthMessageReminder.isIdle this.showLoading)}} + {{#if this.authenticate.isRunning}} String | | The auth method that is currently selected in the dropdown. | -| cluster | Object | | The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. | -| namespace | String | | The currently active namespace. | -| redirectTo | String | | The name of the route to redirect to. | -| selectedAuth | String | | The auth method that is currently selected in the dropdown. | +| Param | Type | Description | +| --- | --- | --- | +| wrappedToken | string | The auth method that is currently selected in the dropdown. | +| cluster | object | The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. | +| namespace- | string | The currently active namespace. | +| selectedAuth | string | The auth method that is currently selected in the dropdown. | +| onSuccess | function | Fired on auth success | **Example** ```js // All properties are passed in via query params. - ``` +``` **See** -- [Uses of AuthForm](https://github.com/hashicorp/vault/search?l=Handlebars&q=AuthForm) +- [Uses of AuthForm](https://github.com/hashicorp/vault/search?l=Handlebars&q=AuthForm+OR+auth-form) - [AuthForm Source Code](https://github.com/hashicorp/vault/blob/master/ui/app/components/auth-form.js) --- From b3489ea7356b33d5a61d42c7a45e235e61f2ee6b Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Tue, 21 Dec 2021 17:00:37 -0700 Subject: [PATCH 05/25] adds mfa form component --- ui/app/components/mfa-form.js | 41 ++++++ ui/app/styles/components/icon.scss | 4 + ui/app/styles/core/buttons.scss | 8 ++ ui/app/templates/components/mfa-form.hbs | 31 +++++ ui/app/templates/vault/cluster/auth.hbs | 122 ++++++++++-------- .../integration/components/mfa-form-test.js | 26 ++++ 6 files changed, 178 insertions(+), 54 deletions(-) create mode 100644 ui/app/components/mfa-form.js create mode 100644 ui/app/templates/components/mfa-form.hbs create mode 100644 ui/tests/integration/components/mfa-form-test.js diff --git a/ui/app/components/mfa-form.js b/ui/app/components/mfa-form.js new file mode 100644 index 000000000000..0e10ef84799d --- /dev/null +++ b/ui/app/components/mfa-form.js @@ -0,0 +1,41 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; +/** + * @module MfaForm + * The MfaForm component is used to enter a passcode when mfa is required to login + * + * @example + * ```js + * + * ``` + * @param {string} clusterId - id of selected cluster + * @param {object} authData - data from initial auth request -- { mfa_enforcement, backend, data } + * @param {function} onSuccess - fired when passcode passes validation + */ + +export default class MfaForm extends Component { + @service auth; + + @tracked passcode; + + @task *validate() { + try { + const response = yield this.auth.totpValidate( + { clusterId: this.args.clusterId, ...this.args.authData }, + this.passcode + ); + this.args.onSuccess(response); + } catch (error) { + console.log(error); + // do something + } + } + + @action submit(e) { + e.preventDefault(); + this.validate.perform(); + } +} diff --git a/ui/app/styles/components/icon.scss b/ui/app/styles/components/icon.scss index e60da0533798..b0c0a5725e91 100644 --- a/ui/app/styles/components/icon.scss +++ b/ui/app/styles/components/icon.scss @@ -50,3 +50,7 @@ margin: 0px 4px; } } + +.icon-blue { + color: $blue; +} diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss index 8f696d408cc2..181c40cb473a 100644 --- a/ui/app/styles/core/buttons.scss +++ b/ui/app/styles/core/buttons.scss @@ -229,3 +229,11 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12); padding: $size-8; width: 100%; } + +.icon-button { + background: transparent; + padding: 0; + margin: 0; + border: none; + cursor: pointer; +} diff --git a/ui/app/templates/components/mfa-form.hbs b/ui/app/templates/components/mfa-form.hbs new file mode 100644 index 000000000000..9c712708813d --- /dev/null +++ b/ui/app/templates/components/mfa-form.hbs @@ -0,0 +1,31 @@ +
+
+

+ Multi-factor authentication is enabled for your account. Enter your authentication code to log in. +

+
+
+ +
+ +
+
+ +
+
+
\ No newline at end of file diff --git a/ui/app/templates/vault/cluster/auth.hbs b/ui/app/templates/vault/cluster/auth.hbs index f37655cae5c3..14b92d1c9357 100644 --- a/ui/app/templates/vault/cluster/auth.hbs +++ b/ui/app/templates/vault/cluster/auth.hbs @@ -5,80 +5,94 @@ {{else}} -

- Sign in to Vault -

+
+ {{#if this.mfaAuthData}} + + {{/if}} +

+ {{if this.mfaAuthData "Authenticate" "Sign in to Vault"}} +

+
{{/if}} - {{#if this.managedNamespaceRoot}} - - -
-
-
- + {{#unless this.mfaAuthData}} + {{#if this.managedNamespaceRoot}} + + +
+
+
+ +
+
+ /{{this.managedNamespaceRoot}} +
+
+
+
+ +
+
+
-
- /{{this.managedNamespaceRoot}} +
+ + + {{else if (has-feature "Namespaces")}} + + +
+
+
-
-
-
- {{else if (has-feature "Namespaces")}} - - -
-
- -
-
-
-
- -
-
-
-
-
-
- {{/if}} + + + {{/if}} + {{/unless}} - + {{#if this.mfaAuthData}} + + {{else}} + + {{/if}}
diff --git a/ui/tests/integration/components/mfa-form-test.js b/ui/tests/integration/components/mfa-form-test.js new file mode 100644 index 000000000000..4407fc65a641 --- /dev/null +++ b/ui/tests/integration/components/mfa-form-test.js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | mfa-form', function (hooks) { + setupRenderingTest(hooks); + + test('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.equal(this.element.textContent.trim(), ''); + + // Template block usage: + await render(hbs` + + template block text + + `); + + assert.equal(this.element.textContent.trim(), 'template block text'); + }); +}); From bcc225e21e9b6617f5a3a3dee66d8f51e86792f3 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Wed, 5 Jan 2022 09:14:33 -0700 Subject: [PATCH 06/25] shows delayed auth message for all methods --- ui/app/components/auth-form.js | 13 ++++++++++++- ui/app/templates/components/auth-form.hbs | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/ui/app/components/auth-form.js b/ui/app/components/auth-form.js index 1daf98a4f256..ecccd74e7405 100644 --- a/ui/app/components/auth-form.js +++ b/ui/app/components/auth-form.js @@ -1,3 +1,4 @@ +import Ember from 'ember'; import { next } from '@ember/runloop'; import { inject as service } from '@ember/service'; import { match, alias, or } from '@ember/object/computed'; @@ -6,7 +7,7 @@ import { dasherize } from '@ember/string'; import Component from '@ember/component'; import { computed } from '@ember/object'; import { supportedAuthBackends } from 'vault/helpers/supported-auth-backends'; -import { task } from 'ember-concurrency'; +import { task, timeout } from 'ember-concurrency'; import { waitFor } from '@ember/test-waiters'; const BACKENDS = supportedAuthBackends(); @@ -225,6 +226,7 @@ export default Component.extend(DEFAULTS, { waitFor(function* (backendType, data) { let clusterId = this.cluster.id; try { + this.delayAuthMessageReminder.perform(); const authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data }); this.onSuccess(authResponse, backendType, data); } catch (e) { @@ -233,6 +235,15 @@ export default Component.extend(DEFAULTS, { }) ), + delayAuthMessageReminder: task(function* () { + if (Ember.testing) { + this.showLoading = true; + yield timeout(0); + } else { + yield timeout(5000); + } + }), + actions: { doSubmit() { let passedData, e; diff --git a/ui/app/templates/components/auth-form.hbs b/ui/app/templates/components/auth-form.hbs index b9bd9426c16a..654a6b43fd1d 100644 --- a/ui/app/templates/components/auth-form.hbs +++ b/ui/app/templates/components/auth-form.hbs @@ -162,7 +162,7 @@ > Sign In - {{#if this.authenticate.isRunning}} + {{#if (and this.delayAuthMessageReminder.isIdle this.showLoading)}} Date: Wed, 5 Jan 2022 09:52:21 -0700 Subject: [PATCH 07/25] adds new code delay to mfa form --- ui/app/components/mfa-form.js | 16 +++++++++++++--- ui/app/styles/core/helpers.scss | 3 +++ ui/app/templates/components/mfa-form.hbs | 16 +++++++++++++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/ui/app/components/mfa-form.js b/ui/app/components/mfa-form.js index 0e10ef84799d..57c32c91cda6 100644 --- a/ui/app/components/mfa-form.js +++ b/ui/app/components/mfa-form.js @@ -2,7 +2,7 @@ import Component from '@glimmer/component'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; -import { task } from 'ember-concurrency'; +import { task, timeout } from 'ember-concurrency'; /** * @module MfaForm * The MfaForm component is used to enter a passcode when mfa is required to login @@ -20,6 +20,7 @@ export default class MfaForm extends Component { @service auth; @tracked passcode; + @tracked countdown; @task *validate() { try { @@ -29,8 +30,17 @@ export default class MfaForm extends Component { ); this.args.onSuccess(response); } catch (error) { - console.log(error); - // do something + // for now treat all errors as incorrect code + this.newCodeDelay.perform(); + } + } + + @task *newCodeDelay() { + this.passcode = null; + this.countdown = 30; + while (this.countdown) { + yield timeout(1000); + this.countdown--; } } diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index 2af3cb0196e2..86daea18fcdb 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -196,3 +196,6 @@ ul.bullet { .has-text-semibold { font-weight: $font-weight-semibold; } +.is-v-centered { + vertical-align: middle; +} diff --git a/ui/app/templates/components/mfa-form.hbs b/ui/app/templates/components/mfa-form.hbs index 9c712708813d..6d9fdb11eece 100644 --- a/ui/app/templates/components/mfa-form.hbs +++ b/ui/app/templates/components/mfa-form.hbs @@ -14,18 +14,32 @@ autocomplete="off" spellcheck="false" autofocus="true" + disabled={{or this.validate.isRunning this.newCodeDelay.isRunning}} @value={{this.passcode}} />
+ {{#if this.newCodeDelay.isRunning}} +
+ +
+ {{/if}} + {{#if this.newCodeDelay.isRunning}} + + {{this.countdown}} + {{/if}}
\ No newline at end of file From b3a5b6c27704e45cb680dd037278cb1e75e9a8f9 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Fri, 7 Jan 2022 16:45:57 -0700 Subject: [PATCH 08/25] adds error views --- ui/app/components/auth-form.js | 31 ++++--------- ui/app/components/mfa-error.js | 43 +++++++++++++++++++ ui/app/services/auth.js | 41 ++++++++++++++++-- ui/app/styles/components/empty-state.scss | 2 +- ui/app/styles/core/helpers.scss | 6 +++ ui/app/templates/components/mfa-error.hbs | 15 +++++++ ui/app/templates/components/splash-page.hbs | 35 ++++++++------- ui/app/templates/vault/cluster/auth.hbs | 5 ++- ui/mirage/handlers/mfa.js | 6 ++- .../integration/components/mfa-error-test.js | 38 ++++++++++++++++ 10 files changed, 177 insertions(+), 45 deletions(-) create mode 100644 ui/app/components/mfa-error.js create mode 100644 ui/app/templates/components/mfa-error.hbs create mode 100644 ui/tests/integration/components/mfa-error-test.js diff --git a/ui/app/components/auth-form.js b/ui/app/components/auth-form.js index ecccd74e7405..8b1ed8aa837a 100644 --- a/ui/app/components/auth-form.js +++ b/ui/app/components/auth-form.js @@ -205,23 +205,6 @@ export default Component.extend(DEFAULTS, { showLoading: or('isLoading', 'authenticate.isRunning', 'fetchMethods.isRunning', 'unwrapToken.isRunning'), - handleError(e, prefixMessage = true) { - this.set('loading', false); - let errors; - if (e.errors) { - errors = e.errors.map((error) => { - if (error.detail) { - return error.detail; - } - return error; - }); - } else { - errors = [e]; - } - let message = prefixMessage ? 'Authentication failed: ' : ''; - this.set('error', `${message}${errors.join('.')}`); - }, - authenticate: task( waitFor(function* (backendType, data) { let clusterId = this.cluster.id; @@ -230,7 +213,10 @@ export default Component.extend(DEFAULTS, { const authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data }); this.onSuccess(authResponse, backendType, data); } catch (e) { - this.handleError(e); + this.set('loading', false); + if (!this.auth.mfaError) { + this.set('error', `Authentication failed: ${this.auth.handleError(e)}`); + } } }) ), @@ -275,11 +261,10 @@ export default Component.extend(DEFAULTS, { return this.authenticate.unlinked().perform(backend.type, data); }, handleError(e) { - if (e) { - this.handleError(e, false); - } else { - this.set('error', null); - } + this.setProperties({ + loading: false, + error: e ? this.auth.handleError(e) : null, + }); }, }, }); diff --git a/ui/app/components/mfa-error.js b/ui/app/components/mfa-error.js new file mode 100644 index 000000000000..ed894a051a4c --- /dev/null +++ b/ui/app/components/mfa-error.js @@ -0,0 +1,43 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { TOTP_NOT_CONFIGURED } from 'vault/services/auth'; + +const TOTP_NA_MSG = + 'Multi-factor authentication is required, but you have not set it up. In order to do so, please contact your administrator.'; +const MFA_ERROR_MSG = + 'Multi-factor authentication is required, but failed. Go back and try again, or contact your administrator.'; + +export { TOTP_NA_MSG, MFA_ERROR_MSG }; + +/** + * @module MfaError + * MfaError components are used to display mfa errors + * + * @example + * ```js + * + * ``` + */ + +export default class MfaError extends Component { + @service auth; + + get isTotp() { + return this.auth.mfaErrors.includes(TOTP_NOT_CONFIGURED); + } + get title() { + return this.isTotp ? 'TOTP not set up' : 'Unauthorized'; + } + get description() { + return this.isTotp ? TOTP_NA_MSG : MFA_ERROR_MSG; + } + + @action + onClose() { + this.auth.set('mfaErrors', null); + if (this.args.onClose) { + this.args.onClose(); + } + } +} diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index c83c49593ca5..8aec1c59a298 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -14,9 +14,10 @@ import { task, timeout } from 'ember-concurrency'; const TOKEN_SEPARATOR = '☃'; const TOKEN_PREFIX = 'vault-'; const ROOT_PREFIX = '_root_'; +const TOTP_NOT_CONFIGURED = 'TOTP mfa required but not configured'; const BACKENDS = supportedAuthBackends(); -export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX }; +export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX, TOTP_NOT_CONFIGURED }; export default Service.extend({ permissions: service(), @@ -24,6 +25,8 @@ export default Service.extend({ IDLE_TIMEOUT: 3 * 60e3, expirationCalcTS: null, isRenewing: false, + mfaErrors: null, + init() { this._super(...arguments); this.checkForRootToken(); @@ -324,17 +327,35 @@ export default Service.extend({ async authenticate(/*{clusterId, backend, data}*/) { const [options] = arguments; const adapter = this.clusterAdapter(); + let resp; + + try { + resp = await adapter.authenticate(options); + } catch (e) { + // check for totp not configured mfa error before throwing + const errors = this.handleError(e); + // stubbing error - verify once API is finalized + if (errors.includes(TOTP_NOT_CONFIGURED)) { + this.set('mfaErrors', errors); + } + throw e; + } - let resp = await adapter.authenticate(options); const mfa_enforcement = resp.auth?.mfa_enforcement; - if (mfa_enforcement) { const usesPasscode = mfa_enforcement.mfa_constraints.findBy('uses_passcode'); if (usesPasscode) { return { mfa_enforcement }; } // silently make request to validate endpoint when passcode is not required - resp = await adapter.mfaValidate(mfa_enforcement); + try { + resp = await adapter.mfaValidate(mfa_enforcement); + } 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; + } } return this.authSuccess(options, resp.auth || resp.data); @@ -351,6 +372,18 @@ export default Service.extend({ return authData; }, + handleError(e) { + if (e.errors) { + return e.errors.map((error) => { + if (error.detail) { + return error.detail; + } + return error; + }); + } + return [e]; + }, + getAuthType() { if (!this.authData) return; return this.authData.backend.type; diff --git a/ui/app/styles/components/empty-state.scss b/ui/app/styles/components/empty-state.scss index a6da6d4052e7..486d4c5290bc 100644 --- a/ui/app/styles/components/empty-state.scss +++ b/ui/app/styles/components/empty-state.scss @@ -60,7 +60,7 @@ } } -.empty-state-icon > .hs-icon { +.empty-state-icon > .flight-icon { float: left; margin-right: $spacing-xs; } diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index 86daea18fcdb..bc3eb12c3737 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -19,6 +19,9 @@ .is-borderless { border: none !important; } +.is-box-shadowless { + box-shadow: none !important; +} .is-relative { position: relative; } @@ -180,6 +183,9 @@ .has-top-margin-xl { margin-top: $spacing-xl; } +.has-top-margin-xxl { + margin-top: $spacing-xxl; +} .has-border-bottom-light { border-radius: 0; border-bottom: 1px solid $grey-light; diff --git a/ui/app/templates/components/mfa-error.hbs b/ui/app/templates/components/mfa-error.hbs new file mode 100644 index 000000000000..019c54492f03 --- /dev/null +++ b/ui/app/templates/components/mfa-error.hbs @@ -0,0 +1,15 @@ +
+ + + +
\ 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 d6970a74258e..36f115a5d62b 100644 --- a/ui/app/templates/components/splash-page.hbs +++ b/ui/app/templates/components/splash-page.hbs @@ -10,21 +10,26 @@ - -
-
-
-
- {{yield (hash header=(component "splash-page/splash-header"))}} +{{! bypass UiWizard and container styling }} +{{#if this.hasAltContent}} + {{yield (hash altContent=(component "splash-page/splash-content"))}} +{{else}} + +
+
+
+
+ {{yield (hash header=(component "splash-page/splash-header"))}} +
+
+ {{yield (hash sub-header=(component "splash-page/splash-header"))}} +
+ + {{yield (hash footer=(component "splash-page/splash-content"))}}
-
- {{yield (hash sub-header=(component "splash-page/splash-header"))}} -
- - {{yield (hash footer=(component "splash-page/splash-content"))}}
-
- \ No newline at end of file + +{{/if}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/auth.hbs b/ui/app/templates/vault/cluster/auth.hbs index 14b92d1c9357..76dd5f54873e 100644 --- a/ui/app/templates/vault/cluster/auth.hbs +++ b/ui/app/templates/vault/cluster/auth.hbs @@ -1,4 +1,7 @@ - + + + + {{#if this.oidcProvider}}
diff --git a/ui/mirage/handlers/mfa.js b/ui/mirage/handlers/mfa.js index 3364af823f02..6d92cf859b08 100644 --- a/ui/mirage/handlers/mfa.js +++ b/ui/mirage/handlers/mfa.js @@ -8,6 +8,10 @@ export default function (server) { const mfaEnforcement = {}; // passthrough original request, cache response and return mfa stub const passthroughLogin = (schema, req) => { + // test totp not configured scenario + if (req.params.user === 'totp-na') { + return new Response(400, {}, { errors: ['TOTP mfa required but not configured'] }); + } const xhr = req.passthrough(); xhr.onreadystatechange = () => { if (xhr.readyState === 4 && xhr.status < 300) { @@ -83,6 +87,6 @@ export default function (server) { return new Response(500, {}, { errors: ['Mirage Handler Error: /sys/mfa/validate'] }); } }, - { timing: 3000 } + { timing: 1000 } ); } diff --git a/ui/tests/integration/components/mfa-error-test.js b/ui/tests/integration/components/mfa-error-test.js new file mode 100644 index 000000000000..9dc4fad51f1a --- /dev/null +++ b/ui/tests/integration/components/mfa-error-test.js @@ -0,0 +1,38 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { click } from '@ember/test-helpers'; +import { TOTP_NOT_CONFIGURED } from 'vault/services/auth'; +import { TOTP_NA_MSG, MFA_ERROR_MSG } from 'vault/components/mfa-error'; +const UNAUTH = 'MFA authorization failed'; + +module('Integration | Component | mfa-error', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + const auth = this.owner.lookup('service:auth'); + auth.set('mfaErrors', [TOTP_NOT_CONFIGURED]); + + this.onClose = () => assert.ok(true, 'onClose event is triggered'); + + await render(hbs``); + + assert.dom('[data-test-empty-state-title]').hasText('TOTP not set up', 'Title renders for TOTP error'); + assert + .dom('[data-test-empty-state-subText]') + .hasText(TOTP_NOT_CONFIGURED, 'Error message renders for TOTP error'); + assert.dom('[data-test-empty-state-message]').hasText(TOTP_NA_MSG, 'Description renders for TOTP error'); + + auth.set('mfaErrors', [UNAUTH]); + await render(hbs``); + + assert.dom('[data-test-empty-state-title]').hasText('Unauthorized', 'Title renders for mfa error'); + assert.dom('[data-test-empty-state-subText]').hasText(UNAUTH, 'Error message renders for mfa error'); + assert.dom('[data-test-empty-state-message]').hasText(MFA_ERROR_MSG, 'Description renders for mfa error'); + + await click('[data-test-go-back]'); + + assert.equal(auth.mfaErrors, null, 'mfaErrors unset in auth service'); + }); +}); From e88666f8c2d3a9cd4a340ef9b0b42db797e33650 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Wed, 12 Jan 2022 08:21:52 -0700 Subject: [PATCH 09/25] fixes merge conflict --- ui/app/styles/components/empty-state.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/app/styles/components/empty-state.scss b/ui/app/styles/components/empty-state.scss index 486d4c5290bc..f45663eb450f 100644 --- a/ui/app/styles/components/empty-state.scss +++ b/ui/app/styles/components/empty-state.scss @@ -60,6 +60,7 @@ } } +.empty-state-icon > .hs-icon, .empty-state-icon > .flight-icon { float: left; margin-right: $spacing-xs; From c56c6a330db6b42ff5a4087736df09af7ff87307 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Wed, 12 Jan 2022 15:16:41 -0700 Subject: [PATCH 10/25] adds integration tests for mfa-form component --- ui/app/controllers/vault/cluster/auth.js | 1 - ui/app/templates/components/mfa-form.hbs | 4 +- .../integration/components/mfa-form-test.js | 76 ++++++++++++++++--- 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/ui/app/controllers/vault/cluster/auth.js b/ui/app/controllers/vault/cluster/auth.js index 9aed0f4d78ee..7960b09ef2f5 100644 --- a/ui/app/controllers/vault/cluster/auth.js +++ b/ui/app/controllers/vault/cluster/auth.js @@ -68,7 +68,6 @@ export default Controller.extend({ actions: { onAuthResponse(authResponse, backend, data) { - console.log(authResponse); const { mfa_enforcement } = authResponse; // mfa methods handled by the backend are validated immediately in the auth service // mfa_enforcement returned for totp only diff --git a/ui/app/templates/components/mfa-form.hbs b/ui/app/templates/components/mfa-form.hbs index 6d9fdb11eece..b9e2e913741a 100644 --- a/ui/app/templates/components/mfa-form.hbs +++ b/ui/app/templates/components/mfa-form.hbs @@ -16,6 +16,7 @@ autofocus="true" disabled={{or this.validate.isRunning this.newCodeDelay.isRunning}} @value={{this.passcode}} + data-test-mfa-passcode={{true}} />
@@ -33,12 +34,13 @@ type="submit" disabled={{or this.validate.isRunning this.newCodeDelay.isRunning}} class="button is-primary {{if this.validate.isRunning "is-loading"}}" + data-test-mfa-validate > Verify {{#if this.newCodeDelay.isRunning}} - {{this.countdown}} + {{this.countdown}} {{/if}}
diff --git a/ui/tests/integration/components/mfa-form-test.js b/ui/tests/integration/components/mfa-form-test.js index 4407fc65a641..c2ad88a56601 100644 --- a/ui/tests/integration/components/mfa-form-test.js +++ b/ui/tests/integration/components/mfa-form-test.js @@ -2,25 +2,81 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { fillIn, click, waitUntil } from '@ember/test-helpers'; +import { run, later } from '@ember/runloop'; module('Integration | Component | mfa-form', function (hooks) { setupRenderingTest(hooks); + setupMirage(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + hooks.beforeEach(function () { + this.clusterId = '123456'; + this.mfaAuthData = { + backend: 'userpass', + data: { username: 'foo', password: 'bar' }, + mfa_enforcement: { + mfa_request_id: 'test-mfa-id', + mfa_constraints: [this.server.create('mfa-method', { type: 'totp' })], + }, + }; + }); + + test('it should validate passcode', async function (assert) { + const validate = async (authData, passcode) => { + await waitUntil(() => + assert.dom('[data-test-mfa-validate]').hasClass('is-loading', 'Loading class applied to button') + ); + assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled while loading'); + assert.deepEqual( + authData, + { clusterId: this.clusterId, ...this.mfaAuthData }, + 'Mfa auth data passed to validate method' + ); + assert.equal(passcode, 'test-code', 'Passcode passed to validate method'); + }; + this.owner.lookup('service:auth').reopen({ + async totpValidate(authData, passcode) { + await validate(authData, passcode); + return 'test response'; + }, + }); + + this.onSuccess = (resp) => + assert.equal(resp, 'test response', 'Response is returned in onSuccess callback'); - await render(hbs``); + await render(hbs` + + `); - assert.equal(this.element.textContent.trim(), ''); + await fillIn('[data-test-mfa-passcode]', 'test-code'); + await click('[data-test-mfa-validate]'); + }); - // Template block usage: + test('it should show countdown on passcode validation failure', async function (assert) { + this.owner.lookup('service:auth').reopen({ + totpValidate() { + throw new Error('Incorrect passcode'); + }, + }); await render(hbs` - - template block text - + `); - assert.equal(this.element.textContent.trim(), 'template block text'); + await fillIn('[data-test-mfa-passcode]', 'test-code'); + later(() => run.cancelTimers(), 50); + await click('[data-test-mfa-validate]'); + assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled during countdown'); + assert.dom('[data-test-mfa-passcode]').isDisabled('Input is disabled during countdown'); + assert.dom('[data-test-mfa-passcode]').hasNoValue('Input value is cleared on error'); + assert.dom('[data-test-inline-error-message]').exists('Alert message renders'); + assert.dom('[data-test-mfa-countdown]').exists('30 second countdown renders'); }); }); From dd906742075217556892a7bf3cf655aebb46b723 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Thu, 13 Jan 2022 09:05:31 -0700 Subject: [PATCH 11/25] fixes auth tests --- ui/tests/acceptance/auth-test.js | 8 +------- ui/tests/integration/components/auth-form-test.js | 2 ++ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/ui/tests/acceptance/auth-test.js b/ui/tests/acceptance/auth-test.js index 25ffac44e4df..49b41142d2bb 100644 --- a/ui/tests/acceptance/auth-test.js +++ b/ui/tests/acceptance/auth-test.js @@ -110,16 +110,10 @@ module('Acceptance | auth', function (hooks) { assert.dom('[data-test-allow-expiration]').doesNotExist('hides beacon when the api is used again'); }); - test('it shows the push notification warning only for okta auth method after submit', async function (assert) { + test('it shows the push notification warning after submit', async function (assert) { await visit('/vault/auth'); await component.selectMethod('token'); await click('[data-test-auth-submit]'); - assert - .dom('[data-test-auth-message="push"]') - .doesNotExist('message is not shown for other authentication methods'); - - await component.selectMethod('okta'); - await click('[data-test-auth-submit]'); assert.dom('[data-test-auth-message="push"]').exists('shows push notification message'); }); }); diff --git a/ui/tests/integration/components/auth-form-test.js b/ui/tests/integration/components/auth-form-test.js index 31b80d0fb25c..d414fa0893c0 100644 --- a/ui/tests/integration/components/auth-form-test.js +++ b/ui/tests/integration/components/auth-form-test.js @@ -18,6 +18,7 @@ const authService = Service.extend({ async authenticate() { return fetch('http://localhost:2000'); }, + handleError() {}, setLastFetch() {}, }); @@ -25,6 +26,7 @@ const workingAuthService = Service.extend({ authenticate() { return resolve({}); }, + handleError() {}, setLastFetch() {}, }); From 8292e99e3ff8f13c6f79dd2a808f84ecbf448b90 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Mon, 14 Feb 2022 09:41:25 -0700 Subject: [PATCH 12/25] updates mfa response handling to align with backend --- ui/app/adapters/cluster.js | 6 +-- ui/app/controllers/vault/cluster/auth.js | 8 ++-- ui/app/services/auth.js | 49 ++++++++++++++++++++---- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/ui/app/adapters/cluster.js b/ui/app/adapters/cluster.js index df95d3cb34a2..f8600442640d 100644 --- a/ui/app/adapters/cluster.js +++ b/ui/app/adapters/cluster.js @@ -126,12 +126,12 @@ export default ApplicationAdapter.extend({ return this.ajax(url, verb, options); }, - mfaValidate({ mfa_request_id, mfa_constraints }, passcode) { + mfaValidate({ mfa_request_id, mfa_constraints }) { const options = { data: { mfa_request_id, - mfa_payload: mfa_constraints.reduce((obj, constraint) => { - obj[constraint.id] = constraint.type === 'totp' ? passcode : ''; + mfa_payload: mfa_constraints.reduce((obj, { selectedMethod, passcode }) => { + obj[selectedMethod.id] = passcode ? [passcode] : []; return obj; }, {}), }, diff --git a/ui/app/controllers/vault/cluster/auth.js b/ui/app/controllers/vault/cluster/auth.js index 7960b09ef2f5..3e98db58ee73 100644 --- a/ui/app/controllers/vault/cluster/auth.js +++ b/ui/app/controllers/vault/cluster/auth.js @@ -68,11 +68,11 @@ export default Controller.extend({ actions: { onAuthResponse(authResponse, backend, data) { - const { mfa_enforcement } = authResponse; + const { mfa_requirement } = authResponse; // mfa methods handled by the backend are validated immediately in the auth service - // mfa_enforcement returned for totp only - if (mfa_enforcement) { - this.set('mfaAuthData', { mfa_enforcement, backend, data }); + // if the user must choose between methods or enter passcodes further action is required + if (mfa_requirement) { + this.set('mfaAuthData', { mfa_requirement, backend, data }); } else { this.authSuccess(authResponse); } diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index 8aec1c59a298..4fb474c7ed54 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -3,6 +3,7 @@ import { resolve, reject } from 'rsvp'; import { assign } from '@ember/polyfills'; import { isArray } from '@ember/array'; import { computed, get } from '@ember/object'; +import { capitalize } from '@ember/string'; import fetch from 'fetch'; import { getOwner } from '@ember/application'; @@ -324,6 +325,39 @@ export default Service.extend({ }); }, + _parseMfaResponse(mfa_requirement) { + // mfa_requirement response comes back in a shape that is not easy to work with + // 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); + m.label = `${typeFormatted} ${m.uses_passcode ? 'passcode' : 'push notification'}`; + }); + constraints.push({ + name: key, + methods, + selectedMethod: isMulti ? null : methods[0], + }); + } + + return { + mfa_requirement: { mfa_request_id, mfa_constraints: constraints }, + requiresAction, + }; + } + return {}; + }, + async authenticate(/*{clusterId, backend, data}*/) { const [options] = arguments; const adapter = this.clusterAdapter(); @@ -341,15 +375,14 @@ export default Service.extend({ throw e; } - const mfa_enforcement = resp.auth?.mfa_enforcement; - if (mfa_enforcement) { - const usesPasscode = mfa_enforcement.mfa_constraints.findBy('uses_passcode'); - if (usesPasscode) { - return { mfa_enforcement }; + 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_enforcement); + 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 @@ -361,8 +394,8 @@ export default Service.extend({ return this.authSuccess(options, resp.auth || resp.data); }, - async totpValidate({ mfa_enforcement, ...options }, passcode) { - const resp = await this.clusterAdapter().mfaValidate(mfa_enforcement, passcode); + async totpValidate({ mfa_requirement, ...options }) { + const resp = await this.clusterAdapter().mfaValidate(mfa_requirement); return this.authSuccess(options, resp.auth || resp.data); }, From f793811f3833969a667e0a08f8f768124a4ff6f8 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Mon, 14 Feb 2022 09:48:45 -0700 Subject: [PATCH 13/25] updates mfa-form to handle multiple methods and constraints --- ui/app/components/mfa-form.js | 52 +++++++++++++++++---- ui/app/templates/components/mfa-form.hbs | 57 +++++++++++++++++------- 2 files changed, 84 insertions(+), 25 deletions(-) diff --git a/ui/app/components/mfa-form.js b/ui/app/components/mfa-form.js index 57c32c91cda6..fcd6d045740d 100644 --- a/ui/app/components/mfa-form.js +++ b/ui/app/components/mfa-form.js @@ -1,7 +1,7 @@ import Component from '@glimmer/component'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; -import { action } from '@ember/object'; +import { action, set } from '@ember/object'; import { task, timeout } from 'ember-concurrency'; /** * @module MfaForm @@ -12,7 +12,7 @@ import { task, timeout } from 'ember-concurrency'; * * ``` * @param {string} clusterId - id of selected cluster - * @param {object} authData - data from initial auth request -- { mfa_enforcement, backend, data } + * @param {object} authData - data from initial auth request -- { mfa_requirement, backend, data } * @param {function} onSuccess - fired when passcode passes validation */ @@ -21,17 +21,49 @@ export default class MfaForm extends Component { @tracked passcode; @tracked countdown; + @tracked errors; + + get constraints() { + return this.args.authData.mfa_requirement.mfa_constraints; + } + get multiConstraint() { + return this.constraints.length > 1; + } + get singleConstraintMultiMethod() { + return !this.isMultiConstraint && this.constraints[0].methods.length > 1; + } + get singlePasscode() { + return ( + !this.isMultiConstraint && + this.constraints[0].methods.length === 1 && + this.constraints[0].methods[0].uses_passcode + ); + } + get description() { + let base = 'Multi-factor authentication is enabled for your account.'; + if (this.singlePasscode) { + base += ' Enter your authentication code to log in.'; + } + if (this.singleConstraintMultiMethod) { + base += ' Select the MFA method you wish to use.'; + } + if (this.multiConstraint) { + base += ` ${this.constraints.length} methods are required for successful authentication.`; + } + return base; + } @task *validate() { try { - const response = yield this.auth.totpValidate( - { clusterId: this.args.clusterId, ...this.args.authData }, - this.passcode - ); + const response = yield this.auth.totpValidate({ + clusterId: this.args.clusterId, + ...this.args.authData, + }); this.args.onSuccess(response); } catch (error) { - // for now treat all errors as incorrect code - this.newCodeDelay.perform(); + this.errors = error.errors; + // update if specific error can be parsed for incorrect passcode + // this.newCodeDelay.perform(); } } @@ -44,6 +76,10 @@ export default class MfaForm extends Component { } } + @action onSelect(constraint, id) { + set(constraint, 'selectedId', id); + set(constraint, 'selectedMethod', constraint.methods.findBy('id', id)); + } @action submit(e) { e.preventDefault(); this.validate.perform(); diff --git a/ui/app/templates/components/mfa-form.hbs b/ui/app/templates/components/mfa-form.hbs index b9e2e913741a..86baf99017f5 100644 --- a/ui/app/templates/components/mfa-form.hbs +++ b/ui/app/templates/components/mfa-form.hbs @@ -1,24 +1,47 @@ -
+
-

- Multi-factor authentication is enabled for your account. Enter your authentication code to log in. +

+ {{this.description}}

+
- -
- -
+ {{#each this.constraints as |constraint index|}} + {{#if index}} +
+ {{/if}} + {{#if (gt constraint.methods.length 1)}} + +
+ {{/if}} + {{/each}}
{{#if this.newCodeDelay.isRunning}}
From 578e69814394e3275a801af51cd88b5782a4dac4 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Mon, 14 Feb 2022 09:49:01 -0700 Subject: [PATCH 14/25] adds noDefault arg to Select component --- ui/lib/core/addon/components/select.js | 20 ++++++++++--------- .../addon/templates/components/select.hbs | 5 +++++ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/ui/lib/core/addon/components/select.js b/ui/lib/core/addon/components/select.js index eb74b4d42a31..00b4e531c6f9 100644 --- a/ui/lib/core/addon/components/select.js +++ b/ui/lib/core/addon/components/select.js @@ -10,15 +10,16 @@ import layout from '../templates/components/select'; *