NP Security HTTP Interceptors (#39477) (#49210)

* We have a NP plugin! :celebration:

* Redirecting to login on all 401s

* Adding commented out code for when credentials are omitted

* Fixing types

* Respond 403 when user changes password with incorrect current password

* Adding AnonymousPaths where we ignore all 401s

* Adding anonymous path tests

* Extracted a dedicated SessionExpires class and added tests

* Fixing plugin after refactoring to add SessionExpired

* Beginning to work on the session timeout interceptor

* Fixing UnauthorizedResponseInterceptor anonymous path test

* Removing test anonymous path

* Trying to improve readability

* Displaying session logout warning

* Mocking out the base path

* Revert "Mocking out the base path"

This reverts commit 824086c168.

* Changing coreMock to use a concrete instance of BasePath

* Adding session timeout interceptor tests

* Adding session timeout tests

* Adding more tests for short session timeouts

* Moving some files to a session folder

* More thrashing around: renaming and reorganizing

* Renaming Interceptor to HttpInterceptor

* Fixing some type errors

* Fixing legacy chrome API tests

* Fixing other tests to use the concrete instance of BasePath

* Adjusting some types

* Putting DeeplyMocked back, I don't get how DeeplyMockedKeys works

* Moving anonymousPaths to public core http

* Reading sessionTimeout from injected vars and supporting null timeout

* Doesn't extend session when there is no response

* Updating docs and snapshots

* Casting sessionTimeout injectedVar to "number | null"

* Fixing i18n issues

* Update x-pack/plugins/security/public/plugin.ts

Co-Authored-By: Larry Gregory <lgregorydev@gmail.com>

* Adding milliseconds postfix to SessionTimeout private fields

* Even better anonymous paths, with some validation

* Adjusting public method docs for IAnonymousPaths

* Adjusting spelling of base-path to basePath

* Update x-pack/plugins/security/public/session/session_timeout.tsx

Co-Authored-By: Larry Gregory <lgregorydev@gmail.com>

* Update src/core/public/http/anonymous_paths.ts

Co-Authored-By: Josh Dover <me@joshdover.com>

* Update src/core/public/http/anonymous_paths.ts

Co-Authored-By: Josh Dover <me@joshdover.com>

* AnonymousPaths implements IAnonymousPaths and uses IBasePath

* Removing DeeplyMocked

* Removing TODOs

* Fixing types...

* Now, ever more normal
This commit is contained in:
Brandon Kobel 2019-10-24 12:15:03 -07:00 committed by GitHub
parent 5cedccdc6a
commit fc28bfec12
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1124 additions and 141 deletions

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [HttpServiceBase](./kibana-plugin-public.httpservicebase.md) &gt; [anonymousPaths](./kibana-plugin-public.httpservicebase.anonymouspaths.md)
## HttpServiceBase.anonymousPaths property
APIs for denoting certain paths for not requiring authentication
<b>Signature:</b>
```typescript
anonymousPaths: IAnonymousPaths;
```

View file

@ -15,6 +15,7 @@ export interface HttpServiceBase
| Property | Type | Description |
| --- | --- | --- |
| [anonymousPaths](./kibana-plugin-public.httpservicebase.anonymouspaths.md) | <code>IAnonymousPaths</code> | APIs for denoting certain paths for not requiring authentication |
| [basePath](./kibana-plugin-public.httpservicebase.basepath.md) | <code>IBasePath</code> | APIs for manipulating the basePath on URL segments. |
| [delete](./kibana-plugin-public.httpservicebase.delete.md) | <code>HttpHandler</code> | Makes an HTTP request with the DELETE method. See [HttpHandler](./kibana-plugin-public.httphandler.md) for options. |
| [fetch](./kibana-plugin-public.httpservicebase.fetch.md) | <code>HttpHandler</code> | Makes an HTTP request. Defaults to a GET request unless overriden. See [HttpHandler](./kibana-plugin-public.httphandler.md) for options. |

View file

@ -0,0 +1,24 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) &gt; [isAnonymous](./kibana-plugin-public.ianonymouspaths.isanonymous.md)
## IAnonymousPaths.isAnonymous() method
Determines whether the provided path doesn't require authentication
<b>Signature:</b>
```typescript
isAnonymous(path: string): boolean;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| path | <code>string</code> | |
<b>Returns:</b>
`boolean`

View file

@ -0,0 +1,21 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md)
## IAnonymousPaths interface
APIs for denoting paths as not requiring authentication
<b>Signature:</b>
```typescript
export interface IAnonymousPaths
```
## Methods
| Method | Description |
| --- | --- |
| [isAnonymous(path)](./kibana-plugin-public.ianonymouspaths.isanonymous.md) | Determines whether the provided path doesn't require authentication |
| [register(path)](./kibana-plugin-public.ianonymouspaths.register.md) | Register <code>path</code> as not requiring authentication |

View file

@ -0,0 +1,24 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) &gt; [register](./kibana-plugin-public.ianonymouspaths.register.md)
## IAnonymousPaths.register() method
Register `path` as not requiring authentication
<b>Signature:</b>
```typescript
register(path: string): void;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| path | <code>string</code> | |
<b>Returns:</b>
`void`

View file

@ -57,6 +57,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [HttpResponse](./kibana-plugin-public.httpresponse.md) | |
| [HttpServiceBase](./kibana-plugin-public.httpservicebase.md) | |
| [I18nStart](./kibana-plugin-public.i18nstart.md) | I18nStart.Context is required by any localizable React component from @<!-- -->kbn/i18n and @<!-- -->elastic/eui packages and is supposed to be used as the topmost component for any i18n-compatible React tree. |
| [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) | APIs for denoting paths as not requiring authentication |
| [IBasePath](./kibana-plugin-public.ibasepath.md) | APIs for manipulating the basePath on URL segments. |
| [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. |
| [IHttpFetchError](./kibana-plugin-public.ihttpfetcherror.md) | |

View file

@ -0,0 +1,107 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { AnonymousPaths } from './anonymous_paths';
import { BasePath } from './base_path_service';
describe('#register', () => {
it(`allows paths that don't start with /`, () => {
const basePath = new BasePath('/foo');
const anonymousPaths = new AnonymousPaths(basePath);
anonymousPaths.register('bar');
});
it(`allows paths that end with '/'`, () => {
const basePath = new BasePath('/foo');
const anonymousPaths = new AnonymousPaths(basePath);
anonymousPaths.register('/bar/');
});
});
describe('#isAnonymous', () => {
it('returns true for registered paths', () => {
const basePath = new BasePath('/foo');
const anonymousPaths = new AnonymousPaths(basePath);
anonymousPaths.register('/bar');
expect(anonymousPaths.isAnonymous('/foo/bar')).toBe(true);
});
it('returns true for paths registered with a trailing slash, but call "isAnonymous" with no trailing slash', () => {
const basePath = new BasePath('/foo');
const anonymousPaths = new AnonymousPaths(basePath);
anonymousPaths.register('/bar/');
expect(anonymousPaths.isAnonymous('/foo/bar')).toBe(true);
});
it('returns true for paths registered without a trailing slash, but call "isAnonymous" with a trailing slash', () => {
const basePath = new BasePath('/foo');
const anonymousPaths = new AnonymousPaths(basePath);
anonymousPaths.register('/bar');
expect(anonymousPaths.isAnonymous('/foo/bar/')).toBe(true);
});
it('returns true for paths registered without a starting slash', () => {
const basePath = new BasePath('/foo');
const anonymousPaths = new AnonymousPaths(basePath);
anonymousPaths.register('bar');
expect(anonymousPaths.isAnonymous('/foo/bar')).toBe(true);
});
it('returns true for paths registered with a starting slash', () => {
const basePath = new BasePath('/foo');
const anonymousPaths = new AnonymousPaths(basePath);
anonymousPaths.register('/bar');
expect(anonymousPaths.isAnonymous('/foo/bar')).toBe(true);
});
it('when there is no basePath and calling "isAnonymous" without a starting slash, returns true for paths registered with a starting slash', () => {
const basePath = new BasePath('/');
const anonymousPaths = new AnonymousPaths(basePath);
anonymousPaths.register('/bar');
expect(anonymousPaths.isAnonymous('bar')).toBe(true);
});
it('when there is no basePath and calling "isAnonymous" with a starting slash, returns true for paths registered with a starting slash', () => {
const basePath = new BasePath('/');
const anonymousPaths = new AnonymousPaths(basePath);
anonymousPaths.register('/bar');
expect(anonymousPaths.isAnonymous('/bar')).toBe(true);
});
it('returns true for paths whose capitalization is different', () => {
const basePath = new BasePath('/foo');
const anonymousPaths = new AnonymousPaths(basePath);
anonymousPaths.register('/BAR');
expect(anonymousPaths.isAnonymous('/foo/bar')).toBe(true);
});
it('returns false for other paths', () => {
const basePath = new BasePath('/foo');
const anonymousPaths = new AnonymousPaths(basePath);
anonymousPaths.register('/bar');
expect(anonymousPaths.isAnonymous('/foo/foo')).toBe(false);
});
it('returns false for sub-paths of registered paths', () => {
const basePath = new BasePath('/foo');
const anonymousPaths = new AnonymousPaths(basePath);
anonymousPaths.register('/bar');
expect(anonymousPaths.isAnonymous('/foo/bar/baz')).toBe(false);
});
});

View file

@ -0,0 +1,53 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { IAnonymousPaths, IBasePath } from 'src/core/public';
export class AnonymousPaths implements IAnonymousPaths {
private readonly paths = new Set<string>();
constructor(private basePath: IBasePath) {}
public isAnonymous(path: string): boolean {
const pathWithoutBasePath = this.basePath.remove(path);
return this.paths.has(this.normalizePath(pathWithoutBasePath));
}
public register(path: string) {
this.paths.add(this.normalizePath(path));
}
private normalizePath(path: string) {
// always lower-case it
let normalized = path.toLowerCase();
// remove the slash from the end
if (normalized.endsWith('/')) {
normalized = normalized.slice(0, normalized.length - 1);
}
// put a slash at the start
if (!normalized.startsWith('/')) {
normalized = `/${normalized}`;
}
// it's normalized!!!
return normalized;
}
}

View file

@ -20,9 +20,11 @@
import { HttpService } from './http_service';
import { HttpSetup } from './types';
import { BehaviorSubject } from 'rxjs';
import { BasePath } from './base_path_service';
import { AnonymousPaths } from './anonymous_paths';
type ServiceSetupMockType = jest.Mocked<HttpSetup> & {
basePath: jest.Mocked<HttpSetup['basePath']>;
basePath: BasePath;
};
const createServiceMock = ({ basePath = '' } = {}): ServiceSetupMockType => ({
@ -34,11 +36,8 @@ const createServiceMock = ({ basePath = '' } = {}): ServiceSetupMockType => ({
patch: jest.fn(),
delete: jest.fn(),
options: jest.fn(),
basePath: {
get: jest.fn(() => basePath),
prepend: jest.fn(path => `${basePath}${path}`),
remove: jest.fn(),
},
basePath: new BasePath(basePath),
anonymousPaths: new AnonymousPaths(new BasePath(basePath)),
addLoadingCount: jest.fn(),
getLoadingCount$: jest.fn().mockReturnValue(new BehaviorSubject(0)),
stop: jest.fn(),

View file

@ -36,6 +36,7 @@ import { HttpInterceptController } from './http_intercept_controller';
import { HttpFetchError } from './http_fetch_error';
import { HttpInterceptHaltError } from './http_intercept_halt_error';
import { BasePath } from './base_path_service';
import { AnonymousPaths } from './anonymous_paths';
const JSON_CONTENT = /^(application\/(json|x-javascript)|text\/(x-)?javascript|x-json)(;.*)?$/;
const NDJSON_CONTENT = /^(application\/ndjson)(;.*)?$/;
@ -57,6 +58,7 @@ export const setup = (
const interceptors = new Set<HttpInterceptor>();
const kibanaVersion = injectedMetadata.getKibanaVersion();
const basePath = new BasePath(injectedMetadata.getBasePath());
const anonymousPaths = new AnonymousPaths(basePath);
function intercept(interceptor: HttpInterceptor) {
interceptors.add(interceptor);
@ -318,6 +320,7 @@ export const setup = (
return {
stop,
basePath,
anonymousPaths,
intercept,
removeAllInterceptors,
fetch,

View file

@ -29,6 +29,11 @@ export interface HttpServiceBase {
*/
basePath: IBasePath;
/**
* APIs for denoting certain paths for not requiring authentication
*/
anonymousPaths: IAnonymousPaths;
/**
* Adds a new {@link HttpInterceptor} to the global HTTP client.
* @param interceptor a {@link HttpInterceptor}
@ -92,6 +97,21 @@ export interface IBasePath {
remove: (url: string) => string;
}
/**
* APIs for denoting paths as not requiring authentication
*/
export interface IAnonymousPaths {
/**
* Determines whether the provided path doesn't require authentication. `path` should include the current basePath.
*/
isAnonymous(path: string): boolean;
/**
* Register `path` as not requiring authentication. `path` should not include the current basePath.
*/
register(path: string): void;
}
/**
* See {@link HttpServiceBase}
* @public

View file

@ -111,6 +111,7 @@ export {
HttpHandler,
HttpBody,
IBasePath,
IAnonymousPaths,
IHttpInterceptController,
IHttpFetchError,
InterceptedHttpResponse,

View file

@ -18,7 +18,7 @@
*/
import { applicationServiceMock } from './application/application_service.mock';
import { chromeServiceMock } from './chrome/chrome_service.mock';
import { CoreContext, CoreSetup, CoreStart, PluginInitializerContext } from '.';
import { CoreContext, CoreSetup, CoreStart, PluginInitializerContext, NotificationsSetup } from '.';
import { docLinksServiceMock } from './doc_links/doc_links_service.mock';
import { fatalErrorsServiceMock } from './fatal_errors/fatal_errors_service.mock';
import { httpServiceMock } from './http/http_service.mock';
@ -41,12 +41,12 @@ export { notificationServiceMock } from './notifications/notifications_service.m
export { overlayServiceMock } from './overlays/overlay_service.mock';
export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock';
function createCoreSetupMock() {
const mock: MockedKeys<CoreSetup> = {
function createCoreSetupMock({ basePath = '' } = {}) {
const mock: MockedKeys<CoreSetup> & { notifications: MockedKeys<NotificationsSetup> } = {
application: applicationServiceMock.createSetupContract(),
context: contextServiceMock.createSetupContract(),
fatalErrors: fatalErrorsServiceMock.createSetupContract(),
http: httpServiceMock.createSetupContract(),
http: httpServiceMock.createSetupContract({ basePath }),
notifications: notificationServiceMock.createSetupContract(),
uiSettings: uiSettingsServiceMock.createSetupContract(),
injectedMetadata: {
@ -57,12 +57,12 @@ function createCoreSetupMock() {
return mock;
}
function createCoreStartMock() {
const mock: MockedKeys<CoreStart> = {
function createCoreStartMock({ basePath = '' } = {}) {
const mock: MockedKeys<CoreStart> & { notifications: MockedKeys<NotificationsSetup> } = {
application: applicationServiceMock.createStartContract(),
chrome: chromeServiceMock.createStartContract(),
docLinks: docLinksServiceMock.createStartContract(),
http: httpServiceMock.createStartContract(),
http: httpServiceMock.createStartContract({ basePath }),
i18n: i18nServiceMock.createStartContract(),
notifications: notificationServiceMock.createStartContract(),
overlays: overlayServiceMock.createStartContract(),

View file

@ -23,10 +23,8 @@ import {
} from './notifications_service';
import { toastsServiceMock } from './toasts/toasts_service.mock';
type DeeplyMocked<T> = { [P in keyof T]: jest.Mocked<T[P]> };
const createSetupContractMock = () => {
const setupContract: DeeplyMocked<NotificationsSetup> = {
const setupContract: MockedKeys<NotificationsSetup> = {
// we have to suppress type errors until decide how to mock es6 class
toasts: toastsServiceMock.createSetupContract(),
};
@ -34,7 +32,7 @@ const createSetupContractMock = () => {
};
const createStartContractMock = () => {
const startContract: DeeplyMocked<NotificationsStart> = {
const startContract: MockedKeys<NotificationsStart> = {
// we have to suppress type errors until decide how to mock es6 class
toasts: toastsServiceMock.createStartContract(),
};

View file

@ -488,6 +488,7 @@ export interface HttpResponse extends InterceptedHttpResponse {
// @public (undocumented)
export interface HttpServiceBase {
addLoadingCount(countSource$: Observable<number>): void;
anonymousPaths: IAnonymousPaths;
basePath: IBasePath;
delete: HttpHandler;
fetch: HttpHandler;
@ -517,6 +518,14 @@ export interface I18nStart {
}) => JSX.Element;
}
// Warning: (ae-missing-release-tag) "IAnonymousPaths" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface IAnonymousPaths {
isAnonymous(path: string): boolean;
register(path: string): void;
}
// @public
export interface IBasePath {
get: () => string;

View file

@ -20,10 +20,20 @@ exports[`#setup constructs UiSettingsClient and UiSettingsApi: UiSettingsApi arg
},
],
},
"basePath": Object {
"get": [MockFunction],
"prepend": [MockFunction],
"remove": [MockFunction],
"anonymousPaths": AnonymousPaths {
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"remove": [Function],
},
"paths": Set {},
},
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"remove": [Function],
},
"delete": [MockFunction],
"fetch": [MockFunction],

View file

@ -229,10 +229,20 @@ exports[`QueryBarInput Should disable autoFocus on EuiFieldText when disableAuto
},
"http": Object {
"addLoadingCount": [MockFunction],
"basePath": Object {
"get": [MockFunction],
"prepend": [MockFunction],
"remove": [MockFunction],
"anonymousPaths": AnonymousPaths {
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"remove": [Function],
},
"paths": Set {},
},
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"remove": [Function],
},
"delete": [MockFunction],
"fetch": [MockFunction],
@ -775,10 +785,20 @@ exports[`QueryBarInput Should disable autoFocus on EuiFieldText when disableAuto
},
"http": Object {
"addLoadingCount": [MockFunction],
"basePath": Object {
"get": [MockFunction],
"prepend": [MockFunction],
"remove": [MockFunction],
"anonymousPaths": AnonymousPaths {
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"remove": [Function],
},
"paths": Set {},
},
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"remove": [Function],
},
"delete": [MockFunction],
"fetch": [MockFunction],
@ -1309,10 +1329,20 @@ exports[`QueryBarInput Should pass the query language to the language switcher 1
},
"http": Object {
"addLoadingCount": [MockFunction],
"basePath": Object {
"get": [MockFunction],
"prepend": [MockFunction],
"remove": [MockFunction],
"anonymousPaths": AnonymousPaths {
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"remove": [Function],
},
"paths": Set {},
},
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"remove": [Function],
},
"delete": [MockFunction],
"fetch": [MockFunction],
@ -1852,10 +1882,20 @@ exports[`QueryBarInput Should pass the query language to the language switcher 1
},
"http": Object {
"addLoadingCount": [MockFunction],
"basePath": Object {
"get": [MockFunction],
"prepend": [MockFunction],
"remove": [MockFunction],
"anonymousPaths": AnonymousPaths {
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"remove": [Function],
},
"paths": Set {},
},
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"remove": [Function],
},
"delete": [MockFunction],
"fetch": [MockFunction],
@ -2386,10 +2426,20 @@ exports[`QueryBarInput Should render the given query 1`] = `
},
"http": Object {
"addLoadingCount": [MockFunction],
"basePath": Object {
"get": [MockFunction],
"prepend": [MockFunction],
"remove": [MockFunction],
"anonymousPaths": AnonymousPaths {
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"remove": [Function],
},
"paths": Set {},
},
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"remove": [Function],
},
"delete": [MockFunction],
"fetch": [MockFunction],
@ -2929,10 +2979,20 @@ exports[`QueryBarInput Should render the given query 1`] = `
},
"http": Object {
"addLoadingCount": [MockFunction],
"basePath": Object {
"get": [MockFunction],
"prepend": [MockFunction],
"remove": [MockFunction],
"anonymousPaths": AnonymousPaths {
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"remove": [Function],
},
"paths": Set {},
},
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"remove": [Function],
},
"delete": [MockFunction],
"fetch": [MockFunction],

View file

@ -19,7 +19,7 @@
import { httpServiceMock } from '../../../../../core/public/mocks';
export const newPlatformHttp = httpServiceMock.createSetupContract();
const newPlatformHttp = httpServiceMock.createSetupContract({ basePath: 'npBasePath' });
jest.doMock('ui/new_platform', () => ({
npSetup: {
core: { http: newPlatformHttp },

View file

@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { newPlatformHttp } from './base_path.test.mocks';
import './base_path.test.mocks';
import { initChromeBasePathApi } from './base_path';
function initChrome() {
@ -26,10 +25,6 @@ function initChrome() {
return chrome;
}
newPlatformHttp.basePath.get.mockImplementation(() => 'gotBasePath');
newPlatformHttp.basePath.prepend.mockImplementation(() => 'addedToPath');
newPlatformHttp.basePath.remove.mockImplementation(() => 'removedFromPath');
beforeEach(() => {
jest.clearAllMocks();
});
@ -37,29 +32,20 @@ beforeEach(() => {
describe('#getBasePath()', () => {
it('proxies to newPlatformHttp.basePath.get()', () => {
const chrome = initChrome();
expect(newPlatformHttp.basePath.prepend).not.toHaveBeenCalled();
expect(chrome.getBasePath()).toBe('gotBasePath');
expect(newPlatformHttp.basePath.get).toHaveBeenCalledTimes(1);
expect(newPlatformHttp.basePath.get).toHaveBeenCalledWith();
expect(chrome.getBasePath()).toBe('npBasePath');
});
});
describe('#addBasePath()', () => {
it('proxies to newPlatformHttp.basePath.prepend(path)', () => {
const chrome = initChrome();
expect(newPlatformHttp.basePath.prepend).not.toHaveBeenCalled();
expect(chrome.addBasePath('foo/bar')).toBe('addedToPath');
expect(newPlatformHttp.basePath.prepend).toHaveBeenCalledTimes(1);
expect(newPlatformHttp.basePath.prepend).toHaveBeenCalledWith('foo/bar');
expect(chrome.addBasePath('/foo/bar')).toBe('npBasePath/foo/bar');
});
});
describe('#removeBasePath', () => {
it('proxies to newPlatformBasePath.basePath.remove(path)', () => {
const chrome = initChrome();
expect(newPlatformHttp.basePath.remove).not.toHaveBeenCalled();
expect(chrome.removeBasePath('foo/bar')).toBe('removedFromPath');
expect(newPlatformHttp.basePath.remove).toHaveBeenCalledTimes(1);
expect(newPlatformHttp.basePath.remove).toHaveBeenCalledWith('foo/bar');
expect(chrome.removeBasePath('npBasePath/foo/bar')).toBe('/foo/bar');
});
});

View file

@ -31,7 +31,7 @@
"xpack.rollupJobs": "legacy/plugins/rollup",
"xpack.searchProfiler": "legacy/plugins/searchprofiler",
"xpack.siem": "legacy/plugins/siem",
"xpack.security": "legacy/plugins/security",
"xpack.security": ["legacy/plugins/security", "plugins/security"],
"xpack.server": "legacy/server",
"xpack.snapshotRestore": "legacy/plugins/snapshot_restore",
"xpack.spaces": ["legacy/plugins/spaces", "plugins/spaces"],

View file

@ -14,5 +14,6 @@ bluebird.Promise.setScheduler(function (fn) { global.setImmediate.call(global, f
const MutationObserver = require('mutation-observer');
Object.defineProperty(window, 'MutationObserver', { value: MutationObserver });
require('whatwg-fetch');
const URL = { createObjectURL: () => '' };
Object.defineProperty(window, 'URL', { value: URL });

View file

@ -127,7 +127,7 @@ describe('Lens App', () => {
beforeEach(() => {
frame = createMockFrame();
core = coreMock.createStart();
core = coreMock.createStart({ basePath: '/testbasepath' });
core.uiSettings.get.mockImplementation(
jest.fn(type => {
@ -140,9 +140,6 @@ describe('Lens App', () => {
}
})
);
(core.http.basePath.get as jest.Mock).mockReturnValue(`/testbasepath`);
(core.http.basePath.prepend as jest.Mock).mockImplementation(s => `/testbasepath${s}`);
});
it('renders the editor frame', () => {

View file

@ -314,7 +314,7 @@ export class ChangePasswordForm extends Component<Props, State> {
};
private handleChangePasswordFailure = (error: Record<string, any>) => {
if (error.body && error.body.statusCode === 401) {
if (error.body && error.body.statusCode === 403) {
this.setState({ currentPasswordError: true });
} else {
toastNotifications.addDanger(

View file

@ -4,15 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import _ from 'lodash';
import { i18n } from '@kbn/i18n';
import { uiModules } from 'ui/modules';
import { isSystemApiRequest } from 'ui/system_api';
import { Path } from 'plugins/xpack_main/services/path';
import { toastNotifications } from 'ui/notify';
import 'plugins/security/services/auto_logout';
import { SessionExpirationWarning } from '../components/session_expiration_warning';
import { npSetup } from 'ui/new_platform';
/**
* Client session timeout is decreased by this number so that Kibana server
@ -20,65 +16,19 @@ import { SessionExpirationWarning } from '../components/session_expiration_warni
* user session up (invalidate access tokens, redirect to logout portal etc.).
* @type {number}
*/
const SESSION_TIMEOUT_GRACE_PERIOD_MS = 5000;
const module = uiModules.get('security', []);
module.config(($httpProvider) => {
$httpProvider.interceptors.push((
$timeout,
$q,
$injector,
sessionTimeout,
Private,
autoLogout
) => {
function refreshSession() {
// Make a simple request to keep the session alive
$injector.get('es').ping();
clearNotifications();
}
const isUnauthenticated = Path.isUnauthenticated();
const notificationLifetime = 60 * 1000;
const notificationOptions = {
color: 'warning',
text: (
<SessionExpirationWarning onRefreshSession={refreshSession} />
),
title: i18n.translate('xpack.security.hacks.warningTitle', {
defaultMessage: 'Warning'
}),
toastLifeTimeMs: Math.min(
(sessionTimeout - SESSION_TIMEOUT_GRACE_PERIOD_MS),
notificationLifetime
),
};
let pendingNotification;
let activeNotification;
let pendingSessionExpiration;
function clearNotifications() {
if (pendingNotification) $timeout.cancel(pendingNotification);
if (pendingSessionExpiration) clearTimeout(pendingSessionExpiration);
if (activeNotification) toastNotifications.remove(activeNotification);
}
function scheduleNotification() {
pendingNotification = $timeout(showNotification, Math.max(sessionTimeout - notificationLifetime, 0));
}
function showNotification() {
activeNotification = toastNotifications.add(notificationOptions);
pendingSessionExpiration = setTimeout(() => autoLogout(), notificationOptions.toastLifeTimeMs);
}
function interceptorFactory(responseHandler) {
return function interceptor(response) {
if (!isUnauthenticated && !isSystemApiRequest(response.config) && sessionTimeout !== null) {
clearNotifications();
scheduleNotification();
if (!isUnauthenticated && !isSystemApiRequest(response.config)) {
npSetup.plugins.security.sessionTimeout.extend();
}
return responseHandler(response);
};

View file

@ -81,7 +81,7 @@ describe('User routes', () => {
.resolves(AuthenticationResult.succeeded({}));
});
it('returns 401 if old password is wrong.', async () => {
it('returns 403 if old password is wrong.', async () => {
loginStub.resolves(AuthenticationResult.failed(new Error('Something went wrong.')));
const response = await changePasswordRoute.handler(request);
@ -89,13 +89,13 @@ describe('User routes', () => {
sinon.assert.notCalled(clusterStub.callWithRequest);
expect(response.isBoom).to.be(true);
expect(response.output.payload).to.eql({
statusCode: 401,
statusCode: 403,
error: 'Unauthorized',
message: 'Something went wrong.'
});
});
it('returns 401 if user can authenticate with new password.', async () => {
it(`returns 401 if user can't authenticate with new password.`, async () => {
loginStub
.withArgs(
sinon.match.instanceOf(KibanaRequest),

View file

@ -108,7 +108,7 @@ export function initUsersApi({ authc: { login }, config }, server) {
return Boom.unauthorized(authenticationResult.error);
}
} catch(err) {
return Boom.unauthorized(err);
throw Boom.forbidden(err);
}
}

View file

@ -181,6 +181,7 @@
"ts-loader": "^6.0.4",
"typescript": "3.5.3",
"vinyl-fs": "^3.0.3",
"whatwg-fetch": "^3.0.0",
"xml-crypto": "^1.4.0",
"yargs": "4.8.1"
},

View file

@ -4,5 +4,5 @@
"kibanaVersion": "kibana",
"configPath": ["xpack", "security"],
"server": true,
"ui": false
"ui": true
}

View file

@ -0,0 +1,11 @@
/*
* 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 { PluginInitializer } from 'src/core/public';
import { SecurityPlugin, SecurityPluginSetup, SecurityPluginStart } from './plugin';
export const plugin: PluginInitializer<SecurityPluginSetup, SecurityPluginStart> = () =>
new SecurityPlugin();

View file

@ -0,0 +1,43 @@
/*
* 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 { Plugin, CoreSetup } from 'src/core/public';
import {
SessionExpired,
SessionTimeout,
SessionTimeoutHttpInterceptor,
UnauthorizedResponseHttpInterceptor,
} from './session';
export class SecurityPlugin implements Plugin<SecurityPluginSetup, SecurityPluginStart> {
public setup(core: CoreSetup) {
const { http, notifications, injectedMetadata } = core;
const { basePath, anonymousPaths } = http;
anonymousPaths.register('/login');
anonymousPaths.register('/logout');
anonymousPaths.register('/logged_out');
const sessionExpired = new SessionExpired(basePath);
http.intercept(new UnauthorizedResponseHttpInterceptor(sessionExpired, anonymousPaths));
const sessionTimeout = new SessionTimeout(
injectedMetadata.getInjectedVar('sessionTimeout', null) as number | null,
notifications,
sessionExpired,
http
);
http.intercept(new SessionTimeoutHttpInterceptor(sessionTimeout, anonymousPaths));
return {
anonymousPaths,
sessionTimeout,
};
}
public start() {}
}
export type SecurityPluginSetup = ReturnType<SecurityPlugin['setup']>;
export type SecurityPluginStart = ReturnType<SecurityPlugin['start']>;

View file

@ -0,0 +1,10 @@
/*
* 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 { SessionExpired } from './session_expired';
export { SessionTimeout } from './session_timeout';
export { SessionTimeoutHttpInterceptor } from './session_timeout_http_interceptor';
export { UnauthorizedResponseHttpInterceptor } from './unauthorized_response_http_interceptor';

View file

@ -4,4 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { SessionExpirationWarning } from './session_expiration_warning';
import { ISessionExpired } from './session_expired';
export function createSessionExpiredMock() {
return {
logout: jest.fn(),
} as jest.Mocked<ISessionExpired>;
}

View file

@ -0,0 +1,46 @@
/*
* 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 { coreMock } from 'src/core/public/mocks';
import { SessionExpired } from './session_expired';
const mockCurrentUrl = (url: string) => window.history.pushState({}, '', url);
it('redirects user to "/logout" when there is no basePath', async () => {
const { basePath } = coreMock.createSetup().http;
mockCurrentUrl('/foo/bar?baz=quz#quuz');
const sessionExpired = new SessionExpired(basePath);
const newUrlPromise = new Promise<string>(resolve => {
jest.spyOn(window.location, 'assign').mockImplementation(url => {
resolve(url);
});
});
sessionExpired.logout();
const url = await newUrlPromise;
expect(url).toBe(
`/logout?next=${encodeURIComponent('/foo/bar?baz=quz#quuz')}&msg=SESSION_EXPIRED`
);
});
it('redirects user to "/${basePath}/logout" and removes basePath from next parameter when there is a basePath', async () => {
const { basePath } = coreMock.createSetup({ basePath: '/foo' }).http;
mockCurrentUrl('/foo/bar?baz=quz#quuz');
const sessionExpired = new SessionExpired(basePath);
const newUrlPromise = new Promise<string>(resolve => {
jest.spyOn(window.location, 'assign').mockImplementation(url => {
resolve(url);
});
});
sessionExpired.logout();
const url = await newUrlPromise;
expect(url).toBe(
`/foo/logout?next=${encodeURIComponent('/bar?baz=quz#quuz')}&msg=SESSION_EXPIRED`
);
});

View file

@ -0,0 +1,24 @@
/*
* 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 { HttpSetup } from 'src/core/public';
export interface ISessionExpired {
logout(): void;
}
export class SessionExpired {
constructor(private basePath: HttpSetup['basePath']) {}
logout() {
const next = this.basePath.remove(
`${window.location.pathname}${window.location.search}${window.location.hash}`
);
window.location.assign(
this.basePath.prepend(`/logout?next=${encodeURIComponent(next)}&msg=SESSION_EXPIRED`)
);
}
}

View file

@ -0,0 +1,13 @@
/*
* 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 { ISessionTimeout } from './session_timeout';
export function createSessionTimeoutMock() {
return {
extend: jest.fn(),
} as jest.Mocked<ISessionTimeout>;
}

View file

@ -0,0 +1,171 @@
/*
* 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 { coreMock } from 'src/core/public/mocks';
import { SessionTimeout } from './session_timeout';
import { createSessionExpiredMock } from './session_expired.mock';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
jest.useFakeTimers();
const expectNoWarningToast = (
notifications: ReturnType<typeof coreMock.createSetup>['notifications']
) => {
expect(notifications.toasts.add).not.toHaveBeenCalled();
};
const expectWarningToast = (
notifications: ReturnType<typeof coreMock.createSetup>['notifications'],
toastLifeTimeMS: number = 60000
) => {
expect(notifications.toasts.add).toHaveBeenCalledTimes(1);
expect(notifications.toasts.add.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"color": "warning",
"text": <SessionTimeoutWarning
onRefreshSession={[Function]}
/>,
"title": "Warning",
"toastLifeTimeMs": ${toastLifeTimeMS},
},
]
`);
};
const expectWarningToastHidden = (
notifications: ReturnType<typeof coreMock.createSetup>['notifications'],
toast: symbol
) => {
expect(notifications.toasts.remove).toHaveBeenCalledTimes(1);
expect(notifications.toasts.remove).toHaveBeenCalledWith(toast);
};
describe('warning toast', () => {
test(`shows session expiration warning toast`, () => {
const { notifications, http } = coreMock.createSetup();
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http);
sessionTimeout.extend();
// we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires
jest.advanceTimersByTime(55 * 1000);
expectWarningToast(notifications);
});
test(`extend delays the warning toast`, () => {
const { notifications, http } = coreMock.createSetup();
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http);
sessionTimeout.extend();
jest.advanceTimersByTime(54 * 1000);
expectNoWarningToast(notifications);
sessionTimeout.extend();
jest.advanceTimersByTime(54 * 1000);
expectNoWarningToast(notifications);
jest.advanceTimersByTime(1 * 1000);
expectWarningToast(notifications);
});
test(`extend hides displayed warning toast`, () => {
const { notifications, http } = coreMock.createSetup();
const toast = Symbol();
notifications.toasts.add.mockReturnValue(toast as any);
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http);
sessionTimeout.extend();
// we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires
jest.advanceTimersByTime(55 * 1000);
expectWarningToast(notifications);
sessionTimeout.extend();
expectWarningToastHidden(notifications, toast);
});
test('clicking "extend" causes a new HTTP request (which implicitly extends the session)', () => {
const { notifications, http } = coreMock.createSetup();
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http);
sessionTimeout.extend();
// we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires
jest.advanceTimersByTime(55 * 1000);
expectWarningToast(notifications);
expect(http.get).not.toHaveBeenCalled();
const toastInput = notifications.toasts.add.mock.calls[0][0];
expect(toastInput).toHaveProperty('text');
const reactComponent = (toastInput as any).text;
const wrapper = mountWithIntl(reactComponent);
wrapper.find('EuiButton[data-test-subj="refreshSessionButton"]').simulate('click');
expect(http.get).toHaveBeenCalled();
});
test('when the session timeout is shorter than 65 seconds, display the warning immediately and for a shorter duration', () => {
const { notifications, http } = coreMock.createSetup();
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(64 * 1000, notifications, sessionExpired, http);
sessionTimeout.extend();
jest.advanceTimersByTime(0);
expectWarningToast(notifications, 59 * 1000);
});
});
describe('session expiration', () => {
test(`expires the session 5 seconds before it really expires`, () => {
const { notifications, http } = coreMock.createSetup();
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http);
sessionTimeout.extend();
jest.advanceTimersByTime(114 * 1000);
expect(sessionExpired.logout).not.toHaveBeenCalled();
jest.advanceTimersByTime(1 * 1000);
expect(sessionExpired.logout).toHaveBeenCalled();
});
test(`extend delays the expiration`, () => {
const { notifications, http } = coreMock.createSetup();
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http);
sessionTimeout.extend();
jest.advanceTimersByTime(114 * 1000);
sessionTimeout.extend();
jest.advanceTimersByTime(114 * 1000);
expect(sessionExpired.logout).not.toHaveBeenCalled();
jest.advanceTimersByTime(1 * 1000);
expect(sessionExpired.logout).toHaveBeenCalled();
});
test(`if the session timeout is shorter than 5 seconds, expire session immediately`, () => {
const { notifications, http } = coreMock.createSetup();
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(4 * 1000, notifications, sessionExpired, http);
sessionTimeout.extend();
jest.advanceTimersByTime(0);
expect(sessionExpired.logout).toHaveBeenCalled();
});
test(`'null' sessionTimeout never logs you out`, () => {
const { notifications, http } = coreMock.createSetup();
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(null, notifications, sessionExpired, http);
sessionTimeout.extend();
jest.advanceTimersByTime(Number.MAX_VALUE);
expect(sessionExpired.logout).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,79 @@
/*
* 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 { NotificationsSetup, Toast, HttpSetup } from 'src/core/public';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { SessionTimeoutWarning } from './session_timeout_warning';
import { ISessionExpired } from './session_expired';
/**
* 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.).
*/
const GRACE_PERIOD_MS = 5 * 1000;
/**
* Duration we'll normally display the warning toast
*/
const WARNING_MS = 60 * 1000;
export interface ISessionTimeout {
extend(): void;
}
export class SessionTimeout {
private warningTimeoutMilliseconds?: number;
private expirationTimeoutMilliseconds?: number;
private warningToast?: Toast;
constructor(
private sessionTimeoutMilliseconds: number | null,
private notifications: NotificationsSetup,
private sessionExpired: ISessionExpired,
private http: HttpSetup
) {}
extend() {
if (this.sessionTimeoutMilliseconds == null) {
return;
}
if (this.warningTimeoutMilliseconds) {
window.clearTimeout(this.warningTimeoutMilliseconds);
}
if (this.expirationTimeoutMilliseconds) {
window.clearTimeout(this.expirationTimeoutMilliseconds);
}
if (this.warningToast) {
this.notifications.toasts.remove(this.warningToast);
}
this.warningTimeoutMilliseconds = window.setTimeout(
() => this.showWarning(),
Math.max(this.sessionTimeoutMilliseconds - WARNING_MS - GRACE_PERIOD_MS, 0)
);
this.expirationTimeoutMilliseconds = window.setTimeout(
() => this.sessionExpired.logout(),
Math.max(this.sessionTimeoutMilliseconds - GRACE_PERIOD_MS, 0)
);
}
private showWarning = () => {
this.warningToast = this.notifications.toasts.add({
color: 'warning',
text: <SessionTimeoutWarning onRefreshSession={this.refreshSession} />,
title: i18n.translate('xpack.security.components.sessionTimeoutWarning.title', {
defaultMessage: 'Warning',
}),
toastLifeTimeMs: Math.min(this.sessionTimeoutMilliseconds! - GRACE_PERIOD_MS, WARNING_MS),
});
};
private refreshSession = () => {
this.http.get('/api/security/v1/me');
};
}

View file

@ -0,0 +1,120 @@
/*
* 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.
*/
// @ts-ignore
import fetchMock from 'fetch-mock/es5/client';
import { SessionTimeoutHttpInterceptor } from './session_timeout_http_interceptor';
import { setup } from '../../../../../src/test_utils/public/http_test_setup';
import { createSessionTimeoutMock } from './session_timeout.mock';
const mockCurrentUrl = (url: string) => window.history.pushState({}, '', url);
const setupHttp = (basePath: string) => {
const { http } = setup(injectedMetadata => {
injectedMetadata.getBasePath.mockReturnValue(basePath);
});
return http;
};
afterEach(() => {
fetchMock.restore();
});
describe('response', () => {
test('extends session timeouts', async () => {
const http = setupHttp('/foo');
const sessionTimeoutMock = createSessionTimeoutMock();
const interceptor = new SessionTimeoutHttpInterceptor(sessionTimeoutMock, http.anonymousPaths);
http.intercept(interceptor);
fetchMock.mock('*', 200);
await http.fetch('/foo-api');
expect(sessionTimeoutMock.extend).toHaveBeenCalled();
});
test(`doesn't extend session timeouts for anonymous paths`, async () => {
mockCurrentUrl('/foo/bar');
const http = setupHttp('/foo');
const sessionTimeoutMock = createSessionTimeoutMock();
const { anonymousPaths } = http;
anonymousPaths.register('/bar');
const interceptor = new SessionTimeoutHttpInterceptor(sessionTimeoutMock, anonymousPaths);
http.intercept(interceptor);
fetchMock.mock('*', 200);
await http.fetch('/foo-api');
expect(sessionTimeoutMock.extend).not.toHaveBeenCalled();
});
test(`doesn't extend session timeouts for system api requests`, async () => {
const http = setupHttp('/foo');
const sessionTimeoutMock = createSessionTimeoutMock();
const interceptor = new SessionTimeoutHttpInterceptor(sessionTimeoutMock, http.anonymousPaths);
http.intercept(interceptor);
fetchMock.mock('*', 200);
await http.fetch('/foo-api', { headers: { 'kbn-system-api': 'true' } });
expect(sessionTimeoutMock.extend).not.toHaveBeenCalled();
});
});
describe('responseError', () => {
test('extends session timeouts', async () => {
const http = setupHttp('/foo');
const sessionTimeoutMock = createSessionTimeoutMock();
const interceptor = new SessionTimeoutHttpInterceptor(sessionTimeoutMock, http.anonymousPaths);
http.intercept(interceptor);
fetchMock.mock('*', 401);
await expect(http.fetch('/foo-api')).rejects.toMatchInlineSnapshot(`[Error: Unauthorized]`);
expect(sessionTimeoutMock.extend).toHaveBeenCalled();
});
test(`doesn't extend session timeouts for anonymous paths`, async () => {
mockCurrentUrl('/foo/bar');
const http = setupHttp('/foo');
const sessionTimeoutMock = createSessionTimeoutMock();
const { anonymousPaths } = http;
anonymousPaths.register('/bar');
const interceptor = new SessionTimeoutHttpInterceptor(sessionTimeoutMock, anonymousPaths);
http.intercept(interceptor);
fetchMock.mock('*', 401);
await expect(http.fetch('/foo-api')).rejects.toMatchInlineSnapshot(`[Error: Unauthorized]`);
expect(sessionTimeoutMock.extend).not.toHaveBeenCalled();
});
test(`doesn't extend session timeouts for system api requests`, async () => {
const http = setupHttp('/foo');
const sessionTimeoutMock = createSessionTimeoutMock();
const interceptor = new SessionTimeoutHttpInterceptor(sessionTimeoutMock, http.anonymousPaths);
http.intercept(interceptor);
fetchMock.mock('*', 401);
await expect(
http.fetch('/foo-api', { headers: { 'kbn-system-api': 'true' } })
).rejects.toMatchInlineSnapshot(`[Error: Unauthorized]`);
expect(sessionTimeoutMock.extend).not.toHaveBeenCalled();
});
test(`doesn't extend session timeouts when there is no response`, async () => {
const http = setupHttp('/foo');
const sessionTimeoutMock = createSessionTimeoutMock();
const interceptor = new SessionTimeoutHttpInterceptor(sessionTimeoutMock, http.anonymousPaths);
http.intercept(interceptor);
fetchMock.mock('*', new Promise((resolve, reject) => reject(new Error('Network is down'))));
await expect(http.fetch('/foo-api')).rejects.toMatchInlineSnapshot(`[Error: Network is down]`);
expect(sessionTimeoutMock.extend).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,50 @@
/*
* 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 { HttpInterceptor, HttpErrorResponse, HttpResponse, IAnonymousPaths } from 'src/core/public';
import { ISessionTimeout } from './session_timeout';
const isSystemAPIRequest = (request: Request) => {
return request.headers.has('kbn-system-api');
};
export class SessionTimeoutHttpInterceptor implements HttpInterceptor {
constructor(private sessionTimeout: ISessionTimeout, private anonymousPaths: IAnonymousPaths) {}
response(httpResponse: HttpResponse) {
if (this.anonymousPaths.isAnonymous(window.location.pathname)) {
return;
}
if (isSystemAPIRequest(httpResponse.request)) {
return;
}
this.sessionTimeout.extend();
}
responseError(httpErrorResponse: HttpErrorResponse) {
if (this.anonymousPaths.isAnonymous(window.location.pathname)) {
return;
}
if (isSystemAPIRequest(httpErrorResponse.request)) {
return;
}
// if we happen to not have a response, for example if there is no
// network connectivity, we won't extend the session because there
// won't be a response with a set-cookie header, which is required
// to extend the session
const { response } = httpErrorResponse;
if (!response) {
return;
}
this.sessionTimeout.extend();
}
}

View file

@ -5,12 +5,12 @@
*/
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { SessionExpirationWarning } from './session_expiration_warning';
import { SessionTimeoutWarning } from './session_timeout_warning';
describe('SessionExpirationWarning', () => {
describe('SessionTimeoutWarning', () => {
it('fires its callback when the OK button is clicked', () => {
const handler = jest.fn();
const wrapper = mountWithIntl(<SessionExpirationWarning onRefreshSession={handler} />);
const wrapper = mountWithIntl(<SessionTimeoutWarning onRefreshSession={handler} />);
expect(handler).toBeCalledTimes(0);
wrapper.find('EuiButton[data-test-subj="refreshSessionButton"]').simulate('click');

View file

@ -12,12 +12,12 @@ interface Props {
onRefreshSession: () => void;
}
export const SessionExpirationWarning = (props: Props) => {
export const SessionTimeoutWarning = (props: Props) => {
return (
<>
<p>
<FormattedMessage
id="xpack.security.components.sessionExpiration.logoutNotification"
id="xpack.security.components.sessionTimeoutWarning.message"
defaultMessage="You will soon be logged out due to inactivity. Click OK to resume."
/>
</p>
@ -29,7 +29,7 @@ export const SessionExpirationWarning = (props: Props) => {
data-test-subj="refreshSessionButton"
>
<FormattedMessage
id="xpack.security.components.sessionExpiration.okButtonText"
id="xpack.security.components.sessionTimeoutWarning.okButtonText"
defaultMessage="OK"
/>
</EuiButton>

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
// @ts-ignore
import fetchMock from 'fetch-mock/es5/client';
import { SessionExpired } from './session_expired';
import { setup } from '../../../../../src/test_utils/public/http_test_setup';
import { UnauthorizedResponseHttpInterceptor } from './unauthorized_response_http_interceptor';
jest.mock('./session_expired');
const drainPromiseQueue = () => {
return new Promise(resolve => {
setImmediate(resolve);
});
};
const mockCurrentUrl = (url: string) => window.history.pushState({}, '', url);
const setupHttp = (basePath: string) => {
const { http } = setup(injectedMetadata => {
injectedMetadata.getBasePath.mockReturnValue(basePath);
});
return http;
};
afterEach(() => {
fetchMock.restore();
});
it(`logs out 401 responses`, async () => {
const http = setupHttp('/foo');
const sessionExpired = new SessionExpired(http.basePath);
const logoutPromise = new Promise(resolve => {
jest.spyOn(sessionExpired, 'logout').mockImplementation(() => resolve());
});
const interceptor = new UnauthorizedResponseHttpInterceptor(sessionExpired, http.anonymousPaths);
http.intercept(interceptor);
fetchMock.mock('*', 401);
let fetchResolved = false;
let fetchRejected = false;
http.fetch('/foo-api').then(() => (fetchResolved = true), () => (fetchRejected = true));
await logoutPromise;
await drainPromiseQueue();
expect(fetchResolved).toBe(false);
expect(fetchRejected).toBe(false);
});
it(`ignores anonymous paths`, async () => {
mockCurrentUrl('/foo/bar');
const http = setupHttp('/foo');
const { anonymousPaths } = http;
anonymousPaths.register('/bar');
const sessionExpired = new SessionExpired(http.basePath);
const interceptor = new UnauthorizedResponseHttpInterceptor(sessionExpired, anonymousPaths);
http.intercept(interceptor);
fetchMock.mock('*', 401);
await expect(http.fetch('/foo-api')).rejects.toMatchInlineSnapshot(`[Error: Unauthorized]`);
expect(sessionExpired.logout).not.toHaveBeenCalled();
});
it(`ignores errors which don't have a response, for example network connectivity issues`, async () => {
const http = setupHttp('/foo');
const sessionExpired = new SessionExpired(http.basePath);
const interceptor = new UnauthorizedResponseHttpInterceptor(sessionExpired, http.anonymousPaths);
http.intercept(interceptor);
fetchMock.mock('*', new Promise((resolve, reject) => reject(new Error('Network is down'))));
await expect(http.fetch('/foo-api')).rejects.toMatchInlineSnapshot(`[Error: Network is down]`);
expect(sessionExpired.logout).not.toHaveBeenCalled();
});
it(`ignores requests which omit credentials`, async () => {
const http = setupHttp('/foo');
const sessionExpired = new SessionExpired(http.basePath);
const interceptor = new UnauthorizedResponseHttpInterceptor(sessionExpired, http.anonymousPaths);
http.intercept(interceptor);
fetchMock.mock('*', 401);
await expect(http.fetch('/foo-api', { credentials: 'omit' })).rejects.toMatchInlineSnapshot(
`[Error: Unauthorized]`
);
expect(sessionExpired.logout).not.toHaveBeenCalled();
});

View file

@ -0,0 +1,42 @@
/*
* 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 {
HttpInterceptor,
HttpErrorResponse,
IHttpInterceptController,
IAnonymousPaths,
} from 'src/core/public';
import { SessionExpired } from './session_expired';
export class UnauthorizedResponseHttpInterceptor implements HttpInterceptor {
constructor(private sessionExpired: SessionExpired, private anonymousPaths: IAnonymousPaths) {}
responseError(httpErrorResponse: HttpErrorResponse, controller: IHttpInterceptController) {
if (this.anonymousPaths.isAnonymous(window.location.pathname)) {
return;
}
// if the request was omitting credentials it's to an anonymous endpoint
// (for example to login) and we don't wish to ever redirect
if (httpErrorResponse.request.credentials === 'omit') {
return;
}
// if we happen to not have a response, for example if there is no
// network connectivity, we don't do anything
const { response } = httpErrorResponse;
if (!response) {
return;
}
if (response.status === 401) {
this.sessionExpired.logout();
controller.halt();
}
}
}

View file

@ -8558,9 +8558,9 @@
"xpack.security.account.passwordsDoNotMatch": "パスワードが一致していません。",
"xpack.security.account.usernameGroupDescription": "この情報は変更できません。",
"xpack.security.account.usernameGroupTitle": "ユーザー名とメールアドレス",
"xpack.security.components.sessionExpiration.logoutNotification": "操作がないため間もなくログアウトします。再開するには [OK] をクリックしてくださ。",
"xpack.security.components.sessionExpiration.okButtonText": "OK",
"xpack.security.hacks.warningTitle": "警告",
"xpack.security.components.sessionTimeoutWarning.message": "操作がないため間もなくログアウトします。再開するには [OK] をクリックしてくださ。",
"xpack.security.components.sessionTimeoutWarning.okButtonText": "OK",
"xpack.security.components.sessionTimeoutWarning.title": "警告",
"xpack.security.loggedOut.login": "ログイン",
"xpack.security.loggedOut.title": "ログアウト完了",
"xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage": "無効なユーザー名またはパスワード再試行してください。",

View file

@ -8715,9 +8715,9 @@
"xpack.security.account.passwordsDoNotMatch": "密码不匹配。",
"xpack.security.account.usernameGroupDescription": "不能更改此信息。",
"xpack.security.account.usernameGroupTitle": "用户名和电子邮件",
"xpack.security.components.sessionExpiration.logoutNotification": "由于处于不活动状态,您即将退出。单击“确定”可以恢复。",
"xpack.security.components.sessionExpiration.okButtonText": "确定",
"xpack.security.hacks.warningTitle": "警告",
"xpack.security.components.sessionTimeoutWarning.message": "由于处于不活动状态,您即将退出。单击“确定”可以恢复。",
"xpack.security.components.sessionTimeoutWarning.okButtonText": "确定",
"xpack.security.components.sessionTimeoutWarning.title": "警告",
"xpack.security.loggedOut.login": "登录",
"xpack.security.loggedOut.title": "已成功退出",
"xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage": "用户名或密码无效。请重试。",