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);
|
||||
},
|
||||
|
||||
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(
|
||||
|
|
|
@ -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 = {
|
||||
|
@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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'),
|
||||
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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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 { 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;
|
||||
|
|
|
@ -51,3 +51,7 @@
|
|||
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;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,21 +10,26 @@
|
|||
</div>
|
||||
</Nav.items>
|
||||
</NavHeader>
|
||||
<UiWizard>
|
||||
<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="column is-4-desktop is-6-tablet">
|
||||
<div class="splash-page-header">
|
||||
{{yield (hash header=(component "splash-page/splash-header"))}}
|
||||
{{! 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="columns is-centered is-gapless is-fullwidth">
|
||||
<div class="column is-4-desktop is-6-tablet">
|
||||
<div class="splash-page-header">
|
||||
{{yield (hash header=(component "splash-page/splash-header"))}}
|
||||
</div>
|
||||
<div class="splash-page-sub-header">
|
||||
{{yield (hash sub-header=(component "splash-page/splash-header"))}}
|
||||
</div>
|
||||
<div class="login-form box is-paddingless is-relative">
|
||||
{{yield (hash content=(component "splash-page/splash-content"))}}
|
||||
</div>
|
||||
{{yield (hash footer=(component "splash-page/splash-content"))}}
|
||||
</div>
|
||||
<div class="splash-page-sub-header">
|
||||
{{yield (hash sub-header=(component "splash-page/splash-header"))}}
|
||||
</div>
|
||||
<div class="login-form box is-paddingless is-relative">
|
||||
{{yield (hash content=(component "splash-page/splash-content"))}}
|
||||
</div>
|
||||
{{yield (hash footer=(component "splash-page/splash-content"))}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiWizard>
|
||||
</UiWizard>
|
||||
{{/if}}
|
|
@ -1,84 +1,101 @@
|
|||
<SplashPage as |Page|>
|
||||
<SplashPage @hasAltContent={{this.auth.mfaErrors}} as |Page|>
|
||||
<Page.altContent>
|
||||
<MfaError @onClose={{fn (mut this.mfaAuthData) null}} />
|
||||
</Page.altContent>
|
||||
<Page.header>
|
||||
{{#if this.oidcProvider}}
|
||||
<div class="box is-shadowless is-flex-v-centered" data-test-auth-logo>
|
||||
<LogoEdition aria-label="Sign in with Hashicorp Vault" />
|
||||
</div>
|
||||
{{else}}
|
||||
<h1 class="title is-3">
|
||||
Sign in to Vault
|
||||
</h1>
|
||||
<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">
|
||||
{{if this.mfaAuthData "Authenticate" "Sign in to Vault"}}
|
||||
</h1>
|
||||
</div>
|
||||
{{/if}}
|
||||
</Page.header>
|
||||
{{#if this.managedNamespaceRoot}}
|
||||
<Page.sub-header>
|
||||
<Toolbar>
|
||||
<div class="toolbar-namespace-picker" data-test-managed-namespace-toolbar>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label">
|
||||
<label class="is-label" for="namespace">Namespace</label>
|
||||
{{#unless this.mfaAuthData}}
|
||||
{{#if this.managedNamespaceRoot}}
|
||||
<Page.sub-header>
|
||||
<Toolbar>
|
||||
<div class="toolbar-namespace-picker" data-test-managed-namespace-toolbar>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label">
|
||||
<label class="is-label" for="namespace">Namespace</label>
|
||||
</div>
|
||||
<div class="field-label">
|
||||
<span class="has-text-grey" data-test-managed-namespace-root>/{{this.managedNamespaceRoot}}</span>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input
|
||||
value={{this.managedNamespaceChild}}
|
||||
placeholder="/ (Default)"
|
||||
oninput={{perform this.updateManagedNamespace value="target.value"}}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
name="namespace"
|
||||
id="namespace"
|
||||
class="input"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-label">
|
||||
<span class="has-text-grey" data-test-managed-namespace-root>/{{this.managedNamespaceRoot}}</span>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</Page.sub-header>
|
||||
{{else if (has-feature "Namespaces")}}
|
||||
<Page.sub-header>
|
||||
<Toolbar class="toolbar-namespace-picker">
|
||||
<div class="field is-horizontal" data-test-namespace-toolbar>
|
||||
<div class="field-label is-normal">
|
||||
<label class="is-label" for="namespace">Namespace</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input
|
||||
value={{this.managedNamespaceChild}}
|
||||
placeholder="/ (Default)"
|
||||
oninput={{perform this.updateManagedNamespace value="target.value"}}
|
||||
value={{this.namespaceQueryParam}}
|
||||
placeholder="/ (Root)"
|
||||
oninput={{perform this.updateNamespace value="target.value"}}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
name="namespace"
|
||||
id="namespace"
|
||||
class="input"
|
||||
type="text"
|
||||
disabled={{this.oidcProvider}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</Page.sub-header>
|
||||
{{else if (has-feature "Namespaces")}}
|
||||
<Page.sub-header>
|
||||
<Toolbar class="toolbar-namespace-picker">
|
||||
<div class="field is-horizontal" data-test-namespace-toolbar>
|
||||
<div class="field-label is-normal">
|
||||
<label class="is-label" for="namespace">Namespace</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input
|
||||
value={{this.namespaceQueryParam}}
|
||||
placeholder="/ (Root)"
|
||||
oninput={{perform this.updateNamespace value="target.value"}}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
name="namespace"
|
||||
id="namespace"
|
||||
class="input"
|
||||
type="text"
|
||||
disabled={{this.oidcProvider}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</Page.sub-header>
|
||||
{{/if}}
|
||||
</Toolbar>
|
||||
</Page.sub-header>
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
<Page.content>
|
||||
<AuthForm
|
||||
@wrappedToken={{this.wrappedToken}}
|
||||
@cluster={{this.model}}
|
||||
@namespace={{this.namespaceQueryParam}}
|
||||
@redirectTo={{this.redirectTo}}
|
||||
@selectedAuth={{this.authMethod}}
|
||||
/>
|
||||
{{#if this.mfaAuthData}}
|
||||
<MfaForm @clusterId={{this.model.id}} @authData={{this.mfaAuthData}} @onSuccess={{action "onMfaSuccess"}} />
|
||||
{{else}}
|
||||
<AuthForm
|
||||
@wrappedToken={{this.wrappedToken}}
|
||||
@cluster={{this.model}}
|
||||
@namespace={{this.namespaceQueryParam}}
|
||||
@redirectTo={{this.redirectTo}}
|
||||
@selectedAuth={{this.authMethod}}
|
||||
@onSuccess={{action "onAuthResponse"}}
|
||||
/>
|
||||
{{/if}}
|
||||
</Page.content>
|
||||
<Page.footer>
|
||||
<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}}/>
|
||||
* ```
|
||||
*
|
||||
* @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 `<option>`s.
|
||||
* @param [name=null] {String} - 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 [labelAttribute=label] {String} - 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 [isFullwidth=false] {Bool} - 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 {string} [label=null] - The label for the select element.
|
||||
* @param {Array} [options=null] - A list of items that the user will select from. This can be an array of strings or objects.
|
||||
* @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 {string} [name = null] - The name of the select, used for the test selector.
|
||||
* @param {string} [valueAttribute = value]- When `options` is an array objects, the key to check for when assigning the option elements value.
|
||||
* @param {string} [labelAttribute = label] - When `options` is an array objects, the key to check for when assigning the option elements' inner text.
|
||||
* @param {boolean} [isInline = false] - Whether or not the select should be displayed as inline-block or block.
|
||||
* @param {boolean} [isFullwidth = false] - Whether or not the select should take up the full width of the parent element.
|
||||
* @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({
|
||||
|
@ -32,5 +33,6 @@ export default Component.extend({
|
|||
labelAttribute: 'label',
|
||||
isInline: false,
|
||||
isFullwidth: false,
|
||||
noDefault: false,
|
||||
onChange() {},
|
||||
});
|
||||
|
|
|
@ -11,6 +11,11 @@
|
|||
onchange={{action this.onChange value="target.value"}}
|
||||
data-test-select={{this.name}}
|
||||
>
|
||||
{{#if this.noDefault}}
|
||||
<option value="">
|
||||
Select one
|
||||
</option>
|
||||
{{/if}}
|
||||
{{#each this.options as |op|}}
|
||||
<option
|
||||
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
|
||||
// individual lookup done in mirage config
|
||||
import base from './base';
|
||||
import mfa from './mfa';
|
||||
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
|
||||
The `AuthForm` is used to sign users into Vault.
|
||||
|
||||
**Params**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| wrappedToken | <code>String</code> | <code></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. |
|
||||
| namespace | <code>String</code> | <code></code> | The currently active namespace. |
|
||||
| redirectTo | <code>String</code> | <code></code> | The name of the route to redirect to. |
|
||||
| selectedAuth | <code>String</code> | <code></code> | The auth method that is currently selected in the dropdown. |
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| wrappedToken | <code>string</code> | The auth method that is currently selected in the dropdown. |
|
||||
| cluster | <code>object</code> | The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. |
|
||||
| namespace- | <code>string</code> | The currently active namespace. |
|
||||
| selectedAuth | <code>string</code> | The auth method that is currently selected in the dropdown. |
|
||||
| onSuccess | <code>function</code> | Fired on auth success |
|
||||
|
||||
**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}} />```
|
||||
|
||||
**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');
|
||||
});
|
||||
|
||||
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 component.selectMethod('token');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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() {
|
||||
return fetch('http://localhost:2000');
|
||||
},
|
||||
handleError() {},
|
||||
setLastFetch() {},
|
||||
});
|
||||
|
||||
|
@ -25,6 +26,7 @@ const workingAuthService = Service.extend({
|
|||
authenticate() {
|
||||
return resolve({});
|
||||
},
|
||||
handleError() {},
|
||||
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