Skip to content

Commit

Permalink
MFA UI Changes (v3) (hashicorp#14145)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
zofskeez committed Feb 17, 2022
1 parent 9d3dbf8 commit 712cc9e
Show file tree
Hide file tree
Showing 26 changed files with 1,070 additions and 164 deletions.
3 changes: 3 additions & 0 deletions changelog/14049.txt
@@ -0,0 +1,3 @@
```release-note:improvement
ui: Adds multi-factor authentication support
```
13 changes: 13 additions & 0 deletions ui/app/adapters/cluster.js
Expand Up @@ -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(
Expand Down
76 changes: 19 additions & 57 deletions ui/app/components/auth-form.js
Expand Up @@ -18,13 +18,13 @@ const BACKENDS = supportedAuthBackends();
*
* @example ```js
* // All properties are passed in via query params.
* <AuthForm @wrappedToken={{wrappedToken}} @cluster={{model}} @namespace={{namespaceQueryParam}} @redirectTo={{redirectTo}} @selectedAuth={{authMethod}}/>```
* <AuthForm @wrappedToken={{wrappedToken}} @cluster={{model}} @namespace={{namespaceQueryParam}} @selectedAuth={{authMethod}} @onSuccess={{action this.onSuccess}} />```
*
* @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 = {
Expand All @@ -45,7 +45,6 @@ export default Component.extend(DEFAULTS, {
selectedAuth: null,
methods: null,
cluster: null,
redirectTo: null,
namespace: null,
wrappedToken: null,
// internal
Expand Down Expand Up @@ -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)}`);
}
}
})
),
Expand All @@ -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: {
Expand Down Expand Up @@ -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,
});
},
},
});
43 changes: 43 additions & 0 deletions 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
* <MfaError />
* ```
*/

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();
}
}
}
89 changes: 89 additions & 0 deletions 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
* <MfaForm @clusterId={this.model.id} @authData={this.authData} />
* ```
* @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();
}
}
46 changes: 43 additions & 3 deletions ui/app/controllers/vault/cluster/auth.js
Expand Up @@ -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('/');
Expand All @@ -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);
},
},
});
22 changes: 22 additions & 0 deletions 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);
});

0 comments on commit 712cc9e

Please sign in to comment.