From a149497b73d7046628c1c75490538173f11f4a09 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Thu, 24 Oct 2019 08:59:45 -0700 Subject: [PATCH] NP Security HTTP Interceptors (#39477) * 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 824086c168aec5cc55c04e5866ceaafdb2ec12f9. * 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 * 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 * Update src/core/public/http/anonymous_paths.ts Co-Authored-By: Josh Dover * Update src/core/public/http/anonymous_paths.ts Co-Authored-By: Josh Dover * AnonymousPaths implements IAnonymousPaths and uses IBasePath * Removing DeeplyMocked * Removing TODOs * Fixing types... * Now, ever more normal --- ...n-public.httpservicebase.anonymouspaths.md | 13 ++ .../kibana-plugin-public.httpservicebase.md | 1 + ...ugin-public.ianonymouspaths.isanonymous.md | 24 +++ .../kibana-plugin-public.ianonymouspaths.md | 21 +++ ...-plugin-public.ianonymouspaths.register.md | 24 +++ .../core/public/kibana-plugin-public.md | 1 + src/core/public/http/anonymous_paths.test.ts | 107 +++++++++++ src/core/public/http/anonymous_paths.ts | 53 ++++++ src/core/public/http/http_service.mock.ts | 11 +- src/core/public/http/http_setup.ts | 3 + src/core/public/http/types.ts | 20 ++ src/core/public/index.ts | 1 + src/core/public/mocks.ts | 14 +- .../notifications_service.mock.ts | 6 +- src/core/public/public.api.md | 9 + .../ui_settings_service.test.ts.snap | 18 +- .../query_bar_input.test.tsx.snap | 108 ++++++++--- .../public/chrome/api/base_path.test.mocks.ts | 2 +- .../ui/public/chrome/api/base_path.test.ts | 22 +-- x-pack/.i18nrc.json | 2 +- x-pack/dev-tools/jest/setup/polyfills.js | 1 + .../lens/public/app_plugin/app.test.tsx | 5 +- .../change_password_form.tsx | 2 +- .../public/hacks/on_session_timeout.js | 56 +----- .../server/routes/api/v1/__tests__/users.js | 6 +- .../security/server/routes/api/v1/users.js | 2 +- x-pack/package.json | 1 + x-pack/plugins/security/kibana.json | 2 +- x-pack/plugins/security/public/index.ts | 11 ++ x-pack/plugins/security/public/plugin.ts | 43 +++++ .../plugins/security/public/session/index.ts | 10 + .../public/session/session_expired.mock.ts} | 8 +- .../public/session/session_expired.test.ts | 46 +++++ .../public/session/session_expired.ts | 24 +++ .../public/session/session_timeout.mock.ts | 13 ++ .../public/session/session_timeout.test.tsx | 171 ++++++++++++++++++ .../public/session/session_timeout.tsx | 79 ++++++++ .../session_timeout_http_interceptor.test.ts | 120 ++++++++++++ .../session_timeout_http_interceptor.ts | 50 +++++ .../session/session_timeout_warning.test.tsx} | 6 +- .../session/session_timeout_warning.tsx} | 6 +- ...thorized_response_http_interceptor.test.ts | 89 +++++++++ .../unauthorized_response_http_interceptor.ts | 42 +++++ .../translations/translations/ja-JP.json | 8 +- .../translations/translations/zh-CN.json | 8 +- 45 files changed, 1126 insertions(+), 143 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-public.httpservicebase.anonymouspaths.md create mode 100644 docs/development/core/public/kibana-plugin-public.ianonymouspaths.isanonymous.md create mode 100644 docs/development/core/public/kibana-plugin-public.ianonymouspaths.md create mode 100644 docs/development/core/public/kibana-plugin-public.ianonymouspaths.register.md create mode 100644 src/core/public/http/anonymous_paths.test.ts create mode 100644 src/core/public/http/anonymous_paths.ts create mode 100644 x-pack/plugins/security/public/index.ts create mode 100644 x-pack/plugins/security/public/plugin.ts create mode 100644 x-pack/plugins/security/public/session/index.ts rename x-pack/{legacy/plugins/security/public/components/session_expiration_warning/index.ts => plugins/security/public/session/session_expired.mock.ts} (58%) create mode 100644 x-pack/plugins/security/public/session/session_expired.test.ts create mode 100644 x-pack/plugins/security/public/session/session_expired.ts create mode 100644 x-pack/plugins/security/public/session/session_timeout.mock.ts create mode 100644 x-pack/plugins/security/public/session/session_timeout.test.tsx create mode 100644 x-pack/plugins/security/public/session/session_timeout.tsx create mode 100644 x-pack/plugins/security/public/session/session_timeout_http_interceptor.test.ts create mode 100644 x-pack/plugins/security/public/session/session_timeout_http_interceptor.ts rename x-pack/{legacy/plugins/security/public/components/session_expiration_warning/session_expiration_warning.test.tsx => plugins/security/public/session/session_timeout_warning.test.tsx} (74%) rename x-pack/{legacy/plugins/security/public/components/session_expiration_warning/session_expiration_warning.tsx => plugins/security/public/session/session_timeout_warning.tsx} (81%) create mode 100644 x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts create mode 100644 x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.ts diff --git a/docs/development/core/public/kibana-plugin-public.httpservicebase.anonymouspaths.md b/docs/development/core/public/kibana-plugin-public.httpservicebase.anonymouspaths.md new file mode 100644 index 00000000000000..e94757c5eb031a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpservicebase.anonymouspaths.md @@ -0,0 +1,13 @@ + + +[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 + +Signature: + +```typescript +anonymousPaths: IAnonymousPaths; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httpservicebase.md b/docs/development/core/public/kibana-plugin-public.httpservicebase.md index a1eef3db42b7dd..9ea77c95b343eb 100644 --- a/docs/development/core/public/kibana-plugin-public.httpservicebase.md +++ b/docs/development/core/public/kibana-plugin-public.httpservicebase.md @@ -15,6 +15,7 @@ export interface HttpServiceBase | Property | Type | Description | | --- | --- | --- | +| [anonymousPaths](./kibana-plugin-public.httpservicebase.anonymouspaths.md) | IAnonymousPaths | APIs for denoting certain paths for not requiring authentication | | [basePath](./kibana-plugin-public.httpservicebase.basepath.md) | IBasePath | APIs for manipulating the basePath on URL segments. | | [delete](./kibana-plugin-public.httpservicebase.delete.md) | HttpHandler | Makes an HTTP request with the DELETE method. See [HttpHandler](./kibana-plugin-public.httphandler.md) for options. | | [fetch](./kibana-plugin-public.httpservicebase.fetch.md) | HttpHandler | Makes an HTTP request. Defaults to a GET request unless overriden. See [HttpHandler](./kibana-plugin-public.httphandler.md) for options. | diff --git a/docs/development/core/public/kibana-plugin-public.ianonymouspaths.isanonymous.md b/docs/development/core/public/kibana-plugin-public.ianonymouspaths.isanonymous.md new file mode 100644 index 00000000000000..92a87668b6ef07 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.ianonymouspaths.isanonymous.md @@ -0,0 +1,24 @@ + + +[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 + +Signature: + +```typescript +isAnonymous(path: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| path | string | | + +Returns: + +`boolean` + diff --git a/docs/development/core/public/kibana-plugin-public.ianonymouspaths.md b/docs/development/core/public/kibana-plugin-public.ianonymouspaths.md new file mode 100644 index 00000000000000..3e5caf49695c29 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.ianonymouspaths.md @@ -0,0 +1,21 @@ + + +[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 + +Signature: + +```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 path as not requiring authentication | + diff --git a/docs/development/core/public/kibana-plugin-public.ianonymouspaths.register.md b/docs/development/core/public/kibana-plugin-public.ianonymouspaths.register.md new file mode 100644 index 00000000000000..88c615da05155c --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.ianonymouspaths.register.md @@ -0,0 +1,24 @@ + + +[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 + +Signature: + +```typescript +register(path: string): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| path | string | | + +Returns: + +`void` + diff --git a/docs/development/core/public/kibana-plugin-public.md b/docs/development/core/public/kibana-plugin-public.md index e787621c3aaf96..57ab8bedde95e1 100644 --- a/docs/development/core/public/kibana-plugin-public.md +++ b/docs/development/core/public/kibana-plugin-public.md @@ -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) | | diff --git a/src/core/public/http/anonymous_paths.test.ts b/src/core/public/http/anonymous_paths.test.ts new file mode 100644 index 00000000000000..bf9212f625f1e4 --- /dev/null +++ b/src/core/public/http/anonymous_paths.test.ts @@ -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); + }); +}); diff --git a/src/core/public/http/anonymous_paths.ts b/src/core/public/http/anonymous_paths.ts new file mode 100644 index 00000000000000..300c4d64df353c --- /dev/null +++ b/src/core/public/http/anonymous_paths.ts @@ -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(); + + 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; + } +} diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts index a94543414acfaa..52f188c7b20a0a 100644 --- a/src/core/public/http/http_service.mock.ts +++ b/src/core/public/http/http_service.mock.ts @@ -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 & { - basePath: jest.Mocked; + 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(), diff --git a/src/core/public/http/http_setup.ts b/src/core/public/http/http_setup.ts index a10358926de1fd..602382e3a5a60e 100644 --- a/src/core/public/http/http_setup.ts +++ b/src/core/public/http/http_setup.ts @@ -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(); 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, diff --git a/src/core/public/http/types.ts b/src/core/public/http/types.ts index 96500d566b3e53..870d4af8f9e861 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -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 diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 3d8714a0011586..24201ff0253cb1 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -110,6 +110,7 @@ export { HttpHandler, HttpBody, IBasePath, + IAnonymousPaths, IHttpInterceptController, IHttpFetchError, InterceptedHttpResponse, diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index 8345980b6869d6..b9cd2577c22172 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -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 = { +function createCoreSetupMock({ basePath = '' } = {}) { + const mock: MockedKeys & { notifications: MockedKeys } = { 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 = { +function createCoreStartMock({ basePath = '' } = {}) { + const mock: MockedKeys & { notifications: MockedKeys } = { 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(), diff --git a/src/core/public/notifications/notifications_service.mock.ts b/src/core/public/notifications/notifications_service.mock.ts index 8f4a1d5bcd4004..464f47e20aa3b0 100644 --- a/src/core/public/notifications/notifications_service.mock.ts +++ b/src/core/public/notifications/notifications_service.mock.ts @@ -23,10 +23,8 @@ import { } from './notifications_service'; import { toastsServiceMock } from './toasts/toasts_service.mock'; -type DeeplyMocked = { [P in keyof T]: jest.Mocked }; - const createSetupContractMock = () => { - const setupContract: DeeplyMocked = { + const setupContract: MockedKeys = { // 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 = { + const startContract: MockedKeys = { // we have to suppress type errors until decide how to mock es6 class toasts: toastsServiceMock.createStartContract(), }; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index ec8a22fe5953cf..11a1b5c0d1d9b0 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -488,6 +488,7 @@ export interface HttpResponse extends InterceptedHttpResponse { // @public (undocumented) export interface HttpServiceBase { addLoadingCount(countSource$: Observable): 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; diff --git a/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap b/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap index f607c924a9e683..84f9a5ab7c5cd3 100644 --- a/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap +++ b/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap @@ -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], diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap b/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap index 06f9e6081e5222..f59afc7165baba 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap @@ -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], diff --git a/src/legacy/ui/public/chrome/api/base_path.test.mocks.ts b/src/legacy/ui/public/chrome/api/base_path.test.mocks.ts index c362b1709fba69..f2c5fd5734b10f 100644 --- a/src/legacy/ui/public/chrome/api/base_path.test.mocks.ts +++ b/src/legacy/ui/public/chrome/api/base_path.test.mocks.ts @@ -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 }, diff --git a/src/legacy/ui/public/chrome/api/base_path.test.ts b/src/legacy/ui/public/chrome/api/base_path.test.ts index 812635ba364838..d3cfc6a3168a83 100644 --- a/src/legacy/ui/public/chrome/api/base_path.test.ts +++ b/src/legacy/ui/public/chrome/api/base_path.test.ts @@ -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'); }); }); diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 735ee0b6b67b59..79a482c4703675 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -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"], diff --git a/x-pack/dev-tools/jest/setup/polyfills.js b/x-pack/dev-tools/jest/setup/polyfills.js index 8e5c5a8025b823..566e4701eeaacc 100644 --- a/x-pack/dev-tools/jest/setup/polyfills.js +++ b/x-pack/dev-tools/jest/setup/polyfills.js @@ -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 }); diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx index 77d0d5a5305aa5..0e3e6b03813091 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx @@ -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', () => { diff --git a/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.tsx b/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.tsx index 9521cbdc58a788..61c0b77decd565 100644 --- a/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.tsx +++ b/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.tsx @@ -314,7 +314,7 @@ export class ChangePasswordForm extends Component { }; private handleChangePasswordFailure = (error: Record) => { - if (error.body && error.body.statusCode === 401) { + if (error.body && error.body.statusCode === 403) { this.setState({ currentPasswordError: true }); } else { toastNotifications.addDanger( diff --git a/x-pack/legacy/plugins/security/public/hacks/on_session_timeout.js b/x-pack/legacy/plugins/security/public/hacks/on_session_timeout.js index 0f1787820a2f24..81b14ee7d8bf41 100644 --- a/x-pack/legacy/plugins/security/public/hacks/on_session_timeout.js +++ b/x-pack/legacy/plugins/security/public/hacks/on_session_timeout.js @@ -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: ( - - ), - 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); }; diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js index 8444efe8790e71..83dfa778f1b505 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js @@ -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), diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/users.js b/x-pack/legacy/plugins/security/server/routes/api/v1/users.js index 9cb2ad799a211c..1d47dc88753486 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/users.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/users.js @@ -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); } } diff --git a/x-pack/package.json b/x-pack/package.json index c8f7345450aa68..fa439a20875475 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -182,6 +182,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" }, diff --git a/x-pack/plugins/security/kibana.json b/x-pack/plugins/security/kibana.json index 7ac9d654eb07ee..9f243a7dfb2fc1 100644 --- a/x-pack/plugins/security/kibana.json +++ b/x-pack/plugins/security/kibana.json @@ -4,5 +4,5 @@ "kibanaVersion": "kibana", "configPath": ["xpack", "security"], "server": true, - "ui": false + "ui": true } diff --git a/x-pack/plugins/security/public/index.ts b/x-pack/plugins/security/public/index.ts new file mode 100644 index 00000000000000..12a3092039d0de --- /dev/null +++ b/x-pack/plugins/security/public/index.ts @@ -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 = () => + new SecurityPlugin(); diff --git a/x-pack/plugins/security/public/plugin.ts b/x-pack/plugins/security/public/plugin.ts new file mode 100644 index 00000000000000..55d125bf993ec6 --- /dev/null +++ b/x-pack/plugins/security/public/plugin.ts @@ -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 { + 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; +export type SecurityPluginStart = ReturnType; diff --git a/x-pack/plugins/security/public/session/index.ts b/x-pack/plugins/security/public/session/index.ts new file mode 100644 index 00000000000000..253207dc1b717e --- /dev/null +++ b/x-pack/plugins/security/public/session/index.ts @@ -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'; diff --git a/x-pack/legacy/plugins/security/public/components/session_expiration_warning/index.ts b/x-pack/plugins/security/public/session/session_expired.mock.ts similarity index 58% rename from x-pack/legacy/plugins/security/public/components/session_expiration_warning/index.ts rename to x-pack/plugins/security/public/session/session_expired.mock.ts index 0aa0c1f9fb0791..e894caafd9594a 100644 --- a/x-pack/legacy/plugins/security/public/components/session_expiration_warning/index.ts +++ b/x-pack/plugins/security/public/session/session_expired.mock.ts @@ -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; +} diff --git a/x-pack/plugins/security/public/session/session_expired.test.ts b/x-pack/plugins/security/public/session/session_expired.test.ts new file mode 100644 index 00000000000000..9c0e4cd8036cc9 --- /dev/null +++ b/x-pack/plugins/security/public/session/session_expired.test.ts @@ -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(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(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` + ); +}); diff --git a/x-pack/plugins/security/public/session/session_expired.ts b/x-pack/plugins/security/public/session/session_expired.ts new file mode 100644 index 00000000000000..3ef15088bb2889 --- /dev/null +++ b/x-pack/plugins/security/public/session/session_expired.ts @@ -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`) + ); + } +} diff --git a/x-pack/plugins/security/public/session/session_timeout.mock.ts b/x-pack/plugins/security/public/session/session_timeout.mock.ts new file mode 100644 index 00000000000000..9917a502790833 --- /dev/null +++ b/x-pack/plugins/security/public/session/session_timeout.mock.ts @@ -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; +} diff --git a/x-pack/plugins/security/public/session/session_timeout.test.tsx b/x-pack/plugins/security/public/session/session_timeout.test.tsx new file mode 100644 index 00000000000000..776247dda94e61 --- /dev/null +++ b/x-pack/plugins/security/public/session/session_timeout.test.tsx @@ -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['notifications'] +) => { + expect(notifications.toasts.add).not.toHaveBeenCalled(); +}; + +const expectWarningToast = ( + notifications: ReturnType['notifications'], + toastLifeTimeMS: number = 60000 +) => { + expect(notifications.toasts.add).toHaveBeenCalledTimes(1); + expect(notifications.toasts.add.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "color": "warning", + "text": , + "title": "Warning", + "toastLifeTimeMs": ${toastLifeTimeMS}, + }, + ] + `); +}; + +const expectWarningToastHidden = ( + notifications: ReturnType['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(); + }); +}); diff --git a/x-pack/plugins/security/public/session/session_timeout.tsx b/x-pack/plugins/security/public/session/session_timeout.tsx new file mode 100644 index 00000000000000..db4926e7f04eac --- /dev/null +++ b/x-pack/plugins/security/public/session/session_timeout.tsx @@ -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: , + 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'); + }; +} diff --git a/x-pack/plugins/security/public/session/session_timeout_http_interceptor.test.ts b/x-pack/plugins/security/public/session/session_timeout_http_interceptor.test.ts new file mode 100644 index 00000000000000..ffbd625590b158 --- /dev/null +++ b/x-pack/plugins/security/public/session/session_timeout_http_interceptor.test.ts @@ -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(); + }); +}); diff --git a/x-pack/plugins/security/public/session/session_timeout_http_interceptor.ts b/x-pack/plugins/security/public/session/session_timeout_http_interceptor.ts new file mode 100644 index 00000000000000..98516cb4a613be --- /dev/null +++ b/x-pack/plugins/security/public/session/session_timeout_http_interceptor.ts @@ -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(); + } +} diff --git a/x-pack/legacy/plugins/security/public/components/session_expiration_warning/session_expiration_warning.test.tsx b/x-pack/plugins/security/public/session/session_timeout_warning.test.tsx similarity index 74% rename from x-pack/legacy/plugins/security/public/components/session_expiration_warning/session_expiration_warning.test.tsx rename to x-pack/plugins/security/public/session/session_timeout_warning.test.tsx index abc5a970eec9ab..a52e7ce4e94b52 100644 --- a/x-pack/legacy/plugins/security/public/components/session_expiration_warning/session_expiration_warning.test.tsx +++ b/x-pack/plugins/security/public/session/session_timeout_warning.test.tsx @@ -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(); + const wrapper = mountWithIntl(); expect(handler).toBeCalledTimes(0); wrapper.find('EuiButton[data-test-subj="refreshSessionButton"]').simulate('click'); diff --git a/x-pack/legacy/plugins/security/public/components/session_expiration_warning/session_expiration_warning.tsx b/x-pack/plugins/security/public/session/session_timeout_warning.tsx similarity index 81% rename from x-pack/legacy/plugins/security/public/components/session_expiration_warning/session_expiration_warning.tsx rename to x-pack/plugins/security/public/session/session_timeout_warning.tsx index 2b957e9b251a7f..e1b4542031ed1f 100644 --- a/x-pack/legacy/plugins/security/public/components/session_expiration_warning/session_expiration_warning.tsx +++ b/x-pack/plugins/security/public/session/session_timeout_warning.tsx @@ -12,12 +12,12 @@ interface Props { onRefreshSession: () => void; } -export const SessionExpirationWarning = (props: Props) => { +export const SessionTimeoutWarning = (props: Props) => { return ( <>

@@ -29,7 +29,7 @@ export const SessionExpirationWarning = (props: Props) => { data-test-subj="refreshSessionButton" > diff --git a/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts b/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts new file mode 100644 index 00000000000000..60f032652221b8 --- /dev/null +++ b/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts @@ -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(); +}); diff --git a/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.ts b/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.ts new file mode 100644 index 00000000000000..a0ef2fdb86b47e --- /dev/null +++ b/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.ts @@ -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(); + } + } +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4ff1459637889e..0d8d4d908231f0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8549,9 +8549,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": "無効なユーザー名またはパスワード再試行してください。", @@ -10470,4 +10470,4 @@ "xpack.fileUpload.fileParser.errorReadingFile": "ファイルの読み込み中にエラーが発生しました", "xpack.fileUpload.fileParser.noFileProvided": "エラー、ファイルが提供されていません" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 116a1f15a139b4..2e99d6d49a8e57 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8706,9 +8706,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": "用户名或密码无效。请重试。", @@ -10627,4 +10627,4 @@ "xpack.fileUpload.fileParser.errorReadingFile": "读取文件时出错", "xpack.fileUpload.fileParser.noFileProvided": "错误,未提供任何文件" } -} \ No newline at end of file +}