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
This commit is contained in:
parent
24d362aa8c
commit
7bd1992bc5
|
@ -0,0 +1,3 @@
|
||||||
|
```release-note:improvement
|
||||||
|
ui: Adds multi-factor authentication support
|
||||||
|
```
|
|
@ -126,6 +126,19 @@ export default ApplicationAdapter.extend({
|
||||||
return this.ajax(url, verb, options);
|
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) {
|
urlFor(endpoint) {
|
||||||
if (!ENDPOINTS.includes(endpoint)) {
|
if (!ENDPOINTS.includes(endpoint)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
|
@ -18,13 +18,13 @@ const BACKENDS = supportedAuthBackends();
|
||||||
*
|
*
|
||||||
* @example ```js
|
* @example ```js
|
||||||
* // All properties are passed in via query params.
|
* // 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 {string} wrappedToken - 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 {object} cluster - 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 {string} namespace- The currently active namespace.
|
||||||
* @param redirectTo=null {String} - The name of the route to redirect to.
|
* @param {string} selectedAuth - The auth method that is currently selected in the dropdown.
|
||||||
* @param selectedAuth=null {String} - The auth method that is currently selected in the dropdown.
|
* @param {function} onSuccess - Fired on auth success
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
|
@ -45,7 +45,6 @@ export default Component.extend(DEFAULTS, {
|
||||||
selectedAuth: null,
|
selectedAuth: null,
|
||||||
methods: null,
|
methods: null,
|
||||||
cluster: null,
|
cluster: null,
|
||||||
redirectTo: null,
|
|
||||||
namespace: null,
|
namespace: null,
|
||||||
wrappedToken: null,
|
wrappedToken: null,
|
||||||
// internal
|
// internal
|
||||||
|
@ -206,54 +205,18 @@ export default Component.extend(DEFAULTS, {
|
||||||
|
|
||||||
showLoading: or('isLoading', 'authenticate.isRunning', 'fetchMethods.isRunning', 'unwrapToken.isRunning'),
|
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(
|
authenticate: task(
|
||||||
waitFor(function* (backendType, data) {
|
waitFor(function* (backendType, data) {
|
||||||
let clusterId = this.cluster.id;
|
let clusterId = this.cluster.id;
|
||||||
try {
|
try {
|
||||||
if (backendType === 'okta') {
|
|
||||||
this.delayAuthMessageReminder.perform();
|
this.delayAuthMessageReminder.perform();
|
||||||
}
|
const authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data });
|
||||||
let authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data });
|
this.onSuccess(authResponse, 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.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} 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) {
|
if (Ember.testing) {
|
||||||
this.showLoading = true;
|
this.showLoading = true;
|
||||||
yield timeout(0);
|
yield timeout(0);
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
yield timeout(5000);
|
yield timeout(5000);
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
|
@ -298,11 +261,10 @@ export default Component.extend(DEFAULTS, {
|
||||||
return this.authenticate.unlinked().perform(backend.type, data);
|
return this.authenticate.unlinked().perform(backend.type, data);
|
||||||
},
|
},
|
||||||
handleError(e) {
|
handleError(e) {
|
||||||
if (e) {
|
this.setProperties({
|
||||||
this.handleError(e, false);
|
loading: false,
|
||||||
} else {
|
error: e ? this.auth.handleError(e) : null,
|
||||||
this.set('error', null);
|
});
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,14 +8,19 @@ export default Controller.extend({
|
||||||
clusterController: controller('vault.cluster'),
|
clusterController: controller('vault.cluster'),
|
||||||
namespaceService: service('namespace'),
|
namespaceService: service('namespace'),
|
||||||
featureFlagService: service('featureFlag'),
|
featureFlagService: service('featureFlag'),
|
||||||
namespaceQueryParam: alias('clusterController.namespaceQueryParam'),
|
auth: service(),
|
||||||
|
router: service(),
|
||||||
|
|
||||||
queryParams: [{ authMethod: 'with', oidcProvider: 'o' }],
|
queryParams: [{ authMethod: 'with', oidcProvider: 'o' }],
|
||||||
|
|
||||||
|
namespaceQueryParam: alias('clusterController.namespaceQueryParam'),
|
||||||
wrappedToken: alias('vaultController.wrappedToken'),
|
wrappedToken: alias('vaultController.wrappedToken'),
|
||||||
authMethod: '',
|
|
||||||
oidcProvider: '',
|
|
||||||
redirectTo: alias('vaultController.redirectTo'),
|
redirectTo: alias('vaultController.redirectTo'),
|
||||||
managedNamespaceRoot: alias('featureFlagService.managedNamespaceRoot'),
|
managedNamespaceRoot: alias('featureFlagService.managedNamespaceRoot'),
|
||||||
|
|
||||||
|
authMethod: '',
|
||||||
|
oidcProvider: '',
|
||||||
|
|
||||||
get managedNamespaceChild() {
|
get managedNamespaceChild() {
|
||||||
let fullParam = this.namespaceQueryParam;
|
let fullParam = this.namespaceQueryParam;
|
||||||
let split = fullParam.split('/');
|
let split = fullParam.split('/');
|
||||||
|
@ -41,4 +46,39 @@ export default Controller.extend({
|
||||||
this.namespaceService.setNamespace(value, true);
|
this.namespaceService.setNamespace(value, true);
|
||||||
this.set('namespaceQueryParam', value);
|
this.set('namespaceQueryParam', value);
|
||||||
}).restartable(),
|
}).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);
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
|
@ -3,6 +3,7 @@ import { resolve, reject } from 'rsvp';
|
||||||
import { assign } from '@ember/polyfills';
|
import { assign } from '@ember/polyfills';
|
||||||
import { isArray } from '@ember/array';
|
import { isArray } from '@ember/array';
|
||||||
import { computed, get } from '@ember/object';
|
import { computed, get } from '@ember/object';
|
||||||
|
import { capitalize } from '@ember/string';
|
||||||
|
|
||||||
import fetch from 'fetch';
|
import fetch from 'fetch';
|
||||||
import { getOwner } from '@ember/application';
|
import { getOwner } from '@ember/application';
|
||||||
|
@ -14,9 +15,10 @@ import { task, timeout } from 'ember-concurrency';
|
||||||
const TOKEN_SEPARATOR = '☃';
|
const TOKEN_SEPARATOR = '☃';
|
||||||
const TOKEN_PREFIX = 'vault-';
|
const TOKEN_PREFIX = 'vault-';
|
||||||
const ROOT_PREFIX = '_root_';
|
const ROOT_PREFIX = '_root_';
|
||||||
|
const TOTP_NOT_CONFIGURED = 'TOTP mfa required but not configured';
|
||||||
const BACKENDS = supportedAuthBackends();
|
const BACKENDS = supportedAuthBackends();
|
||||||
|
|
||||||
export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX };
|
export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX, TOTP_NOT_CONFIGURED };
|
||||||
|
|
||||||
export default Service.extend({
|
export default Service.extend({
|
||||||
permissions: service(),
|
permissions: service(),
|
||||||
|
@ -24,6 +26,8 @@ export default Service.extend({
|
||||||
IDLE_TIMEOUT: 3 * 60e3,
|
IDLE_TIMEOUT: 3 * 60e3,
|
||||||
expirationCalcTS: null,
|
expirationCalcTS: null,
|
||||||
isRenewing: false,
|
isRenewing: false,
|
||||||
|
mfaErrors: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._super(...arguments);
|
this._super(...arguments);
|
||||||
this.checkForRootToken();
|
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}*/) {
|
async authenticate(/*{clusterId, backend, data}*/) {
|
||||||
const [options] = arguments;
|
const [options] = arguments;
|
||||||
const adapter = this.clusterAdapter();
|
const adapter = this.clusterAdapter();
|
||||||
|
let resp;
|
||||||
|
|
||||||
let resp = await adapter.authenticate(options);
|
try {
|
||||||
let authData = await this.persistAuthData(options, resp.auth || resp.data, this.namespaceService.path);
|
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();
|
await this.permissions.getPaths.perform();
|
||||||
return authData;
|
return authData;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleError(e) {
|
||||||
|
if (e.errors) {
|
||||||
|
return e.errors.map((error) => {
|
||||||
|
if (error.detail) {
|
||||||
|
return error.detail;
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [e];
|
||||||
|
},
|
||||||
|
|
||||||
getAuthType() {
|
getAuthType() {
|
||||||
if (!this.authData) return;
|
if (!this.authData) return;
|
||||||
return this.authData.backend.type;
|
return this.authData.backend.type;
|
||||||
|
|
|
@ -51,3 +51,7 @@
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-blue {
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
|
|
@ -237,3 +237,11 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12);
|
||||||
padding: $size-8;
|
padding: $size-8;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
|
@ -19,6 +19,9 @@
|
||||||
.is-borderless {
|
.is-borderless {
|
||||||
border: none !important;
|
border: none !important;
|
||||||
}
|
}
|
||||||
|
.is-box-shadowless {
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
.is-relative {
|
.is-relative {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
@ -188,6 +191,9 @@
|
||||||
.has-top-margin-xl {
|
.has-top-margin-xl {
|
||||||
margin-top: $spacing-xl;
|
margin-top: $spacing-xl;
|
||||||
}
|
}
|
||||||
|
.has-top-margin-xxl {
|
||||||
|
margin-top: $spacing-xxl;
|
||||||
|
}
|
||||||
.has-border-bottom-light {
|
.has-border-bottom-light {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-bottom: 1px solid $grey-light;
|
border-bottom: 1px solid $grey-light;
|
||||||
|
@ -204,7 +210,9 @@ ul.bullet {
|
||||||
.has-text-semibold {
|
.has-text-semibold {
|
||||||
font-weight: $font-weight-semibold;
|
font-weight: $font-weight-semibold;
|
||||||
}
|
}
|
||||||
|
.is-v-centered {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
.has-text-grey-400 {
|
.has-text-grey-400 {
|
||||||
color: $ui-gray-400;
|
color: $ui-gray-400;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
<div class="has-top-margin-xxl">
|
||||||
|
<EmptyState
|
||||||
|
@title={{this.title}}
|
||||||
|
@message={{this.description}}
|
||||||
|
@icon="alert-circle"
|
||||||
|
@bottomBorder={{true}}
|
||||||
|
@subTitle={{join ". " this.auth.mfaErrors}}
|
||||||
|
class="is-box-shadowless"
|
||||||
|
>
|
||||||
|
<button type="button" class="button is-ghost is-transparent" {{on "click" this.onClose}} data-test-go-back>
|
||||||
|
<Icon @name="chevron-left" />
|
||||||
|
Go back
|
||||||
|
</button>
|
||||||
|
</EmptyState>
|
||||||
|
</div>
|
|
@ -0,0 +1,70 @@
|
||||||
|
<div class="auth-form" data-test-mfa-form>
|
||||||
|
<div class="box is-marginless is-shadowless">
|
||||||
|
<p data-test-mfa-description>
|
||||||
|
{{this.description}}
|
||||||
|
</p>
|
||||||
|
<form id="auth-form" {{on "submit" this.submit}}>
|
||||||
|
<MessageError @errors={{this.errors}} class="has-top-margin-s" />
|
||||||
|
<div class="field has-top-margin-l">
|
||||||
|
{{#each this.constraints as |constraint index|}}
|
||||||
|
{{#if index}}
|
||||||
|
<hr />
|
||||||
|
{{/if}}
|
||||||
|
{{#if (gt constraint.methods.length 1)}}
|
||||||
|
<Select
|
||||||
|
@label="Multi-factor authentication method"
|
||||||
|
@options={{constraint.methods}}
|
||||||
|
@valueAttribute={{"id"}}
|
||||||
|
@labelAttribute={{"label"}}
|
||||||
|
@isFullwidth={{true}}
|
||||||
|
@noDefault={{true}}
|
||||||
|
@selectedValue={{constraint.selectedId}}
|
||||||
|
@onChange={{fn this.onSelect constraint}}
|
||||||
|
data-test-mfa-select={{index}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
{{#if constraint.selectedMethod.uses_passcode}}
|
||||||
|
<label for="passcode" class="is-label" data-test-mfa-passcode-label>
|
||||||
|
{{constraint.selectedMethod.label}}
|
||||||
|
</label>
|
||||||
|
<div class="control">
|
||||||
|
<Input
|
||||||
|
id="passcode"
|
||||||
|
name="passcode"
|
||||||
|
class="input"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
autofocus="true"
|
||||||
|
disabled={{or this.validate.isRunning this.newCodeDelay.isRunning}}
|
||||||
|
@value={{constraint.passcode}}
|
||||||
|
data-test-mfa-passcode={{index}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{#if this.newCodeDelay.isRunning}}
|
||||||
|
<div>
|
||||||
|
<AlertInline
|
||||||
|
@type="danger"
|
||||||
|
@sizeSmall={{true}}
|
||||||
|
@message="This code is invalid. Please wait until a new code is available."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
<button
|
||||||
|
id="validate"
|
||||||
|
type="submit"
|
||||||
|
disabled={{or this.validate.isRunning this.newCodeDelay.isRunning}}
|
||||||
|
class="button is-primary {{if this.validate.isRunning "is-loading"}}"
|
||||||
|
data-test-mfa-validate
|
||||||
|
>
|
||||||
|
Verify
|
||||||
|
</button>
|
||||||
|
{{#if this.newCodeDelay.isRunning}}
|
||||||
|
<Icon @name="delay" class="has-text-grey" />
|
||||||
|
<span class="has-text-grey is-v-centered" data-test-mfa-countdown>{{this.countdown}}</span>
|
||||||
|
{{/if}}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -10,7 +10,11 @@
|
||||||
</div>
|
</div>
|
||||||
</Nav.items>
|
</Nav.items>
|
||||||
</NavHeader>
|
</NavHeader>
|
||||||
<UiWizard>
|
{{! bypass UiWizard and container styling }}
|
||||||
|
{{#if this.hasAltContent}}
|
||||||
|
{{yield (hash altContent=(component "splash-page/splash-content"))}}
|
||||||
|
{{else}}
|
||||||
|
<UiWizard>
|
||||||
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-1 is-fullwidth">
|
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-1 is-fullwidth">
|
||||||
<div class="columns is-centered is-gapless is-fullwidth">
|
<div class="columns is-centered is-gapless is-fullwidth">
|
||||||
<div class="column is-4-desktop is-6-tablet">
|
<div class="column is-4-desktop is-6-tablet">
|
||||||
|
@ -27,4 +31,5 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UiWizard>
|
</UiWizard>
|
||||||
|
{{/if}}
|
|
@ -1,15 +1,26 @@
|
||||||
<SplashPage as |Page|>
|
<SplashPage @hasAltContent={{this.auth.mfaErrors}} as |Page|>
|
||||||
|
<Page.altContent>
|
||||||
|
<MfaError @onClose={{fn (mut this.mfaAuthData) null}} />
|
||||||
|
</Page.altContent>
|
||||||
<Page.header>
|
<Page.header>
|
||||||
{{#if this.oidcProvider}}
|
{{#if this.oidcProvider}}
|
||||||
<div class="box is-shadowless is-flex-v-centered" data-test-auth-logo>
|
<div class="box is-shadowless is-flex-v-centered" data-test-auth-logo>
|
||||||
<LogoEdition aria-label="Sign in with Hashicorp Vault" />
|
<LogoEdition aria-label="Sign in with Hashicorp Vault" />
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
<div class="is-flex-row">
|
||||||
|
{{#if this.mfaAuthData}}
|
||||||
|
<button type="button" class="icon-button" {{on "click" (fn (mut this.mfaAuthData) null)}}>
|
||||||
|
<Icon @name="arrow-left" @size="24" aria-label="Back to login" class="icon-blue" />
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
<h1 class="title is-3">
|
<h1 class="title is-3">
|
||||||
Sign in to Vault
|
{{if this.mfaAuthData "Authenticate" "Sign in to Vault"}}
|
||||||
</h1>
|
</h1>
|
||||||
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</Page.header>
|
</Page.header>
|
||||||
|
{{#unless this.mfaAuthData}}
|
||||||
{{#if this.managedNamespaceRoot}}
|
{{#if this.managedNamespaceRoot}}
|
||||||
<Page.sub-header>
|
<Page.sub-header>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
|
@ -71,14 +82,20 @@
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</Page.sub-header>
|
</Page.sub-header>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
{{/unless}}
|
||||||
<Page.content>
|
<Page.content>
|
||||||
|
{{#if this.mfaAuthData}}
|
||||||
|
<MfaForm @clusterId={{this.model.id}} @authData={{this.mfaAuthData}} @onSuccess={{action "onMfaSuccess"}} />
|
||||||
|
{{else}}
|
||||||
<AuthForm
|
<AuthForm
|
||||||
@wrappedToken={{this.wrappedToken}}
|
@wrappedToken={{this.wrappedToken}}
|
||||||
@cluster={{this.model}}
|
@cluster={{this.model}}
|
||||||
@namespace={{this.namespaceQueryParam}}
|
@namespace={{this.namespaceQueryParam}}
|
||||||
@redirectTo={{this.redirectTo}}
|
@redirectTo={{this.redirectTo}}
|
||||||
@selectedAuth={{this.authMethod}}
|
@selectedAuth={{this.authMethod}}
|
||||||
|
@onSuccess={{action "onAuthResponse"}}
|
||||||
/>
|
/>
|
||||||
|
{{/if}}
|
||||||
</Page.content>
|
</Page.content>
|
||||||
<Page.footer>
|
<Page.footer>
|
||||||
<div class="has-short-padding">
|
<div class="has-short-padding">
|
||||||
|
|
|
@ -10,15 +10,16 @@ import layout from '../templates/components/select';
|
||||||
* <Select @label='Date Range' @options={{[{ value: 'berry', label: 'Berry' }]}} @onChange={{onChange}}/>
|
* <Select @label='Date Range' @options={{[{ value: 'berry', label: 'Berry' }]}} @onChange={{onChange}}/>
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param label=null {String} - The label for the select element.
|
* @param {string} [label=null] - 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 {Array} [options=null] - 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 `<option>`s.
|
* @param {string} [selectedValue=null] - The currently selected item. Can also be used to set the default selected item. This should correspond to the `value` of one of the `<option>`s.
|
||||||
* @param [name=null] {String} - The name of the select, used for the test selector.
|
* @param {string} [name = null] - The name of the select, used for the test selector.
|
||||||
* @param [valueAttribute=value] {String} - When `options` is an array objects, the key to check for when assigning the option elements value.
|
* @param {string} [valueAttribute = value]- When `options` is an array objects, the key to check for when assigning the option elements value.
|
||||||
* @param [labelAttribute=label] {String} - When `options` is an array objects, the key to check for when assigning the option elements' inner text.
|
* @param {string} [labelAttribute = label] - When `options` is an array objects, the key to check for when assigning the option elements' inner text.
|
||||||
* @param [isInline=false] {Bool} - Whether or not the select should be displayed as inline-block or block.
|
* @param {boolean} [isInline = false] - Whether or not the select should be displayed as inline-block or block.
|
||||||
* @param [isFullwidth=false] {Bool} - Whether or not the select should take up the full width of the parent element.
|
* @param {boolean} [isFullwidth = false] - Whether or not the select should take up the full width of the parent element.
|
||||||
* @param onChange=null {Func} - The action to take once the user has selected an item. This method will be passed the `value` of the select.
|
* @param {boolean} [noDefault = false] - shows Select One with empty value as first option
|
||||||
|
* @param {Func} [onChange] - The action to take once the user has selected an item. This method will be passed the `value` of the select.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default Component.extend({
|
export default Component.extend({
|
||||||
|
@ -32,5 +33,6 @@ export default Component.extend({
|
||||||
labelAttribute: 'label',
|
labelAttribute: 'label',
|
||||||
isInline: false,
|
isInline: false,
|
||||||
isFullwidth: false,
|
isFullwidth: false,
|
||||||
|
noDefault: false,
|
||||||
onChange() {},
|
onChange() {},
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,6 +11,11 @@
|
||||||
onchange={{action this.onChange value="target.value"}}
|
onchange={{action this.onChange value="target.value"}}
|
||||||
data-test-select={{this.name}}
|
data-test-select={{this.name}}
|
||||||
>
|
>
|
||||||
|
{{#if this.noDefault}}
|
||||||
|
<option value="">
|
||||||
|
Select one
|
||||||
|
</option>
|
||||||
|
{{/if}}
|
||||||
{{#each this.options as |op|}}
|
{{#each this.options as |op|}}
|
||||||
<option
|
<option
|
||||||
value={{or (get op this.valueAttribute) op}}
|
value={{or (get op this.valueAttribute) op}}
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Factory } from 'ember-cli-mirage';
|
||||||
|
|
||||||
|
export default Factory.extend({
|
||||||
|
type: 'okta',
|
||||||
|
uses_passcode: false,
|
||||||
|
|
||||||
|
afterCreate(mfaMethod) {
|
||||||
|
if (mfaMethod.type === 'totp') {
|
||||||
|
mfaMethod.uses_passcode = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
|
@ -1,6 +1,7 @@
|
||||||
// add all handlers here
|
// add all handlers here
|
||||||
// individual lookup done in mirage config
|
// individual lookup done in mirage config
|
||||||
import base from './base';
|
import base from './base';
|
||||||
|
import mfa from './mfa';
|
||||||
import activity from './activity';
|
import activity from './activity';
|
||||||
|
|
||||||
export { base, activity };
|
export { base, activity, mfa };
|
||||||
|
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { Response } from 'miragejs';
|
||||||
|
import Ember from 'ember';
|
||||||
|
import fetch from 'fetch';
|
||||||
|
|
||||||
|
export default function (server) {
|
||||||
|
// initial auth response cache -- lookup by mfa_request_id key
|
||||||
|
const authResponses = {};
|
||||||
|
// mfa requirement cache -- lookup by mfa_request_id key
|
||||||
|
const mfaRequirement = {};
|
||||||
|
// generate different constraint scenarios and return mfa_requirement object
|
||||||
|
const generateMfaRequirement = (req, res) => {
|
||||||
|
const { user } = req.params;
|
||||||
|
// uses_passcode automatically set to true in factory for totp type
|
||||||
|
const m = (type, uses_passcode = false) => server.create('mfa-method', { type, uses_passcode });
|
||||||
|
let mfa_constraints = {};
|
||||||
|
let methods = []; // flat array of methods for easy lookup during validation
|
||||||
|
|
||||||
|
function generator() {
|
||||||
|
const methods = [];
|
||||||
|
const constraintObj = [...arguments].reduce((obj, methodArray, index) => {
|
||||||
|
obj[`test_${index}`] = { any: methodArray };
|
||||||
|
methods.push(...methodArray);
|
||||||
|
return obj;
|
||||||
|
}, {});
|
||||||
|
return [constraintObj, methods];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user === 'mfa-a') {
|
||||||
|
[mfa_constraints, methods] = generator([m('totp')]); // 1 constraint 1 passcode
|
||||||
|
} else if (user === 'mfa-b') {
|
||||||
|
[mfa_constraints, methods] = generator([m('okta')]); // 1 constraint 1 non-passcode
|
||||||
|
} else if (user === 'mfa-c') {
|
||||||
|
[mfa_constraints, methods] = generator([m('totp'), m('duo', true)]); // 1 constraint 2 passcodes
|
||||||
|
} else if (user === 'mfa-d') {
|
||||||
|
[mfa_constraints, methods] = generator([m('okta'), m('duo')]); // 1 constraint 2 non-passcode
|
||||||
|
} else if (user === 'mfa-e') {
|
||||||
|
[mfa_constraints, methods] = generator([m('okta'), m('totp')]); // 1 constraint 1 passcode 1 non-passcode
|
||||||
|
} else if (user === 'mfa-f') {
|
||||||
|
[mfa_constraints, methods] = generator([m('totp')], [m('duo', true)]); // 2 constraints 1 passcode for each
|
||||||
|
} else if (user === 'mfa-g') {
|
||||||
|
[mfa_constraints, methods] = generator([m('okta')], [m('duo')]); // 2 constraints 1 non-passcode for each
|
||||||
|
} else if (user === 'mfa-h') {
|
||||||
|
[mfa_constraints, methods] = generator([m('totp')], [m('okta')]); // 2 constraints 1 passcode 1 non-passcode
|
||||||
|
} else if (user === 'mfa-i') {
|
||||||
|
[mfa_constraints, methods] = generator([m('okta'), m('totp')], [m('totp')]); // 2 constraints 1 passcode/1 non-passcode 1 non-passcode
|
||||||
|
}
|
||||||
|
const numbers = (length) =>
|
||||||
|
Math.random()
|
||||||
|
.toString()
|
||||||
|
.substring(2, length + 2);
|
||||||
|
const mfa_request_id = `${numbers(8)}-${numbers(4)}-${numbers(4)}-${numbers(4)}-${numbers(12)}`;
|
||||||
|
const mfa_requirement = {
|
||||||
|
mfa_request_id,
|
||||||
|
mfa_constraints,
|
||||||
|
};
|
||||||
|
// cache mfa requests to test different validation scenarios
|
||||||
|
mfaRequirement[mfa_request_id] = { methods };
|
||||||
|
// cache auth response to be returned later by sys/mfa/validate
|
||||||
|
authResponses[mfa_request_id] = { ...res };
|
||||||
|
return mfa_requirement;
|
||||||
|
};
|
||||||
|
// passthrough original request, cache response and return mfa stub
|
||||||
|
const passthroughLogin = async (schema, req) => {
|
||||||
|
// test totp not configured scenario
|
||||||
|
if (req.params.user === 'totp-na') {
|
||||||
|
return new Response(400, {}, { errors: ['TOTP mfa required but not configured'] });
|
||||||
|
}
|
||||||
|
const mock = req.params.user ? req.params.user.includes('mfa') : null;
|
||||||
|
// bypass mfa for users that do not match type
|
||||||
|
if (!mock) {
|
||||||
|
req.passthrough();
|
||||||
|
} else if (Ember.testing) {
|
||||||
|
// use root token in test environment
|
||||||
|
const res = await fetch('/v1/auth/token/lookup-self', { headers: { 'X-Vault-Token': 'root' } });
|
||||||
|
if (res.status < 300) {
|
||||||
|
const json = res.json();
|
||||||
|
if (Ember.testing) {
|
||||||
|
json.auth = {
|
||||||
|
...json.data,
|
||||||
|
policies: [],
|
||||||
|
metadata: { username: 'foobar' },
|
||||||
|
};
|
||||||
|
json.data = null;
|
||||||
|
}
|
||||||
|
return { auth: { mfa_requirement: generateMfaRequirement(req, json) } };
|
||||||
|
}
|
||||||
|
return new Response(500, {}, { errors: ['Mirage error fetching root token in testing'] });
|
||||||
|
} else {
|
||||||
|
const xhr = req.passthrough();
|
||||||
|
xhr.onreadystatechange = () => {
|
||||||
|
if (xhr.readyState === 4 && xhr.status < 300) {
|
||||||
|
// XMLHttpRequest response prop only has a getter -- redefine as writable and set value
|
||||||
|
Object.defineProperty(xhr, 'response', {
|
||||||
|
writable: true,
|
||||||
|
value: JSON.stringify({
|
||||||
|
auth: { mfa_requirement: generateMfaRequirement(req, JSON.parse(xhr.responseText)) },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
server.post('/auth/:method/login/:user', passthroughLogin);
|
||||||
|
|
||||||
|
server.post('/sys/mfa/validate', (schema, req) => {
|
||||||
|
try {
|
||||||
|
const { mfa_request_id, mfa_payload } = JSON.parse(req.requestBody);
|
||||||
|
const mfaRequest = mfaRequirement[mfa_request_id];
|
||||||
|
|
||||||
|
if (!mfaRequest) {
|
||||||
|
return new Response(404, {}, { errors: ['MFA Request ID not found'] });
|
||||||
|
}
|
||||||
|
// validate request body
|
||||||
|
for (let constraintId in mfa_payload) {
|
||||||
|
// ensure ids were passed in map
|
||||||
|
const method = mfaRequest.methods.find(({ id }) => id === constraintId);
|
||||||
|
if (!method) {
|
||||||
|
return new Response(
|
||||||
|
400,
|
||||||
|
{},
|
||||||
|
{ errors: [`Invalid MFA constraint id ${constraintId} passed in map`] }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// test non-totp validation by rejecting all pingid requests
|
||||||
|
if (method.type === 'pingid') {
|
||||||
|
return new Response(403, {}, { errors: ['PingId MFA validation failed'] });
|
||||||
|
}
|
||||||
|
// validate totp passcode
|
||||||
|
const passcode = mfa_payload[constraintId][0];
|
||||||
|
if (method.uses_passcode) {
|
||||||
|
if (passcode !== 'test') {
|
||||||
|
const error = !passcode ? 'TOTP passcode not provided' : 'Incorrect TOTP passcode provided';
|
||||||
|
return new Response(403, {}, { errors: [error] });
|
||||||
|
}
|
||||||
|
} else if (passcode) {
|
||||||
|
// for okta and duo, reject if a passcode was provided
|
||||||
|
return new Response(400, {}, { errors: ['Passcode should only be provided for TOTP MFA type'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return authResponses[mfa_request_id];
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return new Response(500, {}, { errors: ['Mirage Handler Error: /sys/mfa/validate'] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -3,25 +3,21 @@
|
||||||
## AuthForm
|
## AuthForm
|
||||||
The `AuthForm` is used to sign users into Vault.
|
The `AuthForm` is used to sign users into Vault.
|
||||||
|
|
||||||
|
**Params**
|
||||||
|
|
||||||
| Param | Type | Default | Description |
|
| Param | Type | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| wrappedToken | <code>String</code> | <code></code> | The auth method that is currently selected in the dropdown. |
|
| wrappedToken | <code>string</code> | The auth method that is currently selected in the dropdown. |
|
||||||
| cluster | <code>Object</code> | <code></code> | The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. |
|
| cluster | <code>object</code> | The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. |
|
||||||
| namespace | <code>String</code> | <code></code> | The currently active namespace. |
|
| namespace- | <code>string</code> | The currently active namespace. |
|
||||||
| redirectTo | <code>String</code> | <code></code> | The name of the route to redirect to. |
|
| selectedAuth | <code>string</code> | The auth method that is currently selected in the dropdown. |
|
||||||
| selectedAuth | <code>String</code> | <code></code> | The auth method that is currently selected in the dropdown. |
|
| onSuccess | <code>function</code> | Fired on auth success |
|
||||||
|
|
||||||
**Example**
|
**Example**
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// All properties are passed in via query params.
|
// All properties are passed in via query params.
|
||||||
<AuthForm
|
<AuthForm @wrappedToken={{wrappedToken}} @cluster={{model}} @namespace={{namespaceQueryParam}} @selectedAuth={{authMethod}} @onSuccess={{action this.onSuccess}} />```
|
||||||
@wrappedToken={{wrappedToken}}
|
|
||||||
@cluster={{model}}
|
|
||||||
@namespace={{namespaceQueryParam}}
|
|
||||||
@redirectTo={{redirectTo}}
|
|
||||||
@selectedAuth={{authMethod}}/>```
|
|
||||||
|
|
||||||
**See**
|
**See**
|
||||||
|
|
||||||
|
|
|
@ -110,16 +110,10 @@ module('Acceptance | auth', function (hooks) {
|
||||||
assert.dom('[data-test-allow-expiration]').doesNotExist('hides beacon when the api is used again');
|
assert.dom('[data-test-allow-expiration]').doesNotExist('hides beacon when the api is used again');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it shows the push notification warning only for okta auth method after submit', async function (assert) {
|
test('it shows the push notification warning after submit', async function (assert) {
|
||||||
await visit('/vault/auth');
|
await visit('/vault/auth');
|
||||||
await component.selectMethod('token');
|
await component.selectMethod('token');
|
||||||
await click('[data-test-auth-submit]');
|
await click('[data-test-auth-submit]');
|
||||||
assert
|
|
||||||
.dom('[data-test-auth-message="push"]')
|
|
||||||
.doesNotExist('message is not shown for other authentication methods');
|
|
||||||
|
|
||||||
await component.selectMethod('okta');
|
|
||||||
await click('[data-test-auth-submit]');
|
|
||||||
assert.dom('[data-test-auth-message="push"]').exists('shows push notification message');
|
assert.dom('[data-test-auth-message="push"]').exists('shows push notification message');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupApplicationTest } from 'ember-qunit';
|
||||||
|
import { click, currentRouteName, fillIn, visit } from '@ember/test-helpers';
|
||||||
|
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||||
|
import ENV from 'vault/config/environment';
|
||||||
|
|
||||||
|
ENV['ember-cli-mirage'].handler = 'mfa';
|
||||||
|
|
||||||
|
module('Acceptance | mfa', function (hooks) {
|
||||||
|
setupApplicationTest(hooks);
|
||||||
|
setupMirage(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.select = async (select = 0, option = 1) => {
|
||||||
|
const selector = `[data-test-mfa-select="${select}"]`;
|
||||||
|
const value = this.element.querySelector(`${selector} option:nth-child(${option + 1})`).value;
|
||||||
|
await fillIn(`${selector} select`, value);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const login = async (user) => {
|
||||||
|
// MfaHandler(server);
|
||||||
|
await visit('/vault/auth');
|
||||||
|
await fillIn('[data-test-select="auth-method"]', 'userpass');
|
||||||
|
await fillIn('[data-test-username]', user);
|
||||||
|
await fillIn('[data-test-password]', 'test');
|
||||||
|
await click('[data-test-auth-submit]');
|
||||||
|
};
|
||||||
|
const didLogin = (assert) => {
|
||||||
|
assert.equal(currentRouteName(), 'vault.cluster.secrets.backends', 'Route transitions after login');
|
||||||
|
};
|
||||||
|
const validate = async (multi) => {
|
||||||
|
await fillIn('[data-test-mfa-passcode="0"]', 'test');
|
||||||
|
if (multi) {
|
||||||
|
await fillIn('[data-test-mfa-passcode="1"]', 'test');
|
||||||
|
}
|
||||||
|
await click('[data-test-mfa-validate]');
|
||||||
|
};
|
||||||
|
|
||||||
|
test('it should handle single mfa constraint with passcode method', async function (assert) {
|
||||||
|
await login('mfa-a');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-mfa-description]')
|
||||||
|
.includesText(
|
||||||
|
'Enter your authentication code to log in.',
|
||||||
|
'Mfa form displays with correct description'
|
||||||
|
);
|
||||||
|
assert.dom('[data-test-mfa-select]').doesNotExist('Select is hidden for single method');
|
||||||
|
assert.dom('[data-test-mfa-passcode]').exists({ count: 1 }, 'Single passcode input renders');
|
||||||
|
await validate();
|
||||||
|
didLogin(assert);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should handle single mfa constraint with push method', async function (assert) {
|
||||||
|
await login('mfa-b');
|
||||||
|
didLogin(assert);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should handle single mfa constraint with 2 passcode methods', async function (assert) {
|
||||||
|
await login('mfa-c');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-mfa-description]')
|
||||||
|
.includesText('Select the MFA method you wish to use.', 'Mfa form displays with correct description');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-mfa-select]')
|
||||||
|
.exists({ count: 1 }, 'Select renders for single constraint with multiple methods');
|
||||||
|
assert.dom('[data-test-mfa-passcode]').doesNotExist('Passcode input hidden until selection is made');
|
||||||
|
await this.select();
|
||||||
|
await validate();
|
||||||
|
didLogin(assert);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should handle single mfa constraint with 2 push methods', async function (assert) {
|
||||||
|
await login('mfa-d');
|
||||||
|
await this.select();
|
||||||
|
await click('[data-test-mfa-validate]');
|
||||||
|
didLogin(assert);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should handle single mfa constraint with 1 passcode and 1 push method', async function (assert) {
|
||||||
|
await login('mfa-e');
|
||||||
|
await this.select(0, 2);
|
||||||
|
assert.dom('[data-test-mfa-passcode]').exists('Passcode input renders');
|
||||||
|
await this.select();
|
||||||
|
assert.dom('[data-test-mfa-passcode]').doesNotExist('Passcode input is hidden for push method');
|
||||||
|
await click('[data-test-mfa-validate]');
|
||||||
|
didLogin(assert);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should handle multiple mfa constraints with 1 passcode method each', async function (assert) {
|
||||||
|
await login('mfa-f');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-mfa-description]')
|
||||||
|
.includesText(
|
||||||
|
'Two methods are required for successful authentication.',
|
||||||
|
'Mfa form displays with correct description'
|
||||||
|
);
|
||||||
|
assert.dom('[data-test-mfa-select]').doesNotExist('Selects do not render for single methods');
|
||||||
|
await validate(true);
|
||||||
|
didLogin(assert);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should handle multi mfa constraint with 1 push method each', async function (assert) {
|
||||||
|
await login('mfa-g');
|
||||||
|
didLogin(assert);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should handle multiple mfa constraints with 1 passcode and 1 push method', async function (assert) {
|
||||||
|
await login('mfa-h');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-mfa-description]')
|
||||||
|
.includesText(
|
||||||
|
'Two methods are required for successful authentication.',
|
||||||
|
'Mfa form displays with correct description'
|
||||||
|
);
|
||||||
|
assert.dom('[data-test-mfa-select]').doesNotExist('Select is hidden for single method');
|
||||||
|
assert.dom('[data-test-mfa-passcode]').exists({ count: 1 }, 'Passcode input renders');
|
||||||
|
await validate();
|
||||||
|
didLogin(assert);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should handle multiple mfa constraints with multiple mixed methods', async function (assert) {
|
||||||
|
await login('mfa-i');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-mfa-description]')
|
||||||
|
.includesText(
|
||||||
|
'Two methods are required for successful authentication.',
|
||||||
|
'Mfa form displays with correct description'
|
||||||
|
);
|
||||||
|
await this.select();
|
||||||
|
await fillIn('[data-test-mfa-passcode="1"]', 'test');
|
||||||
|
await click('[data-test-mfa-validate]');
|
||||||
|
didLogin(assert);
|
||||||
|
});
|
||||||
|
});
|
|
@ -18,6 +18,7 @@ const authService = Service.extend({
|
||||||
async authenticate() {
|
async authenticate() {
|
||||||
return fetch('http://localhost:2000');
|
return fetch('http://localhost:2000');
|
||||||
},
|
},
|
||||||
|
handleError() {},
|
||||||
setLastFetch() {},
|
setLastFetch() {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -25,6 +26,7 @@ const workingAuthService = Service.extend({
|
||||||
authenticate() {
|
authenticate() {
|
||||||
return resolve({});
|
return resolve({});
|
||||||
},
|
},
|
||||||
|
handleError() {},
|
||||||
setLastFetch() {},
|
setLastFetch() {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'ember-qunit';
|
||||||
|
import { render } from '@ember/test-helpers';
|
||||||
|
import { hbs } from 'ember-cli-htmlbars';
|
||||||
|
import { click } from '@ember/test-helpers';
|
||||||
|
import { TOTP_NOT_CONFIGURED } from 'vault/services/auth';
|
||||||
|
import { TOTP_NA_MSG, MFA_ERROR_MSG } from 'vault/components/mfa-error';
|
||||||
|
const UNAUTH = 'MFA authorization failed';
|
||||||
|
|
||||||
|
module('Integration | Component | mfa-error', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
test('it renders', async function (assert) {
|
||||||
|
const auth = this.owner.lookup('service:auth');
|
||||||
|
auth.set('mfaErrors', [TOTP_NOT_CONFIGURED]);
|
||||||
|
|
||||||
|
this.onClose = () => assert.ok(true, 'onClose event is triggered');
|
||||||
|
|
||||||
|
await render(hbs`<MfaError @onClose={{this.onClose}}/>`);
|
||||||
|
|
||||||
|
assert.dom('[data-test-empty-state-title]').hasText('TOTP not set up', 'Title renders for TOTP error');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-empty-state-subText]')
|
||||||
|
.hasText(TOTP_NOT_CONFIGURED, 'Error message renders for TOTP error');
|
||||||
|
assert.dom('[data-test-empty-state-message]').hasText(TOTP_NA_MSG, 'Description renders for TOTP error');
|
||||||
|
|
||||||
|
auth.set('mfaErrors', [UNAUTH]);
|
||||||
|
await render(hbs`<MfaError @onClose={{this.onClose}}/>`);
|
||||||
|
|
||||||
|
assert.dom('[data-test-empty-state-title]').hasText('Unauthorized', 'Title renders for mfa error');
|
||||||
|
assert.dom('[data-test-empty-state-subText]').hasText(UNAUTH, 'Error message renders for mfa error');
|
||||||
|
assert.dom('[data-test-empty-state-message]').hasText(MFA_ERROR_MSG, 'Description renders for mfa error');
|
||||||
|
|
||||||
|
await click('[data-test-go-back]');
|
||||||
|
|
||||||
|
assert.equal(auth.mfaErrors, null, 'mfaErrors unset in auth service');
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { module, test, skip } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'ember-qunit';
|
||||||
|
import { render } from '@ember/test-helpers';
|
||||||
|
import { hbs } from 'ember-cli-htmlbars';
|
||||||
|
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||||
|
import { fillIn, click, waitUntil } from '@ember/test-helpers';
|
||||||
|
import { run, later } from '@ember/runloop';
|
||||||
|
|
||||||
|
module('Integration | Component | mfa-form', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
setupMirage(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.clusterId = '123456';
|
||||||
|
this.mfaAuthData = {
|
||||||
|
backend: 'userpass',
|
||||||
|
data: { username: 'foo', password: 'bar' },
|
||||||
|
};
|
||||||
|
this.authService = this.owner.lookup('service:auth');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should render correct descriptions', async function (assert) {
|
||||||
|
const totpConstraint = this.server.create('mfa-method', { type: 'totp' });
|
||||||
|
const oktaConstraint = this.server.create('mfa-method', { type: 'okta' });
|
||||||
|
const duoConstraint = this.server.create('mfa-method', { type: 'duo' });
|
||||||
|
|
||||||
|
this.mfaAuthData.mfa_requirement = this.authService._parseMfaResponse({
|
||||||
|
mfa_request_id: 'test-mfa-id',
|
||||||
|
mfa_constraints: { test_mfa_1: { any: [totpConstraint] } },
|
||||||
|
}).mfa_requirement;
|
||||||
|
|
||||||
|
await render(hbs`<MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} />`);
|
||||||
|
assert
|
||||||
|
.dom('[data-test-mfa-description]')
|
||||||
|
.includesText(
|
||||||
|
'Enter your authentication code to log in.',
|
||||||
|
'Correct description renders for single passcode'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.mfaAuthData.mfa_requirement = this.authService._parseMfaResponse({
|
||||||
|
mfa_request_id: 'test-mfa-id',
|
||||||
|
mfa_constraints: { test_mfa_1: { any: [duoConstraint, oktaConstraint] } },
|
||||||
|
}).mfa_requirement;
|
||||||
|
|
||||||
|
await render(hbs`<MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} />`);
|
||||||
|
assert
|
||||||
|
.dom('[data-test-mfa-description]')
|
||||||
|
.includesText(
|
||||||
|
'Select the MFA method you wish to use.',
|
||||||
|
'Correct description renders for multiple methods'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.mfaAuthData.mfa_requirement = this.authService._parseMfaResponse({
|
||||||
|
mfa_request_id: 'test-mfa-id',
|
||||||
|
mfa_constraints: { test_mfa_1: { any: [oktaConstraint] }, test_mfa_2: { any: [duoConstraint] } },
|
||||||
|
}).mfa_requirement;
|
||||||
|
|
||||||
|
await render(hbs`<MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} />`);
|
||||||
|
assert
|
||||||
|
.dom('[data-test-mfa-description]')
|
||||||
|
.includesText(
|
||||||
|
'Two methods are required for successful authentication.',
|
||||||
|
'Correct description renders for multiple constraints'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should render method selects and passcode inputs', async function (assert) {
|
||||||
|
const duoConstraint = this.server.create('mfa-method', { type: 'duo', uses_passcode: true });
|
||||||
|
const oktaConstraint = this.server.create('mfa-method', { type: 'okta' });
|
||||||
|
const pingidConstraint = this.server.create('mfa-method', { type: 'pingid' });
|
||||||
|
const { mfa_requirement } = this.authService._parseMfaResponse({
|
||||||
|
mfa_request_id: 'test-mfa-id',
|
||||||
|
mfa_constraints: {
|
||||||
|
test_mfa_1: {
|
||||||
|
any: [pingidConstraint, oktaConstraint],
|
||||||
|
},
|
||||||
|
test_mfa_2: {
|
||||||
|
any: [duoConstraint],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.mfaAuthData.mfa_requirement = mfa_requirement;
|
||||||
|
|
||||||
|
this.server.post('/sys/mfa/validate', (schema, req) => {
|
||||||
|
const json = JSON.parse(req.requestBody);
|
||||||
|
const payload = {
|
||||||
|
mfa_request_id: 'test-mfa-id',
|
||||||
|
mfa_payload: { [oktaConstraint.id]: [], [duoConstraint.id]: ['test-code'] },
|
||||||
|
};
|
||||||
|
assert.deepEqual(json, payload, 'Correct mfa payload passed to validate endpoint');
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.owner.lookup('service:auth').reopen({
|
||||||
|
// override to avoid authSuccess method since it expects an auth payload
|
||||||
|
async totpValidate({ mfa_requirement }) {
|
||||||
|
await this.clusterAdapter().mfaValidate(mfa_requirement);
|
||||||
|
return 'test response';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.onSuccess = (resp) =>
|
||||||
|
assert.equal(resp, 'test response', 'Response is returned in onSuccess callback');
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<MfaForm
|
||||||
|
@clusterId={{this.clusterId}}
|
||||||
|
@authData={{this.mfaAuthData}}
|
||||||
|
@onSuccess={{this.onSuccess}}
|
||||||
|
/>
|
||||||
|
`);
|
||||||
|
await fillIn('[data-test-mfa-select="0"] select', oktaConstraint.id);
|
||||||
|
await fillIn('[data-test-mfa-passcode="1"]', 'test-code');
|
||||||
|
await click('[data-test-mfa-validate]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should validate mfa requirement', async function (assert) {
|
||||||
|
const totpConstraint = this.server.create('mfa-method', { type: 'totp' });
|
||||||
|
const { mfa_requirement } = this.authService._parseMfaResponse({
|
||||||
|
mfa_request_id: 'test-mfa-id',
|
||||||
|
mfa_constraints: {
|
||||||
|
test_mfa: {
|
||||||
|
any: [totpConstraint],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.mfaAuthData.mfa_requirement = mfa_requirement;
|
||||||
|
|
||||||
|
this.server.post('/sys/mfa/validate', (schema, req) => {
|
||||||
|
const json = JSON.parse(req.requestBody);
|
||||||
|
const payload = {
|
||||||
|
mfa_request_id: 'test-mfa-id',
|
||||||
|
mfa_payload: { [totpConstraint.id]: ['test-code'] },
|
||||||
|
};
|
||||||
|
assert.deepEqual(json, payload, 'Correct mfa payload passed to validate endpoint');
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedAuthData = { clusterId: this.clusterId, ...this.mfaAuthData };
|
||||||
|
this.owner.lookup('service:auth').reopen({
|
||||||
|
// override to avoid authSuccess method since it expects an auth payload
|
||||||
|
async totpValidate(authData) {
|
||||||
|
await waitUntil(() =>
|
||||||
|
assert.dom('[data-test-mfa-validate]').hasClass('is-loading', 'Loading class applied to button')
|
||||||
|
);
|
||||||
|
assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled while loading');
|
||||||
|
assert.deepEqual(authData, expectedAuthData, 'Mfa auth data passed to validate method');
|
||||||
|
await this.clusterAdapter().mfaValidate(authData.mfa_requirement);
|
||||||
|
return 'test response';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.onSuccess = (resp) =>
|
||||||
|
assert.equal(resp, 'test response', 'Response is returned in onSuccess callback');
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<MfaForm
|
||||||
|
@clusterId={{this.clusterId}}
|
||||||
|
@authData={{this.mfaAuthData}}
|
||||||
|
@onSuccess={{this.onSuccess}}
|
||||||
|
/>
|
||||||
|
`);
|
||||||
|
await fillIn('[data-test-mfa-passcode]', 'test-code');
|
||||||
|
await click('[data-test-mfa-validate]');
|
||||||
|
});
|
||||||
|
|
||||||
|
// commented out in component until specific error code can be parsed from the api response
|
||||||
|
skip('it should show countdown on passcode validation failure', async function (assert) {
|
||||||
|
this.owner.lookup('service:auth').reopen({
|
||||||
|
totpValidate() {
|
||||||
|
throw new Error('Incorrect passcode');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await render(hbs`
|
||||||
|
<MfaForm
|
||||||
|
@clusterId={{this.clusterId}}
|
||||||
|
@authData={{this.mfaAuthData}}
|
||||||
|
/>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await fillIn('[data-test-mfa-passcode]', 'test-code');
|
||||||
|
later(() => run.cancelTimers(), 50);
|
||||||
|
await click('[data-test-mfa-validate]');
|
||||||
|
assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled during countdown');
|
||||||
|
assert.dom('[data-test-mfa-passcode]').isDisabled('Input is disabled during countdown');
|
||||||
|
assert.dom('[data-test-mfa-passcode]').hasNoValue('Input value is cleared on error');
|
||||||
|
assert.dom('[data-test-inline-error-message]').exists('Alert message renders');
|
||||||
|
assert.dom('[data-test-mfa-countdown]').exists('30 second countdown renders');
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue