mirror of
https://github.com/hashicorp/vault.git
synced 2025-08-23 07:31:09 +02:00
* UI: Move `wrapped_token` login functionality to route (#30465) * move token unwrap functionality to page component * update mfa test * remove wrapped_token logic from page component * more cleanup to relocate unwrap logic * move wrapped_token to route * move unwrap tests to acceptance * move mfa form back * add some padding * update mfa-form tests * get param from params * wait for auth form on back * run rests * UI: Add MFA support for SSO methods (#30489) * initial implementation of mfa validation for sso methods * update typescript interfaces * add stopgap changes to auth service * switch order backend is defined * update login form for tests even though it will be deleted * attempt to stabilize wrapped_query test * =update login form test why not * Update ui/app/components/auth/form/saml.ts Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com> --------- Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com> * Move CSP error to page component (#30492) * initial implementation of mfa validation for sso methods * update typescript interfaces * add stopgap changes to auth service * switch order backend is defined * update login form for tests even though it will be deleted * attempt to stabilize wrapped_query test * =update login form test why not * move csp error to page component * move csp error to page component * Move fetching unauthenticated mounts to the route (#30509) * rename namespace arg to namespaceQueryParam * move fetch mounts to route * add margin to sign in button spacing * update selectors for oidc provider test * add todo delete comments * fix arg typo in test * change method name * fix args handling tab click * remove tests that no longer relate to components functionality * add tests for preselectedAuthType functionality * move typescript interfaces, fix selector * add await * oops * move format method down, make private * move tab formatting to the route * move to page object * fix token unwrap aborting transition * not sure what that is doing there.. * add comments * rename to presetAuthType * use did-insert instead * UI: Implement `Auth::FormTemplate` (#30521) * replace Auth::LoginForm with Auth::FormTemplate * first round of test updates * return null if mounts object is empty * add comment and test for empty sys/internal/mounts data * more test updates * delete listing_visibility test, delete login-form component test * update divs to Hds::Card::Container * add overflow class * remove unused getters * move requesting stored auth type to page component * fix typo * Update ui/app/components/auth/form/oidc-jwt.ts make comment make more sense * small cleanup items, update imports * Delete old auth components (#30527) * delete old components * update codeowners * Update `with` query param functionality (#30537) * update path input to type=hidden * add test coverage * update page test * update auth route * delete login form * update ent test * consolidate logic in getter * add more comments * more comments.. * rename selector * refresh model as well * redirect for invalid query params * move unwrap to redirect * only redirect on invalid query params * add tests for query param * test selector updates * remove todos, update relevant ones with initials * add changelog --------- Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com>
305 lines
11 KiB
JavaScript
305 lines
11 KiB
JavaScript
/**
|
|
* Copyright (c) HashiCorp, Inc.
|
|
* SPDX-License-Identifier: BUSL-1.1
|
|
*/
|
|
|
|
import { module, test } from 'qunit';
|
|
import { setupRenderingTest } from 'ember-qunit';
|
|
import { render, settled, fillIn, click, waitUntil, waitFor } from '@ember/test-helpers';
|
|
import { hbs } from 'ember-cli-htmlbars';
|
|
import { setupMirage } from 'ember-cli-mirage/test-support';
|
|
import { _cancelTimers as cancelTimers, later } from '@ember/runloop';
|
|
import { TOTP_VALIDATION_ERROR } from 'vault/components/mfa/mfa-form';
|
|
import sinon from 'sinon';
|
|
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
|
|
|
module('Integration | Component | mfa-form', function (hooks) {
|
|
setupRenderingTest(hooks);
|
|
setupMirage(hooks);
|
|
|
|
hooks.beforeEach(function () {
|
|
this.onCancel = sinon.spy();
|
|
this.clusterId = '123456';
|
|
this.mfaAuthData = {
|
|
backend: 'userpass',
|
|
data: { username: 'foo', password: 'bar' },
|
|
};
|
|
this.authService = this.owner.lookup('service:auth');
|
|
// setup basic totp mfa_requirement
|
|
// override in tests that require different scenarios
|
|
this.totpConstraint = this.server.create('mfa-method', { type: 'totp' });
|
|
const { mfa_requirement } = this.authService._parseMfaResponse({
|
|
mfa_request_id: 'test-mfa-id',
|
|
mfa_constraints: { test_mfa: { any: [this.totpConstraint] } },
|
|
});
|
|
this.mfaAuthData.mfa_requirement = mfa_requirement;
|
|
});
|
|
|
|
test('it should render correct descriptions', async function (assert) {
|
|
const totpConstraint = this.server.create('mfa-method', { type: 'totp' });
|
|
const oktaConstraint = this.server.create('mfa-method', { type: 'okta' });
|
|
const duoConstraint = this.server.create('mfa-method', { type: 'duo' });
|
|
|
|
this.mfaAuthData.mfa_requirement = this.authService._parseMfaResponse({
|
|
mfa_request_id: 'test-mfa-id',
|
|
mfa_constraints: { test_mfa_1: { any: [totpConstraint] } },
|
|
}).mfa_requirement;
|
|
|
|
await render(
|
|
hbs`<Mfa::MfaForm
|
|
@clusterId={{this.clusterId}}
|
|
@authData={{this.mfaAuthData}}
|
|
@onError={{fn (mut this.error)}}
|
|
@onCancel={{this.onCancel}}
|
|
/>`
|
|
);
|
|
assert
|
|
.dom('[data-test-mfa-description]')
|
|
.includesText(
|
|
'Enter your authentication code to log in.',
|
|
'Correct description renders for single passcode'
|
|
);
|
|
|
|
this.mfaAuthData.mfa_requirement = this.authService._parseMfaResponse({
|
|
mfa_request_id: 'test-mfa-id',
|
|
mfa_constraints: { test_mfa_1: { any: [duoConstraint, oktaConstraint] } },
|
|
}).mfa_requirement;
|
|
|
|
await render(
|
|
hbs`<Mfa::MfaForm
|
|
@clusterId={{this.clusterId}}
|
|
@authData={{this.mfaAuthData}}
|
|
@onError={{fn (mut this.error)}}
|
|
@onCancel={{this.onCancel}}
|
|
/>`
|
|
);
|
|
assert
|
|
.dom('[data-test-mfa-description]')
|
|
.includesText(
|
|
'Select the MFA method you wish to use.',
|
|
'Correct description renders for multiple methods'
|
|
);
|
|
|
|
this.mfaAuthData.mfa_requirement = this.authService._parseMfaResponse({
|
|
mfa_request_id: 'test-mfa-id',
|
|
mfa_constraints: { test_mfa_1: { any: [oktaConstraint] }, test_mfa_2: { any: [duoConstraint] } },
|
|
}).mfa_requirement;
|
|
|
|
await render(
|
|
hbs`<Mfa::MfaForm
|
|
@clusterId={{this.clusterId}}
|
|
@authData={{this.mfaAuthData}}
|
|
@onError={{fn (mut this.error)}}
|
|
@onCancel={{this.onCancel}}
|
|
/>`
|
|
);
|
|
assert
|
|
.dom('[data-test-mfa-description]')
|
|
.includesText(
|
|
'Two methods are required for successful authentication.',
|
|
'Correct description renders for multiple constraints'
|
|
);
|
|
});
|
|
|
|
test('it should render a submit button', async function (assert) {
|
|
await render(hbs`<Mfa::MfaForm
|
|
@clusterId={{this.clusterId}}
|
|
@authData={{this.mfaAuthData}}
|
|
@onCancel={{this.onCancel}}
|
|
/>`);
|
|
|
|
assert.dom('[data-test-mfa-validate]').isNotDisabled('Button is not disabled by default');
|
|
});
|
|
|
|
test('it should render method selects and passcode inputs', async function (assert) {
|
|
assert.expect(2);
|
|
const duoConstraint = this.server.create('mfa-method', { type: 'duo', uses_passcode: true });
|
|
const oktaConstraint = this.server.create('mfa-method', { type: 'okta' });
|
|
const pingidConstraint = this.server.create('mfa-method', { type: 'pingid' });
|
|
const { mfa_requirement } = this.authService._parseMfaResponse({
|
|
mfa_request_id: 'test-mfa-id',
|
|
mfa_constraints: {
|
|
test_mfa_1: {
|
|
any: [pingidConstraint, oktaConstraint],
|
|
},
|
|
test_mfa_2: {
|
|
any: [duoConstraint],
|
|
},
|
|
},
|
|
});
|
|
this.mfaAuthData.mfa_requirement = mfa_requirement;
|
|
|
|
this.server.post('/sys/mfa/validate', (schema, req) => {
|
|
const json = JSON.parse(req.requestBody);
|
|
const payload = {
|
|
mfa_request_id: 'test-mfa-id',
|
|
mfa_payload: { [oktaConstraint.id]: [], [duoConstraint.id]: ['passcode=test-code'] },
|
|
};
|
|
assert.deepEqual(json, payload, 'Correct mfa payload passed to validate endpoint');
|
|
return {};
|
|
});
|
|
|
|
this.owner.lookup('service:auth').reopen({
|
|
// override to avoid authSuccess method since it expects an auth payload
|
|
async totpValidate({ mfa_requirement }) {
|
|
await this.clusterAdapter().mfaValidate(mfa_requirement);
|
|
return 'test response';
|
|
},
|
|
});
|
|
|
|
this.onSuccess = (resp) =>
|
|
assert.strictEqual(resp, 'test response', 'Response is returned in onSuccess callback');
|
|
|
|
await render(
|
|
hbs`<Mfa::MfaForm
|
|
@clusterId={{this.clusterId}}
|
|
@authData={{this.mfaAuthData}}
|
|
@onSuccess={{this.onSuccess}}
|
|
@onCancel={{this.onCancel}}
|
|
/>`
|
|
);
|
|
await fillIn('[data-test-mfa-select="0"] select', oktaConstraint.id);
|
|
await fillIn('[data-test-mfa-passcode="1"]', 'test-code');
|
|
await click('[data-test-mfa-validate]');
|
|
});
|
|
|
|
test('it should validate mfa requirement', async function (assert) {
|
|
assert.expect(5);
|
|
this.server.post('/sys/mfa/validate', (schema, req) => {
|
|
const json = JSON.parse(req.requestBody);
|
|
const payload = {
|
|
mfa_request_id: 'test-mfa-id',
|
|
mfa_payload: { [this.totpConstraint.id]: ['test-code'] },
|
|
};
|
|
assert.deepEqual(json, payload, 'Correct mfa payload passed to validate endpoint');
|
|
return {};
|
|
});
|
|
|
|
const expectedAuthData = { clusterId: this.clusterId, ...this.mfaAuthData };
|
|
this.owner.lookup('service:auth').reopen({
|
|
// override to avoid authSuccess method since it expects an auth payload
|
|
async totpValidate(authData) {
|
|
await waitUntil(() =>
|
|
assert
|
|
.dom('[data-test-mfa-validate] [data-test-icon="loading"]')
|
|
.exists('Loading icon shows on button')
|
|
);
|
|
assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled while loading');
|
|
assert.deepEqual(authData, expectedAuthData, 'Mfa auth data passed to validate method');
|
|
await this.clusterAdapter().mfaValidate(authData.mfa_requirement);
|
|
return 'test response';
|
|
},
|
|
});
|
|
|
|
this.onSuccess = (resp) =>
|
|
assert.strictEqual(resp, 'test response', 'Response is returned in onSuccess callback');
|
|
|
|
await render(
|
|
hbs`<Mfa::MfaForm
|
|
@clusterId={{this.clusterId}}
|
|
@authData={{this.mfaAuthData}}
|
|
@onSuccess={{this.onSuccess}}
|
|
@onCancel={{this.onCancel}}
|
|
/>`
|
|
);
|
|
|
|
await fillIn('[data-test-mfa-passcode]', 'test-code');
|
|
await click('[data-test-mfa-validate]');
|
|
});
|
|
|
|
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 30 seconds',
|
|
// note: the backend returns a duplicate "s" in "30s seconds" in the limit message below. we have intentionally left it as is to ensure our regex for parsing the delay time can handle it
|
|
limit:
|
|
'maximum TOTP validation attempts 4 exceeded the allowed attempts 3. Please try again in 30s seconds',
|
|
};
|
|
const codes = ['used', 'limit'];
|
|
for (const code of codes) {
|
|
this.owner.lookup('service:auth').reopen({
|
|
totpValidate() {
|
|
throw { errors: [messages[code]] };
|
|
},
|
|
});
|
|
|
|
await render(hbs`<Mfa::MfaForm
|
|
@clusterId={{this.clusterId}}
|
|
@authData={{this.mfaAuthData}}
|
|
@onCancel={{this.onCancel}}
|
|
/>`);
|
|
|
|
await fillIn('[data-test-mfa-passcode]', 'foo');
|
|
await click('[data-test-mfa-validate]');
|
|
|
|
await waitFor('[data-test-mfa-countdown]');
|
|
|
|
assert
|
|
.dom('[data-test-mfa-countdown]')
|
|
.includesText('30', '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 defaults countdown to 30 seconds if error message does not indicate when user can try again ', async function (assert) {
|
|
this.owner.lookup('service:auth').reopen({
|
|
totpValidate() {
|
|
throw {
|
|
errors: ['maximum TOTP validation attempts 4 exceeded the allowed attempts 3. Beep-boop.'],
|
|
};
|
|
},
|
|
});
|
|
await render(hbs`<Mfa::MfaForm
|
|
@clusterId={{this.clusterId}}
|
|
@authData={{this.mfaAuthData}}
|
|
@onCancel={{this.onCancel}}
|
|
/>`);
|
|
|
|
await fillIn('[data-test-mfa-passcode]', 'foo');
|
|
await click('[data-test-mfa-validate]');
|
|
|
|
await waitFor('[data-test-mfa-countdown]');
|
|
|
|
assert
|
|
.dom('[data-test-mfa-countdown]')
|
|
.includesText('30', '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) {
|
|
this.owner.lookup('service:auth').reopen({
|
|
totpValidate() {
|
|
throw { errors: ['failed to validate'] };
|
|
},
|
|
});
|
|
await render(hbs`<Mfa::MfaForm
|
|
@clusterId={{this.clusterId}}
|
|
@authData={{this.mfaAuthData}}
|
|
@onCancel={{this.onCancel}}
|
|
/>`);
|
|
|
|
await fillIn('[data-test-mfa-passcode]', 'test-code');
|
|
later(() => cancelTimers(), 50);
|
|
await settled();
|
|
|
|
await click('[data-test-mfa-validate]');
|
|
assert
|
|
.dom('[data-test-message-error]')
|
|
.includesText(TOTP_VALIDATION_ERROR, 'Generic error message renders for passcode validation error');
|
|
});
|
|
|
|
test('it should call onCancel callback', async function (assert) {
|
|
await render(hbs`<Mfa::MfaForm
|
|
@clusterId={{this.clusterId}}
|
|
@authData={{this.mfaAuthData}}
|
|
@onCancel={{this.onCancel}}
|
|
/>`);
|
|
|
|
await click(GENERAL.backButton);
|
|
assert.true(this.onCancel.calledOnce, 'it fires onCancel callback');
|
|
});
|
|
});
|