mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
* 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:
parent
5cedccdc6a
commit
fc28bfec12
45 changed files with 1124 additions and 141 deletions
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpServiceBase](./kibana-plugin-public.httpservicebase.md) > [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;
|
||||
```
|
|
@ -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. |
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) > [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`
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [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 |
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) > [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`
|
||||
|
|
@ -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) | |
|
||||
|
|
107
src/core/public/http/anonymous_paths.test.ts
Normal file
107
src/core/public/http/anonymous_paths.test.ts
Normal 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);
|
||||
});
|
||||
});
|
53
src/core/public/http/anonymous_paths.ts
Normal file
53
src/core/public/http/anonymous_paths.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -111,6 +111,7 @@ export {
|
|||
HttpHandler,
|
||||
HttpBody,
|
||||
IBasePath,
|
||||
IAnonymousPaths,
|
||||
IHttpInterceptController,
|
||||
IHttpFetchError,
|
||||
InterceptedHttpResponse,
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -4,5 +4,5 @@
|
|||
"kibanaVersion": "kibana",
|
||||
"configPath": ["xpack", "security"],
|
||||
"server": true,
|
||||
"ui": false
|
||||
"ui": true
|
||||
}
|
||||
|
|
11
x-pack/plugins/security/public/index.ts
Normal file
11
x-pack/plugins/security/public/index.ts
Normal 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();
|
43
x-pack/plugins/security/public/plugin.ts
Normal file
43
x-pack/plugins/security/public/plugin.ts
Normal 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']>;
|
10
x-pack/plugins/security/public/session/index.ts
Normal file
10
x-pack/plugins/security/public/session/index.ts
Normal 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';
|
|
@ -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>;
|
||||
}
|
|
@ -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`
|
||||
);
|
||||
});
|
24
x-pack/plugins/security/public/session/session_expired.ts
Normal file
24
x-pack/plugins/security/public/session/session_expired.ts
Normal 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`)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>;
|
||||
}
|
171
x-pack/plugins/security/public/session/session_timeout.test.tsx
Normal file
171
x-pack/plugins/security/public/session/session_timeout.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
79
x-pack/plugins/security/public/session/session_timeout.tsx
Normal file
79
x-pack/plugins/security/public/session/session_timeout.tsx
Normal 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');
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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');
|
|
@ -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>
|
|
@ -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();
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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": "無効なユーザー名またはパスワード再試行してください。",
|
||||
|
|
|
@ -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": "用户名或密码无效。请重试。",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue