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

Handle session timeout and user activity #98461

Merged
merged 18 commits into from
May 12, 2021
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
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,95 @@
/*
* 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,
SessionExpirationBody,
SessionExpirationTitle,
} 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(Function),
toastLifeTimeMs: 2147483647,
})
);
});
});

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

const { getByText } = render(
<I18nProvider>
<SessionExpirationTitle sessionState$={sessionState$} />
</I18nProvider>
);
getByText(/Session ends in [0-9]+ seconds/);
});
});

describe('SessionExpirationBody', () => {
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>
<SessionExpirationBody 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>
<SessionExpirationBody sessionState$={sessionState$} onExtend={onExtend} />
</I18nProvider>
);
expect(queryByRole('button', { name: 'Stay logged in' })).toBeNull();
});
});
103 changes: 103 additions & 0 deletions x-pack/plugins/security/public/session/session_expiration_toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* 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 } 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 { FormattedMessage, FormattedRelative } from '@kbn/i18n/react';
import type { ToastInput } from 'src/core/public';

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

export interface SessionExpirationTitleProps {
sessionState$: Observable<SessionState>;
}

export const SessionExpirationTitle: FunctionComponent<SessionExpirationTitleProps> = ({
sessionState$,
}) => {
const state = useObservable(sessionState$);

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

return (
<FormattedMessage
id="xpack.security.sessionExpirationToast.title"
defaultMessage="Session ends {timeout}"
values={{
timeout: (
<FormattedRelative
value={Math.max(state.expiresInMs - GRACE_PERIOD_MS, 0) + Date.now()}
updateInterval={1000}
/>
),
}}
/>
);
};

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

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

if (!state) {
return null;
}

if (state.canBeExtended) {
return (
<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 (
<FormattedMessage
id="xpack.security.components.sessionExpirationToast.endOfLifeWarning"
defaultMessage="You will need to log in again."
/>
);
};

export const createSessionExpirationToast = (
sessionState$: Observable<SessionState>,
onExtend: () => Promise<any>,
onClose?: () => void
): ToastInput => {
return {
color: 'warning',
iconType: 'clock',
title: toMountPoint(<SessionExpirationTitle sessionState$={sessionState$} />),
text: toMountPoint(<SessionExpirationBody 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.

This file was deleted.

Loading