From 932cf92f05d1b031c85f446e7f2d9d688acb0614 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 6 May 2019 15:59:46 +0200 Subject: [PATCH] Introduce Kerberos authentication provider. --- packages/kbn-es/src/cluster.js | 4 + packages/kbn-test/src/es/es_test_cluster.js | 3 +- .../functional_tests/lib/run_elasticsearch.js | 3 +- .../lib/config/schema.ts | 1 + .../security/server/lib/auth_redirect.js | 19 +- .../authentication/authentication_result.ts | 21 +- .../lib/authentication/authenticator.ts | 2 + .../lib/authentication/providers/index.ts | 1 + .../lib/authentication/providers/kerberos.ts | 280 ++++++++++++++ x-pack/scripts/functional_tests.js | 1 + .../kerberos_api_integration/apis/index.ts | 15 + .../apis/security/index.ts | 14 + .../apis/security/kerberos_login.ts | 356 ++++++++++++++++++ .../test/kerberos_api_integration/config.ts | 58 +++ .../fixtures/README.md | 10 + .../fixtures/krb5.conf | 11 + .../fixtures/krb5.keytab | Bin 0 -> 356 bytes 17 files changed, 790 insertions(+), 9 deletions(-) create mode 100644 x-pack/plugins/security/server/lib/authentication/providers/kerberos.ts create mode 100644 x-pack/test/kerberos_api_integration/apis/index.ts create mode 100644 x-pack/test/kerberos_api_integration/apis/security/index.ts create mode 100644 x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts create mode 100644 x-pack/test/kerberos_api_integration/config.ts create mode 100644 x-pack/test/kerberos_api_integration/fixtures/README.md create mode 100755 x-pack/test/kerberos_api_integration/fixtures/krb5.conf create mode 100755 x-pack/test/kerberos_api_integration/fixtures/krb5.keytab diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index 9ea1817e0f49b94..de0405febe02968 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -238,6 +238,10 @@ exports.Cluster = class Cluster { this._process = execa(ES_BIN, args, { cwd: installPath, + env: { + ...process.env, + ...(options.esEnvVars || {}), + }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/packages/kbn-test/src/es/es_test_cluster.js b/packages/kbn-test/src/es/es_test_cluster.js index c8db170cc59e176..53153a21e153b23 100644 --- a/packages/kbn-test/src/es/es_test_cluster.js +++ b/packages/kbn-test/src/es/es_test_cluster.js @@ -62,7 +62,7 @@ export function createEsTestCluster(options = {}) { return esFrom === 'snapshot' ? 3 * minute : 6 * minute; } - async start(esArgs = []) { + async start(esArgs = [], esEnvVars) { let installPath; if (esFrom === 'source') { @@ -87,6 +87,7 @@ export function createEsTestCluster(options = {}) { 'discovery.type=single-node', ...esArgs, ], + esEnvVars, }); } diff --git a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.js b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.js index 7971bf94f802b6b..2e290222b1a9daa 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.js +++ b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.js @@ -27,6 +27,7 @@ export async function runElasticsearch({ config, options }) { const { log, esFrom } = options; const license = config.get('esTestCluster.license'); const esArgs = config.get('esTestCluster.serverArgs'); + const esEnvVars = config.get('esTestCluster.serverEnvVars'); const isSecurityEnabled = esArgs.includes('xpack.security.enabled=true'); const cluster = createEsTestCluster({ @@ -41,7 +42,7 @@ export async function runElasticsearch({ config, options }) { dataArchive: config.get('esTestCluster.dataArchive'), }); - await cluster.start(esArgs); + await cluster.start(esArgs, esEnvVars); if (isSecurityEnabled) { await setupUsers(log, config.get('servers.elasticsearch.port'), [ diff --git a/src/functional_test_runner/lib/config/schema.ts b/src/functional_test_runner/lib/config/schema.ts index 57c0b1135c3afc5..8f43d729b1e10fa 100644 --- a/src/functional_test_runner/lib/config/schema.ts +++ b/src/functional_test_runner/lib/config/schema.ts @@ -166,6 +166,7 @@ export const schema = Joi.object() license: Joi.string().default('oss'), from: Joi.string().default('snapshot'), serverArgs: Joi.array(), + serverEnvVars: Joi.object(), dataArchive: Joi.string(), }) .default(), diff --git a/x-pack/plugins/security/server/lib/auth_redirect.js b/x-pack/plugins/security/server/lib/auth_redirect.js index 844847e38351dc6..fb0cb71aed1541d 100644 --- a/x-pack/plugins/security/server/lib/auth_redirect.js +++ b/x-pack/plugins/security/server/lib/auth_redirect.js @@ -33,18 +33,27 @@ export function authenticateFactory(server) { if (authenticationResult.succeeded()) { return h.authenticated({ credentials: authenticationResult.user }); - } else if (authenticationResult.redirected()) { + } + + if (authenticationResult.redirected()) { // Some authentication mechanisms may require user to be redirected to another location to // initiate or complete authentication flow. It can be Kibana own login page for basic // authentication (username and password) or arbitrary external page managed by 3rd party // Identity Provider for SSO authentication mechanisms. Authentication provider is the one who // decides what location user should be redirected to. return h.redirect(authenticationResult.redirectURL).takeover(); - } else if (authenticationResult.failed()) { + } + + if (authenticationResult.failed()) { server.log(['info', 'authentication'], `Authentication attempt failed: ${authenticationResult.error.message}`); - return wrapError(authenticationResult.error); - } else { - return Boom.unauthorized(); + const error = wrapError(authenticationResult.error); + if (authenticationResult.challenges) { + error.output.headers['WWW-Authenticate'] = authenticationResult.challenges; + } + + return error; } + + return Boom.unauthorized(); }; } diff --git a/x-pack/plugins/security/server/lib/authentication/authentication_result.ts b/x-pack/plugins/security/server/lib/authentication/authentication_result.ts index d30a8592af630de..0af0f251d524ace 100644 --- a/x-pack/plugins/security/server/lib/authentication/authentication_result.ts +++ b/x-pack/plugins/security/server/lib/authentication/authentication_result.ts @@ -8,6 +8,7 @@ * Represents status that `AuthenticationResult` can be in. */ import { AuthenticatedUser } from '../../../common/model'; +import { getErrorStatusCode } from '../errors'; enum AuthenticationResultStatus { /** @@ -40,6 +41,7 @@ enum AuthenticationResultStatus { */ interface AuthenticationOptions { error?: Error; + challenges?: string[]; redirectURL?: string; state?: unknown; user?: AuthenticatedUser; @@ -73,13 +75,21 @@ export class AuthenticationResult { /** * Produces `AuthenticationResult` for the case when authentication fails. * @param error Error that occurred during authentication attempt. + * @param [challenges] Optional list of the challenges that will be returned to the user within + * `WWW-Authenticate` HTTP header. Multiple challenges will result into multiple headers (one per + * challenge) as it's better supported by the browsers than comma separated list within a single + * header. Challenges can only be set for errors with `401` error status. */ - public static failed(error: Error) { + public static failed(error: Error, challenges?: string[]) { if (!error) { throw new Error('Error should be specified.'); } - return new AuthenticationResult(AuthenticationResultStatus.Failed, { error }); + if (challenges != null && getErrorStatusCode(error) !== 401) { + throw new Error('Challenges can only be provided with `401 Unauthorized` errors.'); + } + + return new AuthenticationResult(AuthenticationResultStatus.Failed, { error, challenges }); } /** @@ -117,6 +127,13 @@ export class AuthenticationResult { return this.options.error; } + /** + * Challenges that need to be sent to the user within `WWW-Authenticate` HTTP header. + */ + public get challenges() { + return this.options.challenges; + } + /** * URL that should be used to redirect user to complete authentication only available * for `redirected` result). diff --git a/x-pack/plugins/security/server/lib/authentication/authenticator.ts b/x-pack/plugins/security/server/lib/authentication/authenticator.ts index 317c9cc87b356d3..e8492abb98a4afc 100644 --- a/x-pack/plugins/security/server/lib/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/lib/authentication/authenticator.ts @@ -12,6 +12,7 @@ import { AuthenticationProviderOptions, BaseAuthenticationProvider, BasicAuthenticationProvider, + KerberosAuthenticationProvider, SAMLAuthenticationProvider, TokenAuthenticationProvider, } from './providers'; @@ -32,6 +33,7 @@ const providerMap = new Map< new (options: AuthenticationProviderOptions) => BaseAuthenticationProvider >([ ['basic', BasicAuthenticationProvider], + ['kerberos', KerberosAuthenticationProvider], ['saml', SAMLAuthenticationProvider], ['token', TokenAuthenticationProvider], ]); diff --git a/x-pack/plugins/security/server/lib/authentication/providers/index.ts b/x-pack/plugins/security/server/lib/authentication/providers/index.ts index 0cc59f8a15a365d..88e5c24aae31513 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/index.ts +++ b/x-pack/plugins/security/server/lib/authentication/providers/index.ts @@ -6,5 +6,6 @@ export { BaseAuthenticationProvider, AuthenticationProviderOptions } from './base'; export { BasicAuthenticationProvider, BasicCredentials } from './basic'; +export { KerberosAuthenticationProvider } from './kerberos'; export { SAMLAuthenticationProvider } from './saml'; export { TokenAuthenticationProvider } from './token'; diff --git a/x-pack/plugins/security/server/lib/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/lib/authentication/providers/kerberos.ts new file mode 100644 index 000000000000000..17f82ba3aa04b1e --- /dev/null +++ b/x-pack/plugins/security/server/lib/authentication/providers/kerberos.ts @@ -0,0 +1,280 @@ +/* + * 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 Boom from 'boom'; +import { Legacy } from 'kibana'; +import { get } from 'lodash'; +import { canRedirectRequest } from '../../can_redirect_request'; +import { getErrorStatusCode } from '../../errors'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; +import { BaseAuthenticationProvider } from './base'; + +/** + * The state supported by the provider. + */ +interface ProviderState { + /** + * Access token users get in exchange for SPNEGO token and that should be provided with every + * request to Elasticsearch on behalf of the authenticated user. This token will eventually expire. + */ + accessToken: string; +} + +/** + * If request with access token fails with `401 Unauthorized` then this token is no + * longer valid and we should try to refresh it. Another use case that we should + * temporarily support (until elastic/elasticsearch#38866 is fixed) is when token + * document has been removed and ES responds with `500 Internal Server Error`. + * @param err Error returned from Elasticsearch. + */ +function isAccessTokenExpiredError(err?: any) { + const errorStatusCode = getErrorStatusCode(err); + return ( + errorStatusCode === 401 || + (errorStatusCode === 500 && + err && + err.body && + err.body.error && + err.body.error.reason === 'token document is missing and must be present') + ); +} + +/** + * Parses request's `Authorization` HTTP header if present and extracts authentication scheme. + * @param request Request instance to extract authentication scheme for. + */ +function getRequestAuthenticationScheme(request: Legacy.Request) { + const authorization = request.headers.authorization; + if (!authorization) { + return ''; + } + + return authorization.split(/\s+/)[0].toLowerCase(); +} + +/** + * Provider that supports Kerberos request authentication. + */ +export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { + /** + * Performs Kerberos request authentication. + * @param request Request instance. + * @param [state] Optional state object associated with the provider. + */ + public async authenticate(request: Legacy.Request, state?: ProviderState | null) { + this.debug(`Trying to authenticate user request to ${request.url.path}.`); + + const authenticationScheme = getRequestAuthenticationScheme(request); + if (authenticationScheme && authenticationScheme !== 'negotiate') { + this.debug(`Unsupported authentication scheme: ${authenticationScheme}`); + return AuthenticationResult.notHandled(); + } + + let authenticationResult = await this.authenticateViaHeader(request); + if (state && authenticationResult.notHandled()) { + authenticationResult = await this.authenticateViaState(request, state); + if (authenticationResult.failed() && isAccessTokenExpiredError(authenticationResult.error)) { + authenticationResult = AuthenticationResult.notHandled(); + } + } + + // If we couldn't authenticate by means of all methods above, let's try to check if Elasticsearch can + // start authentication mechanism negotiation, otherwise just return authentication result we have. + return authenticationResult.notHandled() + ? await this.authenticateViaSPNEGO(request, state) + : authenticationResult; + } + + /** + * Invalidates access token retrieved in exchange for SPNEGO token if it exists. + * @param request Request instance. + * @param state State value previously stored by the provider. + */ + public async deauthenticate(request: Legacy.Request, state?: ProviderState) { + this.debug(`Trying to deauthenticate user via ${request.url.path}.`); + + if (!state || !state.accessToken) { + this.debug('There is no access token invalidate.'); + return DeauthenticationResult.notHandled(); + } + + try { + const { + invalidated_tokens: invalidatedAccessTokensCount, + } = await this.options.client.callWithInternalUser('shield.deleteAccessToken', { + body: { token: state.accessToken }, + }); + + if (invalidatedAccessTokensCount === 0) { + this.debug('User access token was already invalidated.'); + } else if (invalidatedAccessTokensCount === 1) { + this.debug('User access token has been successfully invalidated.'); + } else { + this.debug( + `${invalidatedAccessTokensCount} user access tokens were invalidated, this is unexpected.` + ); + } + } catch (err) { + this.debug(`Failed invalidating user's access token: ${err.message}`); + return DeauthenticationResult.failed(err); + } + + return DeauthenticationResult.redirectTo('/logged_out'); + } + + /** + * Validates whether request contains `Negotiate ***` Authorization header and just passes it + * forward to Elasticsearch backend. + * @param request Request instance. + */ + private async authenticateViaHeader(request: Legacy.Request) { + this.debug('Trying to authenticate via header.'); + + const authorization = request.headers.authorization; + if (!authorization) { + this.debug('Authorization header is not presented.'); + return AuthenticationResult.notHandled(); + } + + // First attempt to exchange SPNEGO token for an access token. + let accessToken: string; + try { + accessToken = (await this.options.client.callWithRequest(request, 'shield.getAccessToken', { + body: { grant_type: 'client_credentials' }, + })).access_token; + } catch (err) { + this.debug(`Failed to exchange SPNEGO token for an access token: ${err.message}`); + return AuthenticationResult.failed(err); + } + + this.debug('Get token API request to Elasticsearch successful'); + + // We validate that access token exists in the response so other private methods in this class + // can rely on them both existing. + if (!accessToken) { + return AuthenticationResult.failed( + new Error('Unexpected response from get token API - no access token present') + ); + } + + // Then attempt to query for the user details using the new token + const originalAuthorizationHeader = request.headers.authorization; + request.headers.authorization = `Bearer ${accessToken}`; + + try { + const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + + this.debug('User has been authenticated with new access token'); + + return AuthenticationResult.succeeded(user, { accessToken }); + } catch (err) { + this.debug(`Failed to authenticate request via access token: ${err.message}`); + + // Restore `Authorization` header we've just set. We can end up here only if newly generated + // access token was rejected by Elasticsearch for some reason and it doesn't make any sense to + // keep it in the request object since it can confuse other consumers of the request down the + // line (e.g. in the next authentication provider). + request.headers.authorization = originalAuthorizationHeader; + + return AuthenticationResult.failed(err); + } + } + + /** + * Tries to extract access token from state and adds it to the request before it's + * forwarded to Elasticsearch backend. + * @param request Request instance. + * @param state State value previously stored by the provider. + */ + private async authenticateViaState(request: Legacy.Request, { accessToken }: ProviderState) { + this.debug('Trying to authenticate via state.'); + + if (!accessToken) { + this.debug('Access token is not found in state.'); + return AuthenticationResult.notHandled(); + } + + request.headers.authorization = `Bearer ${accessToken}`; + + try { + const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + + this.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user); + } catch (err) { + this.debug(`Failed to authenticate request via state: ${err.message}`); + + // Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before, + // otherwise it would have been used or completely rejected by the `authenticateViaHeader`. + // We can't just set `authorization` to `undefined` or `null`, we should remove this property + // entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if + // it's called with this request once again down the line (e.g. in the next authentication provider). + delete request.headers.authorization; + + return AuthenticationResult.failed(err); + } + } + + /** + * Tries to query Elasticsearch and see if we can rely on SPNEGO to authenticate user. + * @param request Request instance. + * @param [state] Optional state object associated with the provider. + */ + private async authenticateViaSPNEGO(request: Legacy.Request, state?: ProviderState | null) { + this.debug('Trying to authenticate request via SPNEGO.'); + + // If client can't handle redirect response, basically if it's an AJAX request, we shouldn't + // use SPNEGO to not log user in unintentionally (e.g. AJAX request sent from `logged_out` page). + if (!canRedirectRequest(request)) { + this.debug('SPNEGO can not be used by AJAX requests.'); + return AuthenticationResult.notHandled(); + } + + // Try to authenticate current request with Elasticsearch to see whether it supports SPNEGO. + let authenticationError: Error; + try { + await this.options.client.callWithRequest(request, 'shield.authenticate'); + this.debug('Request was not supposed to be authenticated, ignoring result.'); + return AuthenticationResult.notHandled(); + } catch (err) { + // Fail immediately if we get unexpected error (e.g. ES isn't available). We should not touch + // session cookie in this case. + if (getErrorStatusCode(err) !== 401) { + return AuthenticationResult.failed(err); + } + + authenticationError = err; + } + + const challenges = ([] as string[]).concat( + get(authenticationError, 'body.error.header[WWW-Authenticate]') || '' + ); + + if (challenges.some(challenge => challenge.toLowerCase() === 'negotiate')) { + this.debug(`SPNEGO is supported by the backend, challenges are: [${challenges}].`); + return AuthenticationResult.failed(Boom.unauthorized(), ['Negotiate']); + } + + this.debug(`SPNEGO is not supported by the backend, challenges are: [${challenges}].`); + + // If we failed to do SPNEGO and have a session with expired token that belongs to Kerberos + // authentication provider then it means Elasticsearch isn't configured to use Kerberos anymore. + // In this case we should reply with the `401` error and allow Authenticator to clear the cookie. + // Otherwise give a chance to the next authentication provider to authenticate request. + return state + ? AuthenticationResult.failed(Boom.unauthorized()) + : AuthenticationResult.notHandled(); + } + + /** + * Logs message with `debug` level and kerberos/security related tags. + * @param message Message to log. + */ + private debug(message: string) { + this.options.log(['debug', 'security', 'kerberos'], message); + } +} diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 4bf21fbea604007..d3cffc14b949852 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -12,6 +12,7 @@ require('@kbn/test').runTestsCli([ require.resolve('../test/api_integration/config_security_basic.js'), require.resolve('../test/api_integration/config.js'), require.resolve('../test/plugin_api_integration/config.js'), + require.resolve('../test/kerberos_api_integration/config'), require.resolve('../test/saml_api_integration/config.js'), require.resolve('../test/token_api_integration/config.js'), require.resolve('../test/spaces_api_integration/spaces_only/config'), diff --git a/x-pack/test/kerberos_api_integration/apis/index.ts b/x-pack/test/kerberos_api_integration/apis/index.ts new file mode 100644 index 000000000000000..5905edfcf59a19e --- /dev/null +++ b/x-pack/test/kerberos_api_integration/apis/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { KibanaFunctionalTestDefaultProviders } from '../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function({ loadTestFile }: KibanaFunctionalTestDefaultProviders) { + describe('apis Kerberos', function() { + this.tags('ciGroup6'); + loadTestFile(require.resolve('./security')); + }); +} diff --git a/x-pack/test/kerberos_api_integration/apis/security/index.ts b/x-pack/test/kerberos_api_integration/apis/security/index.ts new file mode 100644 index 000000000000000..3442d4ead165643 --- /dev/null +++ b/x-pack/test/kerberos_api_integration/apis/security/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function({ loadTestFile }: KibanaFunctionalTestDefaultProviders) { + describe('security', () => { + loadTestFile(require.resolve('./kerberos_login')); + }); +} diff --git a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts new file mode 100644 index 000000000000000..142d3d6ecaaaff5 --- /dev/null +++ b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts @@ -0,0 +1,356 @@ +/* + * 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 expect from '@kbn/expect'; +import request, { Cookie } from 'request'; +import { delay } from 'bluebird'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService }: KibanaFunctionalTestDefaultProviders) { + const spnegoToken = + 'YIIChwYGKwYBBQUCoIICezCCAnegDTALBgkqhkiG9xIBAgKiggJkBIICYGCCAlwGCSqGSIb3EgECAgEAboICSzCCAkegAwIBBaEDAgEOogcDBQAAAAAAo4IBW2GCAVcwggFToAMCAQWhERsPVEVTVC5FTEFTVElDLkNPohwwGqADAgEDoRMwERsESFRUUBsJbG9jYWxob3N0o4IBGTCCARWgAwIBEqEDAgECooIBBwSCAQNBN2a1Rso+KEJsDwICYLCt7ACLzdlbhEZF5YNsehO109b/WiZR1VTK6kCQyDdBdQFefyvV8EiC35mz7XnTb239nWz6xBGbdmtjSfF0XzpXKbL/zGzLEKkEXQuqFLPUN6qEJXsh0OoNdj9OWwmTr93FVyugs1hO/E5wjlAe2SDYpBN6uZICXu6dFg9nLQKkb/XgbgKM7ZZvgA/UElWDgHav4nPO1VWppCCLKHqXTRnvpr/AsxeON4qeJLaukxBigfIaJlLFMNQal5H7MyXa0j3Y1sckbURnWoBt6r4XE7c8F8cz0rYoGwoCO+Cs5tNutKY6XcsAFbLh59hjgIkhVBhhyTeypIHSMIHPoAMCARKigccEgcSsXqIRAcHfZivrbHfsnvbFgmzmnrKVPFNtJ9Hl23KunCsNW49nP4VF2dEf9n12prDaIguJDV5LPHpTew9rmCj1GCahKJ9bJbRKIgImLFd+nelm3E2zxRqAhrgM1469oDg0ksE3+5lJBuJlVEECMp0F/gxvEiL7DhasICqw+FOJ/jD9QUYvg+E6BIxWgZyPszaxerzBBszAhIF1rxCHRRL1KLjskNeJlBhH77DkAO6AEmsYGdsgEq7b7uCov9PKPiiPAuFF'; + const supertest = getService('supertestWithoutAuth'); + + describe('Kerberos authentication', () => { + before(async () => { + // HACK: remove as soon as we have a solution for https://github.com/elastic/elasticsearch/issues/41943. + await getService('esSupertest') + .post('/_security/role/krb5-user') + .send({ cluster: ['cluster:admin/xpack/security/token/create'] }) + .expect(200); + + await getService('esSupertest') + .post('/_security/role_mapping/krb5') + .send({ + roles: ['krb5-user'], + enabled: true, + rules: { field: { 'realm.name': 'kerb1' } }, + }) + .expect(200); + }); + + it('should reject API requests if client is not authenticated', async () => { + await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .expect(401); + }); + + describe('initiating SPNEGO', () => { + it('should properly initiate SPNEGO', async () => { + const spnegoResponse = await supertest.get('/abc/xyz/spnego?one=two three').expect(401); + + expect(spnegoResponse.headers['set-cookie']).to.be(undefined); + expect(spnegoResponse.headers['www-authenticate']).to.be('Negotiate'); + }); + + it('AJAX requests should not initiate SPNEGO', async () => { + const ajaxResponse = await supertest + .get('/abc/xyz/spnego?one=two three') + .set('kbn-xsrf', 'xxx') + .expect(401); + + expect(ajaxResponse.headers['set-cookie']).to.be(undefined); + expect(ajaxResponse.headers['www-authenticate']).to.be(undefined); + }); + }); + + describe('finishing SPNEGO', () => { + it('should properly set cookie and authenticate user', async () => { + const response = await supertest + .get('/api/security/v1/me') + .set('Authorization', `Negotiate ${spnegoToken}`) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = request.cookie(cookies[0])!; + expect(sessionCookie.key).to.be('sid'); + expect(sessionCookie.value).to.not.be.empty(); + expect(sessionCookie.path).to.be('/'); + expect(sessionCookie.httpOnly).to.be(true); + + await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200, { + username: 'tester@TEST.ELASTIC.CO', + roles: ['krb5-user'], + scope: [], + full_name: null, + email: null, + metadata: { + kerberos_user_principal_name: 'tester@TEST.ELASTIC.CO', + kerberos_realm: 'TEST.ELASTIC.CO', + }, + enabled: true, + authentication_realm: { name: 'kerb1', type: 'kerberos' }, + lookup_realm: { name: 'kerb1', type: 'kerberos' }, + }); + }); + + it('should fail if SPNEGO token is rejected', async () => { + const spnegoResponse = await supertest + .get('/api/security/v1/me') + .set('Authorization', `Negotiate ${Buffer.from('Hello').toString('base64')}`) + .expect(401); + expect(spnegoResponse.headers['set-cookie']).to.be(undefined); + expect(spnegoResponse.headers['www-authenticate']).to.be(undefined); + }); + }); + + describe('API access with active session', () => { + let sessionCookie: Cookie; + + beforeEach(async () => { + const response = await supertest + .get('/api/security/v1/me') + .set('Authorization', `Negotiate ${spnegoToken}`) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + sessionCookie = request.cookie(cookies[0])!; + expect(sessionCookie.key).to.be('sid'); + expect(sessionCookie.value).to.not.be.empty(); + expect(sessionCookie.path).to.be('/'); + expect(sessionCookie.httpOnly).to.be(true); + }); + + it('should extend cookie on every successful non-system API call', async () => { + const apiResponseOne = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponseOne.headers['set-cookie']).to.not.be(undefined); + const sessionCookieOne = request.cookie(apiResponseOne.headers['set-cookie'][0])!; + + expect(sessionCookieOne.value).to.not.be.empty(); + expect(sessionCookieOne.value).to.not.equal(sessionCookie.value); + + const apiResponseTwo = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponseTwo.headers['set-cookie']).to.not.be(undefined); + const sessionCookieTwo = request.cookie(apiResponseTwo.headers['set-cookie'][0])!; + + expect(sessionCookieTwo.value).to.not.be.empty(); + expect(sessionCookieTwo.value).to.not.equal(sessionCookieOne.value); + }); + + it('should not extend cookie for system API calls', async () => { + const systemAPIResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('kbn-system-api', 'true') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(systemAPIResponse.headers['set-cookie']).to.be(undefined); + }); + + it('should fail and preserve session cookie if unsupported authentication schema is used', async () => { + const apiResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Authorization', 'Bearer AbCdEf') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + expect(apiResponse.headers['set-cookie']).to.be(undefined); + }); + }); + + describe('logging out', () => { + it('should redirect to `logged_out` page after successful logout', async () => { + // First authenticate user to retrieve session cookie. + const response = await supertest + .get('/api/security/v1/me') + .set('Authorization', `Negotiate ${spnegoToken}`) + .expect(200); + + let cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = request.cookie(cookies[0])!; + expect(sessionCookie.key).to.be('sid'); + expect(sessionCookie.value).to.not.be.empty(); + expect(sessionCookie.path).to.be('/'); + expect(sessionCookie.httpOnly).to.be(true); + + // And then log user out. + const logoutResponse = await supertest + .get('/api/security/v1/logout') + .set('Cookie', sessionCookie.cookieString()) + .expect(302); + + cookies = logoutResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const logoutCookie = request.cookie(cookies[0])!; + expect(logoutCookie.key).to.be('sid'); + expect(logoutCookie.value).to.be.empty(); + expect(logoutCookie.path).to.be('/'); + expect(logoutCookie.httpOnly).to.be(true); + expect(logoutCookie.maxAge).to.be(0); + + expect(logoutResponse.headers.location).to.be('/logged_out'); + + // Token that was stored in the previous cookie should be invalidated as well and old + // session cookie should not allow API access. + const apiResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + expect(apiResponse.headers['set-cookie']).to.be(undefined); + // Currently we don't initiate SPNEGO for AJAX requests. + expect(apiResponse.headers['www-authenticate']).to.be(undefined); + }); + + it('should redirect to home page if session cookie is not provided', async () => { + const logoutResponse = await supertest.get('/api/security/v1/logout').expect(302); + + expect(logoutResponse.headers['set-cookie']).to.be(undefined); + expect(logoutResponse.headers.location).to.be('/'); + }); + }); + + describe('API access with expired access token.', () => { + let sessionCookie: Cookie; + + beforeEach(async () => { + const response = await supertest + .get('/api/security/v1/me') + .set('Authorization', `Negotiate ${spnegoToken}`) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + sessionCookie = request.cookie(cookies[0])!; + expect(sessionCookie.key).to.be('sid'); + expect(sessionCookie.value).to.not.be.empty(); + expect(sessionCookie.path).to.be('/'); + expect(sessionCookie.httpOnly).to.be(true); + }); + + it('AJAX call should not initiate SPNEGO', async function() { + this.timeout(40000); + + // Access token expiration is set to 15s for API integration tests. + // Let's wait for 20s to make sure token expires. + await delay(20000); + + const apiResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + expect(apiResponse.headers['set-cookie']).to.be(undefined); + expect(apiResponse.headers['www-authenticate']).to.be(undefined); + }); + + it('non-AJAX call should initiate SPNEGO and clear existing cookie', async function() { + this.timeout(40000); + + // Access token expiration is set to 15s for API integration tests. + // Let's wait for 20s to make sure token expires. + await delay(20000); + + const nonAjaxResponse = await supertest + .get('/') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + const cookies = nonAjaxResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const clearedCookie = request.cookie(cookies[0])!; + expect(clearedCookie.key).to.be('sid'); + expect(clearedCookie.value).to.be.empty(); + expect(clearedCookie.path).to.be('/'); + expect(clearedCookie.httpOnly).to.be(true); + expect(clearedCookie.maxAge).to.be(0); + + expect(nonAjaxResponse.headers['www-authenticate']).to.be('Negotiate'); + }); + }); + + describe('API access with missing access token document.', () => { + let sessionCookie: Cookie; + + beforeEach(async () => { + const response = await supertest + .get('/api/security/v1/me') + .set('Authorization', `Negotiate ${spnegoToken}`) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + sessionCookie = request.cookie(cookies[0])!; + expect(sessionCookie.key).to.be('sid'); + expect(sessionCookie.value).to.not.be.empty(); + expect(sessionCookie.path).to.be('/'); + expect(sessionCookie.httpOnly).to.be(true); + + // Let's delete tokens from `.security-tokens` index directly to simulate the case when + // Elasticsearch automatically removes access token document from the index after some + // period of time. + const esResponse = await getService('es').deleteByQuery({ + index: '.security-tokens', + q: 'doc_type:token', + refresh: true, + }); + expect(esResponse) + .to.have.property('deleted') + .greaterThan(0); + }); + + it('AJAX call should not initiate SPNEGO', async function() { + const apiResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + expect(apiResponse.headers['set-cookie']).to.be(undefined); + expect(apiResponse.headers['www-authenticate']).to.be(undefined); + }); + + it('non-AJAX call should initiate SPNEGO and clear existing cookie', async function() { + const nonAjaxResponse = await supertest + .get('/') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + const cookies = nonAjaxResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const clearedCookie = request.cookie(cookies[0])!; + expect(clearedCookie.key).to.be('sid'); + expect(clearedCookie.value).to.be.empty(); + expect(clearedCookie.path).to.be('/'); + expect(clearedCookie.httpOnly).to.be(true); + expect(clearedCookie.maxAge).to.be(0); + + expect(nonAjaxResponse.headers['www-authenticate']).to.be('Negotiate'); + }); + }); + }); +} diff --git a/x-pack/test/kerberos_api_integration/config.ts b/x-pack/test/kerberos_api_integration/config.ts new file mode 100644 index 000000000000000..6dae4e28ef542ae --- /dev/null +++ b/x-pack/test/kerberos_api_integration/config.ts @@ -0,0 +1,58 @@ +/* + * 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 { resolve } from 'path'; +import { KibanaFunctionalTestDefaultProviders } from '../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default async function({ readConfigFile }: KibanaFunctionalTestDefaultProviders) { + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.js')); + + const kerberosKeytabPath = resolve( + __dirname, + '../../test/kerberos_api_integration/fixtures/krb5.keytab' + ); + const kerberosConfigPath = resolve( + __dirname, + '../../test/kerberos_api_integration/fixtures/krb5.conf' + ); + + return { + testFiles: [require.resolve('./apis')], + servers: xPackAPITestsConfig.get('servers'), + services: { + es: xPackAPITestsConfig.get('services.es'), + esSupertest: xPackAPITestsConfig.get('services.esSupertest'), + supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), + }, + junit: { + reportName: 'X-Pack Kerberos API Integration Tests', + }, + + esTestCluster: { + ...xPackAPITestsConfig.get('esTestCluster'), + serverArgs: [ + ...xPackAPITestsConfig.get('esTestCluster.serverArgs'), + 'xpack.security.authc.token.enabled=true', + 'xpack.security.authc.token.timeout=15s', + `xpack.security.authc.realms.kerberos.kerb1.keytab.path=${kerberosKeytabPath}`, + ], + serverEnvVars: { + // We're going to use the same TGT multiple times and during a short period of time, so we + // have to disable replay cache so that ES doesn't complain about that. + ES_JAVA_OPTS: `-Djava.security.krb5.conf=${kerberosConfigPath} -Dsun.security.krb5.rcache=none`, + }, + }, + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + '--xpack.security.authProviders=["kerberos"]', + ], + }, + }; +} diff --git a/x-pack/test/kerberos_api_integration/fixtures/README.md b/x-pack/test/kerberos_api_integration/fixtures/README.md new file mode 100644 index 000000000000000..0234b8bd560b321 --- /dev/null +++ b/x-pack/test/kerberos_api_integration/fixtures/README.md @@ -0,0 +1,10 @@ +# Kerberos Fixtures + +Kerberos fixtures are created with the following principals: + +* tester@TEST.ELASTIC.CO (password is `changeme`) +* host/kerberos.test.elastic.co@TEST.ELASTIC.CO +* HTTP/localhost@TEST.ELASTIC.CO + +The SPNEGO token used in tests is generated for for `tester@TEST.ELASTIC.CO`. We can re-use it multiple times because we +disable replay cache (`-Dsun.security.krb5.rcache=none`) and set max possible `clockskew` in `krb5.conf`. \ No newline at end of file diff --git a/x-pack/test/kerberos_api_integration/fixtures/krb5.conf b/x-pack/test/kerberos_api_integration/fixtures/krb5.conf new file mode 100755 index 000000000000000..1bad4eef0277b26 --- /dev/null +++ b/x-pack/test/kerberos_api_integration/fixtures/krb5.conf @@ -0,0 +1,11 @@ +[libdefaults] + default_realm = TEST.ELASTIC.CO + clockskew = 2147483647 + +[realms] + TEST.ELASTIC.CO = { + max_life = 2147483647s + } + +[domain_realm] + localhost = TEST.ELASTIC.CO diff --git a/x-pack/test/kerberos_api_integration/fixtures/krb5.keytab b/x-pack/test/kerberos_api_integration/fixtures/krb5.keytab new file mode 100755 index 0000000000000000000000000000000000000000..93499602ece918156a7808843466112219182c58 GIT binary patch literal 356 zcmZQ&VqjoMVPIn54{;3+(R1~23=Z*h)^qk}V9CfYE@6Sk!4f0Ulq{6`PXWXtt}|8MR6^dfj?i2dv- ztAL6@_JmMmk066U4v($X}8Rv2D_4GFwlh?Tdix2)6ycO O-S&R~G6(>pVQdWm literal 0 HcmV?d00001