336 lines
11 KiB
JavaScript
336 lines
11 KiB
JavaScript
/**
|
|
* Copyright (c) HashiCorp, Inc.
|
|
* SPDX-License-Identifier: MPL-2.0
|
|
*/
|
|
|
|
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 { 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';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
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.
|
|
* @param {function} [setOktaNumberChallenge] - Sets whether we are waiting for okta number challenge to be used to sign in.
|
|
* @param {boolean} [waitingForOktaNumberChallenge=false] - Determines if we are waiting for the Okta Number Challenge to sign in.
|
|
* @param {function} [setCancellingAuth] - Sets whether we are cancelling or not the login authentication for Okta Number Challenge.
|
|
* @param {boolean} [cancelAuthForOktaNumberChallenge=false] - Determines if we are cancelling the login authentication for the Okta Number Challenge.
|
|
*/
|
|
|
|
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,
|
|
authMethods: BACKENDS,
|
|
|
|
// number answer for okta number challenge if applicable
|
|
oktaNumberChallengeAnswer: null,
|
|
|
|
didReceiveAttrs() {
|
|
this._super(...arguments);
|
|
const {
|
|
wrappedToken: token,
|
|
oldWrappedToken: oldToken,
|
|
oldNamespace: oldNS,
|
|
namespace: ns,
|
|
selectedAuth: newMethod,
|
|
oldSelectedAuth: oldMethod,
|
|
cancelAuthForOktaNumberChallenge: cancelAuth,
|
|
} = this;
|
|
// if we are cancelling the login then we reset the number challenge answer and cancel the current authenticate and polling tasks
|
|
if (cancelAuth) {
|
|
this.set('oktaNumberChallengeAnswer', null);
|
|
this.authenticate.cancelAll();
|
|
this.pollForOktaNumberChallenge.cancelAll();
|
|
}
|
|
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
|
|
const activeEle = this.element.querySelector('li.is-active');
|
|
if (activeEle) {
|
|
activeEle.scrollIntoView();
|
|
}
|
|
|
|
next(() => {
|
|
const 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() {
|
|
const 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);
|
|
},
|
|
|
|
getAuthBackend(type) {
|
|
const { wrappedToken, methods, selectedAuth, selectedAuthIsPath: keyIsPath } = this;
|
|
const selected = type || selectedAuth;
|
|
if (!methods && !wrappedToken) {
|
|
return {};
|
|
}
|
|
// if type is provided we can ignore path since we are attempting to lookup a specific backend by type
|
|
if (keyIsPath && !type) {
|
|
return methods.findBy('path', selected);
|
|
}
|
|
return BACKENDS.findBy('type', selected);
|
|
},
|
|
|
|
selectedAuthIsPath: match('selectedAuth', /\/$/),
|
|
selectedAuthBackend: computed(
|
|
'wrappedToken',
|
|
'methods',
|
|
'methods.[]',
|
|
'selectedAuth',
|
|
'selectedAuthIsPath',
|
|
function () {
|
|
return this.getAuthBackend();
|
|
}
|
|
),
|
|
|
|
providerName: computed('selectedAuthBackend.type', function () {
|
|
if (!this.selectedAuthBackend) {
|
|
return;
|
|
}
|
|
let type = this.selectedAuthBackend.type || 'token';
|
|
type = type.toLowerCase();
|
|
const 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 () {
|
|
const hasMethodsWithPath = this.hasMethodsWithPath;
|
|
const methodsToShow = this.methodsToShow;
|
|
return hasMethodsWithPath ? methodsToShow.concat(BACKENDS) : methodsToShow;
|
|
}),
|
|
|
|
hasMethodsWithPath: computed('methodsToShow', function () {
|
|
return this.methodsToShow.isAny('path');
|
|
}),
|
|
methodsToShow: computed('methods', function () {
|
|
const methods = this.methods || [];
|
|
const 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');
|
|
const adapter = this.store.adapterFor('tools');
|
|
try {
|
|
const 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* () {
|
|
const store = this.store;
|
|
try {
|
|
const 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) {
|
|
const {
|
|
selectedAuth,
|
|
cluster: { id: clusterId },
|
|
} = this;
|
|
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.onSuccess(authResponse, backendType, data);
|
|
} catch (e) {
|
|
this.set('isLoading', false);
|
|
if (!this.auth.mfaError) {
|
|
this.set('error', `Authentication failed: ${this.auth.handleError(e)}`);
|
|
}
|
|
}
|
|
})
|
|
),
|
|
|
|
pollForOktaNumberChallenge: task(function* (nonce, mount) {
|
|
// yield for 1s to wait to see if there is a login error before polling
|
|
yield timeout(1000);
|
|
if (this.error) {
|
|
return;
|
|
}
|
|
let response = null;
|
|
this.setOktaNumberChallenge(true);
|
|
this.setCancellingAuth(false);
|
|
// keep polling /auth/okta/verify/:nonce API every 1s until a response is given with the correct number for the Okta Number Challenge
|
|
while (response === null) {
|
|
// when testing, the polling loop causes promises to be rejected making acceptance tests fail
|
|
// so disable the poll in tests
|
|
if (Ember.testing) {
|
|
return;
|
|
}
|
|
yield timeout(1000);
|
|
response = yield this.auth.getOktaNumberChallengeAnswer(nonce, mount);
|
|
}
|
|
this.set('oktaNumberChallengeAnswer', response);
|
|
}),
|
|
|
|
delayAuthMessageReminder: task(function* () {
|
|
if (Ember.testing) {
|
|
yield timeout(0);
|
|
} else {
|
|
yield timeout(5000);
|
|
}
|
|
}),
|
|
|
|
actions: {
|
|
doSubmit(passedData, event, token) {
|
|
if (event) {
|
|
event.preventDefault();
|
|
}
|
|
if (token) {
|
|
this.set('token', token);
|
|
}
|
|
this.set('error', null);
|
|
// if callback from oidc or jwt we have a token at this point
|
|
const backend = token ? this.getAuthBackend('token') : this.selectedAuthBackend || {};
|
|
const backendMeta = BACKENDS.find(
|
|
(b) => (b.type || '').toLowerCase() === (backend.type || '').toLowerCase()
|
|
);
|
|
const attributes = (backendMeta || {}).formAttributes || [];
|
|
const data = this.getProperties(...attributes);
|
|
|
|
if (passedData) {
|
|
Object.assign(data, passedData);
|
|
}
|
|
if (this.customPath || backend.id) {
|
|
data.path = this.customPath || backend.id;
|
|
}
|
|
// add nonce field for okta backend
|
|
if (backend.type === 'okta') {
|
|
data.nonce = uuidv4();
|
|
// add a default path of okta if it doesn't exist to be used for Okta Number Challenge
|
|
if (!data.path) {
|
|
data.path = 'okta';
|
|
}
|
|
}
|
|
return this.authenticate.unlinked().perform(backend.type, data);
|
|
},
|
|
handleError(e) {
|
|
this.setProperties({
|
|
isLoading: false,
|
|
error: e ? this.auth.handleError(e) : null,
|
|
});
|
|
},
|
|
returnToLoginFromOktaNumberChallenge() {
|
|
this.setOktaNumberChallenge(false);
|
|
this.set('oktaNumberChallengeAnswer', null);
|
|
},
|
|
},
|
|
});
|