Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Server-Side sessions #68117

Merged
merged 52 commits into from
Aug 18, 2020
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
bfd42f0
Implement Server-Side sessions.
azasypkin Jun 3, 2020
fc0ab5d
Merge branch 'master' into issue-xxx-sss
azasypkin Jul 8, 2020
194b06e
Added functional tests for the OIDC capture URL flow, Authenticator t…
azasypkin Jul 8, 2020
176fe3b
Remove session that belongs to a not configured provider in all cases.
azasypkin Jul 8, 2020
3f85d63
More tests, don't clean session on password change (temporarily).
azasypkin Jul 8, 2020
50a09bb
Merge branch 'master' into issue-xxx-sss
azasypkin Jul 9, 2020
ada2b9f
Add capture-url tests, session cookie, and session management service…
azasypkin Jul 9, 2020
a30e49b
Merge branch 'master' into issue-xxx-sss
azasypkin Jul 9, 2020
f033f72
Merge branch 'master' into issue-xxx-sss
azasypkin Jul 14, 2020
38f15bb
Merge branch 'master' into issue-xxx-sss
azasypkin Jul 15, 2020
959a535
Merge branch 'master' into issue-xxx-sss
azasypkin Jul 15, 2020
9d3273b
Review#1: handle review comments, add more tests.
azasypkin Jul 15, 2020
7133991
Review#1: manually fix Jest snapshots as they are not updated by Jest…
azasypkin Jul 16, 2020
e915c3f
Merge branch 'master' into issue-xxx-sss
azasypkin Jul 16, 2020
21bb805
Add basic docs and session integration tests.
azasypkin Jul 16, 2020
11b3c4e
Fix outdated test file link.
azasypkin Jul 16, 2020
8bd08e5
Fix more outdated test file links.
azasypkin Jul 16, 2020
3fe0d46
Merge branch 'master' into issue-xxx-sss
azasypkin Jul 17, 2020
ccbcec1
More tweaks to the brand new session api integration tests.
azasypkin Jul 17, 2020
04801a1
Wait for the `GREEN` status of session index before running tests.
azasypkin Jul 17, 2020
b6f3987
Merge branch 'master' into issue-xxx-sss
azasypkin Jul 22, 2020
3f1fa9c
Merge branch 'master' into issue-xxx-sss
azasypkin Jul 31, 2020
b91f050
Review#2: handle review feedback.
azasypkin Jul 31, 2020
09acb02
Merge branch 'master' into issue-xxx-sss
azasypkin Jul 31, 2020
38f41cf
Merge branch 'master' into issue-xxx-sss
azasypkin Aug 3, 2020
e98dbd0
Review#2: properly update session index when user is active.
azasypkin Aug 3, 2020
a86884c
Remove duplicated test suite declaration.
azasypkin Aug 3, 2020
6acf005
Review#2: use SID-scoped loggers inside of `Session`.
azasypkin Aug 3, 2020
d518e99
Merge branch 'master' into issue-xxx-sss
azasypkin Aug 4, 2020
a8d46f1
Review#3: generate index name inside of `SessionIndex` and get rid of…
azasypkin Aug 4, 2020
d3c8454
Merge branch 'master' into issue-xxx-sss
azasypkin Aug 10, 2020
bcbd9c2
Sync PR with the latest upstream ESLint rules.
azasypkin Aug 10, 2020
fb47b91
Merge branch 'master' into issue-xxx-sss
azasypkin Aug 11, 2020
b40adf6
Review#4: more comments and logs, use 32 bytes for SID and AAD instea…
azasypkin Aug 11, 2020
9548184
Review#4: properly handle empty sessions in `SessionTimeout` service,…
azasypkin Aug 11, 2020
f03d978
Merge branch 'master' into issue-xxx-sss
azasypkin Aug 11, 2020
2d3ea32
Review#5: allow smaller cleanup timeouts in dev mode (for tests).
azasypkin Aug 11, 2020
8ee6b90
Merge branch 'master' into issue-xxx-sss
azasypkin Aug 12, 2020
21d24ed
Review#5: decrease minimum value for cleanup interval to 10s to make …
azasypkin Aug 12, 2020
efdbef2
Update docs.
azasypkin Aug 12, 2020
92437c0
Merge branch 'master' into issue-xxx-sss
azasypkin Aug 12, 2020
d69116a
Merge branch 'master' into issue-xxx-sss
elasticmachine Aug 12, 2020
ed6944d
Merge branch 'master' into issue-xxx-sss
elasticmachine Aug 13, 2020
ca64e87
Merge branch 'master' into issue-xxx-sss
elasticmachine Aug 13, 2020
4d0ee0d
Merge branch 'master' into issue-xxx-sss
azasypkin Aug 14, 2020
378c239
Review#6: incorporate docs review suggestions.
azasypkin Aug 14, 2020
a4cdb60
tests: make sure we always wait for the green status before trying to…
azasypkin Aug 14, 2020
b178201
Merge branch 'master' into issue-xxx-sss
azasypkin Aug 17, 2020
3ded572
Review#7: handle more docs comments.
azasypkin Aug 17, 2020
5bc9502
Review#8: more doc improvements.
azasypkin Aug 17, 2020
1d27f02
Merge branch 'master' into issue-xxx-sss
azasypkin Aug 17, 2020
8ea6939
Review#8: more docs comments.
azasypkin Aug 17, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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({
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: there are couple of drawbacks of this approach (slower than rendering custom simple HTML and if hash isn't specified we'll have unnecessary redirect), but for now benefits outweight them for me (same approach for all providers, using core's HTTP and App services).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree!

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.'));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than failing with a fatal error, what do you think about rendering a custom error page? The fatal error page gives you an option to clear your session, which doesn't actually have anything to do with your user session. I'd love to have a page that helps users fix their problem (or somehow retry), but that part might not be feasible given the number of potential causes for a failed login.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love to have a page that helps users fix their problem (or somehow retry), but that part might not be feasible given the number of potential causes for a failed login.

Yeah, it's kind of similar to #61232 as well. I thought we could approach them together and involve Core UI Team as well. For all cases there are basically 2 generic actions we can propose:

  • Retry (applicable in this case, but probably not in SAML/OIDC)
  • Clear session and go back to root page

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we could approach them together and involve Core UI Team as well.

That seems reasonable, feel free to defer this then

}

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