Skip to content

Commit

Permalink
NP Security HTTP Interceptors (#39477)
Browse files Browse the repository at this point in the history
* 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 824086c.

* Changing coreMock to use a concrete instance of BasePath

* Adding session timeout interceptor tests

* Adding session timeout tests

* Adding more tests for short session timeouts

* Moving some files to a session folder

* More thrashing around: renaming and reorganizing

* Renaming Interceptor to HttpInterceptor

* Fixing some type errors

* Fixing legacy chrome API tests

* Fixing other tests to use the concrete instance of BasePath

* Adjusting some types

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

* Moving anonymousPaths to public core http

* Reading sessionTimeout from injected vars and supporting null timeout

* Doesn't extend session when there is no response

* Updating docs and snapshots

* Casting sessionTimeout injectedVar to "number | null"

* Fixing i18n issues

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

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

* Adding milliseconds postfix to SessionTimeout private fields

* Even better anonymous paths, with some validation

* Adjusting public method docs for IAnonymousPaths

* Adjusting spelling of base-path to basePath

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

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

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

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

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

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

* AnonymousPaths implements IAnonymousPaths and uses IBasePath

* Removing DeeplyMocked

* Removing TODOs

* Fixing types...

* Now, ever more normal
  • Loading branch information
kobelb committed Oct 24, 2019
1 parent 9e58b27 commit a149497
Show file tree
Hide file tree
Showing 45 changed files with 1,126 additions and 143 deletions.
@@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [HttpServiceBase](./kibana-plugin-public.httpservicebase.md) &gt; [anonymousPaths](./kibana-plugin-public.httpservicebase.anonymouspaths.md)

## HttpServiceBase.anonymousPaths property

APIs for denoting certain paths for not requiring authentication

<b>Signature:</b>

```typescript
anonymousPaths: IAnonymousPaths;
```
Expand Up @@ -15,6 +15,7 @@ export interface HttpServiceBase

| Property | Type | Description |
| --- | --- | --- |
| [anonymousPaths](./kibana-plugin-public.httpservicebase.anonymouspaths.md) | <code>IAnonymousPaths</code> | APIs for denoting certain paths for not requiring authentication |
| [basePath](./kibana-plugin-public.httpservicebase.basepath.md) | <code>IBasePath</code> | APIs for manipulating the basePath on URL segments. |
| [delete](./kibana-plugin-public.httpservicebase.delete.md) | <code>HttpHandler</code> | Makes an HTTP request with the DELETE method. See [HttpHandler](./kibana-plugin-public.httphandler.md) for options. |
| [fetch](./kibana-plugin-public.httpservicebase.fetch.md) | <code>HttpHandler</code> | Makes an HTTP request. Defaults to a GET request unless overriden. See [HttpHandler](./kibana-plugin-public.httphandler.md) for options. |
Expand Down
@@ -0,0 +1,24 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) &gt; [isAnonymous](./kibana-plugin-public.ianonymouspaths.isanonymous.md)

## IAnonymousPaths.isAnonymous() method

Determines whether the provided path doesn't require authentication

<b>Signature:</b>

```typescript
isAnonymous(path: string): boolean;
```

## Parameters

| Parameter | Type | Description |
| --- | --- | --- |
| path | <code>string</code> | |

<b>Returns:</b>

`boolean`

@@ -0,0 +1,21 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md)

## IAnonymousPaths interface

APIs for denoting paths as not requiring authentication

<b>Signature:</b>

```typescript
export interface IAnonymousPaths
```

## Methods

| Method | Description |
| --- | --- |
| [isAnonymous(path)](./kibana-plugin-public.ianonymouspaths.isanonymous.md) | Determines whether the provided path doesn't require authentication |
| [register(path)](./kibana-plugin-public.ianonymouspaths.register.md) | Register <code>path</code> as not requiring authentication |

@@ -0,0 +1,24 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) &gt; [register](./kibana-plugin-public.ianonymouspaths.register.md)

## IAnonymousPaths.register() method

Register `path` as not requiring authentication

<b>Signature:</b>

```typescript
register(path: string): void;
```

## Parameters

| Parameter | Type | Description |
| --- | --- | --- |
| path | <code>string</code> | |

<b>Returns:</b>

`void`

1 change: 1 addition & 0 deletions docs/development/core/public/kibana-plugin-public.md
Expand Up @@ -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) | |
Expand Down
107 changes: 107 additions & 0 deletions 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);
});
});
53 changes: 53 additions & 0 deletions 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<string>();

constructor(private basePath: IBasePath) {}

public isAnonymous(path: string): boolean {
const pathWithoutBasePath = this.basePath.remove(path);
return this.paths.has(this.normalizePath(pathWithoutBasePath));
}

public register(path: string) {
this.paths.add(this.normalizePath(path));
}

private normalizePath(path: string) {
// always lower-case it
let normalized = path.toLowerCase();

// remove the slash from the end
if (normalized.endsWith('/')) {
normalized = normalized.slice(0, normalized.length - 1);
}

// put a slash at the start
if (!normalized.startsWith('/')) {
normalized = `/${normalized}`;
}

// it's normalized!!!
return normalized;
}
}
11 changes: 5 additions & 6 deletions src/core/public/http/http_service.mock.ts
Expand Up @@ -20,9 +20,11 @@
import { HttpService } from './http_service';
import { HttpSetup } from './types';
import { BehaviorSubject } from 'rxjs';
import { BasePath } from './base_path_service';
import { AnonymousPaths } from './anonymous_paths';

type ServiceSetupMockType = jest.Mocked<HttpSetup> & {
basePath: jest.Mocked<HttpSetup['basePath']>;
basePath: BasePath;
};

const createServiceMock = ({ basePath = '' } = {}): ServiceSetupMockType => ({
Expand All @@ -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(),
Expand Down
3 changes: 3 additions & 0 deletions src/core/public/http/http_setup.ts
Expand Up @@ -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)(;.*)?$/;
Expand All @@ -57,6 +58,7 @@ export const setup = (
const interceptors = new Set<HttpInterceptor>();
const kibanaVersion = injectedMetadata.getKibanaVersion();
const basePath = new BasePath(injectedMetadata.getBasePath());
const anonymousPaths = new AnonymousPaths(basePath);

function intercept(interceptor: HttpInterceptor) {
interceptors.add(interceptor);
Expand Down Expand Up @@ -318,6 +320,7 @@ export const setup = (
return {
stop,
basePath,
anonymousPaths,
intercept,
removeAllInterceptors,
fetch,
Expand Down
20 changes: 20 additions & 0 deletions src/core/public/http/types.ts
Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/core/public/index.ts
Expand Up @@ -110,6 +110,7 @@ export {
HttpHandler,
HttpBody,
IBasePath,
IAnonymousPaths,
IHttpInterceptController,
IHttpFetchError,
InterceptedHttpResponse,
Expand Down
14 changes: 7 additions & 7 deletions src/core/public/mocks.ts
Expand Up @@ -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';
Expand All @@ -41,12 +41,12 @@ export { notificationServiceMock } from './notifications/notifications_service.m
export { overlayServiceMock } from './overlays/overlay_service.mock';
export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock';

function createCoreSetupMock() {
const mock: MockedKeys<CoreSetup> = {
function createCoreSetupMock({ basePath = '' } = {}) {
const mock: MockedKeys<CoreSetup> & { notifications: MockedKeys<NotificationsSetup> } = {
application: applicationServiceMock.createSetupContract(),
context: contextServiceMock.createSetupContract(),
fatalErrors: fatalErrorsServiceMock.createSetupContract(),
http: httpServiceMock.createSetupContract(),
http: httpServiceMock.createSetupContract({ basePath }),
notifications: notificationServiceMock.createSetupContract(),
uiSettings: uiSettingsServiceMock.createSetupContract(),
injectedMetadata: {
Expand All @@ -57,12 +57,12 @@ function createCoreSetupMock() {
return mock;
}

function createCoreStartMock() {
const mock: MockedKeys<CoreStart> = {
function createCoreStartMock({ basePath = '' } = {}) {
const mock: MockedKeys<CoreStart> & { notifications: MockedKeys<NotificationsSetup> } = {
application: applicationServiceMock.createStartContract(),
chrome: chromeServiceMock.createStartContract(),
docLinks: docLinksServiceMock.createStartContract(),
http: httpServiceMock.createStartContract(),
http: httpServiceMock.createStartContract({ basePath }),
i18n: i18nServiceMock.createStartContract(),
notifications: notificationServiceMock.createStartContract(),
overlays: overlayServiceMock.createStartContract(),
Expand Down

0 comments on commit a149497

Please sign in to comment.