diff --git a/changelog/14049.txt b/changelog/14049.txt
new file mode 100644
index 000000000..93af683bb
--- /dev/null
+++ b/changelog/14049.txt
@@ -0,0 +1,3 @@
+```release-note:improvement
+ui: Adds multi-factor authentication support
+```
\ No newline at end of file
diff --git a/ui/app/adapters/cluster.js b/ui/app/adapters/cluster.js
index 4b52350b1..f86004426 100644
--- a/ui/app/adapters/cluster.js
+++ b/ui/app/adapters/cluster.js
@@ -126,6 +126,19 @@ export default ApplicationAdapter.extend({
return this.ajax(url, verb, options);
},
+ mfaValidate({ mfa_request_id, mfa_constraints }) {
+ const options = {
+ data: {
+ mfa_request_id,
+ mfa_payload: mfa_constraints.reduce((obj, { selectedMethod, passcode }) => {
+ obj[selectedMethod.id] = passcode ? [passcode] : [];
+ return obj;
+ }, {}),
+ },
+ };
+ return this.ajax('/v1/sys/mfa/validate', 'POST', options);
+ },
+
urlFor(endpoint) {
if (!ENDPOINTS.includes(endpoint)) {
throw new Error(
diff --git a/ui/app/components/auth-form.js b/ui/app/components/auth-form.js
index 5e7f8a7fc..8b1ed8aa8 100644
--- a/ui/app/components/auth-form.js
+++ b/ui/app/components/auth-form.js
@@ -18,13 +18,13 @@ const BACKENDS = supportedAuthBackends();
*
* @example ```js
* // All properties are passed in via query params.
- * ```
+ * ```
*
- * @param wrappedToken=null {String} - The auth method that is currently selected in the dropdown.
- * @param cluster=null {Object} - The auth method that is currently selected in the dropdown. This corresponds to an Ember Model.
- * @param namespace=null {String} - The currently active namespace.
- * @param redirectTo=null {String} - The name of the route to redirect to.
- * @param selectedAuth=null {String} - The auth method that is currently selected in the dropdown.
+ * @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 = {
@@ -45,7 +45,6 @@ export default Component.extend(DEFAULTS, {
selectedAuth: null,
methods: null,
cluster: null,
- redirectTo: null,
namespace: null,
wrappedToken: null,
// internal
@@ -206,54 +205,18 @@ export default Component.extend(DEFAULTS, {
showLoading: or('isLoading', 'authenticate.isRunning', 'fetchMethods.isRunning', 'unwrapToken.isRunning'),
- handleError(e, prefixMessage = true) {
- this.set('loading', false);
- let errors;
- if (e.errors) {
- errors = e.errors.map((error) => {
- if (error.detail) {
- return error.detail;
- }
- return error;
- });
- } else {
- errors = [e];
- }
- let message = prefixMessage ? 'Authentication failed: ' : '';
- this.set('error', `${message}${errors.join('.')}`);
- },
-
authenticate: task(
waitFor(function* (backendType, data) {
let clusterId = this.cluster.id;
try {
- if (backendType === 'okta') {
- this.delayAuthMessageReminder.perform();
- }
- let authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data });
-
- let { isRoot, namespace } = authResponse;
- let transition;
- let { redirectTo } = this;
- if (redirectTo) {
- // reset the value on the controller because it's bound here
- this.set('redirectTo', '');
- // here we don't need the namespace because it will be encoded in redirectTo
- transition = this.router.transitionTo(redirectTo);
- } else {
- transition = this.router.transitionTo('vault.cluster', { queryParams: { namespace } });
- }
- // returning this w/then because if we keep it
- // in the task, it will get cancelled when the component in un-rendered
- yield transition.followRedirects().then(() => {
- if (isRoot) {
- 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.'
- );
- }
- });
+ this.delayAuthMessageReminder.perform();
+ const authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data });
+ this.onSuccess(authResponse, backendType, data);
} catch (e) {
- this.handleError(e);
+ this.set('loading', false);
+ if (!this.auth.mfaError) {
+ this.set('error', `Authentication failed: ${this.auth.handleError(e)}`);
+ }
}
})
),
@@ -262,9 +225,9 @@ export default Component.extend(DEFAULTS, {
if (Ember.testing) {
this.showLoading = true;
yield timeout(0);
- return;
+ } else {
+ yield timeout(5000);
}
- yield timeout(5000);
}),
actions: {
@@ -298,11 +261,10 @@ export default Component.extend(DEFAULTS, {
return this.authenticate.unlinked().perform(backend.type, data);
},
handleError(e) {
- if (e) {
- this.handleError(e, false);
- } else {
- this.set('error', null);
- }
+ this.setProperties({
+ loading: false,
+ error: e ? this.auth.handleError(e) : null,
+ });
},
},
});
diff --git a/ui/app/components/mfa-error.js b/ui/app/components/mfa-error.js
new file mode 100644
index 000000000..ed894a051
--- /dev/null
+++ b/ui/app/components/mfa-error.js
@@ -0,0 +1,43 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { TOTP_NOT_CONFIGURED } from 'vault/services/auth';
+
+const TOTP_NA_MSG =
+ 'Multi-factor authentication is required, but you have not set it up. In order to do so, please contact your administrator.';
+const MFA_ERROR_MSG =
+ 'Multi-factor authentication is required, but failed. Go back and try again, or contact your administrator.';
+
+export { TOTP_NA_MSG, MFA_ERROR_MSG };
+
+/**
+ * @module MfaError
+ * MfaError components are used to display mfa errors
+ *
+ * @example
+ * ```js
+ *
+ * ```
+ */
+
+export default class MfaError extends Component {
+ @service auth;
+
+ get isTotp() {
+ return this.auth.mfaErrors.includes(TOTP_NOT_CONFIGURED);
+ }
+ get title() {
+ return this.isTotp ? 'TOTP not set up' : 'Unauthorized';
+ }
+ get description() {
+ return this.isTotp ? TOTP_NA_MSG : MFA_ERROR_MSG;
+ }
+
+ @action
+ onClose() {
+ this.auth.set('mfaErrors', null);
+ if (this.args.onClose) {
+ this.args.onClose();
+ }
+ }
+}
diff --git a/ui/app/components/mfa-form.js b/ui/app/components/mfa-form.js
new file mode 100644
index 000000000..6ba69b320
--- /dev/null
+++ b/ui/app/components/mfa-form.js
@@ -0,0 +1,89 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { action, set } from '@ember/object';
+import { task, timeout } from 'ember-concurrency';
+import { numberToWord } from 'vault/helpers/number-to-word';
+/**
+ * @module MfaForm
+ * The MfaForm component is used to enter a passcode when mfa is required to login
+ *
+ * @example
+ * ```js
+ *
+ * ```
+ * @param {string} clusterId - id of selected cluster
+ * @param {object} authData - data from initial auth request -- { mfa_requirement, backend, data }
+ * @param {function} onSuccess - fired when passcode passes validation
+ */
+
+export default class MfaForm extends Component {
+ @service auth;
+
+ @tracked passcode;
+ @tracked countdown;
+ @tracked errors;
+
+ get constraints() {
+ return this.args.authData.mfa_requirement.mfa_constraints;
+ }
+ get multiConstraint() {
+ return this.constraints.length > 1;
+ }
+ get singleConstraintMultiMethod() {
+ return !this.isMultiConstraint && this.constraints[0].methods.length > 1;
+ }
+ get singlePasscode() {
+ return (
+ !this.isMultiConstraint &&
+ this.constraints[0].methods.length === 1 &&
+ this.constraints[0].methods[0].uses_passcode
+ );
+ }
+ get description() {
+ let base = 'Multi-factor authentication is enabled for your account.';
+ if (this.singlePasscode) {
+ base += ' Enter your authentication code to log in.';
+ }
+ if (this.singleConstraintMultiMethod) {
+ base += ' Select the MFA method you wish to use.';
+ }
+ if (this.multiConstraint) {
+ const num = this.constraints.length;
+ base += ` ${numberToWord(num, true)} methods are required for successful authentication.`;
+ }
+ return base;
+ }
+
+ @task *validate() {
+ try {
+ const response = yield this.auth.totpValidate({
+ clusterId: this.args.clusterId,
+ ...this.args.authData,
+ });
+ this.args.onSuccess(response);
+ } catch (error) {
+ this.errors = error.errors;
+ // TODO: update if specific error can be parsed for incorrect passcode
+ // this.newCodeDelay.perform();
+ }
+ }
+
+ @task *newCodeDelay() {
+ this.passcode = null;
+ this.countdown = 30;
+ while (this.countdown) {
+ yield timeout(1000);
+ this.countdown--;
+ }
+ }
+
+ @action onSelect(constraint, id) {
+ set(constraint, 'selectedId', id);
+ set(constraint, 'selectedMethod', constraint.methods.findBy('id', id));
+ }
+ @action submit(e) {
+ e.preventDefault();
+ this.validate.perform();
+ }
+}
diff --git a/ui/app/controllers/vault/cluster/auth.js b/ui/app/controllers/vault/cluster/auth.js
index 103fff827..3e98db58e 100644
--- a/ui/app/controllers/vault/cluster/auth.js
+++ b/ui/app/controllers/vault/cluster/auth.js
@@ -8,14 +8,19 @@ export default Controller.extend({
clusterController: controller('vault.cluster'),
namespaceService: service('namespace'),
featureFlagService: service('featureFlag'),
- namespaceQueryParam: alias('clusterController.namespaceQueryParam'),
+ auth: service(),
+ router: service(),
+
queryParams: [{ authMethod: 'with', oidcProvider: 'o' }],
+
+ namespaceQueryParam: alias('clusterController.namespaceQueryParam'),
wrappedToken: alias('vaultController.wrappedToken'),
- authMethod: '',
- oidcProvider: '',
redirectTo: alias('vaultController.redirectTo'),
managedNamespaceRoot: alias('featureFlagService.managedNamespaceRoot'),
+ authMethod: '',
+ oidcProvider: '',
+
get managedNamespaceChild() {
let fullParam = this.namespaceQueryParam;
let split = fullParam.split('/');
@@ -41,4 +46,39 @@ export default Controller.extend({
this.namespaceService.setNamespace(value, true);
this.set('namespaceQueryParam', value);
}).restartable(),
+
+ authSuccess({ isRoot, namespace }) {
+ let transition;
+ 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 (isRoot) {
+ 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;
+ // mfa methods handled by the backend are validated immediately in the auth service
+ // if the user must choose between methods or enter passcodes further action is required
+ if (mfa_requirement) {
+ this.set('mfaAuthData', { mfa_requirement, backend, data });
+ } else {
+ this.authSuccess(authResponse);
+ }
+ },
+ onMfaSuccess(authResponse) {
+ this.authSuccess(authResponse);
+ },
+ },
});
diff --git a/ui/app/helpers/number-to-word.js b/ui/app/helpers/number-to-word.js
new file mode 100644
index 000000000..7369ecabd
--- /dev/null
+++ b/ui/app/helpers/number-to-word.js
@@ -0,0 +1,22 @@
+import { helper } from '@ember/component/helper';
+
+export function numberToWord(number, capitalize) {
+ const word =
+ {
+ 0: 'zero',
+ 1: 'one',
+ 2: 'two',
+ 3: 'three',
+ 4: 'four',
+ 5: 'five',
+ 6: 'six',
+ 7: 'seven',
+ 8: 'eight',
+ 9: 'nine',
+ }[number] || number;
+ return capitalize && typeof word === 'string' ? `${word.charAt(0).toUpperCase()}${word.slice(1)}` : word;
+}
+
+export default helper(function ([number], { capitalize }) {
+ return numberToWord(number, capitalize);
+});
diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js
index ee1fb727c..b25e50282 100644
--- a/ui/app/services/auth.js
+++ b/ui/app/services/auth.js
@@ -3,6 +3,7 @@ import { resolve, reject } from 'rsvp';
import { assign } from '@ember/polyfills';
import { isArray } from '@ember/array';
import { computed, get } from '@ember/object';
+import { capitalize } from '@ember/string';
import fetch from 'fetch';
import { getOwner } from '@ember/application';
@@ -14,9 +15,10 @@ import { task, timeout } from 'ember-concurrency';
const TOKEN_SEPARATOR = '☃';
const TOKEN_PREFIX = 'vault-';
const ROOT_PREFIX = '_root_';
+const TOTP_NOT_CONFIGURED = 'TOTP mfa required but not configured';
const BACKENDS = supportedAuthBackends();
-export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX };
+export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX, TOTP_NOT_CONFIGURED };
export default Service.extend({
permissions: service(),
@@ -24,6 +26,8 @@ export default Service.extend({
IDLE_TIMEOUT: 3 * 60e3,
expirationCalcTS: null,
isRenewing: false,
+ mfaErrors: null,
+
init() {
this._super(...arguments);
this.checkForRootToken();
@@ -322,16 +326,98 @@ export default Service.extend({
});
},
+ _parseMfaResponse(mfa_requirement) {
+ // mfa_requirement response comes back in a shape that is not easy to work with
+ // convert to array of objects and add necessary properties to satisfy the view
+ if (mfa_requirement) {
+ const { mfa_request_id, mfa_constraints } = mfa_requirement;
+ let requiresAction; // if multiple constraints or methods or passcode input is needed further action will be required
+ const constraints = [];
+ for (let key in mfa_constraints) {
+ const methods = mfa_constraints[key].any;
+ const isMulti = methods.length > 1;
+ if (isMulti || methods.findBy('uses_passcode')) {
+ requiresAction = true;
+ }
+ // friendly label for display in MfaForm
+ methods.forEach((m) => {
+ const typeFormatted = m.type === 'totp' ? m.type.toUpperCase() : capitalize(m.type);
+ m.label = `${typeFormatted} ${m.uses_passcode ? 'passcode' : 'push notification'}`;
+ });
+ constraints.push({
+ name: key,
+ methods,
+ selectedMethod: isMulti ? null : methods[0],
+ });
+ }
+
+ return {
+ mfa_requirement: { mfa_request_id, mfa_constraints: constraints },
+ requiresAction,
+ };
+ }
+ return {};
+ },
+
async authenticate(/*{clusterId, backend, data}*/) {
const [options] = arguments;
const adapter = this.clusterAdapter();
+ let resp;
- let resp = await adapter.authenticate(options);
- let authData = await this.persistAuthData(options, resp.auth || resp.data, this.namespaceService.path);
+ try {
+ resp = await adapter.authenticate(options);
+ } catch (e) {
+ // TODO: check for totp not configured mfa error before throwing
+ const errors = this.handleError(e);
+ // stubbing error - verify once API is finalized
+ if (errors.includes(TOTP_NOT_CONFIGURED)) {
+ this.set('mfaErrors', errors);
+ }
+ throw e;
+ }
+
+ const { mfa_requirement, requiresAction } = this._parseMfaResponse(resp.auth?.mfa_requirement);
+ if (mfa_requirement) {
+ if (requiresAction) {
+ return { mfa_requirement };
+ }
+ // silently make request to validate endpoint when passcode is not required
+ try {
+ resp = await adapter.mfaValidate(mfa_requirement);
+ } catch (e) {
+ // it's not clear in the auth-form component whether mfa validation is taking place for non-totp method
+ // since mfa errors display a screen rather than flash message handle separately
+ this.set('mfaErrors', this.handleError(e));
+ throw e;
+ }
+ }
+
+ return this.authSuccess(options, resp.auth || resp.data);
+ },
+
+ async totpValidate({ mfa_requirement, ...options }) {
+ const resp = await this.clusterAdapter().mfaValidate(mfa_requirement);
+ return this.authSuccess(options, resp.auth || resp.data);
+ },
+
+ async authSuccess(options, response) {
+ const authData = await this.persistAuthData(options, response, this.namespaceService.path);
await this.permissions.getPaths.perform();
return authData;
},
+ handleError(e) {
+ if (e.errors) {
+ return e.errors.map((error) => {
+ if (error.detail) {
+ return error.detail;
+ }
+ return error;
+ });
+ }
+ return [e];
+ },
+
getAuthType() {
if (!this.authData) return;
return this.authData.backend.type;
diff --git a/ui/app/styles/components/icon.scss b/ui/app/styles/components/icon.scss
index bbb55ec20..c97c63ed6 100644
--- a/ui/app/styles/components/icon.scss
+++ b/ui/app/styles/components/icon.scss
@@ -51,3 +51,7 @@
margin-right: 4px;
}
}
+
+.icon-blue {
+ color: $blue;
+}
diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss
index 24bb5c391..871de5c9f 100644
--- a/ui/app/styles/core/buttons.scss
+++ b/ui/app/styles/core/buttons.scss
@@ -237,3 +237,11 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12);
padding: $size-8;
width: 100%;
}
+
+.icon-button {
+ background: transparent;
+ padding: 0;
+ margin: 0;
+ border: none;
+ cursor: pointer;
+}
diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss
index 73f31d248..27cb0d244 100644
--- a/ui/app/styles/core/helpers.scss
+++ b/ui/app/styles/core/helpers.scss
@@ -19,6 +19,9 @@
.is-borderless {
border: none !important;
}
+.is-box-shadowless {
+ box-shadow: none !important;
+}
.is-relative {
position: relative;
}
@@ -188,6 +191,9 @@
.has-top-margin-xl {
margin-top: $spacing-xl;
}
+.has-top-margin-xxl {
+ margin-top: $spacing-xxl;
+}
.has-border-bottom-light {
border-radius: 0;
border-bottom: 1px solid $grey-light;
@@ -204,7 +210,9 @@ ul.bullet {
.has-text-semibold {
font-weight: $font-weight-semibold;
}
-
+.is-v-centered {
+ vertical-align: middle;
+}
.has-text-grey-400 {
color: $ui-gray-400;
}
diff --git a/ui/app/templates/components/mfa-error.hbs b/ui/app/templates/components/mfa-error.hbs
new file mode 100644
index 000000000..019c54492
--- /dev/null
+++ b/ui/app/templates/components/mfa-error.hbs
@@ -0,0 +1,15 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/app/templates/components/mfa-form.hbs b/ui/app/templates/components/mfa-form.hbs
new file mode 100644
index 000000000..86baf9901
--- /dev/null
+++ b/ui/app/templates/components/mfa-form.hbs
@@ -0,0 +1,70 @@
+
+
+
+ {{this.description}}
+
+
+
+
\ No newline at end of file
diff --git a/ui/app/templates/components/splash-page.hbs b/ui/app/templates/components/splash-page.hbs
index d6970a742..36f115a5d 100644
--- a/ui/app/templates/components/splash-page.hbs
+++ b/ui/app/templates/components/splash-page.hbs
@@ -10,21 +10,26 @@
-
-
-
\ No newline at end of file
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/auth.hbs b/ui/app/templates/vault/cluster/auth.hbs
index f37655cae..76dd5f548 100644
--- a/ui/app/templates/vault/cluster/auth.hbs
+++ b/ui/app/templates/vault/cluster/auth.hbs
@@ -1,84 +1,101 @@
-
+
+
+
+
{{#if this.oidcProvider}}
{{else}}
-
- Sign in to Vault
-
+
+ {{#if this.mfaAuthData}}
+
+ {{/if}}
+
+ {{if this.mfaAuthData "Authenticate" "Sign in to Vault"}}
+
diff --git a/ui/lib/core/addon/components/select.js b/ui/lib/core/addon/components/select.js
index eb74b4d42..00b4e531c 100644
--- a/ui/lib/core/addon/components/select.js
+++ b/ui/lib/core/addon/components/select.js
@@ -10,15 +10,16 @@ import layout from '../templates/components/select';
*
* ```
*
- * @param label=null {String} - The label for the select element.
- * @param options=null {Array} - A list of items that the user will select from. This can be an array of strings or objects.
- * @param [selectedValue=null] {String} - The currently selected item. Can also be used to set the default selected item. This should correspond to the `value` of one of the `
+ {{/if}}
{{#each this.options as |op|}}