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

[7.x] Handle session timeout and user activity (#98461) #99959

Merged
merged 1 commit into from
May 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 additions & 0 deletions x-pack/plugins/security/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,31 @@ export const NAME_REGEX = /^(?! )[a-zA-Z0-9 !"#$%&'()*+,\-./\\:;<=>?@\[\]^_`{|}~
* Maximum length of usernames and role names.
*/
export const MAX_NAME_LENGTH = 1024;

/**
* Client session timeout is decreased by this number so that Kibana server can still access session
* content during logout request to properly clean user session up (invalidate access tokens,
* redirect to logout portal etc.).
*/
export const SESSION_GRACE_PERIOD_MS = 5 * 1000;

/**
* Duration we'll normally display the warning toast
*/
export const SESSION_EXPIRATION_WARNING_MS = 5 * 60 * 1000;

/**
* Current session info is checked this number of milliseconds before the warning toast shows. This
* will prevent the toast from being shown if the session has already been extended.
*/
export const SESSION_CHECK_MS = 1000;

/**
* Session will be extended at most once this number of milliseconds while user activity is detected.
*/
export const SESSION_EXTENSION_THROTTLE_MS = 60 * 1000;

/**
* Route to get session info and extend session expiration
*/
export const SESSION_ROUTE = '/internal/security/session';
5 changes: 2 additions & 3 deletions x-pack/plugins/security/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
import type { AuthenticationProvider } from './model';

export interface SessionInfo {
now: number;
idleTimeoutExpiration: number | null;
lifespanExpiration: number | null;
expiresInMs: number | null;
canBeExtended: boolean;
provider: AuthenticationProvider;
}
11 changes: 2 additions & 9 deletions x-pack/plugins/security/public/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,7 @@ import type { ConfigType } from './config';
import { ManagementService } from './management';
import { SecurityNavControlService } from './nav_control';
import { SecurityCheckupService } from './security_checkup';
import type { ISessionTimeout } from './session';
import {
SessionExpired,
SessionTimeout,
SessionTimeoutHttpInterceptor,
UnauthorizedResponseHttpInterceptor,
} from './session';
import { SessionExpired, SessionTimeout, UnauthorizedResponseHttpInterceptor } from './session';

export interface PluginSetupDependencies {
licensing: LicensingPluginSetup;
Expand All @@ -58,7 +52,7 @@ export class SecurityPlugin
PluginSetupDependencies,
PluginStartDependencies
> {
private sessionTimeout!: ISessionTimeout;
private sessionTimeout!: SessionTimeout;
private readonly authenticationService = new AuthenticationService();
private readonly navControlService = new SecurityNavControlService();
private readonly securityLicenseService = new SecurityLicenseService();
Expand All @@ -84,7 +78,6 @@ export class SecurityPlugin
const sessionExpired = new SessionExpired(logoutUrl, tenant);
http.intercept(new UnauthorizedResponseHttpInterceptor(sessionExpired, anonymousPaths));
this.sessionTimeout = new SessionTimeout(notifications, sessionExpired, http, tenant);
http.intercept(new SessionTimeoutHttpInterceptor(this.sessionTimeout, anonymousPaths));

const { license } = this.securityLicenseService.setup({ license$: licensing.license$ });

Expand Down
3 changes: 1 addition & 2 deletions x-pack/plugins/security/public/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,5 @@
*/

export { SessionExpired } from './session_expired';
export { SessionTimeout, ISessionTimeout } from './session_timeout';
export { SessionTimeoutHttpInterceptor } from './session_timeout_http_interceptor';
export { SessionTimeout } from './session_timeout';
export { UnauthorizedResponseHttpInterceptor } from './unauthorized_response_http_interceptor';
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { fireEvent, render } from '@testing-library/react';
import React from 'react';
import { of } from 'rxjs';

import { I18nProvider } from '@kbn/i18n/react';

import { createSessionExpirationToast, SessionExpirationToast } from './session_expiration_toast';
import type { SessionState } from './session_timeout';

describe('createSessionExpirationToast', () => {
it('creates a toast', () => {
const sessionState$ = of<SessionState>({
lastExtensionTime: Date.now(),
expiresInMs: 60 * 1000,
canBeExtended: true,
});
const onExtend = jest.fn();
const onClose = jest.fn();
const toast = createSessionExpirationToast(sessionState$, onExtend, onClose);

expect(toast).toEqual(
expect.objectContaining({
color: 'warning',
iconType: 'clock',
onClose: expect.any(Function),
text: expect.any(Function),
title: expect.any(String),
toastLifeTimeMs: 2147483647,
})
);
});
});

describe('SessionExpirationToast', () => {
it('renders session expiration time', () => {
const sessionState$ = of<SessionState>({
lastExtensionTime: Date.now(),
expiresInMs: 60 * 1000,
canBeExtended: true,
});

const { getByText } = render(
<I18nProvider>
<SessionExpirationToast sessionState$={sessionState$} onExtend={jest.fn()} />
</I18nProvider>
);
getByText(/You will be logged out in [0-9]+ seconds/);
});

it('renders extend button if session can be extended', () => {
const sessionState$ = of<SessionState>({
lastExtensionTime: Date.now(),
expiresInMs: 60 * 1000,
canBeExtended: true,
});
const onExtend = jest.fn().mockReturnValue(new Promise(() => {}));

const { getByRole } = render(
<I18nProvider>
<SessionExpirationToast sessionState$={sessionState$} onExtend={onExtend} />
</I18nProvider>
);
fireEvent.click(getByRole('button', { name: 'Stay logged in' }));

expect(onExtend).toHaveBeenCalled();
});

it('does not render extend button if session cannot be extended', () => {
const sessionState$ = of<SessionState>({
lastExtensionTime: Date.now(),
expiresInMs: 60 * 1000,
canBeExtended: false,
});
const onExtend = jest.fn();

const { queryByRole } = render(
<I18nProvider>
<SessionExpirationToast sessionState$={sessionState$} onExtend={onExtend} />
</I18nProvider>
);
expect(queryByRole('button', { name: 'Stay logged in' })).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React from 'react';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import useObservable from 'react-use/lib/useObservable';
import type { Observable } from 'rxjs';

import { i18n } from '@kbn/i18n';
import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react';
import type { ToastInput } from 'src/core/public';

import { toMountPoint } from '../../../../../src/plugins/kibana_react/public';
import { SESSION_GRACE_PERIOD_MS } from '../../common/constants';
import type { SessionState } from './session_timeout';

export interface SessionExpirationToastProps {
sessionState$: Observable<SessionState>;
onExtend: () => Promise<any>;
}

export const SessionExpirationToast: FunctionComponent<SessionExpirationToastProps> = ({
sessionState$,
onExtend,
}) => {
const state = useObservable(sessionState$);
const [{ loading }, extend] = useAsyncFn(onExtend);

if (!state || !state.expiresInMs) {
return null;
}

const expirationWarning = (
<FormattedMessage
id="xpack.security.sessionExpirationToast.body"
defaultMessage="You will be logged out {timeout}."
values={{
timeout: (
<FormattedRelative
value={Math.max(state.expiresInMs - SESSION_GRACE_PERIOD_MS, 0) + Date.now()}
updateInterval={1000}
/>
),
}}
/>
);

if (state.canBeExtended) {
return (
<>
{expirationWarning}
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButton size="s" color="warning" isLoading={loading} onClick={extend}>
<FormattedMessage
id="xpack.security.sessionExpirationToast.extendButton"
defaultMessage="Stay logged in"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}

return expirationWarning;
};

export const createSessionExpirationToast = (
sessionState$: Observable<SessionState>,
onExtend: () => Promise<any>,
onClose: () => void
): ToastInput => {
return {
color: 'warning',
iconType: 'clock',
title: i18n.translate('xpack.security.sessionExpirationToast.title', {
defaultMessage: 'Session timeout',
}),
text: toMountPoint(
<SessionExpirationToast sessionState$={sessionState$} onExtend={onExtend} />
),
onClose,
toastLifeTimeMs: 0x7fffffff, // Toast is hidden based on observable so using maximum possible timeout
};
};

This file was deleted.

This file was deleted.

Loading