diff --git a/ui/app/components/auth/login-form.hbs b/ui/app/components/auth/login-form.hbs new file mode 100644 index 0000000000..e8b4d0c2e2 --- /dev/null +++ b/ui/app/components/auth/login-form.hbs @@ -0,0 +1,23 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{#if this.waitingForOktaNumberChallenge}} + +{{else}} + +{{/if}} \ No newline at end of file diff --git a/ui/app/components/auth/login-form.js b/ui/app/components/auth/login-form.js new file mode 100644 index 0000000000..e3551ce68d --- /dev/null +++ b/ui/app/components/auth/login-form.js @@ -0,0 +1,108 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import Ember from 'ember'; +import { service } from '@ember/service'; +import { task, timeout } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +/** + * @module AuthLoginForm + * The Auth::LoginForm wraps OktaNumberChallenge and AuthForm to manage the login flow and is responsible for calling the authenticate method + * + * @example + * + * + * @param {string} wrappedToken - Query param value of a wrapped token that can be used to login when added directly to the URL via the "wrapped_token" query param + * @param {object} cluster - The route model which is the ember data cluster model. contains information such as cluster id, name and boolean for if the cluster is in standby + * @param {string} namespace- Namespace query param, passed to AuthForm and set by typing in namespace input or URL + * @param {string} selectedAuth - The auth method selected in the dropdown, passed to auth service's authenticate method + * @param {function} onSuccess - Callback that fires the "onAuthResponse" action in the auth controller and handles transitioning after success + */ + +export default class AuthLoginFormComponent extends Component { + @service auth; + + @tracked authError = null; + @tracked oktaNumberChallengeAnswer = ''; + @tracked waitingForOktaNumberChallenge = false; + + @action + performAuth(backendType, data) { + this.authenticate.unlinked().perform(backendType, data); + } + + @task + @waitFor + *delayAuthMessageReminder() { + if (Ember.testing) { + yield timeout(0); + } else { + yield timeout(5000); + } + } + + @task + @waitFor + *authenticate(backendType, data) { + const { + selectedAuth, + cluster: { id: clusterId }, + } = this.args; + try { + if (backendType === 'okta') { + this.pollForOktaNumberChallenge.perform(data.nonce, data.path); + } else { + this.delayAuthMessageReminder.perform(); + } + const authResponse = yield this.auth.authenticate({ + clusterId, + backend: backendType, + data, + selectedAuth, + }); + + this.args.onSuccess(authResponse, backendType, data); + } catch (e) { + if (!this.auth.mfaError) { + this.authError = `Authentication failed: ${this.auth.handleError(e)}`; + } + } + } + + @task + @waitFor + *pollForOktaNumberChallenge(nonce, mount) { + // yield for 1s to wait to see if there is a login error before polling + yield timeout(1000); + if (this.authError) return; + + this.waitingForOktaNumberChallenge = true; + // keep polling /auth/okta/verify/:nonce API every 1s until response returns with correct_number + let response = null; + while (response === null) { + // disable polling for tests otherwise promises reject and acceptance tests fail + if (Ember.testing) return; + + yield timeout(1000); + response = yield this.auth.getOktaNumberChallengeAnswer(nonce, mount); + } + // display correct number so user can select on personal MFA device + this.oktaNumberChallengeAnswer = response; + } + + @action + onCancel() { + // reset variables and stop polling tasks if canceling login + this.authError = null; + this.oktaNumberChallengeAnswer = null; + this.waitingForOktaNumberChallenge = false; + this.authenticate.cancelAll(); + this.pollForOktaNumberChallenge.cancelAll(); + } +} diff --git a/ui/app/components/auth/page.hbs b/ui/app/components/auth/page.hbs index e8b4d0c2e2..046a9655d9 100644 --- a/ui/app/components/auth/page.hbs +++ b/ui/app/components/auth/page.hbs @@ -3,21 +3,117 @@ SPDX-License-Identifier: BUSL-1.1 ~}} -{{#if this.waitingForOktaNumberChallenge}} - +{{#if this.mfaErrors}} +
+ + + +
{{else}} - + + <:header> + {{#if @oidcProviderQueryParam}} +
+ +
+ {{else}} +
+
+ +
+
+
+ {{#if this.mfaAuthData}} + + {{/if}} +

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

+
+ {{/if}} + + + <:subHeader> + {{#if (has-feature "Namespaces")}} + {{#unless this.mfaAuthData}} + +
+
+ +
+ {{#if this.flags.hvdManagedNamespaceRoot}} +
+ /{{this.flags.hvdManagedNamespaceRoot}} +
+ {{/if}} +
+
+
+ +
+
+
+
+
+ {{/unless}} + {{/if}} + + + <:content> + {{#if this.mfaAuthData}} + + {{else}} + + {{/if}} + + + <:footer> +
+

+ {{#if @oidcProviderQueryParam}} + Once you log in, you will be redirected back to your application. If you require login credentials, contact your + administrator. + {{else}} + Contact your administrator for login credentials. + {{/if}} +

+
+ +
{{/if}} \ No newline at end of file diff --git a/ui/app/components/auth/page.js b/ui/app/components/auth/page.js index 83fb397116..85dc29c207 100644 --- a/ui/app/components/auth/page.js +++ b/ui/app/components/auth/page.js @@ -4,10 +4,7 @@ */ import Component from '@glimmer/component'; -import Ember from 'ember'; import { service } from '@ember/service'; -import { task, timeout } from 'ember-concurrency'; -import { waitFor } from '@ember/test-waiters'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; @@ -16,93 +13,60 @@ import { action } from '@ember/object'; * The Auth::Page wraps OktaNumberChallenge and AuthForm to manage the login flow and is responsible for calling the authenticate method * * @example - * + * * - * @param {string} wrappedToken - Query param value of a wrapped token that can be used to login when added directly to the URL via the "wrapped_token" query param - * @param {object} cluster - The route model which is the ember data cluster model. contains information such as cluster id, name and boolean for if the cluster is in standby - * @param {string} namespace- Namespace query param, passed to AuthForm and set by typing in namespace input or URL - * @param {string} selectedAuth - The auth method selected in the dropdown, passed to auth service's authenticate method - * @param {function} onSuccess - Callback that fires the "onAuthResponse" action in the auth controller and handles transitioning after success - */ + * @param {string} authMethodQueryParam - auth method type to login with, updated by selecting an auth method from the dropdown + * @param {string} namespaceQueryParam - namespace to login with, updated by typing in to the namespace input + * @param {string} oidcProviderQueryParam - oidc provider query param, set in url as "?o=someprovider" + * @param {function} onAuthSuccess - callback task in controller that receives the auth response (after MFA, if enabled) when login is successful + * @param {function} onNamespaceUpdate - callback task that passes user input to the controller to update the login namespace in the url query params + * @param {string} wrappedToken - passed down to the AuthForm component and can be used to login if added directly to the URL via the "wrapped_token" query param + * */ -export default class AuthPageComponent extends Component { - @service auth; +export default class AuthPage extends Component { + @service flags; - @tracked authError = null; - @tracked oktaNumberChallengeAnswer = ''; - @tracked waitingForOktaNumberChallenge = false; + @tracked mfaErrors; + @tracked mfaAuthData; - @action - performAuth(backendType, data) { - this.authenticate.unlinked().perform(backendType, data); + get namespaceInput() { + const namespaceQP = this.args.namespaceQueryParam; + if (this.flags.hvdManagedNamespaceRoot) { + // When managed, the user isn't allowed to edit the prefix `admin/` for their nested namespace + const split = namespaceQP.split('/'); + if (split.length > 1) { + split.shift(); + return `/${split.join('/')}`; + } + return ''; + } + return namespaceQP; } - @task - @waitFor - *delayAuthMessageReminder() { - if (Ember.testing) { - yield timeout(0); + @action + handleNamespaceUpdate(event) { + this.args.onNamespaceUpdate(event.target.value); + } + + @action + onAuthResponse(authResponse, backend, data) { + const { mfa_requirement } = authResponse; + // if an mfa requirement exists further action is required + if (mfa_requirement) { + this.mfaAuthData = { mfa_requirement, backend, data }; } else { - yield timeout(5000); + this.args.onAuthSuccess(authResponse); } } - @task - @waitFor - *authenticate(backendType, data) { - const { - selectedAuth, - cluster: { id: clusterId }, - } = this.args; - try { - if (backendType === 'okta') { - this.pollForOktaNumberChallenge.perform(data.nonce, data.path); - } else { - this.delayAuthMessageReminder.perform(); - } - const authResponse = yield this.auth.authenticate({ - clusterId, - backend: backendType, - data, - selectedAuth, - }); - - this.args.onSuccess(authResponse, backendType, data); - } catch (e) { - if (!this.auth.mfaError) { - this.authError = `Authentication failed: ${this.auth.handleError(e)}`; - } - } - } - - @task - @waitFor - *pollForOktaNumberChallenge(nonce, mount) { - // yield for 1s to wait to see if there is a login error before polling - yield timeout(1000); - if (this.authError) return; - - this.waitingForOktaNumberChallenge = true; - // keep polling /auth/okta/verify/:nonce API every 1s until response returns with correct_number - let response = null; - while (response === null) { - // disable polling for tests otherwise promises reject and acceptance tests fail - if (Ember.testing) return; - - yield timeout(1000); - response = yield this.auth.getOktaNumberChallengeAnswer(nonce, mount); - } - // display correct number so user can select on personal MFA device - this.oktaNumberChallengeAnswer = response; - } - @action - onCancel() { - // reset variables and stop polling tasks if canceling login - this.authError = null; - this.oktaNumberChallengeAnswer = null; - this.waitingForOktaNumberChallenge = false; - this.authenticate.cancelAll(); - this.pollForOktaNumberChallenge.cancelAll(); + onMfaSuccess(authResponse) { + this.args.onAuthSuccess(authResponse); + } + + @action + onMfaErrorDismiss() { + this.mfaAuthData = null; + this.mfaErrors = null; } } diff --git a/ui/app/controllers/vault/cluster/auth.js b/ui/app/controllers/vault/cluster/auth.js index 66a798cac0..b68f22285f 100644 --- a/ui/app/controllers/vault/cluster/auth.js +++ b/ui/app/controllers/vault/cluster/auth.js @@ -26,20 +26,6 @@ export default Controller.extend({ authMethod: '', oidcProvider: '', - get namespaceInput() { - const namespaceQP = this.clusterController.namespaceQueryParam; - if (this.hvdManagedNamespaceRoot) { - // When managed, the user isn't allowed to edit the prefix `admin/` for their nested namespace - const split = namespaceQP.split('/'); - if (split.length > 1) { - split.shift(); - return `/${split.join('/')}`; - } - return ''; - } - return namespaceQP; - }, - fullNamespaceFromInput(value) { const strippedNs = sanitizePath(value); if (this.hvdManagedNamespaceRoot) { @@ -57,48 +43,29 @@ export default Controller.extend({ this.set('namespaceQueryParam', ns); }).restartable(), - authSuccess({ isRoot, namespace }) { - let transition; - this.version.fetchVersion(); - 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 (this.version.isEnterprise) { - this.customMessages.fetchMessages(namespace); - } - - if (isRoot) { - this.auth.set('isRootToken', true); - 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) { - const { mfa_requirement } = authResponse; - // if an mfa requirement exists further action is required - if (mfa_requirement) { - this.set('mfaAuthData', { mfa_requirement, backend, data }); + authSuccess({ isRoot, namespace }) { + let transition; + this.version.fetchVersion(); + 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 { - this.authSuccess(authResponse); + transition = this.router.transitionTo('vault.cluster', { queryParams: { namespace } }); } - }, - onMfaSuccess(authResponse) { - this.authSuccess(authResponse); - }, - onMfaErrorDismiss() { - this.setProperties({ - mfaAuthData: null, - mfaErrors: null, + transition.followRedirects().then(() => { + if (this.version.isEnterprise) { + this.customMessages.fetchMessages(namespace); + } + + if (isRoot) { + this.auth.set('isRootToken', true); + 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.' + ); + } }); }, }, diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index cc369c819f..c070a7794c 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -371,6 +371,7 @@ export default Service.extend({ shouldRenew() { const now = this.now(); const lastFetch = this.lastFetch; + // renewAfterEpoch is a unix timestamp of login time + half of ttl const renewTime = this.renewAfterEpoch; if (!this.currentTokenName || this.tokenExpired || this.allowExpiration || !renewTime) { return false; diff --git a/ui/app/templates/components/mfa/mfa-form.hbs b/ui/app/templates/components/mfa/mfa-form.hbs index c1f1b1d911..34808f8fa5 100644 --- a/ui/app/templates/components/mfa/mfa-form.hbs +++ b/ui/app/templates/components/mfa/mfa-form.hbs @@ -57,9 +57,7 @@ {{/each}} {{#if this.countdown}} -
- -
+ {{/if}} - - - - -{{else}} - - <:header> - {{#if this.oidcProvider}} -
- -
- {{else}} -
-
- -
-
-
- {{#if this.mfaAuthData}} - - {{/if}} -

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

-
- {{/if}} - - - <:subHeader> - {{#if (has-feature "Namespaces")}} - {{#unless this.mfaAuthData}} - -
-
- -
- {{#if this.hvdManagedNamespaceRoot}} -
- /{{this.hvdManagedNamespaceRoot}} -
- {{/if}} -
-
-
- -
-
-
-
-
- {{/unless}} - {{/if}} - - - <:content> - {{#if this.mfaAuthData}} - - {{else}} - - {{/if}} - - - <:footer> -
-

- {{#if this.oidcProvider}} - Once you log in, you will be redirected back to your application. If you require login credentials, contact your - administrator. - {{else}} - Contact your administrator for login credentials. - {{/if}} -

-
- -
-{{/if}} \ No newline at end of file + \ No newline at end of file diff --git a/ui/tests/acceptance/access/namespaces/index-test.js b/ui/tests/acceptance/access/namespaces/index-test.js index 0a64769e26..ae361e9505 100644 --- a/ui/tests/acceptance/access/namespaces/index-test.js +++ b/ui/tests/acceptance/access/namespaces/index-test.js @@ -3,24 +3,23 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { currentRouteName } from '@ember/test-helpers'; +import { currentRouteName, visit } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; -import page from 'vault/tests/pages/access/namespaces/index'; -import authPage from 'vault/tests/pages/auth'; +import { login } from 'vault/tests/helpers/auth/auth-helpers'; module('Acceptance | Enterprise | /access/namespaces', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); hooks.beforeEach(function () { - return authPage.login(); + return login(); }); test('it navigates to namespaces page', async function (assert) { assert.expect(1); - await page.visit(); + await visit('/vault/access/namespaces'); assert.strictEqual( currentRouteName(), 'vault.cluster.access.namespaces.index', @@ -30,7 +29,7 @@ module('Acceptance | Enterprise | /access/namespaces', function (hooks) { test('it should render correct number of namespaces', async function (assert) { assert.expect(3); - await page.visit(); + await visit('/vault/access/namespaces'); const store = this.owner.lookup('service:store'); // Default page size is 15 assert.strictEqual(store.peekAll('namespace').length, 15, 'Store has 15 namespaces records'); diff --git a/ui/tests/helpers/auth/auth-helpers.ts b/ui/tests/helpers/auth/auth-helpers.ts new file mode 100644 index 0000000000..2e82473a1b --- /dev/null +++ b/ui/tests/helpers/auth/auth-helpers.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { click, fillIn, visit } from '@ember/test-helpers'; +import VAULT_KEYS from 'vault/tests/helpers/vault-keys'; +import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; + +const { rootToken } = VAULT_KEYS; + +export const login = async (token = rootToken) => { + // make sure we're always logged out and logged back in + await visit('/vault/logout'); + // clear session storage to ensure we have a clean state + window.localStorage.clear(); + await visit('/vault/auth?with=token'); + await fillIn(AUTH_FORM.input('token'), token); + return await click(AUTH_FORM.login); +}; diff --git a/ui/tests/integration/components/auth/page-test.js b/ui/tests/integration/components/auth/page-test.js index b9b55abd4d..45628cd391 100644 --- a/ui/tests/integration/components/auth/page-test.js +++ b/ui/tests/integration/components/auth/page-test.js @@ -28,7 +28,7 @@ module('Integration | Component | auth | page ', function (hooks) { this.renderComponent = async () => { return render(hbs` -