From 56c6eec8b875c430a9bbbb80d727257e10e5a864 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 23 Nov 2020 14:23:48 +0100 Subject: [PATCH] Make it possible to use Kibana anonymous authentication provider with ES anonymous access. --- .../providers/anonymous.test.ts | 73 +++++++++++++------ .../authentication/providers/anonymous.ts | 57 ++++++++------- x-pack/plugins/security/server/config.test.ts | 26 +++++-- x-pack/plugins/security/server/config.ts | 26 ++++--- .../anonymous_es_anonymous.config.ts | 40 ++++++++++ .../tests/anonymous/login.ts | 28 ++++--- 6 files changed, 174 insertions(+), 76 deletions(-) create mode 100644 x-pack/test/security_api_integration/anonymous_es_anonymous.config.ts diff --git a/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts b/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts index c296cb9c8e94d5..f1edf2c9a8290a 100644 --- a/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts @@ -29,32 +29,47 @@ function expectAuthenticateCall( expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); } +enum CredentialsType { + Basic = 'Basic', + ApiKey = 'ApiKey', + None = 'ES native anonymous', +} + describe('AnonymousAuthenticationProvider', () => { const user = mockAuthenticatedUser({ authentication_provider: { type: 'anonymous', name: 'anonymous1' }, }); - for (const useBasicCredentials of [true, false]) { - describe(`with ${useBasicCredentials ? '`Basic`' : '`ApiKey`'} credentials`, () => { + for (const credentialsType of [ + CredentialsType.Basic, + CredentialsType.ApiKey, + CredentialsType.None, + ]) { + describe(`with ${credentialsType} credentials`, () => { let provider: AnonymousAuthenticationProvider; let mockOptions: ReturnType; let authorization: string; beforeEach(() => { mockOptions = mockAuthenticationProviderOptions({ name: 'anonymous1' }); - provider = useBasicCredentials - ? new AnonymousAuthenticationProvider(mockOptions, { - credentials: { username: 'user', password: 'pass' }, - }) - : new AnonymousAuthenticationProvider(mockOptions, { - credentials: { apiKey: 'some-apiKey' }, - }); - authorization = useBasicCredentials - ? new HTTPAuthorizationHeader( + let credentials; + switch (credentialsType) { + case CredentialsType.Basic: + credentials = { username: 'user', password: 'pass' }; + authorization = new HTTPAuthorizationHeader( 'Basic', new BasicHTTPAuthorizationHeaderCredentials('user', 'pass').toString() - ).toString() - : new HTTPAuthorizationHeader('ApiKey', 'some-apiKey').toString(); + ).toString(); + break; + case CredentialsType.ApiKey: + credentials = { apiKey: 'some-apiKey' }; + authorization = new HTTPAuthorizationHeader('ApiKey', 'some-apiKey').toString(); + break; + default: + break; + } + + provider = new AnonymousAuthenticationProvider(mockOptions, { credentials }); }); describe('`login` method', () => { @@ -111,23 +126,29 @@ describe('AnonymousAuthenticationProvider', () => { }); it('does not handle authentication via `authorization` header.', async () => { - const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); + const originalAuthorizationHeader = 'Basic credentials'; + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: originalAuthorizationHeader }, + }); await expect(provider.authenticate(request)).resolves.toEqual( AuthenticationResult.notHandled() ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(request.headers.authorization).toBe(authorization); + expect(request.headers.authorization).toBe(originalAuthorizationHeader); }); it('does not handle authentication via `authorization` header even if state exists.', async () => { - const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); + const originalAuthorizationHeader = 'Basic credentials'; + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: originalAuthorizationHeader }, + }); await expect(provider.authenticate(request, {})).resolves.toEqual( AuthenticationResult.notHandled() ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(request.headers.authorization).toBe(authorization); + expect(request.headers.authorization).toBe(originalAuthorizationHeader); }); it('succeeds for non-AJAX requests if state is available.', async () => { @@ -191,7 +212,7 @@ describe('AnonymousAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); - if (!useBasicCredentials) { + if (credentialsType === CredentialsType.ApiKey) { it('properly handles extended format for the ApiKey credentials', async () => { provider = new AnonymousAuthenticationProvider(mockOptions, { credentials: { apiKey: { id: 'some-id', key: 'some-key' } }, @@ -237,9 +258,19 @@ describe('AnonymousAuthenticationProvider', () => { }); it('`getHTTPAuthenticationScheme` method', () => { - expect(provider.getHTTPAuthenticationScheme()).toBe( - useBasicCredentials ? 'basic' : 'apikey' - ); + let expectedAuthenticationScheme; + switch (credentialsType) { + case CredentialsType.Basic: + expectedAuthenticationScheme = 'basic'; + break; + case CredentialsType.ApiKey: + expectedAuthenticationScheme = 'apikey'; + break; + default: + expectedAuthenticationScheme = null; + break; + } + expect(provider.getHTTPAuthenticationScheme()).toBe(expectedAuthenticationScheme); }); }); } diff --git a/x-pack/plugins/security/server/authentication/providers/anonymous.ts b/x-pack/plugins/security/server/authentication/providers/anonymous.ts index 6f02cce371a413..44770d42227afa 100644 --- a/x-pack/plugins/security/server/authentication/providers/anonymous.ts +++ b/x-pack/plugins/security/server/authentication/providers/anonymous.ts @@ -59,9 +59,10 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider static readonly type = 'anonymous'; /** - * Defines HTTP authorization header that should be used to authenticate request. + * Defines HTTP authorization header that should be used to authenticate request. It isn't defined + * if provider should rely on Elasticsearch native anonymous access. */ - private readonly httpAuthorizationHeader: HTTPAuthorizationHeader; + private readonly httpAuthorizationHeader?: HTTPAuthorizationHeader; constructor( protected readonly options: Readonly, @@ -72,29 +73,31 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider super(options); const credentials = anonymousOptions?.credentials; - if (!credentials) { - throw new Error('Credentials must be specified'); - } - - if (isAPIKeyCredentials(credentials)) { - this.logger.debug('Anonymous requests will be authenticated via API key.'); - this.httpAuthorizationHeader = new HTTPAuthorizationHeader( - 'ApiKey', - typeof credentials.apiKey === 'string' - ? credentials.apiKey - : new BasicHTTPAuthorizationHeaderCredentials( - credentials.apiKey.id, - credentials.apiKey.key - ).toString() - ); + if (credentials) { + if (isAPIKeyCredentials(credentials)) { + this.logger.debug('Anonymous requests will be authenticated via API key.'); + this.httpAuthorizationHeader = new HTTPAuthorizationHeader( + 'ApiKey', + typeof credentials.apiKey === 'string' + ? credentials.apiKey + : new BasicHTTPAuthorizationHeaderCredentials( + credentials.apiKey.id, + credentials.apiKey.key + ).toString() + ); + } else { + this.logger.debug('Anonymous requests will be authenticated via username and password.'); + this.httpAuthorizationHeader = new HTTPAuthorizationHeader( + 'Basic', + new BasicHTTPAuthorizationHeaderCredentials( + credentials.username, + credentials.password + ).toString() + ); + } } else { - this.logger.debug('Anonymous requests will be authenticated via username and password.'); - this.httpAuthorizationHeader = new HTTPAuthorizationHeader( - 'Basic', - new BasicHTTPAuthorizationHeaderCredentials( - credentials.username, - credentials.password - ).toString() + this.logger.debug( + 'Anonymous requests will be authenticated using Elasticsearch native anonymous access.' ); } } @@ -155,7 +158,7 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider * HTTP header that provider attaches to all successfully authenticated requests to Elasticsearch. */ public getHTTPAuthenticationScheme() { - return this.httpAuthorizationHeader.scheme.toLowerCase(); + return this.httpAuthorizationHeader?.scheme.toLowerCase() ?? null; } /** @@ -164,7 +167,9 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider * @param state State value previously stored by the provider. */ private async authenticateViaAuthorizationHeader(request: KibanaRequest, state?: unknown) { - const authHeaders = { authorization: this.httpAuthorizationHeader.toString() }; + const authHeaders = this.httpAuthorizationHeader + ? { authorization: this.httpAuthorizationHeader.toString() } + : ({} as Record); try { const user = await this.getUser(request, authHeaders); this.logger.debug( diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index a306e701e4e8d2..f4f578e5ad905d 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -877,15 +877,27 @@ describe('config schema', () => { ); }); - it('requires `credentials`', () => { - expect(() => + it('does not require `credentials`', () => { + expect( ConfigSchema.validate({ authc: { providers: { anonymous: { anonymous1: { order: 0 } } } }, - }) - ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1.anonymous.anonymous1.credentials]: expected at least one defined value but got [undefined]" + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "anonymous": Object { + "anonymous1": Object { + "description": "Continue as Guest", + "enabled": true, + "hint": "For anonymous users", + "icon": "globe", + "order": 0, + "session": Object { + "idleTimeout": null, + }, + "showInSelector": true, + }, + }, + } `); }); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index b46c8dc2178a40..bd0c654eac31f6 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -149,18 +149,20 @@ const providersConfigSchema = schema.object( }), }, { - credentials: schema.oneOf([ - schema.object({ - username: schema.string(), - password: schema.string(), - }), - schema.object({ - apiKey: schema.oneOf([ - schema.object({ id: schema.string(), key: schema.string() }), - schema.string(), - ]), - }), - ]), + credentials: schema.maybe( + schema.oneOf([ + schema.object({ + username: schema.string(), + password: schema.string(), + }), + schema.object({ + apiKey: schema.oneOf([ + schema.object({ id: schema.string(), key: schema.string() }), + schema.string(), + ]), + }), + ]) + ), } ), }, diff --git a/x-pack/test/security_api_integration/anonymous_es_anonymous.config.ts b/x-pack/test/security_api_integration/anonymous_es_anonymous.config.ts new file mode 100644 index 00000000000000..c19b3e6024a708 --- /dev/null +++ b/x-pack/test/security_api_integration/anonymous_es_anonymous.config.ts @@ -0,0 +1,40 @@ +/* + * 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 { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const anonymousAPITestsConfig = await readConfigFile(require.resolve('./anonymous.config.ts')); + return { + ...anonymousAPITestsConfig.getAll(), + + junit: { + reportName: 'X-Pack Security API Integration Tests (Anonymous with ES anonymous access)', + }, + + esTestCluster: { + ...anonymousAPITestsConfig.get('esTestCluster'), + serverArgs: [ + ...anonymousAPITestsConfig.get('esTestCluster.serverArgs'), + 'xpack.security.authc.anonymous.username=anonymous_user', + 'xpack.security.authc.anonymous.roles=anonymous_role', + ], + }, + + kbnTestServer: { + ...anonymousAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...anonymousAPITestsConfig + .get('kbnTestServer.serverArgs') + .filter((arg: string) => !arg.startsWith('--xpack.security.authc.providers')), + `--xpack.security.authc.providers=${JSON.stringify({ + anonymous: { anonymous1: { order: 0 } }, + basic: { basic1: { order: 1 } }, + })}`, + ], + }, + }; +} diff --git a/x-pack/test/security_api_integration/tests/anonymous/login.ts b/x-pack/test/security_api_integration/tests/anonymous/login.ts index e7c876f54ee5a0..559dc7cc78036a 100644 --- a/x-pack/test/security_api_integration/tests/anonymous/login.ts +++ b/x-pack/test/security_api_integration/tests/anonymous/login.ts @@ -31,18 +31,24 @@ export default function ({ getService }: FtrProviderContext) { expect(cookie.maxAge).to.be(0); } + const isElasticsearchAnonymousAccessEnabled = (config.get( + 'esTestCluster.serverArgs' + ) as string[]).some((setting) => setting.startsWith('xpack.security.authc.anonymous')); + describe('Anonymous authentication', () => { - before(async () => { - await security.user.create('anonymous_user', { - password: 'changeme', - roles: [], - full_name: 'Guest', + if (!isElasticsearchAnonymousAccessEnabled) { + before(async () => { + await security.user.create('anonymous_user', { + password: 'changeme', + roles: [], + full_name: 'Guest', + }); }); - }); - after(async () => { - await security.user.delete('anonymous_user'); - }); + after(async () => { + await security.user.delete('anonymous_user'); + }); + } it('should reject API requests if client is not authenticated', async () => { await supertest.get('/internal/security/me').set('kbn-xsrf', 'xxx').expect(401); @@ -97,7 +103,9 @@ export default function ({ getService }: FtrProviderContext) { expect(user.username).to.eql('anonymous_user'); expect(user.authentication_provider).to.eql({ type: 'anonymous', name: 'anonymous1' }); - expect(user.authentication_type).to.eql('realm'); + expect(user.authentication_type).to.eql( + isElasticsearchAnonymousAccessEnabled ? 'anonymous' : 'realm' + ); // Do not assert on the `authentication_realm`, as the value differs for on-prem vs cloud });