+
{{yield}}
\ No newline at end of file
diff --git a/ui/lib/core/addon/components/linked-block.js b/ui/lib/core/addon/components/linked-block.js
index 6de17c462..41977a627 100644
--- a/ui/lib/core/addon/components/linked-block.js
+++ b/ui/lib/core/addon/components/linked-block.js
@@ -23,6 +23,7 @@ import { encodePath } from 'vault/utils/path-encoding-helpers';
* @param {Object} [queryParams=null] - queryParams can be passed via this property. It needs to be an object.
* @param {String} [linkPrefix=null] - Overwrite the params with custom route. See KMIP.
* @param {Boolean} [encode=false] - Encode the path.
+ * @param {boolean} [disabled] - disable the link -- prevents on click and removes linked-block hover styling
*/
export default class LinkedBlockComponent extends Component {
@@ -30,32 +31,34 @@ export default class LinkedBlockComponent extends Component {
@action
onClick(event) {
- const $target = event.target;
- const isAnchorOrButton =
- $target.tagName === 'A' ||
- $target.tagName === 'BUTTON' ||
- $target.closest('button') ||
- $target.closest('a');
- if (!isAnchorOrButton) {
- let params = this.args.params;
- if (this.args.encode) {
- params = params.map((param, index) => {
- if (index === 0 || typeof param !== 'string') {
- return param;
- }
- return encodePath(param);
- });
+ if (!this.args.disabled) {
+ const $target = event.target;
+ const isAnchorOrButton =
+ $target.tagName === 'A' ||
+ $target.tagName === 'BUTTON' ||
+ $target.closest('button') ||
+ $target.closest('a');
+ if (!isAnchorOrButton) {
+ let params = this.args.params;
+ if (this.args.encode) {
+ params = params.map((param, index) => {
+ if (index === 0 || typeof param !== 'string') {
+ return param;
+ }
+ return encodePath(param);
+ });
+ }
+ const queryParams = this.args.queryParams;
+ if (queryParams) {
+ params.push({ queryParams });
+ }
+ if (this.args.linkPrefix) {
+ let targetRoute = this.args.params[0];
+ targetRoute = `${this.args.linkPrefix}.${targetRoute}`;
+ this.args.params[0] = targetRoute;
+ }
+ this.router.transitionTo(...params);
}
- const queryParams = this.args.queryParams;
- if (queryParams) {
- params.push({ queryParams });
- }
- if (this.args.linkPrefix) {
- let targetRoute = this.args.params[0];
- targetRoute = `${this.args.linkPrefix}.${targetRoute}`;
- this.args.params[0] = targetRoute;
- }
- this.router.transitionTo(...params);
}
}
}
diff --git a/ui/lib/core/addon/components/search-select.js b/ui/lib/core/addon/components/search-select.js
index 5e8403a5d..fa0f2daca 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/lib/core/addon/templates/components/search-select.hbs b/ui/lib/core/addon/templates/components/search-select.hbs
index 2bd8b082d..1f75e4ca0 100644
--- a/ui/lib/core/addon/templates/components/search-select.hbs
+++ b/ui/lib/core/addon/templates/components/search-select.hbs
@@ -10,12 +10,14 @@
id=this.id
}}
{{else}}
-
+ {{#if this.label}}
+
+ {{/if}}
{{#if this.subLabel}}
{{this.subLabel}}
{{/if}}
diff --git a/ui/lib/core/icon-mappings.js b/ui/lib/core/icon-mappings.js
index 021cbb30b..3d11821f4 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/mirage/factories/mfa-duo-method.js b/ui/mirage/factories/mfa-duo-method.js
new file mode 100644
index 000000000..9f8d132c2
--- /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 000000000..f4a077750
--- /dev/null
+++ b/ui/mirage/factories/mfa-login-enforcement.js
@@ -0,0 +1,41 @@
+import { Factory } from 'ember-cli-mirage';
+
+export default Factory.extend({
+ auth_method_accessors: null,
+ auth_method_types: null,
+ identity_entity_ids: null,
+ identity_group_ids: null,
+ mfa_method_ids: null,
+ name: null,
+ namespace_id: 'root',
+
+ afterCreate(record, server) {
+ // initialize arrays and stub some data if not provided
+ if (!record.name) {
+ // use random string for generated name
+ record.update('name', (Math.random() + 1).toString(36).substring(2));
+ }
+ if (!record.mfa_method_ids) {
+ // aggregate all existing methods and choose a random one
+ const methods = ['Totp', 'Duo', 'Okta', 'Pingid'].reduce((methods, type) => {
+ const records = server.schema.db[`mfa${type}Methods`].where({});
+ if (records.length) {
+ methods.push(...records);
+ }
+ return methods;
+ }, []);
+ // if no methods were found create one since it is a required for login enforcements
+ if (!methods.length) {
+ methods.push(server.create('mfa-totp-method'));
+ }
+ const method = methods.length ? methods[Math.floor(Math.random() * methods.length)] : null;
+ record.update('mfa_method_ids', method ? [method.id] : []);
+ }
+ const keys = ['auth_method_accessors', 'auth_method_types', 'identity_group_ids', 'identity_entity_ids'];
+ keys.forEach((key) => {
+ if (!record[key]) {
+ record.update(key, key === 'auth_method_types' ? ['userpass'] : []);
+ }
+ });
+ },
+});
diff --git a/ui/mirage/factories/mfa-okta-method.js b/ui/mirage/factories/mfa-okta-method.js
new file mode 100644
index 000000000..2bf495066
--- /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 000000000..f95481219
--- /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 000000000..9b82a2b31
--- /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 = '';
+ }
+ },
+});
diff --git a/ui/mirage/handlers/index.js b/ui/mirage/handlers/index.js
index edf6bfb1a..cd134b582 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 000000000..a28d304fd
--- /dev/null
+++ b/ui/mirage/handlers/mfa-config.js
@@ -0,0 +1,171 @@
+import { Response } from 'miragejs';
+
+export default function (server) {
+ const methods = ['totp', 'duo', 'okta', 'pingid'];
+ const required = {
+ totp: ['issuer'],
+ duo: ['secret_key', 'integration_key', 'api_hostname'],
+ okta: ['org_name', 'api_token'],
+ pingid: ['settings_file_base64'],
+ };
+
+ const validate = (type, data, cb) => {
+ if (!methods.includes(type)) {
+ return new Response(400, {}, { errors: [`Method ${type} not found`] });
+ }
+ if (data) {
+ const missing = required[type].reduce((params, key) => {
+ if (!data[key]) {
+ params.push(key);
+ }
+ return params;
+ }, []);
+ if (missing.length) {
+ return new Response(400, {}, { errors: [`Missing required parameters: [${missing.join(', ')}]`] });
+ }
+ }
+ return cb();
+ };
+
+ const dbKeyFromType = (type) => `mfa${type.charAt(0).toUpperCase()}${type.slice(1)}Methods`;
+
+ const generateListResponse = (schema, isMethod) => {
+ let records = [];
+ if (isMethod) {
+ methods.forEach((method) => {
+ records.addObjects(schema.db[dbKeyFromType(method)].where({}));
+ });
+ } else {
+ records = schema.db.mfaLoginEnforcements.where({});
+ }
+ // seed the db with a few records if none exist
+ if (!records.length) {
+ if (isMethod) {
+ methods.forEach((type) => {
+ records.push(server.create(`mfa-${type}-method`));
+ });
+ } else {
+ records = server.createList('mfa-login-enforcement', 4).toArray();
+ }
+ }
+ const dataKey = isMethod ? 'id' : 'name';
+ const data = records.reduce(
+ (resp, record) => {
+ resp.key_info[record[dataKey]] = record;
+ resp.keys.push(record[dataKey]);
+ return resp;
+ },
+ {
+ key_info: {},
+ keys: [],
+ }
+ );
+ return { data };
+ };
+
+ // list methods
+ server.get('/identity/mfa/method/', (schema) => {
+ return generateListResponse(schema, true);
+ });
+ // fetch method by id
+ server.get('/identity/mfa/method/:id', (schema, { params: { id } }) => {
+ let record;
+ for (const method of methods) {
+ record = schema.db[dbKeyFromType(method)].find(id);
+ if (record) {
+ break;
+ }
+ }
+ // inconvenient when testing edit route to return a 404 on refresh since mirage memory is cleared
+ // flip this variable to test 404 state if needed
+ const shouldError = false;
+ // create a new record so data is always returned
+ if (!record && !shouldError) {
+ return { data: server.create('mfa-totp-method') };
+ }
+ return !record ? new Response(404, {}, { errors: [] }) : { data: record };
+ });
+ // create method
+ server.post('/identity/mfa/method/:type', (schema, { params: { type }, requestBody }) => {
+ const data = JSON.parse(requestBody);
+ return validate(type, data, () => {
+ const record = server.create(`mfa-${type}-method`, data);
+ return { data: { method_id: record.id } };
+ });
+ });
+ // update method
+ server.put('/identity/mfa/method/:type/:id', (schema, { params: { type, id }, requestBody }) => {
+ const data = JSON.parse(requestBody);
+ return validate(type, data, () => {
+ schema.db[dbKeyFromType(type)].update(id, data);
+ return {};
+ });
+ });
+ // delete method
+ server.delete('/identity/mfa/method/:type/:id', (schema, { params: { type, id } }) => {
+ return validate(type, null, () => {
+ schema.db[dbKeyFromType(type)].remove(id);
+ return {};
+ });
+ });
+ // list enforcements
+ server.get('/identity/mfa/login-enforcement', (schema) => {
+ return generateListResponse(schema);
+ });
+ // fetch enforcement by name
+ server.get('/identity/mfa/login-enforcement/:name', (schema, { params: { name } }) => {
+ const record = schema.db.mfaLoginEnforcements.findBy({ name });
+ // inconvenient when testing edit route to return a 404 on refresh since mirage memory is cleared
+ // flip this variable to test 404 state if needed
+ const shouldError = false;
+ // create a new record so data is always returned
+ if (!record && !shouldError) {
+ return { data: server.create('mfa-login-enforcement', { name }) };
+ }
+ return !record ? new Response(404, {}, { errors: [] }) : { data: record };
+ });
+ // create/update enforcement
+ server.post('/identity/mfa/login-enforcement/:name', (schema, { params: { name }, requestBody }) => {
+ const data = JSON.parse(requestBody);
+ // at least one method id is required
+ if (!data.mfa_method_ids?.length) {
+ return new Response(400, {}, { errors: ['missing method ids'] });
+ }
+ // at least one of the following targets is required
+ const required = [
+ 'auth_method_accessors',
+ 'auth_method_types',
+ 'identity_group_ids',
+ 'identity_entity_ids',
+ ];
+ let hasRequired = false;
+ for (let key of required) {
+ if (data[key]?.length) {
+ hasRequired = true;
+ break;
+ }
+ }
+ if (!hasRequired) {
+ return new Response(
+ 400,
+ {},
+ {
+ errors: [
+ 'One of auth_method_accessors, auth_method_types, identity_group_ids, identity_entity_ids must be specified',
+ ],
+ }
+ );
+ }
+ if (schema.db.mfaLoginEnforcements.findBy({ name })) {
+ schema.db.mfaLoginEnforcements.update({ name }, data);
+ } else {
+ schema.db.mfaLoginEnforcements.insert(data);
+ }
+ return { ...data, id: data.name };
+ });
+ // delete enforcement
+ server.delete('/identity/mfa/login-enforcement/:name', (schema, { params: { name } }) => {
+ schema.db.mfaLoginEnforcements.remove({ name });
+ return {};
+ });
+}
diff --git a/ui/mirage/handlers/mfa.js b/ui/mirage/handlers/mfa-login.js
similarity index 67%
rename from ui/mirage/handlers/mfa.js
rename to ui/mirage/handlers/mfa-login.js
index aa7aff7ea..830809ec4 100644
--- a/ui/mirage/handlers/mfa.js
+++ b/ui/mirage/handlers/mfa-login.js
@@ -2,11 +2,58 @@ import { Response } from 'miragejs';
import Ember from 'ember';
import fetch from 'fetch';
+// initial auth response cache -- lookup by mfa_request_id key
+const authResponses = {};
+// mfa requirement cache -- lookup by mfa_request_id key
+const mfaRequirement = {};
+
+// may be imported in tests when the validation request needs to be intercepted to make assertions prior to returning a response
+// in that case it may be helpful to still use this validation logic to ensure to payload is as expected
+export const validationHandler = (schema, req) => {
+ try {
+ const { mfa_request_id, mfa_payload } = JSON.parse(req.requestBody);
+ const mfaRequest = mfaRequirement[mfa_request_id];
+
+ if (!mfaRequest) {
+ return new Response(404, {}, { errors: ['MFA Request ID not found'] });
+ }
+ // validate request body
+ for (let constraintId in mfa_payload) {
+ // ensure ids were passed in map
+ const method = mfaRequest.methods.find(({ id }) => id === constraintId);
+ if (!method) {
+ return new Response(400, {}, { errors: [`Invalid MFA constraint id ${constraintId} passed in map`] });
+ }
+ // test non-totp validation by rejecting all pingid requests
+ if (method.type === 'pingid') {
+ return new Response(403, {}, { errors: ['PingId MFA validation failed'] });
+ }
+ // validate totp passcode
+ const passcode = mfa_payload[constraintId][0];
+ if (method.uses_passcode) {
+ if (passcode !== 'test') {
+ const error =
+ {
+ used: 'code already used; new code is available in 30 seconds',
+ limit:
+ 'maximum TOTP validation attempts 4 exceeded the allowed attempts 3. Please try again in 15 seconds',
+ }[passcode] || 'failed to validate';
+ console.log(error);
+ return new Response(403, {}, { errors: [error] });
+ }
+ } else if (passcode) {
+ // for okta and duo, reject if a passcode was provided
+ return new Response(400, {}, { errors: ['Passcode should only be provided for TOTP MFA type'] });
+ }
+ }
+ return authResponses[mfa_request_id];
+ } catch (error) {
+ console.log(error);
+ return new Response(500, {}, { errors: ['Mirage Handler Error: /sys/mfa/validate'] });
+ }
+};
+
export default function (server) {
- // initial auth response cache -- lookup by mfa_request_id key
- const authResponses = {};
- // mfa requirement cache -- lookup by mfa_request_id key
- const mfaRequirement = {};
// generate different constraint scenarios and return mfa_requirement object
const generateMfaRequirement = (req, res) => {
const { user } = req.params;
@@ -104,48 +151,5 @@ export default function (server) {
};
server.post('/auth/:method/login/:user', passthroughLogin);
- server.post('/sys/mfa/validate', (schema, req) => {
- try {
- const { mfa_request_id, mfa_payload } = JSON.parse(req.requestBody);
- const mfaRequest = mfaRequirement[mfa_request_id];
-
- if (!mfaRequest) {
- return new Response(404, {}, { errors: ['MFA Request ID not found'] });
- }
- // validate request body
- for (let constraintId in mfa_payload) {
- // ensure ids were passed in map
- const method = mfaRequest.methods.find(({ id }) => id === constraintId);
- if (!method) {
- return new Response(
- 400,
- {},
- { errors: [`Invalid MFA constraint id ${constraintId} passed in map`] }
- );
- }
- // test non-totp validation by rejecting all pingid requests
- if (method.type === 'pingid') {
- return new Response(403, {}, { errors: ['PingId MFA validation failed'] });
- }
- // validate totp passcode
- const passcode = mfa_payload[constraintId][0];
- if (method.uses_passcode) {
- if (passcode !== 'test') {
- const error =
- passcode === 'used'
- ? 'code already used; new code is available in 30 seconds'
- : 'failed to validate';
- return new Response(403, {}, { errors: [error] });
- }
- } else if (passcode) {
- // for okta and duo, reject if a passcode was provided
- return new Response(400, {}, { errors: ['Passcode should only be provided for TOTP MFA type'] });
- }
- }
- return authResponses[mfa_request_id];
- } catch (error) {
- console.log(error);
- return new Response(500, {}, { errors: ['Mirage Handler Error: /sys/mfa/validate'] });
- }
- });
+ server.post('/sys/mfa/validate', validationHandler);
}
diff --git a/ui/mirage/identity-managers/application.js b/ui/mirage/identity-managers/application.js
new file mode 100644
index 000000000..4c5c951d9
--- /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();
+ }
+}
diff --git a/ui/package.json b/ui/package.json
index 5ea7fae10..a3f14fe3c 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/public/duo.svg b/ui/public/duo.svg
new file mode 100644
index 000000000..72a97e5d1
--- /dev/null
+++ b/ui/public/duo.svg
@@ -0,0 +1,5 @@
+
diff --git a/ui/public/images/mfa-landing.png b/ui/public/images/mfa-landing.png
new file mode 100644
index 000000000..45e441443
--- /dev/null
+++ b/ui/public/images/mfa-landing.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e1f104007ba0e1c2e806b86ba33350bea18ceca77ebba52fe1a68186bc6fdde5
+size 63398
diff --git a/ui/public/okta.svg b/ui/public/okta.svg
new file mode 100644
index 000000000..6b6e8906e
--- /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 000000000..99b33fefc
--- /dev/null
+++ b/ui/public/pingid.svg
@@ -0,0 +1,11 @@
+
diff --git a/ui/tests/acceptance/mfa-test.js b/ui/tests/acceptance/mfa-login-test.js
similarity index 83%
rename from ui/tests/acceptance/mfa-test.js
rename to ui/tests/acceptance/mfa-login-test.js
index 94eab2310..449cadc4c 100644
--- a/ui/tests/acceptance/mfa-test.js
+++ b/ui/tests/acceptance/mfa-login-test.js
@@ -1,15 +1,16 @@
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
-import { click, currentRouteName, fillIn, visit } from '@ember/test-helpers';
+import { click, currentRouteName, fillIn, visit, waitUntil, find } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import ENV from 'vault/config/environment';
+import { validationHandler } from '../../mirage/handlers/mfa-login';
-module('Acceptance | mfa', function (hooks) {
+module('Acceptance | mfa-login', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.before(function () {
- ENV['ember-cli-mirage'].handler = 'mfa';
+ ENV['ember-cli-mirage'].handler = 'mfaLogin';
});
hooks.beforeEach(function () {
this.select = async (select = 0, option = 1) => {
@@ -56,7 +57,27 @@ module('Acceptance | mfa', function (hooks) {
});
test('it should handle single mfa constraint with push method', async function (assert) {
- assert.expect(1);
+ assert.expect(6);
+
+ server.post('/sys/mfa/validate', async (schema, req) => {
+ await waitUntil(() => find('[data-test-mfa-description]'));
+ assert
+ .dom('[data-test-mfa-description]')
+ .hasText(
+ 'Multi-factor authentication is enabled for your account.',
+ 'Mfa form displays with correct description'
+ );
+ assert.dom('[data-test-mfa-label]').hasText('Okta push notification', 'Correct method renders');
+ assert
+ .dom('[data-test-mfa-push-instruction]')
+ .hasText('Check device for push notification', 'Push notification instruction renders');
+ assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled while validating');
+ assert
+ .dom('[data-test-mfa-validate]')
+ .hasClass('is-loading', 'Loading class applied to button while validating');
+ return validationHandler(schema, req);
+ });
+
await login('mfa-b');
didLogin(assert);
});
diff --git a/ui/tests/integration/components/mfa-form-test.js b/ui/tests/integration/components/mfa-form-test.js
index 62c65cc0c..c779447d1 100644
--- a/ui/tests/integration/components/mfa-form-test.js
+++ b/ui/tests/integration/components/mfa-form-test.js
@@ -5,7 +5,7 @@ import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { fillIn, click, waitUntil } from '@ember/test-helpers';
import { _cancelTimers as cancelTimers, later } from '@ember/runloop';
-import { VALIDATION_ERROR } from 'vault/components/mfa-form';
+import { TOTP_VALIDATION_ERROR } from 'vault/components/mfa-form';
module('Integration | Component | mfa-form', function (hooks) {
setupRenderingTest(hooks);
@@ -38,7 +38,9 @@ module('Integration | Component | mfa-form', function (hooks) {
mfa_constraints: { test_mfa_1: { any: [totpConstraint] } },
}).mfa_requirement;
- await render(hbs`
`);
+ await render(
+ hbs`
`
+ );
assert
.dom('[data-test-mfa-description]')
.includesText(
@@ -51,7 +53,9 @@ module('Integration | Component | mfa-form', function (hooks) {
mfa_constraints: { test_mfa_1: { any: [duoConstraint, oktaConstraint] } },
}).mfa_requirement;
- await render(hbs`
`);
+ await render(
+ hbs`
`
+ );
assert
.dom('[data-test-mfa-description]')
.includesText(
@@ -64,7 +68,9 @@ module('Integration | Component | mfa-form', function (hooks) {
mfa_constraints: { test_mfa_1: { any: [oktaConstraint] }, test_mfa_2: { any: [duoConstraint] } },
}).mfa_requirement;
- await render(hbs`
`);
+ await render(
+ hbs`
`
+ );
assert
.dom('[data-test-mfa-description]')
.includesText(
@@ -164,28 +170,39 @@ module('Integration | Component | mfa-form', function (hooks) {
await click('[data-test-mfa-validate]');
});
- test('it should show countdown on passcode already used error', async function (assert) {
- this.owner.lookup('service:auth').reopen({
- totpValidate() {
- throw { errors: ['code already used; new code is available in 45 seconds'] };
- },
- });
- await render(hbs`
-
- `);
+ test('it should show countdown on passcode already used and rate limit errors', async function (assert) {
+ const messages = {
+ used: 'code already used; new code is available in 45 seconds',
+ limit:
+ 'maximum TOTP validation attempts 4 exceeded the allowed attempts 3. Please try again in 15 seconds',
+ };
+ const codes = ['used', 'limit'];
+ for (let code of codes) {
+ this.owner.lookup('service:auth').reopen({
+ totpValidate() {
+ throw { errors: [messages[code]] };
+ },
+ });
+ await render(hbs`
+
+ `);
- await fillIn('[data-test-mfa-passcode]', 'test-code');
- later(() => cancelTimers(), 50);
- await click('[data-test-mfa-validate]');
- assert
- .dom('[data-test-mfa-countdown]')
- .hasText('45', 'countdown renders with correct initial value from error response');
- assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled during countdown');
- assert.dom('[data-test-mfa-passcode]').isDisabled('Input is disabled during countdown');
- assert.dom('[data-test-inline-error-message]').exists('Alert message renders');
+ await fillIn('[data-test-mfa-passcode]', code);
+ later(() => cancelTimers(), 50);
+ await click('[data-test-mfa-validate]');
+ assert
+ .dom('[data-test-mfa-countdown]')
+ .hasText(
+ code === 'used' ? '45' : '15',
+ 'countdown renders with correct initial value from error response'
+ );
+ assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled during countdown');
+ assert.dom('[data-test-mfa-passcode]').isDisabled('Input is disabled during countdown');
+ assert.dom('[data-test-inline-error-message]').exists('Alert message renders');
+ }
});
test('it should show error message for passcode invalid error', async function (assert) {
@@ -206,6 +223,6 @@ module('Integration | Component | mfa-form', function (hooks) {
await click('[data-test-mfa-validate]');
assert
.dom('[data-test-error]')
- .includesText(VALIDATION_ERROR, 'Generic error message renders for passcode validation error');
+ .includesText(TOTP_VALIDATION_ERROR, 'Generic error message renders for passcode validation error');
});
});
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 000000000..522a5e3c0
--- /dev/null
+++ b/ui/tests/integration/components/mfa-login-enforcement-form-test.js
@@ -0,0 +1,251 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, click, fillIn } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+
+module('Integration | Component | mfa-login-enforcement-form', function (hooks) {
+ setupRenderingTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.model = this.store.createRecord('mfa-login-enforcement');
+ this.server.get('/sys/auth', () => ({
+ data: { 'userpass/': { type: 'userpass', accessor: 'auth_userpass_1234' } },
+ }));
+ this.server.get('/identity/mfa/method', () => ({
+ data: {
+ key_info: {
+ 123456: { type: 'totp' },
+ },
+ keys: ['123456'],
+ },
+ }));
+ });
+
+ test('it should render correct fields', async function (assert) {
+ await render(hbs`
+
+ `);
+
+ const fields = {
+ name: {
+ label: 'Name',
+ subText:
+ 'The name for this enforcement. Giving it a name means that you can refer to it again later. This name will not be editable later.',
+ },
+ methods: {
+ label: 'MFA methods',
+ subText: 'The MFA method(s) that this enforcement will apply to.',
+ },
+ targets: {
+ label: 'Targets',
+ subText:
+ 'The list of authentication types, authentication mounts, groups, and/or entities that will require this MFA configuration.',
+ },
+ };
+
+ const subTexts = this.element.querySelectorAll('[data-test-label-subtext]');
+ Object.keys(fields).forEach((field, index) => {
+ const { label, subText } = fields[field];
+ assert.dom(`[data-test-mlef-label="${field}"]`).hasText(label, `${field} field label renders`);
+ assert.dom(subTexts[index]).hasText(subText, `${subText} field label sub text renders`);
+ });
+ assert.dom('[data-test-mlef-input="name"]').exists(`Name field input renders`);
+ assert.dom('[data-test-mlef-search="methods"]').exists('MFA method search select renders');
+ assert.dom('[data-test-mlef-select="target-type"]').exists('Target type selector renders');
+ assert.dom('[data-test-mlef-select="accessor"]').exists('Auth mount target selector renders by default');
+ });
+
+ test('it should render inline', async function (assert) {
+ this.errors = this.model.validate().state;
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-mlef-input="name"]').exists(`Name field input renders`);
+ assert.dom('[data-test-mlef-search="methods"]').doesNotExist('MFA method search select does not render');
+ assert.dom('[data-test-mlef-select="target-type"]').exists('Target type selector renders');
+ assert
+ .dom('[data-test-inline-error-message]')
+ .exists({ count: 2 }, 'External validation errors are displayed');
+ });
+
+ test('it should display field validation errors on save', async function (assert) {
+ await render(hbs`
+
+ `);
+
+ await click('[data-test-mlef-save]');
+ const errors = this.element.querySelectorAll('[data-test-inline-error-message]');
+ assert.dom(errors[0]).hasText('Name is required', 'Name error message renders');
+ assert.dom(errors[1]).hasText('At least one MFA method is required', 'Methods error message renders');
+ assert
+ .dom(errors[2])
+ .hasText(
+ "At least one target is required. If you've selected one, click 'Add' to make sure it's added to this enforcement.",
+ 'Targets error message renders'
+ );
+ });
+
+ test('it should save new enforcement', async function (assert) {
+ assert.expect(5);
+
+ this.server.post('/identity/mfa/login-enforcement/bar', () => {
+ assert.ok(true, 'save request sent to server');
+ return {};
+ });
+
+ await render(hbs`
+
+ `);
+
+ await fillIn('[data-test-mlef-input="name"]', 'bar');
+ await click('.ember-basic-dropdown-trigger');
+ await click('.ember-power-select-option');
+ await fillIn('[data-test-mlef-select="accessor"] select', 'auth_userpass_1234');
+ await click('[data-test-mlef-add-target]');
+ await click('[data-test-mlef-save]');
+ assert.true(this.didSave, 'onSave callback triggered');
+ assert.equal(this.model.name, 'bar', 'Name property set on model');
+ assert.equal(this.model.mfa_methods.firstObject.id, '123456', 'Mfa method added to model');
+ assert.equal(
+ this.model.auth_method_accessors.firstObject,
+ 'auth_userpass_1234',
+ 'Target saved to correct model property'
+ );
+ });
+
+ test('it should populate fields with model data', async function (assert) {
+ this.model.name = 'foo';
+ const [method] = (await this.store.query('mfa-method', {})).toArray();
+ this.model.mfa_methods.addObject(method);
+ this.model.auth_method_accessors.addObject('auth_userpass_1234');
+
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-mlef-input="name"]').hasValue('foo', 'Name input is populated');
+ assert.dom('.search-select-list-item').includesText('TOTP', 'MFA method type renders in selected option');
+ assert
+ .dom('.search-select-list-item small')
+ .hasText('123456', 'MFA method id renders in selected option');
+ assert
+ .dom('[data-test-row-label="Authentication mount"]')
+ .hasText('Authentication mount', 'Selected target type renders');
+ assert
+ .dom('[data-test-value-div="Authentication mount"]')
+ .hasText('auth_userpass_1234', 'Selected target value renders');
+
+ await click('[data-test-mlef-remove-target]');
+ await click('[data-test-mlef-save]');
+ assert
+ .dom('[data-test-inline-error-message]')
+ .includesText('At least one target is required', 'Target is removed');
+ assert.notOk(this.model.auth_method_accessors.length, 'Target is removed from appropriate model prop');
+
+ await fillIn('[data-test-mlef-select="accessor"] select', 'auth_userpass_1234');
+ await click('[data-test-mlef-add-target]');
+ await click('[data-test-selected-list-button="delete"]');
+ await click('[data-test-mlef-save]');
+ assert
+ .dom('[data-test-inline-error-message]')
+ .hasText('At least one MFA method is required', 'Target is removed');
+ });
+
+ test('it should add and remove targets', async function (assert) {
+ assert.expect();
+
+ this.server.get('/identity/entity/id', () => ({
+ data: {
+ key_info: { 1234: { name: 'foo entity' } },
+ keys: ['1234'],
+ },
+ }));
+ this.server.get('/identity/group/id', () => ({
+ data: {
+ key_info: { 1234: { name: 'bar group' } },
+ keys: ['1234'],
+ },
+ }));
+ this.model.auth_method_accessors.addObject('auth_userpass_1234');
+ this.model.auth_method_types.addObject('userpass');
+ const [entity] = (await this.store.query('identity/entity', {})).toArray();
+ this.model.identity_entities.addObject(entity);
+ const [group] = (await this.store.query('identity/group', {})).toArray();
+ this.model.identity_groups.addObject(group);
+
+ await render(hbs`
+
+ `);
+
+ const targets = [
+ {
+ label: 'Authentication mount',
+ value: 'auth_userpass_1234',
+ key: 'auth_method_accessors',
+ type: 'accessor',
+ },
+ { label: 'Authentication method', value: 'userpass', key: 'auth_method_types', type: 'method' },
+ { label: 'Group', value: 'bar group 1234', key: 'identity_groups', type: 'identity/group' },
+ { label: 'Entity', value: 'foo entity 1234', key: 'identity_entities', type: 'identity/entity' },
+ ];
+
+ for (const [index, target] of targets.entries()) {
+ // target populated from model
+ assert
+ .dom(`[data-test-row-label="${target.label}"]`)
+ .hasText(target.label, `${target.label} target populated with correct type label`);
+ assert
+ .dom(`[data-test-value-div="${target.label}"]`)
+ .hasText(target.value, `${target.label} target populated with correct value`);
+ // remove target
+ await click(`[data-test-mlef-remove-target="${target.label}"]`);
+ assert
+ .dom('[data-test-mlef-target]')
+ .exists({ count: targets.length - (index + 1) }, `${target.label} target removed`);
+ assert.notOk(this.model[target.key].length, `${target.label} removed from correct model prop`);
+ }
+ // add targets
+ for (const target of targets) {
+ await fillIn('[data-test-mlef-select="target-type"] select', target.type);
+ if (['Group', 'Entity'].includes(target.label)) {
+ await click(`[data-test-mlef-search="${target.type}"] .ember-basic-dropdown-trigger`);
+ await click('.ember-power-select-option');
+ } else {
+ const key = target.label === 'Authentication method' ? 'auth-method' : 'accessor';
+ const value = target.label === 'Authentication method' ? 'userpass' : 'auth_userpass_1234';
+ await fillIn(`[data-test-mlef-select="${key}"] select`, value);
+ }
+ await click('[data-test-mlef-add-target]');
+ assert.ok(this.model[target.key].length, `${target.label} added to correct model prop`);
+ }
+ assert.dom('[data-test-mlef-target]').exists({ count: 4 }, 'All targets were added back');
+ });
+});
diff --git a/ui/tests/integration/components/mfa-login-enforcement-header-test.js b/ui/tests/integration/components/mfa-login-enforcement-header-test.js
new file mode 100644
index 000000000..a994b3e88
--- /dev/null
+++ b/ui/tests/integration/components/mfa-login-enforcement-header-test.js
@@ -0,0 +1,65 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, click } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+
+module('Integration | Component | mfa-login-enforcement-header', function (hooks) {
+ setupRenderingTest(hooks);
+ setupMirage(hooks);
+
+ test('it renders heading', async function (assert) {
+ await render(hbs`
`);
+
+ assert.dom('[data-test-mleh-title]').includesText('New enforcement');
+ assert.dom('[data-test-mleh-title] svg').hasClass('flight-icon-lock', 'Lock icon renders');
+ assert
+ .dom('[data-test-mleh-description]')
+ .includesText('An enforcement will define which auth types', 'Description renders');
+ assert.dom('[data-test-mleh-radio]').doesNotExist('Radio cards are hidden when not inline display mode');
+ assert
+ .dom('[data-test-component="search-select"]')
+ .doesNotExist('Search select is hidden when not inline display mode');
+ });
+
+ test('it renders inline', async function (assert) {
+ assert.expect(7);
+
+ this.server.get('/identity/mfa/login-enforcement', () => {
+ assert.ok(true, 'Request made to fetch enforcements');
+ return {
+ data: {
+ key_info: {
+ foo: { name: 'foo' },
+ },
+ keys: ['foo'],
+ },
+ };
+ });
+
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-mleh-title]').includesText('Enforcement');
+ assert
+ .dom('[data-test-mleh-description]')
+ .includesText('An enforcement includes the authentication types', 'Description renders');
+
+ for (const option of ['new', 'existing', 'skip']) {
+ await click(`[data-test-mleh-radio="${option}"] input`);
+ assert.equal(this.value, option, 'Value is updated on radio select');
+ if (option === 'existing') {
+ await click('.ember-basic-dropdown-trigger');
+ await click('.ember-power-select-option');
+ }
+ }
+
+ assert.equal(this.enforcement.name, 'foo', 'Existing enforcement is selected');
+ });
+});
diff --git a/ui/tests/integration/components/mfa-method-list-item-test.js b/ui/tests/integration/components/mfa-method-list-item-test.js
new file mode 100644
index 000000000..7f9c6f8c1
--- /dev/null
+++ b/ui/tests/integration/components/mfa-method-list-item-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-method-list-item', function (hooks) {
+ setupRenderingTest(hooks);
+
+ skip('it renders', async function (assert) {
+ // Set any properties with this.set('myProperty', 'value');
+ // Handle any actions with this.set('myAction', function(val) { ... });
+
+ await render(hbs`
`);
+
+ assert.dom(this.element).hasText('');
+
+ // Template block usage:
+ await render(hbs`
+
+ template block text
+
+ `);
+
+ assert.dom(this.element).hasText('template block text');
+ });
+});
diff --git a/ui/tests/unit/serializers/mfa-login-enforcement-test.js b/ui/tests/unit/serializers/mfa-login-enforcement-test.js
new file mode 100644
index 000000000..248789c14
--- /dev/null
+++ b/ui/tests/unit/serializers/mfa-login-enforcement-test.js
@@ -0,0 +1,33 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Serializer | mfa-login-enforcement', function (hooks) {
+ setupTest(hooks);
+
+ test('it should transform property names for hasMany relationships', function (assert) {
+ const serverData = {
+ name: 'foo',
+ mfa_method_ids: ['1'],
+ auth_method_types: ['userpass'],
+ auth_method_accessors: ['auth_approle_17a552c6'],
+ identity_entity_ids: ['2', '3'],
+ identity_group_ids: ['4', '5', '6'],
+ };
+ const tranformedData = {
+ name: 'foo',
+ mfa_methods: ['1'],
+ auth_method_types: ['userpass'],
+ auth_method_accessors: ['auth_approle_17a552c6'],
+ identity_entities: ['2', '3'],
+ identity_groups: ['4', '5', '6'],
+ };
+ const mutableData = { ...serverData };
+ const serializer = this.owner.lookup('serializer:mfa-login-enforcement');
+
+ serializer.transformHasManyKeys(mutableData, 'model');
+ assert.deepEqual(mutableData, tranformedData, 'hasMany property names are transformed for model');
+
+ serializer.transformHasManyKeys(mutableData, 'server');
+ assert.deepEqual(mutableData, serverData, 'hasMany property names are transformed for server');
+ });
+});
diff --git a/ui/yarn.lock b/ui/yarn.lock
index 4b18eade9..cda6465c1 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"