[7.x] Add TLS client authentication support. (#43317)

This commit is contained in:
Aleh Zasypkin 2019-08-15 08:48:04 +02:00 committed by GitHub
parent c63218ce5d
commit d106a4b3a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 289 additions and 7 deletions

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [IKibanaSocket](./kibana-plugin-server.ikibanasocket.md) &gt; [authorizationError](./kibana-plugin-server.ikibanasocket.authorizationerror.md)
## IKibanaSocket.authorizationError property
The reason why the peer's certificate has not been verified. This property becomes available only when `authorized` is `false`<!-- -->.
<b>Signature:</b>
```typescript
readonly authorizationError?: Error;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [IKibanaSocket](./kibana-plugin-server.ikibanasocket.md) &gt; [authorized](./kibana-plugin-server.ikibanasocket.authorized.md)
## IKibanaSocket.authorized property
Indicates whether or not the peer certificate was signed by one of the specified CAs. When TLS isn't used the value is `undefined`<!-- -->.
<b>Signature:</b>
```typescript
readonly authorized?: boolean;
```

View file

@ -12,6 +12,13 @@ A tiny abstraction for TCP socket.
export interface IKibanaSocket
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [authorizationError](./kibana-plugin-server.ikibanasocket.authorizationerror.md) | <code>Error</code> | The reason why the peer's certificate has not been verified. This property becomes available only when <code>authorized</code> is <code>false</code>. |
| [authorized](./kibana-plugin-server.ikibanasocket.authorized.md) | <code>boolean</code> | Indicates whether or not the peer certificate was signed by one of the specified CAs. When TLS isn't used the value is <code>undefined</code>. |
## Methods
| Method | Description |

View file

@ -296,6 +296,10 @@ files that should be trusted.
Details on the format, and the valid options, are available via the
https://www.openssl.org/docs/man1.0.2/apps/ciphers.html#CIPHER-LIST-FORMAT[OpenSSL cipher list format documentation].
`server.ssl.clientAuthentication:`:: *Default: none* Controls the servers behavior in regard to requesting a certificate from client
connections. Valid values are `required`, `optional`, and `none`. `required` forces a client to present a certificate, while `optional`
requests a client certificate but the client is not required to present one.
`server.ssl.enabled:`:: *Default: "false"* Enables SSL for outgoing requests
from the Kibana server to the browser. When set to `true`,
`server.ssl.certificate` and `server.ssl.key` are required.

View file

@ -45,6 +45,7 @@ Object {
"!SRP",
"!CAMELLIA",
],
"clientAuthentication": "none",
"enabled": false,
"supportedProtocols": Array [
"TLSv1.1",

View file

@ -17,7 +17,9 @@
* under the License.
*/
import { config } from '.';
import { config, HttpConfig } from '.';
import { Env } from '../config';
import { getEnvOptions } from '../config/__mocks__/env';
test('has defaults for config', () => {
const httpSchema = config.schema;
@ -111,6 +113,46 @@ describe('with TLS', () => {
expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot();
});
test('throws if TLS is not enabled but `clientAuthentication` is `optional`', () => {
const httpSchema = config.schema;
const obj = {
port: 1234,
ssl: {
enabled: false,
clientAuthentication: 'optional',
},
};
expect(() => httpSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot(
`"[ssl]: must enable ssl to use [clientAuthentication]"`
);
});
test('throws if TLS is not enabled but `clientAuthentication` is `required`', () => {
const httpSchema = config.schema;
const obj = {
port: 1234,
ssl: {
enabled: false,
clientAuthentication: 'required',
},
};
expect(() => httpSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot(
`"[ssl]: must enable ssl to use [clientAuthentication]"`
);
});
test('can specify `none` for [clientAuthentication] if ssl is not enabled', () => {
const obj = {
ssl: {
enabled: false,
clientAuthentication: 'none',
},
};
const configValue = config.schema.validate(obj);
expect(configValue.ssl.clientAuthentication).toBe('none');
});
test('can specify single `certificateAuthority` as a string', () => {
const obj = {
ssl: {
@ -202,4 +244,55 @@ describe('with TLS', () => {
httpSchema.validate(allKnownWithOneUnknownProtocols)
).toThrowErrorMatchingSnapshot();
});
test('HttpConfig instance should properly interpret `none` client authentication', () => {
const httpConfig = new HttpConfig(
config.schema.validate({
ssl: {
enabled: true,
key: 'some-key-path',
certificate: 'some-certificate-path',
clientAuthentication: 'none',
},
}),
Env.createDefault(getEnvOptions())
);
expect(httpConfig.ssl.requestCert).toBe(false);
expect(httpConfig.ssl.rejectUnauthorized).toBe(false);
});
test('HttpConfig instance should properly interpret `optional` client authentication', () => {
const httpConfig = new HttpConfig(
config.schema.validate({
ssl: {
enabled: true,
key: 'some-key-path',
certificate: 'some-certificate-path',
clientAuthentication: 'optional',
},
}),
Env.createDefault(getEnvOptions())
);
expect(httpConfig.ssl.requestCert).toBe(true);
expect(httpConfig.ssl.rejectUnauthorized).toBe(false);
});
test('HttpConfig instance should properly interpret `required` client authentication', () => {
const httpConfig = new HttpConfig(
config.schema.validate({
ssl: {
enabled: true,
key: 'some-key-path',
certificate: 'some-certificate-path',
clientAuthentication: 'required',
},
}),
Env.createDefault(getEnvOptions())
);
expect(httpConfig.ssl.requestCert).toBe(true);
expect(httpConfig.ssl.rejectUnauthorized).toBe(true);
});
});

View file

@ -17,16 +17,22 @@
* under the License.
*/
jest.mock('fs', () => ({
readFileSync: jest.fn(),
}));
import supertest from 'supertest';
import { Request, ResponseToolkit } from 'hapi';
import Joi from 'joi';
import { defaultValidationErrorHandler, HapiValidationError } from './http_tools';
import { defaultValidationErrorHandler, HapiValidationError, getServerOptions } from './http_tools';
import { HttpServer } from './http_server';
import { HttpConfig } from './http_config';
import { HttpConfig, config } from './http_config';
import { Router } from './router';
import { loggingServiceMock } from '../logging/logging_service.mock';
import { ByteSizeValue } from '@kbn/config-schema';
import { Env } from '../config';
import { getEnvOptions } from '../config/__mocks__/env';
const emptyOutput = {
statusCode: 400,
@ -41,6 +47,8 @@ const emptyOutput = {
},
};
afterEach(() => jest.clearAllMocks());
describe('defaultValidationErrorHandler', () => {
it('formats value validation errors correctly', () => {
expect.assertions(1);
@ -97,3 +105,68 @@ describe('timeouts', () => {
await server.stop();
});
});
describe('getServerOptions', () => {
beforeEach(() =>
jest.requireMock('fs').readFileSync.mockImplementation((path: string) => `content-${path}`)
);
it('properly configures TLS with default options', () => {
const httpConfig = new HttpConfig(
config.schema.validate({
ssl: {
enabled: true,
key: 'some-key-path',
certificate: 'some-certificate-path',
},
}),
Env.createDefault(getEnvOptions())
);
expect(getServerOptions(httpConfig).tls).toMatchInlineSnapshot(`
Object {
"ca": undefined,
"cert": "content-some-certificate-path",
"ciphers": "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384:DHE-RSA-AES256-SHA384:ECDHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA256:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA",
"honorCipherOrder": true,
"key": "content-some-key-path",
"passphrase": undefined,
"rejectUnauthorized": false,
"requestCert": false,
"secureOptions": 67108864,
}
`);
});
it('properly configures TLS with client authentication', () => {
const httpConfig = new HttpConfig(
config.schema.validate({
ssl: {
enabled: true,
key: 'some-key-path',
certificate: 'some-certificate-path',
certificateAuthorities: ['ca-1', 'ca-2'],
clientAuthentication: 'required',
},
}),
Env.createDefault(getEnvOptions())
);
expect(getServerOptions(httpConfig).tls).toMatchInlineSnapshot(`
Object {
"ca": Array [
"content-ca-1",
"content-ca-2",
],
"cert": "content-some-certificate-path",
"ciphers": "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384:DHE-RSA-AES256-SHA384:ECDHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA256:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA",
"honorCipherOrder": true,
"key": "content-some-key-path",
"passphrase": undefined,
"rejectUnauthorized": true,
"requestCert": true,
"secureOptions": 67108864,
}
`);
});
});

View file

@ -71,6 +71,7 @@ export function getServerOptions(config: HttpConfig, { configureTLS = true } = {
passphrase: ssl.keyPassphrase,
secureOptions: ssl.getSecureOptions(),
requestCert: ssl.requestCert,
rejectUnauthorized: ssl.rejectUnauthorized,
};
options.tls = tlsOptions;

View file

@ -56,4 +56,50 @@ describe('KibanaSocket', () => {
expect(socket.getPeerCertificate()).toBe(null);
});
});
describe('authorized', () => {
it('returns `undefined` for net.Socket instance', () => {
const socket = new KibanaSocket(new Socket());
expect(socket.authorized).toBeUndefined();
});
it('mirrors the value of tls.Socket.authorized', () => {
const tlsSocket = new TLSSocket(new Socket());
tlsSocket.authorized = true;
let socket = new KibanaSocket(tlsSocket);
expect(tlsSocket.authorized).toBe(true);
expect(socket.authorized).toBe(true);
tlsSocket.authorized = false;
socket = new KibanaSocket(tlsSocket);
expect(tlsSocket.authorized).toBe(false);
expect(socket.authorized).toBe(false);
});
});
describe('authorizationError', () => {
it('returns `undefined` for net.Socket instance', () => {
const socket = new KibanaSocket(new Socket());
expect(socket.authorizationError).toBeUndefined();
});
it('mirrors the value of tls.Socket.authorizationError', () => {
const tlsSocket = new TLSSocket(new Socket());
tlsSocket.authorizationError = undefined as any;
let socket = new KibanaSocket(tlsSocket);
expect(tlsSocket.authorizationError).toBeUndefined();
expect(socket.authorizationError).toBeUndefined();
const authorizationError = new Error('some error');
tlsSocket.authorizationError = authorizationError;
socket = new KibanaSocket(tlsSocket);
expect(tlsSocket.authorizationError).toBe(authorizationError);
expect(socket.authorizationError).toBe(authorizationError);
});
});
});

View file

@ -37,10 +37,30 @@ export interface IKibanaSocket {
* @returns An object representing the peer's certificate.
*/
getPeerCertificate(detailed?: boolean): PeerCertificate | DetailedPeerCertificate | null;
/**
* Indicates whether or not the peer certificate was signed by one of the specified CAs. When TLS
* isn't used the value is `undefined`.
*/
readonly authorized?: boolean;
/**
* The reason why the peer's certificate has not been verified. This property becomes available
* only when `authorized` is `false`.
*/
readonly authorizationError?: Error;
}
export class KibanaSocket implements IKibanaSocket {
constructor(private readonly socket: Socket) {}
readonly authorized?: boolean;
readonly authorizationError?: Error;
constructor(private readonly socket: Socket) {
if (this.socket instanceof TLSSocket) {
this.authorized = this.socket.authorized;
this.authorizationError = this.socket.authorizationError;
}
}
getPeerCertificate(detailed: true): DetailedPeerCertificate | null;
getPeerCertificate(detailed: false): PeerCertificate | null;

View file

@ -49,13 +49,20 @@ export const sslSchema = schema.object(
schema.oneOf([schema.literal('TLSv1'), schema.literal('TLSv1.1'), schema.literal('TLSv1.2')]),
{ defaultValue: ['TLSv1.1', 'TLSv1.2'], minSize: 1 }
),
requestCert: schema.maybe(schema.boolean({ defaultValue: false })),
clientAuthentication: schema.oneOf(
[schema.literal('none'), schema.literal('optional'), schema.literal('required')],
{ defaultValue: 'none' }
),
},
{
validate: ssl => {
if (ssl.enabled && (!ssl.key || !ssl.certificate)) {
return 'must specify [certificate] and [key] when ssl is enabled';
}
if (!ssl.enabled && ssl.clientAuthentication !== 'none') {
return 'must enable ssl to use [clientAuthentication]';
}
},
}
);
@ -69,7 +76,8 @@ export class SslConfig {
public certificate: string | undefined;
public certificateAuthorities: string[] | undefined;
public keyPassphrase: string | undefined;
public requestCert: boolean | undefined;
public requestCert: boolean;
public rejectUnauthorized: boolean;
public cipherSuites: string[];
public supportedProtocols: string[];
@ -86,7 +94,8 @@ export class SslConfig {
this.keyPassphrase = config.keyPassphrase;
this.cipherSuites = config.cipherSuites;
this.supportedProtocols = config.supportedProtocols;
this.requestCert = config.requestCert;
this.requestCert = config.clientAuthentication !== 'none';
this.rejectUnauthorized = config.clientAuthentication === 'required';
}
/**

View file

@ -261,6 +261,8 @@ export type IContextProvider<TContext extends Record<string, any>, TContextName
// @public
export interface IKibanaSocket {
readonly authorizationError?: Error;
readonly authorized?: boolean;
// (undocumented)
getPeerCertificate(detailed: true): DetailedPeerCertificate | null;
// (undocumented)