Skip to content

Commit

Permalink
Implement Server-Side sessions.
Browse files Browse the repository at this point in the history
  • Loading branch information
azasypkin committed Jul 7, 2020
1 parent 648468d commit 0d1e2cb
Show file tree
Hide file tree
Showing 86 changed files with 3,943 additions and 2,535 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { ApplicationSetup, StartServicesAccessor, HttpSetup } from 'src/core/public';
import {
ApplicationSetup,
StartServicesAccessor,
HttpSetup,
FatalErrorsSetup,
} from 'src/core/public';
import { AuthenticatedUser } from '../../common/model';
import { ConfigType } from '../config';
import { PluginStartDependencies } from '../plugin';
Expand All @@ -13,9 +18,11 @@ import { loginApp } from './login';
import { logoutApp } from './logout';
import { loggedOutApp } from './logged_out';
import { overwrittenSessionApp } from './overwritten_session';
import { captureURLApp } from './capture_url';

interface SetupParams {
application: ApplicationSetup;
fatalErrors: FatalErrorsSetup;
config: ConfigType;
http: HttpSetup;
getStartServices: StartServicesAccessor<PluginStartDependencies>;
Expand All @@ -36,6 +43,7 @@ export interface AuthenticationServiceSetup {
export class AuthenticationService {
public setup({
application,
fatalErrors,
config,
getStartServices,
http,
Expand All @@ -48,6 +56,7 @@ export class AuthenticationService {
.apiKeysEnabled;

accessAgreementApp.create({ application, getStartServices });
captureURLApp.create({ application, fatalErrors, http });
loginApp.create({ application, config, getStartServices, http });
logoutApp.create({ application, http });
loggedOutApp.create({ application, getStartServices, http });
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { AppMount, ScopedHistory } from 'src/core/public';
import { captureURLApp } from './capture_url_app';

import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks';

describe('captureURLApp', () => {
beforeAll(() => {
Object.defineProperty(window, 'location', {
value: { href: 'https://some-host' },
writable: true,
});
});

it('properly registers application', () => {
const coreSetupMock = coreMock.createSetup();

captureURLApp.create(coreSetupMock);

expect(coreSetupMock.http.anonymousPaths.register).toHaveBeenCalledTimes(1);
expect(coreSetupMock.http.anonymousPaths.register).toHaveBeenCalledWith(
'/internal/security/capture-url'
);

expect(coreSetupMock.application.register).toHaveBeenCalledTimes(1);

const [[appRegistration]] = coreSetupMock.application.register.mock.calls;
expect(appRegistration).toEqual({
id: 'security_capture_url',
chromeless: true,
appRoute: '/internal/security/capture-url',
title: 'Capture URL',
mount: expect.any(Function),
});
});

it('properly handles captured URL', async () => {
window.location.href = `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent(
'/mock-base-path/app/home'
)}&providerType=saml&providerName=saml1#/?_g=()`;

const coreSetupMock = coreMock.createSetup();
coreSetupMock.http.post.mockResolvedValue({ location: '/mock-base-path/app/home#/?_g=()' });

captureURLApp.create(coreSetupMock);

const [[{ mount }]] = coreSetupMock.application.register.mock.calls;
await (mount as AppMount)({
element: document.createElement('div'),
appBasePath: '',
onAppLeave: jest.fn(),
history: (scopedHistoryMock.create() as unknown) as ScopedHistory,
});

expect(coreSetupMock.http.post).toHaveBeenCalledTimes(1);
expect(coreSetupMock.http.post).toHaveBeenCalledWith('/internal/security/login', {
body: JSON.stringify({
providerType: 'saml',
providerName: 'saml1',
currentURL: `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent(
'/mock-base-path/app/home'
)}&providerType=saml&providerName=saml1#/?_g=()`,
}),
});

expect(window.location.href).toBe('/mock-base-path/app/home#/?_g=()');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { parse } from 'url';
import { ApplicationSetup, FatalErrorsSetup, HttpSetup } from 'src/core/public';

interface CreateDeps {
application: ApplicationSetup;
http: HttpSetup;
fatalErrors: FatalErrorsSetup;
}

/**
* Some authentication providers need to know current user URL to, for example, restore it after a
* complex authentication handshake. But most of the Kibana URLs include hash fragment that is never
* sent to the server. To capture that authentication provider can redirect user to this app putting
* path segment into the `next` query string parameter (so that it's not lost during redirect). And
* since browsers preserve hash fragments during redirects (assuming redirect location doesn't
* specify its own hash fragment, which is true in our case) this app can capture both path and
* hash URL segments and send them back to the authentication provider via login endpoint.
*
* The flow can look like this:
* 1. User visits `/app/kibana#/management/elasticsearch` that initiates authentication.
* 2. Provider redirect user to `/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1`.
* 3. Browser preserves hash segment and users ends up at `/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1#/management/elasticsearch`.
* 4. The app captures full URL and sends it back as is via login endpoint:
* {
* providerType: 'saml',
* providerName: 'saml1',
* currentURL: 'https://kibana.com/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1#/management/elasticsearch'
* }
* 5. Login endpoint handler parses and validates `next` parameter, joins it with the hash segment
* and finally passes it to the provider that initiated capturing.
*/
export const captureURLApp = Object.freeze({
id: 'security_capture_url',
create({ application, fatalErrors, http }: CreateDeps) {
http.anonymousPaths.register('/internal/security/capture-url');
application.register({
id: this.id,
title: 'Capture URL',
chromeless: true,
appRoute: '/internal/security/capture-url',
async mount() {
try {
const { providerName, providerType } = parse(window.location.href, true).query ?? {};
if (!providerName || !providerType) {
fatalErrors.add(new Error('Provider to capture URL for is not specified.'));
return () => {};
}

const { location } = await http.post<{ location: string }>('/internal/security/login', {
body: JSON.stringify({ providerType, providerName, currentURL: window.location.href }),
});

window.location.href = location;
} catch (err) {
fatalErrors.add(new Error('Cannot login with captured URL.'));
}

return () => {};
},
});
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

export { captureURLApp } from './capture_url_app';
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ describe('LoginForm', () => {
'/some-base-path/app/home#/?_g=()'
)}`;
const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' });
coreStartMock.http.post.mockResolvedValue({});
coreStartMock.http.post.mockResolvedValue({ location: '/some-base-path/app/home#/?_g=()' });

const wrapper = mountWithIntl(
<LoginForm
Expand All @@ -180,7 +180,7 @@ describe('LoginForm', () => {
loginAssistanceMessage=""
selector={{
enabled: false,
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }],
providers: [{ type: 'basic', name: 'basic1', usesLoginForm: true }],
}}
/>
);
Expand All @@ -198,7 +198,14 @@ describe('LoginForm', () => {

expect(coreStartMock.http.post).toHaveBeenCalledTimes(1);
expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', {
body: JSON.stringify({ username: 'username1', password: 'password1' }),
body: JSON.stringify({
providerType: 'basic',
providerName: 'basic1',
currentURL: `https://some-host/login?next=${encodeURIComponent(
'/some-base-path/app/home#/?_g=()'
)}`,
params: { username: 'username1', password: 'password1' },
}),
});

expect(window.location.href).toBe('/some-base-path/app/home#/?_g=()');
Expand Down Expand Up @@ -363,7 +370,7 @@ describe('LoginForm', () => {
});

expect(coreStartMock.http.post).toHaveBeenCalledTimes(1);
expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login_with', {
expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', {
body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }),
});

Expand Down Expand Up @@ -407,7 +414,7 @@ describe('LoginForm', () => {
});

expect(coreStartMock.http.post).toHaveBeenCalledTimes(1);
expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login_with', {
expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', {
body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }),
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import {
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { HttpStart, IHttpFetchError, NotificationsStart } from 'src/core/public';
import { parseNext } from '../../../../../common/parse_next';
import { LoginSelector } from '../../../../../common/login_state';
import { LoginValidator } from './validate_login';

Expand Down Expand Up @@ -401,11 +400,25 @@ export class LoginForm extends Component<Props, State> {
message: { type: MessageType.None },
});

const { http } = this.props;
// We try to log in with the provider that uses login form and has the lowest order.
const providerToLoginWith = this.props.selector.providers.find(
(provider) => provider.usesLoginForm
)!;

try {
await http.post('/internal/security/login', { body: JSON.stringify({ username, password }) });
window.location.href = parseNext(window.location.href, http.basePath.serverBasePath);
const { location } = await this.props.http.post<{ location: string }>(
'/internal/security/login',
{
body: JSON.stringify({
providerType: providerToLoginWith.type,
providerName: providerToLoginWith.name,
currentURL: window.location.href,
params: { username, password },
}),
}
);

window.location.href = location;
} catch (error) {
const message =
(error as IHttpFetchError).response?.status === 401
Expand All @@ -432,7 +445,7 @@ export class LoginForm extends Component<Props, State> {

try {
const { location } = await this.props.http.post<{ location: string }>(
'/internal/security/login_with',
'/internal/security/login',
{ body: JSON.stringify({ providerType, providerName, currentURL: window.location.href }) }
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import React from 'react';
import { EuiButton } from '@elastic/eui';
import { act } from '@testing-library/react';
import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers';
import { OverwrittenSessionPage } from './overwritten_session_page';
Expand All @@ -15,6 +16,13 @@ import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.
import { AuthenticationStatePage } from '../components/authentication_state_page';

describe('OverwrittenSessionPage', () => {
beforeAll(() => {
Object.defineProperty(window, 'location', {
value: { href: 'https://some-host' },
writable: true,
});
});

it('renders as expected', async () => {
const basePathMock = coreMock.createStart({ basePath: '/mock-base-path' }).http.basePath;
const authenticationSetupMock = authenticationMock.createSetup();
Expand All @@ -36,4 +44,30 @@ describe('OverwrittenSessionPage', () => {

expect(wrapper.find(AuthenticationStatePage)).toMatchSnapshot();
});

it('properly parses `next` parameter', async () => {
window.location.href = `https://host.com/mock-base-path/security/overwritten_session?next=${encodeURIComponent(
'/mock-base-path/app/home#/?_g=()'
)}`;

const basePathMock = coreMock.createStart({ basePath: '/mock-base-path' }).http.basePath;
const authenticationSetupMock = authenticationMock.createSetup();
authenticationSetupMock.getCurrentUser.mockResolvedValue(
mockAuthenticatedUser({ username: 'mock-user' })
);

const wrapper = mountWithIntl(
<OverwrittenSessionPage basePath={basePathMock} authc={authenticationSetupMock} />
);

// Shouldn't render anything if username isn't yet available.
expect(wrapper.isEmptyRender()).toBe(true);

await act(async () => {
await nextTick();
wrapper.update();
});

expect(wrapper.find(EuiButton).prop('href')).toBe('/mock-base-path/app/home#/?_g=()');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import ReactDOM from 'react-dom';
import { EuiButton } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { CoreStart, IBasePath } from 'src/core/public';
import { parseNext } from '../../../common/parse_next';
import { AuthenticationServiceSetup } from '../authentication_service';
import { AuthenticationStatePage } from '../components';

Expand Down Expand Up @@ -36,7 +37,7 @@ export function OverwrittenSessionPage({ authc, basePath }: Props) {
/>
}
>
<EuiButton href={basePath.prepend('/')}>
<EuiButton href={parseNext(window.location.href, basePath.serverBasePath)}>
<FormattedMessage
id="xpack.security.overwrittenSession.continueAsUserText"
defaultMessage="Continue as {username}"
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/security/public/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export class SecurityPlugin

this.authc = this.authenticationService.setup({
application: core.application,
fatalErrors: core.fatalErrors,
config: this.config,
getStartServices: core.getStartServices,
http: core.http,
Expand Down
Loading

0 comments on commit 0d1e2cb

Please sign in to comment.