From e31ec7eb547b9743c106a9801ebe2c88086f59b9 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Tue, 6 Oct 2020 20:40:28 +0200 Subject: [PATCH] Give user the option to log out if they encounter a 403 (#75538) --- .../core/server/kibana-plugin-core-server.md | 1 + ...in-core-server.onpreresponserender.body.md | 13 ++ ...core-server.onpreresponserender.headers.md | 13 ++ ...-plugin-core-server.onpreresponserender.md | 21 +++ ...plugin-core-server.onpreresponsetoolkit.md | 1 + ...core-server.onpreresponsetoolkit.render.md | 13 ++ src/core/server/http/http_server.mocks.ts | 1 + src/core/server/http/http_service.mock.ts | 1 + src/core/server/http/index.ts | 1 + .../http/integration_tests/lifecycle.test.ts | 61 +++++++++ .../server/http/lifecycle/on_pre_response.ts | 79 ++++++++--- src/core/server/index.ts | 1 + src/core/server/server.api.md | 7 + test/functional/page_objects/common_page.ts | 7 +- test/functional/page_objects/error_page.ts | 10 +- .../authentication/can_redirect_request.ts | 6 +- .../reset_session_page.test.tsx.snap | 3 + .../authorization/api_authorization.test.ts | 4 +- .../server/authorization/api_authorization.ts | 4 +- .../authorization/app_authorization.test.ts | 2 +- .../server/authorization/app_authorization.ts | 2 +- .../authorization_service.test.ts | 3 + ...n_service.ts => authorization_service.tsx} | 40 ++++++ .../authorization/reset_session_page.test.tsx | 27 ++++ .../authorization/reset_session_page.tsx | 128 ++++++++++++++++++ x-pack/plugins/security/server/plugin.ts | 1 + .../server/routes/authorization/index.ts | 2 + .../authorization/reset_session_page.ts | 28 ++++ .../on_post_auth_interceptor.ts | 30 ++-- .../apis/console/feature_controls.ts | 4 +- .../apis/features/features/features.ts | 4 +- .../apis/metrics_ui/feature_controls.ts | 10 +- .../apis/ml/annotations/create_annotations.ts | 6 +- .../apis/ml/annotations/delete_annotations.ts | 6 +- .../apis/ml/annotations/get_annotations.ts | 6 +- .../apis/ml/annotations/update_annotations.ts | 6 +- .../apis/ml/anomaly_detectors/create.ts | 8 +- .../apis/ml/anomaly_detectors/get.ts | 24 ++-- .../apis/ml/calendars/create_calendars.ts | 12 +- .../apis/ml/calendars/delete_calendars.ts | 8 +- .../apis/ml/calendars/get_calendars.ts | 8 +- .../apis/ml/calendars/update_calendars.ts | 10 +- .../apis/ml/data_frame_analytics/delete.ts | 12 +- .../apis/ml/data_frame_analytics/get.ts | 24 ++-- .../apis/ml/data_frame_analytics/update.ts | 12 +- .../apis/ml/filters/create_filters.ts | 12 +- .../apis/ml/filters/delete_filters.ts | 8 +- .../apis/ml/filters/get_filters.ts | 12 +- .../apis/ml/filters/update_filters.ts | 8 +- .../apis/ml/job_validation/cardinality.ts | 6 +- .../apis/ml/job_validation/validate.ts | 6 +- .../apis/ml/jobs/close_jobs.ts | 8 +- .../apis/ml/jobs/delete_jobs.ts | 8 +- .../apis/ml/jobs/jobs_exist.ts | 4 +- .../apis/ml/jobs/jobs_summary.ts | 4 +- .../apis/ml/modules/setup_module.ts | 6 +- .../ml/results/get_anomalies_table_data.ts | 6 +- .../apis/ml/results/get_categorizer_stats.ts | 12 +- .../apis/ml/results/get_stopped_partitions.ts | 6 +- .../security_solution/feature_controls.ts | 10 +- .../apis/uptime/feature_controls.ts | 10 +- .../basic/tests/feature_controls.ts | 38 +++--- .../tests/settings/agent_configuration.ts | 6 +- .../anomaly_detection/no_access_user.ts | 8 +- .../settings/anomaly_detection/read_user.ts | 4 +- .../anomaly_detection/no_access_user.ts | 8 +- .../settings/anomaly_detection/read_user.ts | 4 +- .../tests/encrypted_saved_objects_api.ts | 2 +- .../apps/apm/feature_controls/apm_security.ts | 4 +- .../feature_controls/canvas_security.ts | 24 +--- .../canvas/feature_controls/canvas_spaces.ts | 4 +- .../feature_controls/dashboard_security.ts | 16 +-- .../feature_controls/dev_tools_security.ts | 12 +- .../feature_controls/discover_security.ts | 4 +- .../graph/feature_controls/graph_security.ts | 4 +- .../infrastructure_security.ts | 13 +- .../feature_controls/infrastructure_spaces.ts | 2 +- .../infra/feature_controls/logs_security.ts | 13 +- .../infra/feature_controls/logs_spaces.ts | 2 +- .../maps/feature_controls/maps_security.ts | 13 +- .../apps/maps/feature_controls/maps_spaces.ts | 2 +- .../apps/ml/permissions/no_ml_access.ts | 2 +- .../feature_controls/timelion_security.ts | 20 +-- .../feature_controls/timelion_spaces.ts | 6 +- .../feature_controls/uptime_security.ts | 4 +- .../feature_controls/visualize_security.ts | 8 +- .../functional/page_objects/security_page.ts | 4 +- .../apis/fleet/agents/delete.ts | 4 +- .../apis/fleet/agents/list.ts | 2 +- .../common/suites/bulk_create.ts | 6 +- .../common/suites/bulk_get.ts | 6 +- .../common/suites/bulk_update.ts | 6 +- .../common/suites/create.ts | 4 +- .../common/suites/delete.ts | 4 +- .../common/suites/export.ts | 4 +- .../common/suites/get.ts | 4 +- .../common/suites/import.ts | 6 +- .../common/suites/resolve_import_errors.ts | 6 +- .../common/suites/update.ts | 4 +- .../security_and_spaces/apis/bulk_create.ts | 12 +- .../security_and_spaces/apis/bulk_get.ts | 4 +- .../security_and_spaces/apis/bulk_update.ts | 11 +- .../security_and_spaces/apis/import.ts | 10 +- .../apis/resolve_import_errors.ts | 18 ++- .../security_only/apis/bulk_create.ts | 12 +- .../security_only/apis/bulk_get.ts | 4 +- .../security_only/apis/bulk_update.ts | 11 +- .../security_only/apis/import.ts | 10 +- .../apis/resolve_import_errors.ts | 18 ++- .../common/suites/copy_to_space.ts | 33 +++-- .../suites/resolve_copy_to_space_conflicts.ts | 41 ++++-- .../security_and_spaces/apis/copy_to_space.ts | 23 ++-- .../apis/resolve_copy_to_space_conflicts.ts | 22 +-- 113 files changed, 863 insertions(+), 445 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreresponserender.body.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreresponserender.headers.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreresponserender.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.render.md create mode 100644 x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap rename x-pack/plugins/security/server/authorization/{authorization_service.ts => authorization_service.tsx} (80%) create mode 100644 x-pack/plugins/security/server/authorization/reset_session_page.test.tsx create mode 100644 x-pack/plugins/security/server/authorization/reset_session_page.tsx create mode 100644 x-pack/plugins/security/server/routes/authorization/reset_session_page.ts diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index be8b7c27495a..a484c856ec01 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -121,6 +121,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | | [OnPreResponseExtensions](./kibana-plugin-core-server.onpreresponseextensions.md) | Additional data to extend a response. | | [OnPreResponseInfo](./kibana-plugin-core-server.onpreresponseinfo.md) | Response status code. | +| [OnPreResponseRender](./kibana-plugin-core-server.onpreresponserender.md) | Additional data to extend a response when rendering a new body | | [OnPreResponseToolkit](./kibana-plugin-core-server.onpreresponsetoolkit.md) | A tool set defining an outcome of OnPreResponse interceptor for incoming request. | | [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) | A tool set defining an outcome of OnPreRouting interceptor for incoming request. | | [OpsMetrics](./kibana-plugin-core-server.opsmetrics.md) | Regroups metrics gathered by all the collectors. This contains metrics about the os/runtime, the kibana process and the http server. | diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.body.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.body.md new file mode 100644 index 000000000000..ab5b5e7a4f27 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.body.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreResponseRender](./kibana-plugin-core-server.onpreresponserender.md) > [body](./kibana-plugin-core-server.onpreresponserender.body.md) + +## OnPreResponseRender.body property + +the body to use in the response + +Signature: + +```typescript +body: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.headers.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.headers.md new file mode 100644 index 000000000000..100d12f63d16 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.headers.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreResponseRender](./kibana-plugin-core-server.onpreresponserender.md) > [headers](./kibana-plugin-core-server.onpreresponserender.headers.md) + +## OnPreResponseRender.headers property + +additional headers to attach to the response + +Signature: + +```typescript +headers?: ResponseHeaders; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.md new file mode 100644 index 000000000000..0a7ce2d54670 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreResponseRender](./kibana-plugin-core-server.onpreresponserender.md) + +## OnPreResponseRender interface + +Additional data to extend a response when rendering a new body + +Signature: + +```typescript +export interface OnPreResponseRender +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [body](./kibana-plugin-core-server.onpreresponserender.body.md) | string | the body to use in the response | +| [headers](./kibana-plugin-core-server.onpreresponserender.headers.md) | ResponseHeaders | additional headers to attach to the response | + diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md index 44da09d0cc68..14070038132d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md @@ -17,4 +17,5 @@ export interface OnPreResponseToolkit | Property | Type | Description | | --- | --- | --- | | [next](./kibana-plugin-core-server.onpreresponsetoolkit.next.md) | (responseExtensions?: OnPreResponseExtensions) => OnPreResponseResult | To pass request to the next handler | +| [render](./kibana-plugin-core-server.onpreresponsetoolkit.render.md) | (responseRender: OnPreResponseRender) => OnPreResponseResult | To override the response with a different body | diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.render.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.render.md new file mode 100644 index 000000000000..7dced7fe8dee --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.render.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreResponseToolkit](./kibana-plugin-core-server.onpreresponsetoolkit.md) > [render](./kibana-plugin-core-server.onpreresponsetoolkit.render.md) + +## OnPreResponseToolkit.render property + +To override the response with a different body + +Signature: + +```typescript +render: (responseRender: OnPreResponseRender) => OnPreResponseResult; +``` diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts index 9deaa73d8aac..6aad232cf42b 100644 --- a/src/core/server/http/http_server.mocks.ts +++ b/src/core/server/http/http_server.mocks.ts @@ -175,6 +175,7 @@ type ToolkitMock = jest.Mocked { return { + render: jest.fn(), next: jest.fn(), rewriteUrl: jest.fn(), }; diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index f81708145edc..df837dc35505 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -198,6 +198,7 @@ const createAuthToolkitMock = (): jest.Mocked => ({ }); const createOnPreResponseToolkitMock = (): jest.Mocked => ({ + render: jest.fn(), next: jest.fn(), }); diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index 7513e6096608..cb842b2f6026 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -83,6 +83,7 @@ export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth'; export { OnPreResponseHandler, OnPreResponseToolkit, + OnPreResponseRender, OnPreResponseExtensions, OnPreResponseInfo, } from './lifecycle/on_pre_response'; diff --git a/src/core/server/http/integration_tests/lifecycle.test.ts b/src/core/server/http/integration_tests/lifecycle.test.ts index b9548bf7a8d7..59090d101acb 100644 --- a/src/core/server/http/integration_tests/lifecycle.test.ts +++ b/src/core/server/http/integration_tests/lifecycle.test.ts @@ -1286,6 +1286,67 @@ describe('OnPreResponse', () => { expect(requestBody).toStrictEqual({}); }); + + it('supports rendering a different response body', async () => { + const { registerOnPreResponse, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => { + return res.ok({ + headers: { + 'Original-Header-A': 'A', + }, + body: 'original', + }); + }); + + registerOnPreResponse((req, res, t) => { + return t.render({ body: 'overridden' }); + }); + + await server.start(); + + const result = await supertest(innerServer.listener).get('/').expect(200, 'overridden'); + + expect(result.header['original-header-a']).toBe('A'); + }); + + it('supports rendering a different response body + headers', async () => { + const { registerOnPreResponse, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => { + return res.ok({ + headers: { + 'Original-Header-A': 'A', + 'Original-Header-B': 'B', + }, + body: 'original', + }); + }); + + registerOnPreResponse((req, res, t) => { + return t.render({ + headers: { + 'Original-Header-A': 'AA', + 'New-Header-C': 'C', + }, + body: 'overridden', + }); + }); + + await server.start(); + + const result = await supertest(innerServer.listener).get('/').expect(200, 'overridden'); + + expect(result.header['original-header-a']).toBe('AA'); + expect(result.header['original-header-b']).toBe('B'); + expect(result.header['new-header-c']).toBe('C'); + }); }); describe('run interceptors in the right order', () => { diff --git a/src/core/server/http/lifecycle/on_pre_response.ts b/src/core/server/http/lifecycle/on_pre_response.ts index 4d1b53313a51..37dddf4dd476 100644 --- a/src/core/server/http/lifecycle/on_pre_response.ts +++ b/src/core/server/http/lifecycle/on_pre_response.ts @@ -17,16 +17,23 @@ * under the License. */ -import { Lifecycle, Request, ResponseToolkit as HapiResponseToolkit } from 'hapi'; +import { Lifecycle, Request, ResponseObject, ResponseToolkit as HapiResponseToolkit } from 'hapi'; import Boom from 'boom'; import { Logger } from '../../logging'; import { HapiResponseAdapter, KibanaRequest, ResponseHeaders } from '../router'; enum ResultType { + render = 'render', next = 'next', } +interface Render { + type: ResultType.render; + body: string; + headers?: ResponseHeaders; +} + interface Next { type: ResultType.next; headers?: ResponseHeaders; @@ -35,7 +42,18 @@ interface Next { /** * @internal */ -type OnPreResponseResult = Next; +type OnPreResponseResult = Render | Next; + +/** + * Additional data to extend a response when rendering a new body + * @public + */ +export interface OnPreResponseRender { + /** additional headers to attach to the response */ + headers?: ResponseHeaders; + /** the body to use in the response */ + body: string; +} /** * Additional data to extend a response. @@ -55,6 +73,12 @@ export interface OnPreResponseInfo { } const preResponseResult = { + render(responseRender: OnPreResponseRender): OnPreResponseResult { + return { type: ResultType.render, body: responseRender.body, headers: responseRender?.headers }; + }, + isRender(result: OnPreResponseResult): result is Render { + return result && result.type === ResultType.render; + }, next(responseExtensions?: OnPreResponseExtensions): OnPreResponseResult { return { type: ResultType.next, headers: responseExtensions?.headers }; }, @@ -68,11 +92,14 @@ const preResponseResult = { * @public */ export interface OnPreResponseToolkit { + /** To override the response with a different body */ + render: (responseRender: OnPreResponseRender) => OnPreResponseResult; /** To pass request to the next handler */ next: (responseExtensions?: OnPreResponseExtensions) => OnPreResponseResult; } const toolkit: OnPreResponseToolkit = { + render: preResponseResult.render, next: preResponseResult.next, }; @@ -106,26 +133,36 @@ export function adoptToHapiOnPreResponseFormat(fn: OnPreResponseHandler, log: Lo : response.statusCode; const result = await fn(KibanaRequest.from(request), { statusCode }, toolkit); - if (!preResponseResult.isNext(result)) { + + if (preResponseResult.isNext(result)) { + if (result.headers) { + if (isBoom(response)) { + findHeadersIntersection(response.output.headers, result.headers, log); + // hapi wraps all error response in Boom object internally + response.output.headers = { + ...response.output.headers, + ...(result.headers as any), // hapi types don't specify string[] as valid value + }; + } else { + findHeadersIntersection(response.headers, result.headers, log); + setHeaders(response, result.headers); + } + } + } else if (preResponseResult.isRender(result)) { + const overriddenResponse = responseToolkit.response(result.body).code(statusCode); + + const originalHeaders = isBoom(response) ? response.output.headers : response.headers; + setHeaders(overriddenResponse, originalHeaders); + if (result.headers) { + setHeaders(overriddenResponse, result.headers); + } + + return overriddenResponse; + } else { throw new Error( `Unexpected result from OnPreResponse. Expected OnPreResponseResult, but given: ${result}.` ); } - if (result.headers) { - if (isBoom(response)) { - findHeadersIntersection(response.output.headers, result.headers, log); - // hapi wraps all error response in Boom object internally - response.output.headers = { - ...response.output.headers, - ...(result.headers as any), // hapi types don't specify string[] as valid value - }; - } else { - findHeadersIntersection(response.headers, result.headers, log); - for (const [headerName, headerValue] of Object.entries(result.headers)) { - response.header(headerName, headerValue as any); // hapi types don't specify string[] as valid value - } - } - } } } catch (error) { log.error(error); @@ -140,6 +177,12 @@ function isBoom(response: any): response is Boom { return response instanceof Boom; } +function setHeaders(response: ResponseObject, headers: ResponseHeaders) { + for (const [headerName, headerValue] of Object.entries(headers)) { + response.header(headerName, headerValue as any); // hapi types don't specify string[] as valid value + } +} + // NOTE: responseHeaders contains not a full list of response headers, but only explicitly set on a response object. // any headers added by hapi internally, like `content-type`, `content-length`, etc. are not present here. function findHeadersIntersection( diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 887dc50d5f78..fc091bd17bdf 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -173,6 +173,7 @@ export { OnPostAuthToolkit, OnPreResponseHandler, OnPreResponseToolkit, + OnPreResponseRender, OnPreResponseExtensions, OnPreResponseInfo, RedirectResponseOptions, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index a718ae8a6ff1..a877700a48bc 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1530,9 +1530,16 @@ export interface OnPreResponseInfo { statusCode: number; } +// @public +export interface OnPreResponseRender { + body: string; + headers?: ResponseHeaders; +} + // @public export interface OnPreResponseToolkit { next: (responseExtensions?: OnPreResponseExtensions) => OnPreResponseResult; + render: (responseRender: OnPreResponseRender) => OnPreResponseResult; } // Warning: (ae-forgotten-export) The symbol "OnPreRoutingResult" needs to be exported by the entry point index.d.ts diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 459f596b3025..41667e1f26c8 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -434,7 +434,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo } } - async getBodyText() { + async getJsonBodyText() { if (await find.existsByCssSelector('a[id=rawdata-tab]', defaultFindTimeout)) { // Firefox has 3 tabs and requires navigation to see Raw output await find.clickByCssSelector('a[id=rawdata-tab]'); @@ -449,6 +449,11 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo } } + async getBodyText() { + const body = await find.byCssSelector('body'); + return await body.getVisibleText(); + } + /** * Helper to detect an OSS licensed Kibana * Useful for functional testing in cloud environment diff --git a/test/functional/page_objects/error_page.ts b/test/functional/page_objects/error_page.ts index 332ce835d0b1..bc256f55155d 100644 --- a/test/functional/page_objects/error_page.ts +++ b/test/functional/page_objects/error_page.ts @@ -26,17 +26,11 @@ export function ErrorPageProvider({ getPageObjects }: FtrProviderContext) { class ErrorPage { public async expectForbidden() { const messageText = await common.getBodyText(); - expect(messageText).to.eql( - JSON.stringify({ - statusCode: 403, - error: 'Forbidden', - message: 'Forbidden', - }) - ); + expect(messageText).to.contain('You do not have permission to access the requested page'); } public async expectNotFound() { - const messageText = await common.getBodyText(); + const messageText = await common.getJsonBodyText(); expect(messageText).to.eql( JSON.stringify({ statusCode: 404, diff --git a/x-pack/plugins/security/server/authentication/can_redirect_request.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.ts index 7e2b2e5eaf9f..4dd89a4990d6 100644 --- a/x-pack/plugins/security/server/authentication/can_redirect_request.ts +++ b/x-pack/plugins/security/server/authentication/can_redirect_request.ts @@ -17,10 +17,14 @@ const KIBANA_VERSION_HEADER = 'kbn-version'; */ export function canRedirectRequest(request: KibanaRequest) { const headers = request.headers; + const route = request.route; const hasVersionHeader = headers.hasOwnProperty(KIBANA_VERSION_HEADER); const hasXsrfHeader = headers.hasOwnProperty(KIBANA_XSRF_HEADER); - const isApiRoute = request.route.options.tags.includes(ROUTE_TAG_API); + const isApiRoute = + route.options.tags.includes(ROUTE_TAG_API) || + (route.path.startsWith('/api/') && route.path !== '/api/security/logout') || + route.path.startsWith('/internal/'); const isAjaxRequest = hasVersionHeader || hasXsrfHeader; return !isApiRoute && !isAjaxRequest; diff --git a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap new file mode 100644 index 000000000000..7ff1dbfcb1a4 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ResetSessionPage renders as expected 1`] = `"MockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; diff --git a/x-pack/plugins/security/server/authorization/api_authorization.test.ts b/x-pack/plugins/security/server/authorization/api_authorization.test.ts index d4ec9a0e0db5..22336a7db9a3 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.test.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.test.ts @@ -100,7 +100,7 @@ describe('initAPIAuthorization', () => { expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); - test(`protected route when "mode.useRbacForRequest()" returns true and user isn't authorized responds with a 404`, async () => { + test(`protected route when "mode.useRbacForRequest()" returns true and user isn't authorized responds with a 403`, async () => { const mockHTTPSetup = coreMock.createSetup().http; const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' }); initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingSystemMock.create().get()); @@ -129,7 +129,7 @@ describe('initAPIAuthorization', () => { await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); - expect(mockResponse.notFound).toHaveBeenCalledTimes(1); + expect(mockResponse.forbidden).toHaveBeenCalledTimes(1); expect(mockPostAuthToolkit.next).not.toHaveBeenCalled(); expect(mockCheckPrivileges).toHaveBeenCalledWith({ kibana: [mockAuthz.actions.api.get('foo')], diff --git a/x-pack/plugins/security/server/authorization/api_authorization.ts b/x-pack/plugins/security/server/authorization/api_authorization.ts index 9129330ec947..813ed8d064d9 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.ts @@ -37,7 +37,7 @@ export function initAPIAuthorization( return toolkit.next(); } - logger.warn(`User not authorized for "${request.url.path}": responding with 404`); - return response.notFound(); + logger.warn(`User not authorized for "${request.url.path}": responding with 403`); + return response.forbidden(); }); } diff --git a/x-pack/plugins/security/server/authorization/app_authorization.test.ts b/x-pack/plugins/security/server/authorization/app_authorization.test.ts index f40d502a9cd7..f035e6eaa365 100644 --- a/x-pack/plugins/security/server/authorization/app_authorization.test.ts +++ b/x-pack/plugins/security/server/authorization/app_authorization.test.ts @@ -170,7 +170,7 @@ describe('initAppAuthorization', () => { await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); - expect(mockResponse.notFound).toHaveBeenCalledTimes(1); + expect(mockResponse.forbidden).toHaveBeenCalledTimes(1); expect(mockPostAuthToolkit.next).not.toHaveBeenCalled(); expect(mockCheckPrivileges).toHaveBeenCalledWith({ kibana: mockAuthz.actions.app.get('foo') }); expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); diff --git a/x-pack/plugins/security/server/authorization/app_authorization.ts b/x-pack/plugins/security/server/authorization/app_authorization.ts index 4170fd2cdb38..713266fc3b5c 100644 --- a/x-pack/plugins/security/server/authorization/app_authorization.ts +++ b/x-pack/plugins/security/server/authorization/app_authorization.ts @@ -73,6 +73,6 @@ export function initAppAuthorization( } logger.debug(`not authorized for "${appId}"`); - return response.notFound(); + return response.forbidden(); }); } diff --git a/x-pack/plugins/security/server/authorization/authorization_service.test.ts b/x-pack/plugins/security/server/authorization/authorization_service.test.ts index c00127f7d122..33abc22fdf09 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.test.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.test.ts @@ -72,6 +72,7 @@ it(`#setup returns exposed services`, () => { loggers: loggingSystemMock.create(), kibanaIndexName, packageVersion: 'some-version', + buildNumber: 42, features: mockFeaturesSetup, getSpacesService: mockGetSpacesService, getCurrentUser: jest.fn(), @@ -130,6 +131,7 @@ describe('#start', () => { loggers: loggingSystemMock.create(), kibanaIndexName, packageVersion: 'some-version', + buildNumber: 42, features: featuresPluginMock.createSetup(), getSpacesService: jest .fn() @@ -201,6 +203,7 @@ it('#stop unsubscribes from license and ES updates.', async () => { loggers: loggingSystemMock.create(), kibanaIndexName, packageVersion: 'some-version', + buildNumber: 42, features: featuresPluginMock.createSetup(), getSpacesService: jest .fn() diff --git a/x-pack/plugins/security/server/authorization/authorization_service.ts b/x-pack/plugins/security/server/authorization/authorization_service.tsx similarity index 80% rename from x-pack/plugins/security/server/authorization/authorization_service.ts rename to x-pack/plugins/security/server/authorization/authorization_service.tsx index fd3a60fb4d90..9547295af4df 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.tsx @@ -4,8 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import querystring from 'querystring'; + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; import { Subscription, Observable } from 'rxjs'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; + import type { Capabilities as UICapabilities } from '../../../../../src/core/types'; + import { LoggerFactory, KibanaRequest, @@ -43,6 +50,8 @@ import { APPLICATION_PREFIX } from '../../common/constants'; import { SecurityLicense } from '../../common/licensing'; import { CheckPrivilegesWithRequest } from './types'; import { OnlineStatusRetryScheduler } from '../elasticsearch'; +import { canRedirectRequest } from '../authentication'; +import { ResetSessionPage } from './reset_session_page'; import { AuthenticatedUser } from '..'; export { Actions } from './actions'; @@ -51,6 +60,7 @@ export { featurePrivilegeIterator } from './privileges'; interface AuthorizationServiceSetupParams { packageVersion: string; + buildNumber: number; http: HttpServiceSetup; capabilities: CapabilitiesSetup; clusterClient: ILegacyClusterClient; @@ -89,6 +99,7 @@ export class AuthorizationService { http, capabilities, packageVersion, + buildNumber, clusterClient, license, loggers, @@ -154,6 +165,35 @@ export class AuthorizationService { initAPIAuthorization(http, authz, loggers.get('api-authorization')); initAppAuthorization(http, authz, loggers.get('app-authorization'), features); + http.registerOnPreResponse((request, preResponse, toolkit) => { + if (preResponse.statusCode === 403 && canRedirectRequest(request)) { + const basePath = http.basePath.get(request); + const next = `${basePath}${request.url.path}`; + const regularBundlePath = `${basePath}/${buildNumber}/bundles`; + + const logoutUrl = http.basePath.prepend( + `/api/security/logout?${querystring.stringify({ next })}` + ); + const styleSheetPaths = [ + `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`, + `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, + `${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, + `${basePath}/ui/legacy_light_theme.css`, + ]; + + const body = renderToStaticMarkup( + + ); + + return toolkit.render({ body, headers: { 'Content-Security-Policy': http.csp.header } }); + } + return toolkit.next(); + }); + return authz; } diff --git a/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx b/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx new file mode 100644 index 000000000000..5a15f4603cfc --- /dev/null +++ b/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx @@ -0,0 +1,27 @@ +/* + * 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 React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ResetSessionPage } from './reset_session_page'; + +jest.mock('../../../../../src/core/server/rendering/views/fonts', () => ({ + Fonts: () => <>MockedFonts, +})); + +describe('ResetSessionPage', () => { + it('renders as expected', async () => { + const body = renderToStaticMarkup( + + ); + + expect(body).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/reset_session_page.tsx b/x-pack/plugins/security/server/authorization/reset_session_page.tsx new file mode 100644 index 000000000000..5ab6fe941ae1 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/reset_session_page.tsx @@ -0,0 +1,128 @@ +/* + * 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 React from 'react'; +// @ts-expect-error no definitions in component folder +import { EuiButton, EuiButtonEmpty } from '@elastic/eui/lib/components/button'; +// @ts-expect-error no definitions in component folder +import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui/lib/components/page'; +// @ts-expect-error no definitions in component folder +import { EuiEmptyPrompt } from '@elastic/eui/lib/components/empty_prompt'; +// @ts-expect-error no definitions in component folder +import { appendIconComponentCache } from '@elastic/eui/lib/components/icon/icon'; +// @ts-expect-error no definitions in component folder +import { icon as EuiIconAlert } from '@elastic/eui/lib/components/icon/assets/alert'; + +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { Fonts } from '../../../../../src/core/server/rendering/views/fonts'; + +// Preload the alert icon used by `EuiEmptyPrompt` to ensure that it's loaded +// in advance the first time this page is rendered server-side. If not, the +// icon svg wouldn't contain any paths the first time the page was rendered. +appendIconComponentCache({ + alert: EuiIconAlert, +}); + +export function ResetSessionPage({ + logoutUrl, + styleSheetPaths, + basePath, +}: { + logoutUrl: string; + styleSheetPaths: string[]; + basePath: string; +}) { + const uiPublicUrl = `${basePath}/ui`; + return ( + + + {styleSheetPaths.map((path) => ( + + ))} + + + + + + + +