UI - support redirecting to an intended URL after authentication (#7088)

* add redirect_to query param

* alias auth controller state to vault controller where the query param is defined

* capture the current url before redirecting a user to auth if they're being redirected

* consume and reset the redirectTo query param when authenticating

* make sure that the current url when logging out does not get set as the redirect_to query param

* add unit tests for the mixin and make it so that redirects from the root don't end up in redirect_to

* acceptance tests for redirect
This commit is contained in:
Matthew Irish 2019-08-01 18:50:43 -05:00 committed by GitHub
parent 1a7a71385a
commit 3da6487cf4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 114 additions and 11 deletions

View File

@ -192,12 +192,20 @@ export default Component.extend(DEFAULTS, {
authenticate: task(function*(backendType, data) {
let clusterId = this.cluster.id;
let targetRoute = this.redirectTo || 'vault.cluster';
try {
let authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data });
let { isRoot, namespace } = authResponse;
let transition = this.router.transitionTo(targetRoute, { queryParams: { namespace } });
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(() => {

View File

@ -7,9 +7,11 @@ export default Controller.extend({
queryParams: [
{
wrappedToken: 'wrapped_token',
redirectTo: 'redirect_to',
},
],
wrappedToken: '',
redirectTo: '',
env: config.environment,
auth: service(),
store: service(),

View File

@ -11,7 +11,7 @@ export default Controller.extend({
queryParams: [{ authMethod: 'with' }],
wrappedToken: alias('vaultController.wrappedToken'),
authMethod: '',
redirectTo: null,
redirectTo: alias('vaultController.redirectTo'),
updateNamespace: task(function*(value) {
// debounce

View File

@ -6,26 +6,41 @@ const INIT = 'vault.cluster.init';
const UNSEAL = 'vault.cluster.unseal';
const AUTH = 'vault.cluster.auth';
const CLUSTER = 'vault.cluster';
const CLUSTER_INDEX = 'vault.cluster.index';
const OIDC_CALLBACK = 'vault.cluster.oidc-callback';
const DR_REPLICATION_SECONDARY = 'vault.cluster.replication-dr-promote';
export { INIT, UNSEAL, AUTH, CLUSTER, DR_REPLICATION_SECONDARY };
export { INIT, UNSEAL, AUTH, CLUSTER, CLUSTER_INDEX, DR_REPLICATION_SECONDARY };
export default Mixin.create({
auth: service(),
store: service(),
router: service(),
transitionToTargetRoute(transition) {
transitionToTargetRoute(transition = {}) {
const targetRoute = this.targetRouteName(transition);
if (targetRoute && targetRoute !== this.routeName) {
if (
targetRoute &&
targetRoute !== this.routeName &&
targetRoute !== transition.targetName &&
targetRoute !== this.router.currentRouteName
) {
if (
// only want to redirect if we're going to authenticate
targetRoute === AUTH &&
transition.targetName !== CLUSTER_INDEX
) {
return this.transitionTo(targetRoute, { queryParams: { redirect_to: this.router.currentURL } });
}
return this.transitionTo(targetRoute);
}
return RSVP.resolve();
},
beforeModel() {
return this.transitionToTargetRoute();
beforeModel(transition) {
return this.transitionToTargetRoute(transition);
},
clusterModel() {

View File

@ -22,7 +22,7 @@ export default Route.extend(ModelBoundaryRoute, {
this.console.set('isOpen', false);
this.console.clearLog(true);
this.clearModelCache();
this.replaceWith('vault.cluster');
this.replaceWith('vault.cluster.auth', { queryParams: { redirect_to: '' } });
this.flashMessages.clearMessages();
this.permissions.reset();
},

View File

@ -0,0 +1,39 @@
import { currentURL, visit } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import authPage from 'vault/tests/pages/auth';
module('Acceptance | redirect_to functionality', function(hooks) {
setupApplicationTest(hooks);
test('redirect to a route after authentication', async function(assert) {
let url = '/vault/secrets/secret/create';
await visit(url);
assert.equal(
currentURL(),
`/vault/auth?redirect_to=${encodeURIComponent(url)}&with=token`,
'encodes url for the query param'
);
// the login method on this page does another visit call that we don't want here
await authPage.tokenInput('root').submit();
assert.equal(currentURL(), url, 'navigates to the redirect_to url after auth');
});
test('redirect from root does not include redirect_to', async function(assert) {
let url = '/';
await visit(url);
assert.equal(currentURL(), `/vault/auth?with=token`, 'there is no redirect_to query param');
});
test('redirect to a route after authentication with a query param', async function(assert) {
let url = '/vault/secrets/secret/create?initialKey=hello';
await visit(url);
assert.equal(
currentURL(),
`/vault/auth?redirect_to=${encodeURIComponent(url)}&with=token`,
'encodes url for the query param'
);
await authPage.tokenInput('root').submit();
assert.equal(currentURL(), url, 'navigates to the redirect_to with the query param after auth');
});
});

View File

@ -1,13 +1,21 @@
import { assign } from '@ember/polyfills';
import EmberObject from '@ember/object';
import ClusterRouteMixin from 'vault/mixins/cluster-route';
import { INIT, UNSEAL, AUTH, CLUSTER, DR_REPLICATION_SECONDARY } from 'vault/mixins/cluster-route';
import {
INIT,
UNSEAL,
AUTH,
CLUSTER,
CLUSTER_INDEX,
DR_REPLICATION_SECONDARY,
} from 'vault/mixins/cluster-route';
import { module, test } from 'qunit';
import sinon from 'sinon';
module('Unit | Mixin | cluster route', function() {
function createClusterRoute(
clusterModel = {},
methods = { hasKeyData: () => false, authToken: () => null }
methods = { router: {}, hasKeyData: () => false, authToken: () => null, transitionTo: () => {} }
) {
let ClusterRouteObject = EmberObject.extend(
ClusterRouteMixin,
@ -80,4 +88,35 @@ module('Unit | Mixin | cluster route', function() {
'forwards when not a DR secondary and navigating to DR_REPLICATION_SECONDARY'
);
});
test('#transitionToTargetRoute', function(assert) {
let redirectRouteURL = '/vault/secrets/secret/create';
let subject = createClusterRoute({ needsInit: false, sealed: false });
subject.router.currentURL = redirectRouteURL;
let spy = sinon.spy(subject, 'transitionTo');
subject.transitionToTargetRoute();
assert.ok(
spy.calledWithExactly(AUTH, { queryParams: { redirect_to: redirectRouteURL } }),
'calls transitionTo with the expected args'
);
spy.restore();
});
test('#transitionToTargetRoute with auth as a target', function(assert) {
let subject = createClusterRoute({ needsInit: false, sealed: false });
let spy = sinon.spy(subject, 'transitionTo');
// in this case it's already transitioning to the AUTH route so we don't need to call transitionTo again
subject.transitionToTargetRoute({ targetName: AUTH });
assert.ok(spy.notCalled, 'transitionTo is not called');
spy.restore();
});
test('#transitionToTargetRoute with auth target, coming from cluster route', function(assert) {
let subject = createClusterRoute({ needsInit: false, sealed: false });
let spy = sinon.spy(subject, 'transitionTo');
subject.transitionToTargetRoute({ targetName: CLUSTER_INDEX });
assert.ok(spy.calledWithExactly(AUTH), 'calls transitionTo without redirect_to');
spy.restore();
});
});