open-vault/ui/app/components/auth-form.js
Jordan Reimer 7bd1992bc5
MFA UI Changes (v3) (#14145)
* adds development workflow to mirage config

* adds mirage handler and factory for mfa workflow

* adds mfa handling to auth service and cluster adapter

* moves auth success logic from form to controller

* adds mfa form component

* shows delayed auth message for all methods

* adds new code delay to mfa form

* adds error views

* fixes merge conflict

* adds integration tests for mfa-form component

* fixes auth tests

* updates mfa response handling to align with backend

* updates mfa-form to handle multiple methods and constraints

* adds noDefault arg to Select component

* updates mirage mfa handler to align with backend and adds generator for various mfa scenarios

* adds tests

* flaky test fix attempt

* reverts test fix attempt

* adds changelog entry

* updates comments for todo items

* removes faker from mfa mirage factory and handler

* adds number to word helper

* fixes tests
2022-02-17 15:40:25 -07:00

271 lines
7.9 KiB
JavaScript

import Ember from 'ember';
import { next } from '@ember/runloop';
import { inject as service } from '@ember/service';
import { match, alias, or } from '@ember/object/computed';
import { assign } from '@ember/polyfills';
import { dasherize } from '@ember/string';
import Component from '@ember/component';
import { computed } from '@ember/object';
import { supportedAuthBackends } from 'vault/helpers/supported-auth-backends';
import { task, timeout } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
const BACKENDS = supportedAuthBackends();
/**
* @module AuthForm
* The `AuthForm` is used to sign users into Vault.
*
* @example ```js
* // All properties are passed in via query params.
* <AuthForm @wrappedToken={{wrappedToken}} @cluster={{model}} @namespace={{namespaceQueryParam}} @selectedAuth={{authMethod}} @onSuccess={{action this.onSuccess}} />```
*
* @param {string} wrappedToken - The auth method that is currently selected in the dropdown.
* @param {object} cluster - The auth method that is currently selected in the dropdown. This corresponds to an Ember Model.
* @param {string} namespace- The currently active namespace.
* @param {string} selectedAuth - The auth method that is currently selected in the dropdown.
* @param {function} onSuccess - Fired on auth success
*/
const DEFAULTS = {
token: null,
username: null,
password: null,
customPath: null,
};
export default Component.extend(DEFAULTS, {
router: service(),
auth: service(),
flashMessages: service(),
store: service(),
csp: service('csp-event'),
// passed in via a query param
selectedAuth: null,
methods: null,
cluster: null,
namespace: null,
wrappedToken: null,
// internal
oldNamespace: null,
didReceiveAttrs() {
this._super(...arguments);
let {
wrappedToken: token,
oldWrappedToken: oldToken,
oldNamespace: oldNS,
namespace: ns,
selectedAuth: newMethod,
oldSelectedAuth: oldMethod,
} = this;
next(() => {
if (!token && (oldNS === null || oldNS !== ns)) {
this.fetchMethods.perform();
}
this.set('oldNamespace', ns);
// we only want to trigger this once
if (token && !oldToken) {
this.unwrapToken.perform(token);
this.set('oldWrappedToken', token);
}
if (oldMethod && oldMethod !== newMethod) {
this.resetDefaults();
}
this.set('oldSelectedAuth', newMethod);
});
},
didRender() {
this._super(...arguments);
// on very narrow viewports the active tab may be overflowed, so we scroll it into view here
let activeEle = this.element.querySelector('li.is-active');
if (activeEle) {
activeEle.scrollIntoView();
}
next(() => {
let firstMethod = this.firstMethod();
// set `with` to the first method
if (
!this.wrappedToken &&
((this.fetchMethods.isIdle && firstMethod && !this.selectedAuth) ||
(this.selectedAuth && !this.selectedAuthBackend))
) {
this.set('selectedAuth', firstMethod);
}
});
},
firstMethod() {
let firstMethod = this.methodsToShow.firstObject;
if (!firstMethod) return;
// prefer backends with a path over those with a type
return firstMethod.path || firstMethod.type;
},
resetDefaults() {
this.setProperties(DEFAULTS);
},
selectedAuthIsPath: match('selectedAuth', /\/$/),
selectedAuthBackend: computed(
'wrappedToken',
'methods',
'methods.[]',
'selectedAuth',
'selectedAuthIsPath',
function () {
let { wrappedToken, methods, selectedAuth, selectedAuthIsPath: keyIsPath } = this;
if (!methods && !wrappedToken) {
return {};
}
if (keyIsPath) {
return methods.findBy('path', selectedAuth);
}
return BACKENDS.findBy('type', selectedAuth);
}
),
providerName: computed('selectedAuthBackend.type', function () {
if (!this.selectedAuthBackend) {
return;
}
let type = this.selectedAuthBackend.type || 'token';
type = type.toLowerCase();
let templateName = dasherize(type);
return templateName;
}),
hasCSPError: alias('csp.connectionViolations.firstObject'),
cspErrorText: `This is a standby Vault node but can't communicate with the active node via request forwarding. Sign in at the active node to use the Vault UI.`,
allSupportedMethods: computed('methodsToShow', 'hasMethodsWithPath', function () {
let hasMethodsWithPath = this.hasMethodsWithPath;
let methodsToShow = this.methodsToShow;
return hasMethodsWithPath ? methodsToShow.concat(BACKENDS) : methodsToShow;
}),
hasMethodsWithPath: computed('methodsToShow', function () {
return this.methodsToShow.isAny('path');
}),
methodsToShow: computed('methods', function () {
let methods = this.methods || [];
let shownMethods = methods.filter((m) =>
BACKENDS.find((b) => b.type.toLowerCase() === m.type.toLowerCase())
);
return shownMethods.length ? shownMethods : BACKENDS;
}),
unwrapToken: task(
waitFor(function* (token) {
// will be using the Token Auth Method, so set it here
this.set('selectedAuth', 'token');
let adapter = this.store.adapterFor('tools');
try {
let response = yield adapter.toolAction('unwrap', null, { clientToken: token });
this.set('token', response.auth.client_token);
this.send('doSubmit');
} catch (e) {
this.set('error', `Token unwrap failed: ${e.errors[0]}`);
}
})
),
fetchMethods: task(
waitFor(function* () {
let store = this.store;
try {
let methods = yield store.findAll('auth-method', {
adapterOptions: {
unauthenticated: true,
},
});
this.set(
'methods',
methods.map((m) => {
const method = m.serialize({ includeId: true });
return {
...method,
mountDescription: method.description,
};
})
);
next(() => {
store.unloadAll('auth-method');
});
} catch (e) {
this.set('error', `There was an error fetching Auth Methods: ${e.errors[0]}`);
}
})
),
showLoading: or('isLoading', 'authenticate.isRunning', 'fetchMethods.isRunning', 'unwrapToken.isRunning'),
authenticate: task(
waitFor(function* (backendType, data) {
let clusterId = this.cluster.id;
try {
this.delayAuthMessageReminder.perform();
const authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data });
this.onSuccess(authResponse, backendType, data);
} catch (e) {
this.set('loading', false);
if (!this.auth.mfaError) {
this.set('error', `Authentication failed: ${this.auth.handleError(e)}`);
}
}
})
),
delayAuthMessageReminder: task(function* () {
if (Ember.testing) {
this.showLoading = true;
yield timeout(0);
} else {
yield timeout(5000);
}
}),
actions: {
doSubmit() {
let passedData, e;
if (arguments.length > 1) {
[passedData, e] = arguments;
} else {
[e] = arguments;
}
if (e) {
e.preventDefault();
}
let data = {};
this.setProperties({
error: null,
});
let backend = this.selectedAuthBackend || {};
let backendMeta = BACKENDS.find(
(b) => (b.type || '').toLowerCase() === (backend.type || '').toLowerCase()
);
let attributes = (backendMeta || {}).formAttributes || [];
data = assign(data, this.getProperties(...attributes));
if (passedData) {
data = assign(data, passedData);
}
if (this.customPath || backend.id) {
data.path = this.customPath || backend.id;
}
return this.authenticate.unlinked().perform(backend.type, data);
},
handleError(e) {
this.setProperties({
loading: false,
error: e ? this.auth.handleError(e) : null,
});
},
},
});