UI: Refactor auth controller so it does less (#27710)

* move some auth controller logic to route page component

* remove unused vars

* fix action handling so this context is retained

* rename authpage to auth-form-page

* rename auth-route-page to auth-splash-page

* link jira VAULT-28251

* wowww typo

* add padding to mfa form alert message

* update component name in tests

* alphabetize args

* use auth helpers for login method

* remove async, await

* rename components

* update jsdoc

* add comment
This commit is contained in:
claire bontempo 2024-07-15 10:49:06 -07:00 committed by GitHub
parent d9cd3a094a
commit d35a915f57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 344 additions and 283 deletions

View File

@ -0,0 +1,23 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{#if this.waitingForOktaNumberChallenge}}
<OktaNumberChallenge
@correctAnswer={{this.oktaNumberChallengeAnswer}}
@hasError={{this.authError}}
@onReturnToLogin={{this.onCancel}}
/>
{{else}}
<AuthForm
@wrappedToken={{@wrappedToken}}
@cluster={{@cluster}}
@namespace={{@namespace}}
@selectedAuth={{@selectedAuth}}
@error={{this.authError}}
@performAuth={{this.performAuth}}
@authIsRunning={{this.authenticate.isRunning}}
@delayIsIdle={{this.delayAuthMessageReminder.isIdle}}
/>
{{/if}}

View File

@ -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
* <Auth::LoginForm @wrappedToken={{this.wrappedToken}} @cluster={{this.model}} @namespace={{this.namespaceQueryParam}} @selectedAuth={{this.authMethod}} @onSuccess={{action "onAuthResponse"}} />
*
* @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();
}
}

View File

@ -3,21 +3,117 @@
SPDX-License-Identifier: BUSL-1.1
~}}
{{#if this.waitingForOktaNumberChallenge}}
<OktaNumberChallenge
@correctAnswer={{this.oktaNumberChallengeAnswer}}
@hasError={{this.authError}}
@onReturnToLogin={{this.onCancel}}
/>
{{#if this.mfaErrors}}
<div class="has-top-margin-xxl" data-test-mfa-error>
<EmptyState
@title="Unauthorized"
@message="Multi-factor authentication is required, but failed. Go back and try again, or contact your administrator."
@icon="alert-circle"
@bottomBorder={{true}}
@subTitle={{join ". " this.mfaErrors}}
class="is-shadowless"
>
<Hds::Button @text="Go back" @icon="chevron-left" @color="tertiary" {{on "click" this.onMfaErrorDismiss}} />
</EmptyState>
</div>
{{else}}
<AuthForm
@wrappedToken={{@wrappedToken}}
@cluster={{@cluster}}
@namespace={{@namespace}}
@selectedAuth={{@selectedAuth}}
@error={{this.authError}}
@performAuth={{this.performAuth}}
@authIsRunning={{this.authenticate.isRunning}}
@delayIsIdle={{this.delayAuthMessageReminder.isIdle}}
/>
<SplashPage>
<:header>
{{#if @oidcProviderQueryParam}}
<div class="box is-shadowless is-flex-v-centered" data-test-auth-logo>
<LogoEdition aria-label="Sign in with Hashicorp Vault" />
</div>
{{else}}
<div class="is-flex-v-centered has-bottom-margin-xxl">
<div class="brand-icon-large">
<Icon @name="vault" @size="24" @stretched={{true}} />
</div>
</div>
<div class="is-flex-row">
{{#if this.mfaAuthData}}
<Hds::Button
@text="Back to login"
@icon="arrow-left"
@isIconOnly={{true}}
@color="tertiary"
{{on "click" (fn (mut this.mfaAuthData) null)}}
/>
{{/if}}
<h1 class="title is-3">
{{if this.mfaAuthData "Authenticate" "Sign in to Vault"}}
</h1>
</div>
{{/if}}
</:header>
<:subHeader>
{{#if (has-feature "Namespaces")}}
{{#unless this.mfaAuthData}}
<Toolbar class="toolbar-namespace-picker">
<div class="field is-horizontal" data-test-namespace-toolbar>
<div class="field-label is-normal">
<label class="is-label" for="namespace">Namespace</label>
</div>
{{#if this.flags.hvdManagedNamespaceRoot}}
<div class="field-label">
<span class="has-text-grey" data-test-managed-namespace-root>/{{this.flags.hvdManagedNamespaceRoot}}</span>
</div>
{{/if}}
<div class="field-body">
<div class="field">
<div class="control">
<input
data-test-auth-form-ns-input
value={{this.namespaceInput}}
placeholder={{if this.flags.hvdManagedNamespaceRoot "/ (Default)" "/ (Root)"}}
{{on "input" this.handleNamespaceUpdate}}
autocomplete="off"
spellcheck="false"
name="namespace"
id="namespace"
class="input"
type="text"
disabled={{@oidcProviderQueryParam}}
/>
</div>
</div>
</div>
</div>
</Toolbar>
{{/unless}}
{{/if}}
</:subHeader>
<:content>
{{#if this.mfaAuthData}}
<Mfa::MfaForm
@clusterId={{@cluster.id}}
@authData={{this.mfaAuthData}}
@onSuccess={{this.onMfaSuccess}}
@onError={{fn (mut this.mfaErrors)}}
/>
{{else}}
<Auth::LoginForm
@wrappedToken={{@wrappedToken}}
@cluster={{@cluster}}
@namespace={{@namespaceQueryParam}}
@selectedAuth={{@authMethodQueryParam}}
@onSuccess={{this.onAuthResponse}}
/>
{{/if}}
</:content>
<:footer>
<div class="has-short-padding">
<p class="help has-text-grey-dark" data-test-auth-helptext>
{{#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}}
</p>
</div>
</:footer>
</SplashPage>
{{/if}}

View File

@ -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
* <Auth::Page @wrappedToken={{this.wrappedToken}} @cluster={{this.model}} @namespace={{this.namespaceQueryParam}} @selectedAuth={{this.authMethod}} @onSuccess={{action "onAuthResponse"}} />
* <Auth::Page @namespaceQueryParam={{this.namespaceQueryParam}} @onAuthSuccess={{action "authSuccess"}} @oidcProviderQueryParam={{this.oidcProvider}} @cluster={{this.model}} @onNamespaceUpdate={{perform this.updateNamespace}} />
*
* @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;
}
}

View File

@ -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.'
);
}
});
},
},

View File

@ -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;

View File

@ -57,9 +57,7 @@
{{/each}}
</div>
{{#if this.countdown}}
<div>
<AlertInline @type="danger" @message={{this.codeDelayMessage}} />
</div>
<AlertInline @type="danger" class="has-bottom-padding-m" @message={{this.codeDelayMessage}} />
{{/if}}
<Hds::Button
@text="Verify"

View File

@ -3,117 +3,12 @@
SPDX-License-Identifier: BUSL-1.1
~}}
{{#if this.mfaErrors}}
<div class="has-top-margin-xxl" data-test-mfa-error>
<EmptyState
@title="Unauthorized"
@message="Multi-factor authentication is required, but failed. Go back and try again, or contact your administrator."
@icon="alert-circle"
@bottomBorder={{true}}
@subTitle={{join ". " this.mfaErrors}}
class="is-shadowless"
>
<Hds::Button @text="Go back" @icon="chevron-left" @color="tertiary" {{on "click" (action "onMfaErrorDismiss")}} />
</EmptyState>
</div>
{{else}}
<SplashPage>
<:header>
{{#if this.oidcProvider}}
<div class="box is-shadowless is-flex-v-centered" data-test-auth-logo>
<LogoEdition aria-label="Sign in with Hashicorp Vault" />
</div>
{{else}}
<div class="is-flex-v-centered has-bottom-margin-xxl">
<div class="brand-icon-large">
<Icon @name="vault" @size="24" @stretched={{true}} />
</div>
</div>
<div class="is-flex-row">
{{#if this.mfaAuthData}}
<Hds::Button
@text="Back to login"
@icon="arrow-left"
@isIconOnly={{true}}
@color="tertiary"
{{on "click" (fn (mut this.mfaAuthData) null)}}
/>
{{/if}}
<h1 class="title is-3">
{{if this.mfaAuthData "Authenticate" "Sign in to Vault"}}
</h1>
</div>
{{/if}}
</:header>
<:subHeader>
{{#if (has-feature "Namespaces")}}
{{#unless this.mfaAuthData}}
<Toolbar class="toolbar-namespace-picker">
<div class="field is-horizontal" data-test-namespace-toolbar>
<div class="field-label is-normal">
<label class="is-label" for="namespace">Namespace</label>
</div>
{{#if this.hvdManagedNamespaceRoot}}
<div class="field-label">
<span class="has-text-grey" data-test-managed-namespace-root>/{{this.hvdManagedNamespaceRoot}}</span>
</div>
{{/if}}
<div class="field-body">
<div class="field">
<div class="control">
<input
data-test-auth-form-ns-input
value={{this.namespaceInput}}
placeholder={{if this.hvdManagedNamespaceRoot "/ (Default)" "/ (Root)"}}
{{on "input" (perform this.updateNamespace value="target.value")}}
autocomplete="off"
spellcheck="false"
name="namespace"
id="namespace"
class="input"
type="text"
disabled={{this.oidcProvider}}
/>
</div>
</div>
</div>
</div>
</Toolbar>
{{/unless}}
{{/if}}
</:subHeader>
<:content>
{{#if this.mfaAuthData}}
<Mfa::MfaForm
@clusterId={{this.model.id}}
@authData={{this.mfaAuthData}}
@onSuccess={{action "onMfaSuccess"}}
@onError={{fn (mut this.mfaErrors)}}
/>
{{else}}
<Auth::Page
@wrappedToken={{this.wrappedToken}}
@cluster={{this.model}}
@namespace={{this.namespaceQueryParam}}
@selectedAuth={{this.authMethod}}
@onSuccess={{action "onAuthResponse"}}
/>
{{/if}}
</:content>
<:footer>
<div class="has-short-padding">
<p class="help has-text-grey-dark" data-test-auth-helptext>
{{#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}}
</p>
</div>
</:footer>
</SplashPage>
{{/if}}
<Auth::Page
@authMethodQueryParam={{this.authMethod}}
@cluster={{this.model}}
@namespaceQueryParam={{this.namespaceQueryParam}}
@oidcProviderQueryParam={{this.oidcProvider}}
@onAuthSuccess={{action "authSuccess"}}
@onNamespaceUpdate={{perform this.updateNamespace}}
@wrappedToken={{this.wrappedToken}}
/>

View File

@ -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');

View File

@ -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);
};

View File

@ -28,7 +28,7 @@ module('Integration | Component | auth | page ', function (hooks) {
this.renderComponent = async () => {
return render(hbs`
<Auth::Page
<Auth::LoginForm
@wrappedToken={{this.wrappedToken}}
@cluster={{this.cluster}}
@namespace={{this.namespaceQueryParam}}

View File

@ -1,10 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { create, visitable } from 'ember-cli-page-object';
export default create({
visit: visitable('/vault/access/namespaces'),
});