Introduce Kerberos authentication provider. (#36112)

This commit is contained in:
Aleh Zasypkin 2019-05-29 10:40:19 +03:00 committed by GitHub
parent 963152f3c9
commit 580edcd1c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1327 additions and 66 deletions

View file

@ -258,6 +258,10 @@ exports.Cluster = class Cluster {
this._process = execa(ES_BIN, args, {
cwd: installPath,
env: {
...process.env,
...(options.esEnvVars || {}),
},
stdio: ['ignore', 'pipe', 'pipe'],
});

View file

@ -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,
});
}

View file

@ -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'), [

View file

@ -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(),

View file

@ -103,6 +103,31 @@ describe('lib/auth_redirect', function () {
sinon.assert.notCalled(h.authenticated);
});
it('includes `WWW-Authenticate` header if `authenticate` fails to authenticate user and provides challenges', async () => {
const originalEsError = Boom.unauthorized('some message');
originalEsError.output.headers['WWW-Authenticate'] = [
'Basic realm="Access to prod", charset="UTF-8"',
'Basic',
'Negotiate'
];
server.plugins.security.authenticate.withArgs(request).resolves(
AuthenticationResult.failed(originalEsError, ['Negotiate'])
);
const response = await authenticate(request, h);
sinon.assert.calledWithExactly(
server.log,
['info', 'authentication'],
'Authentication attempt failed: some message'
);
expect(response.message).to.eql(originalEsError.message);
expect(response.output.headers).to.eql({ 'WWW-Authenticate': ['Negotiate'] });
sinon.assert.notCalled(h.redirect);
sinon.assert.notCalled(h.authenticated);
});
it('returns `unauthorized` when authentication can not be handled', async () => {
server.plugins.security.authenticate.withArgs(request).returns(
Promise.resolve(AuthenticationResult.notHandled())

View file

@ -26,25 +26,38 @@ export function authenticateFactory(server) {
let authenticationResult;
try {
authenticationResult = await server.plugins.security.authenticate(request);
} catch(err) {
} catch (err) {
server.log(['error', 'authentication'], err);
return wrapError(err);
}
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()) {
server.log(['info', 'authentication'], `Authentication attempt failed: ${authenticationResult.error.message}`);
return wrapError(authenticationResult.error);
} else {
return Boom.unauthorized();
}
if (authenticationResult.failed()) {
server.log(
['info', 'authentication'],
`Authentication attempt failed: ${authenticationResult.error.message}`
);
const error = wrapError(authenticationResult.error);
if (authenticationResult.challenges) {
error.output.headers['WWW-Authenticate'] = authenticationResult.challenges;
}
return error;
}
return Boom.unauthorized();
};
}

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import { AuthenticatedUser } from '../../../common/model';
import { AuthenticationResult } from './authentication_result';
@ -45,6 +46,28 @@ describe('AuthenticationResult', () => {
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.redirectURL).toBeUndefined();
});
it('can provide `challenges` for `401` errors', () => {
const failureReason = Boom.unauthorized();
const authenticationResult = AuthenticationResult.failed(failureReason, ['Negotiate']);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.notHandled()).toBe(false);
expect(authenticationResult.succeeded()).toBe(false);
expect(authenticationResult.redirected()).toBe(false);
expect(authenticationResult.challenges).toEqual(['Negotiate']);
expect(authenticationResult.error).toBe(failureReason);
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.redirectURL).toBeUndefined();
});
it('can not provide `challenges` for non-`401` errors', () => {
expect(() => AuthenticationResult.failed(Boom.badRequest(), ['Negotiate'])).toThrowError(
'Challenges can only be provided with `401 Unauthorized` errors.'
);
});
});
describe('succeeded', () => {

View file

@ -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 in 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).

View file

@ -12,6 +12,8 @@ import {
AuthenticationProviderOptions,
BaseAuthenticationProvider,
BasicAuthenticationProvider,
KerberosAuthenticationProvider,
RequestWithLoginAttempt,
SAMLAuthenticationProvider,
TokenAuthenticationProvider,
OIDCAuthenticationProvider,
@ -37,6 +39,7 @@ const providerMap = new Map<
) => BaseAuthenticationProvider
>([
['basic', BasicAuthenticationProvider],
['kerberos', KerberosAuthenticationProvider],
['saml', SAMLAuthenticationProvider],
['token', TokenAuthenticationProvider],
['oidc', OIDCAuthenticationProvider],
@ -163,7 +166,7 @@ class Authenticator {
* Performs request authentication using configured chain of authentication providers.
* @param request Request instance.
*/
async authenticate(request: Legacy.Request) {
async authenticate(request: RequestWithLoginAttempt) {
assertRequest(request);
const isSystemApiRequest = this.server.plugins.kibana.systemApi.isSystemApiRequest(request);
@ -227,7 +230,7 @@ class Authenticator {
* Deauthenticates current request.
* @param request Request instance.
*/
async deauthenticate(request: Legacy.Request) {
async deauthenticate(request: RequestWithLoginAttempt) {
assertRequest(request);
const sessionValue = await this.getSessionValue(request);
@ -307,8 +310,10 @@ export async function initAuthenticator(server: Legacy.Server) {
return loginAttempts.get(request);
});
server.expose('authenticate', (request: Legacy.Request) => authenticator.authenticate(request));
server.expose('deauthenticate', (request: Legacy.Request) =>
server.expose('authenticate', (request: RequestWithLoginAttempt) =>
authenticator.authenticate(request)
);
server.expose('deauthenticate', (request: RequestWithLoginAttempt) =>
authenticator.deauthenticate(request)
);
server.expose('registerAuthScopeGetter', (scopeExtender: ScopesGetter) =>

View file

@ -7,6 +7,14 @@
import { Legacy } from 'kibana';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { LoginAttempt } from '../login_attempt';
/**
* Describes a request complemented with `loginAttempt` method.
*/
export interface RequestWithLoginAttempt extends Legacy.Request {
loginAttempt: () => LoginAttempt;
}
/**
* Represents available provider options.
@ -40,7 +48,10 @@ export abstract class BaseAuthenticationProvider {
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
abstract authenticate(request: Legacy.Request, state?: unknown): Promise<AuthenticationResult>;
abstract authenticate(
request: RequestWithLoginAttempt,
state?: unknown
): Promise<AuthenticationResult>;
/**
* Invalidates user session associated with the request.

View file

@ -10,8 +10,7 @@ import { Legacy } from 'kibana';
import { canRedirectRequest } from '../../can_redirect_request';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { LoginAttempt } from '../login_attempt';
import { BaseAuthenticationProvider } from './base';
import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base';
/**
* Utility class that knows how to decorate request with proper Basic authentication headers.
@ -24,7 +23,7 @@ export class BasicCredentials {
* @param username User name.
* @param password User password.
*/
public static decorateRequest<T extends Legacy.Request>(
public static decorateRequest<T extends RequestWithLoginAttempt>(
request: T,
username: string,
password: string
@ -48,10 +47,6 @@ export class BasicCredentials {
}
}
type RequestWithLoginAttempt = Legacy.Request & {
loginAttempt: () => LoginAttempt;
};
/**
* The state supported by the provider.
*/
@ -153,7 +148,7 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider {
* forward to Elasticsearch backend.
* @param request Request instance.
*/
private async authenticateViaHeader(request: Legacy.Request) {
private async authenticateViaHeader(request: RequestWithLoginAttempt) {
this.debug('Trying to authenticate via header.');
const authorization = request.headers.authorization;
@ -189,7 +184,10 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider {
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaState(request: Legacy.Request, { authorization }: ProviderState) {
private async authenticateViaState(
request: RequestWithLoginAttempt,
{ authorization }: ProviderState
) {
this.debug('Trying to authenticate via state.');
if (!authorization) {

View file

@ -4,8 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { BaseAuthenticationProvider, AuthenticationProviderOptions } from './base';
export {
BaseAuthenticationProvider,
AuthenticationProviderOptions,
RequestWithLoginAttempt,
} from './base';
export { BasicAuthenticationProvider, BasicCredentials } from './basic';
export { KerberosAuthenticationProvider } from './kerberos';
export { SAMLAuthenticationProvider } from './saml';
export { TokenAuthenticationProvider } from './token';
export { OIDCAuthenticationProvider } from './oidc';

View file

@ -0,0 +1,326 @@
/*
* 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 sinon from 'sinon';
import { requestFixture } from '../../__tests__/__fixtures__/request';
import { LoginAttempt } from '../login_attempt';
import { mockAuthenticationProviderOptions } from './base.mock';
import { KerberosAuthenticationProvider } from './kerberos';
describe('KerberosAuthenticationProvider', () => {
let provider: KerberosAuthenticationProvider;
let callWithRequest: sinon.SinonStub;
let callWithInternalUser: sinon.SinonStub;
beforeEach(() => {
const providerOptions = mockAuthenticationProviderOptions();
callWithRequest = providerOptions.client.callWithRequest as sinon.SinonStub;
callWithInternalUser = providerOptions.client.callWithInternalUser as sinon.SinonStub;
provider = new KerberosAuthenticationProvider(providerOptions);
});
describe('`authenticate` method', () => {
it('does not handle AJAX request that can not be authenticated.', async () => {
const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } });
const authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.notHandled()).toBe(true);
});
it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => {
const request = requestFixture({ headers: { authorization: 'Basic some:credentials' } });
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
});
sinon.assert.notCalled(callWithRequest);
expect(request.headers.authorization).toBe('Basic some:credentials');
expect(authenticationResult.notHandled()).toBe(true);
});
it('does not handle requests with non-empty `loginAttempt`.', async () => {
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
});
sinon.assert.notCalled(callWithRequest);
expect(authenticationResult.notHandled()).toBe(true);
});
it('does not handle requests that can be authenticated without `Negotiate` header.', async () => {
const request = requestFixture();
callWithRequest.withArgs(request, 'shield.authenticate').resolves({});
const authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.notHandled()).toBe(true);
});
it('does not handle requests if backend does not support Kerberos.', async () => {
const request = requestFixture();
callWithRequest.withArgs(request, 'shield.authenticate').rejects(Boom.unauthorized());
let authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.notHandled()).toBe(true);
callWithRequest
.withArgs(request, 'shield.authenticate')
.rejects(Boom.unauthorized(null, 'Basic'));
authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.notHandled()).toBe(true);
});
it('fails if state is present, but backend does not support Kerberos.', async () => {
const request = requestFixture();
callWithRequest.withArgs(request, 'shield.authenticate').rejects(Boom.unauthorized());
let authenticationResult = await provider.authenticate(request, { accessToken: 'token' });
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('output.statusCode', 401);
expect(authenticationResult.challenges).toBeUndefined();
callWithRequest
.withArgs(request, 'shield.authenticate')
.rejects(Boom.unauthorized(null, 'Basic'));
authenticationResult = await provider.authenticate(request, { accessToken: 'token' });
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('output.statusCode', 401);
expect(authenticationResult.challenges).toBeUndefined();
});
it('fails with `Negotiate` challenge if backend supports Kerberos.', async () => {
const request = requestFixture();
callWithRequest
.withArgs(request, 'shield.authenticate')
.rejects(Boom.unauthorized(null, 'Negotiate'));
const authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('output.statusCode', 401);
expect(authenticationResult.challenges).toEqual(['Negotiate']);
});
it('fails if request authentication is failed with non-401 error.', async () => {
const request = requestFixture();
callWithRequest.withArgs(request, 'shield.authenticate').rejects(Boom.serverUnavailable());
const authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('output.statusCode', 503);
expect(authenticationResult.challenges).toBeUndefined();
});
it('gets an access token in exchange to SPNEGO one and stores it in the state.', async () => {
const user = { username: 'user' };
const request = requestFixture({ headers: { authorization: 'negotiate spnego' } });
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer some-token' } }),
'shield.authenticate'
)
.resolves(user);
callWithRequest
.withArgs(request, 'shield.getAccessToken')
.resolves({ access_token: 'some-token' });
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledWithExactly(callWithRequest, request, 'shield.getAccessToken', {
body: { grant_type: 'client_credentials' },
});
expect(request.headers.authorization).toBe('Bearer some-token');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toEqual({ accessToken: 'some-token' });
});
it('fails if could not retrieve an access token in exchange to SPNEGO one.', async () => {
const request = requestFixture({ headers: { authorization: 'negotiate spnego' } });
const failureReason = Boom.unauthorized();
callWithRequest.withArgs(request, 'shield.getAccessToken').rejects(failureReason);
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledWithExactly(callWithRequest, request, 'shield.getAccessToken', {
body: { grant_type: 'client_credentials' },
});
expect(request.headers.authorization).toBe('negotiate spnego');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
expect(authenticationResult.challenges).toBeUndefined();
});
it('fails if could not retrieve user using the new access token.', async () => {
const request = requestFixture({ headers: { authorization: 'negotiate spnego' } });
const failureReason = Boom.unauthorized();
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer some-token' } }),
'shield.authenticate'
)
.rejects(failureReason);
callWithRequest
.withArgs(request, 'shield.getAccessToken')
.resolves({ access_token: 'some-token' });
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledWithExactly(callWithRequest, request, 'shield.getAccessToken', {
body: { grant_type: 'client_credentials' },
});
expect(request.headers.authorization).toBe('negotiate spnego');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
expect(authenticationResult.challenges).toBeUndefined();
});
it('succeeds if state contains a valid token.', async () => {
const user = { username: 'user' };
const request = requestFixture();
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
});
expect(request.headers.authorization).toBe('Bearer some-valid-token');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toBeUndefined();
});
it('fails if token from the state is rejected because of unknown reason.', async () => {
const request = requestFixture();
const failureReason = Boom.internal('Token is not valid!');
callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason);
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-invalid-token',
});
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
sinon.assert.neverCalledWith(callWithRequest, 'shield.getAccessToken');
});
it('fails with `Negotiate` challenge if token from the state is expired and backend supports Kerberos.', async () => {
const request = requestFixture();
callWithRequest.rejects(Boom.unauthorized(null, 'Negotiate'));
const authenticationResult = await provider.authenticate(request, {
accessToken: 'expired-token',
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('output.statusCode', 401);
expect(authenticationResult.challenges).toEqual(['Negotiate']);
});
it('fails with `Negotiate` challenge if access token document is missing and backend supports Kerberos.', async () => {
const request = requestFixture({ headers: {} });
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer expired-token' } }),
'shield.authenticate'
)
.rejects({
statusCode: 500,
body: { error: { reason: 'token document is missing and must be present' } },
})
.withArgs(sinon.match({ headers: {} }), 'shield.authenticate')
.rejects(Boom.unauthorized(null, 'Negotiate'));
const authenticationResult = await provider.authenticate(request, {
accessToken: 'missing-token',
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('output.statusCode', 401);
expect(authenticationResult.challenges).toEqual(['Negotiate']);
});
});
describe('`deauthenticate` method', () => {
it('returns `notHandled` if state is not presented or does not include access token.', async () => {
const request = requestFixture();
let deauthenticateResult = await provider.deauthenticate(request);
expect(deauthenticateResult.notHandled()).toBe(true);
deauthenticateResult = await provider.deauthenticate(request, {} as any);
expect(deauthenticateResult.notHandled()).toBe(true);
deauthenticateResult = await provider.deauthenticate(request, { somethingElse: 'x' } as any);
expect(deauthenticateResult.notHandled()).toBe(true);
sinon.assert.notCalled(callWithInternalUser);
});
it('fails if `deleteAccessToken` call fails.', async () => {
const request = requestFixture();
const accessToken = 'x-access-token';
const failureReason = new Error('Unknown error');
callWithInternalUser.withArgs('shield.deleteAccessToken').rejects(failureReason);
const authenticationResult = await provider.deauthenticate(request, {
accessToken,
});
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { token: accessToken },
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('invalidates access token and redirects to `/logged_out` page.', async () => {
const request = requestFixture();
const accessToken = 'x-access-token';
callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 1 });
const authenticationResult = await provider.deauthenticate(request, {
accessToken,
});
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { token: accessToken },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/logged_out');
});
});
});

View file

@ -0,0 +1,275 @@
/*
* 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 { get } from 'lodash';
import { Legacy } from 'kibana';
import { getErrorStatusCode } from '../../errors';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { BaseAuthenticationProvider, RequestWithLoginAttempt } 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: RequestWithLoginAttempt) {
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: RequestWithLoginAttempt, 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();
}
if (request.loginAttempt().getCredentials() != null) {
this.debug('Login attempt is detected, but it is not supported by the provider');
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: RequestWithLoginAttempt) {
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');
// 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: RequestWithLoginAttempt,
{ 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: RequestWithLoginAttempt,
state?: ProviderState | null
) {
this.debug('Trying to authenticate request via SPNEGO.');
// 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<string | string[]>(authenticationError, 'output.headers[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);
}
}

View file

@ -6,6 +6,7 @@
import sinon from 'sinon';
import Boom from 'boom';
import { LoginAttempt } from '../login_attempt';
import { mockAuthenticationProviderOptions } from './base.mock';
import { requestFixture } from '../../__tests__/__fixtures__/request';
@ -34,6 +35,23 @@ describe('OIDCAuthenticationProvider', () => {
expect(authenticationResult.notHandled()).toBe(true);
});
it('does not handle requests with non-empty `loginAttempt`.', async () => {
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
sinon.assert.notCalled(callWithRequest);
sinon.assert.notCalled(callWithInternalUser);
expect(authenticationResult.notHandled()).toBe(true);
});
it('redirects non-AJAX request that can not be authenticated to the OpenId Connect Provider.', async () => {
const request = requestFixture({ path: '/some-path', basePath: '/s/foo' });

View file

@ -15,6 +15,7 @@ import {
AuthenticationProviderOptions,
BaseAuthenticationProvider,
AuthenticationProviderSpecificOptions,
RequestWithLoginAttempt,
} from './base';
/**
@ -54,7 +55,7 @@ interface ProviderState {
/**
* Defines the shape of an incoming OpenID Connect Request
*/
type OIDCIncomingRequest = Legacy.Request & {
type OIDCIncomingRequest = RequestWithLoginAttempt & {
payload: {
iss?: string;
login_hint?: string;
@ -80,7 +81,7 @@ type OIDCIncomingRequest = Legacy.Request & {
* an OpenID Connect Provider
* @param request Request instance.
*/
function isOIDCIncomingRequest(request: Legacy.Request): request is OIDCIncomingRequest {
function isOIDCIncomingRequest(request: RequestWithLoginAttempt): request is OIDCIncomingRequest {
return (
(request.payload != null && !!(request.payload as Record<string, unknown>).iss) ||
(request.query != null &&
@ -134,7 +135,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
public async authenticate(request: Legacy.Request, state?: ProviderState | null) {
public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) {
this.debug(`Trying to authenticate user request to ${request.url.path}.`);
let {
@ -145,6 +146,11 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
return authenticationResult;
}
if (request.loginAttempt().getCredentials() != null) {
this.debug('Login attempt is detected, but it is not supported by the provider');
return AuthenticationResult.notHandled();
}
if (state && authenticationResult.notHandled()) {
authenticationResult = await this.authenticateViaState(request, state);
if (authenticationResult.failed() && isAccessTokenExpiredError(authenticationResult.error)) {
@ -250,10 +256,11 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
* Initiates an authentication attempt by either providing the realm name or the issuer to Elasticsearch
*
* @param request Request instance.
* @param params
* @param params OIDC authentication parameters.
* @param [sessionState] Optional state object associated with the provider.
*/
private async initiateOIDCAuthentication(
request: Legacy.Request,
request: RequestWithLoginAttempt,
params: { realm: string } | { iss: string; login_hint?: string },
sessionState?: ProviderState | null
) {
@ -305,7 +312,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
* forward to Elasticsearch backend.
* @param request Request instance.
*/
private async authenticateViaHeader(request: Legacy.Request) {
private async authenticateViaHeader(request: RequestWithLoginAttempt) {
this.debug('Trying to authenticate via header.');
const authorization = request.headers.authorization;
@ -347,7 +354,10 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaState(request: Legacy.Request, { accessToken }: ProviderState) {
private async authenticateViaState(
request: RequestWithLoginAttempt,
{ accessToken }: ProviderState
) {
this.debug('Trying to authenticate via state.');
if (!accessToken) {
@ -385,7 +395,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
* @param state State value previously stored by the provider.
*/
private async authenticateViaRefreshToken(
request: Legacy.Request,
request: RequestWithLoginAttempt,
{ refreshToken }: ProviderState
) {
this.debug('Trying to refresh elasticsearch access token.');

View file

@ -8,6 +8,7 @@ import Boom from 'boom';
import sinon from 'sinon';
import { requestFixture } from '../../__tests__/__fixtures__/request';
import { LoginAttempt } from '../login_attempt';
import { mockAuthenticationProviderOptions } from './base.mock';
import { SAMLAuthenticationProvider } from './saml';
@ -33,6 +34,35 @@ describe('SAMLAuthenticationProvider', () => {
expect(authenticationResult.notHandled()).toBe(true);
});
it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => {
const request = requestFixture({ headers: { authorization: 'Basic some:credentials' } });
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
sinon.assert.notCalled(callWithRequest);
expect(request.headers.authorization).toBe('Basic some:credentials');
expect(authenticationResult.notHandled()).toBe(true);
});
it('does not handle requests with non-empty `loginAttempt`.', async () => {
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
sinon.assert.notCalled(callWithRequest);
expect(authenticationResult.notHandled()).toBe(true);
});
it('redirects non-AJAX request that can not be authenticated to the IdP.', async () => {
const request = requestFixture({ path: '/some-path', basePath: '/s/foo' });
@ -189,19 +219,6 @@ describe('SAMLAuthenticationProvider', () => {
expect(authenticationResult.state).toBeUndefined();
});
it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => {
const request = requestFixture({ headers: { authorization: 'Basic some:credentials' } });
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
sinon.assert.notCalled(callWithRequest);
expect(request.headers.authorization).toBe('Basic some:credentials');
expect(authenticationResult.notHandled()).toBe(true);
});
it('fails if token from the state is rejected because of unknown reason.', async () => {
const request = requestFixture();

View file

@ -11,7 +11,7 @@ import { getErrorStatusCode } from '../../errors';
import { AuthenticatedUser } from '../../../../common/model';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { BaseAuthenticationProvider } from './base';
import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base';
/**
* The state supported by the provider (for the SAML handshake or established session).
@ -50,7 +50,7 @@ interface SAMLRequestQuery {
/**
* Defines the shape of the request with a body containing SAML response.
*/
type RequestWithSAMLPayload = Legacy.Request & {
type RequestWithSAMLPayload = RequestWithLoginAttempt & {
payload: { SAMLResponse: string; RelayState?: string };
};
@ -78,7 +78,7 @@ function isAccessTokenExpiredError(err?: any) {
* @param request Request instance.
*/
function isRequestWithSAMLResponsePayload(
request: Legacy.Request
request: RequestWithLoginAttempt
): request is RequestWithSAMLPayload {
return request.payload != null && !!(request.payload as any).SAMLResponse;
}
@ -100,7 +100,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
public async authenticate(request: Legacy.Request, state?: ProviderState | null) {
public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) {
this.debug(`Trying to authenticate user request to ${request.url.path}.`);
let {
@ -112,6 +112,11 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
return authenticationResult;
}
if (request.loginAttempt().getCredentials() != null) {
this.debug('Login attempt is detected, but it is not supported by the provider');
return AuthenticationResult.notHandled();
}
if (state && authenticationResult.notHandled()) {
authenticationResult = await this.authenticateViaState(request, state);
if (authenticationResult.failed() && isAccessTokenExpiredError(authenticationResult.error)) {
@ -180,7 +185,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* forward to Elasticsearch backend.
* @param request Request instance.
*/
private async authenticateViaHeader(request: Legacy.Request) {
private async authenticateViaHeader(request: RequestWithLoginAttempt) {
this.debug('Trying to authenticate via header.');
const authorization = request.headers.authorization;
@ -355,7 +360,10 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaState(request: Legacy.Request, { accessToken }: ProviderState) {
private async authenticateViaState(
request: RequestWithLoginAttempt,
{ accessToken }: ProviderState
) {
this.debug('Trying to authenticate via state.');
if (!accessToken) {
@ -392,7 +400,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* @param state State value previously stored by the provider.
*/
private async authenticateViaRefreshToken(
request: Legacy.Request,
request: RequestWithLoginAttempt,
{ refreshToken }: ProviderState
) {
this.debug('Trying to refresh access token.');
@ -469,7 +477,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* Tries to start SAML handshake and eventually receive a token.
* @param request Request instance.
*/
private async authenticateViaHandshake(request: Legacy.Request) {
private async authenticateViaHandshake(request: RequestWithLoginAttempt) {
this.debug('Trying to initiate SAML handshake.');
// If client can't handle redirect response, we shouldn't initiate SAML handshake.

View file

@ -9,8 +9,7 @@ import { canRedirectRequest } from '../../can_redirect_request';
import { getErrorStatusCode } from '../../errors';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { LoginAttempt } from '../login_attempt';
import { BaseAuthenticationProvider } from './base';
import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base';
/**
* The state supported by the provider.
@ -29,10 +28,6 @@ interface ProviderState {
refreshToken?: string;
}
type RequestWithLoginAttempt = Legacy.Request & {
loginAttempt: () => LoginAttempt;
};
/**
* 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
@ -160,7 +155,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider {
* forward to Elasticsearch backend.
* @param request Request instance.
*/
private async authenticateViaHeader(request: Legacy.Request) {
private async authenticateViaHeader(request: RequestWithLoginAttempt) {
this.debug('Trying to authenticate via header.');
const authorization = request.headers.authorization;
@ -252,7 +247,10 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider {
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaState(request: Legacy.Request, { accessToken }: ProviderState) {
private async authenticateViaState(
request: RequestWithLoginAttempt,
{ accessToken }: ProviderState
) {
this.debug('Trying to authenticate via state.');
if (!accessToken) {
@ -289,7 +287,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider {
* @param state State value previously stored by the provider.
*/
private async authenticateViaRefreshToken(
request: Legacy.Request,
request: RequestWithLoginAttempt,
{ refreshToken }: ProviderState
) {
this.debug('Trying to refresh access token.');
@ -357,7 +355,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider {
* Constructs login page URL using current url path as `next` query string parameter.
* @param request Request instance.
*/
private getLoginPageURL(request: Legacy.Request) {
private getLoginPageURL(request: RequestWithLoginAttempt) {
const nextURL = encodeURIComponent(`${request.getBasePath()}${request.url.path}`);
return `${this.options.basePath}/login?next=${nextURL}`;
}

View file

@ -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/oidc_api_integration/config.js'),

View file

@ -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'));
});
}

View file

@ -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'));
});
}

View file

@ -0,0 +1,375 @@
/*
* 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');
const config = getService('config');
function checkCookieIsSet(cookie: Cookie) {
expect(cookie.value).to.not.be.empty();
expect(cookie.key).to.be('sid');
expect(cookie.path).to.be('/');
expect(cookie.httpOnly).to.be(true);
expect(cookie.maxAge).to.be(null);
}
function checkCookieIsCleared(cookie: Cookie) {
expect(cookie.value).to.be.empty();
expect(cookie.key).to.be('sid');
expect(cookie.path).to.be('/');
expect(cookie.httpOnly).to.be(true);
expect(cookie.maxAge).to.be(0);
}
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);
});
it('does not prevent basic login', async () => {
const [username, password] = config.get('servers.elasticsearch.auth').split(':');
const response = await supertest
.post('/api/security/v1/login')
.set('kbn-xsrf', 'xxx')
.send({ username, password })
.expect(204);
const cookies = response.headers['set-cookie'];
expect(cookies).to.have.length(1);
const cookie = request.cookie(cookies[0])!;
checkCookieIsSet(cookie);
const { body: user } = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', cookie.cookieString())
.expect(200);
expect(user.username).to.eql(username);
expect(user.authentication_realm).to.eql({ name: 'reserved', type: 'reserved' });
});
describe('initiating SPNEGO', () => {
it('non-AJAX requests 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 properly 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('Negotiate');
});
});
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])!;
checkCookieIsSet(sessionCookie);
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])!;
checkCookieIsSet(sessionCookie);
});
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])!;
checkCookieIsSet(sessionCookieOne);
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])!;
checkCookieIsSet(sessionCookieTwo);
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])!;
checkCookieIsSet(sessionCookie);
// 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);
checkCookieIsCleared(request.cookie(cookies[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);
// If Kibana detects cookie with invalid token it tries to clear it.
cookies = apiResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
checkCookieIsCleared(request.cookie(cookies[0])!);
expect(apiResponse.headers['www-authenticate']).to.be('Negotiate');
});
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])!;
checkCookieIsSet(sessionCookie);
});
it('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 apiResponse = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(401);
const cookies = apiResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
checkCookieIsCleared(request.cookie(cookies[0])!);
expect(apiResponse.headers['www-authenticate']).to.be('Negotiate');
});
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);
checkCookieIsCleared(request.cookie(cookies[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])!;
checkCookieIsSet(sessionCookie);
// 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 initiate SPNEGO and clear existing cookie', async function() {
const apiResponse = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(401);
const cookies = apiResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
checkCookieIsCleared(request.cookie(cookies[0])!);
expect(apiResponse.headers['www-authenticate']).to.be('Negotiate');
});
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);
checkCookieIsCleared(request.cookie(cookies[0])!);
expect(nonAjaxResponse.headers['www-authenticate']).to.be('Negotiate');
});
});
});
}

View file

@ -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=${JSON.stringify(['kerberos', 'basic'])}`,
],
},
};
}

View file

@ -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`.

View file

@ -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

Binary file not shown.

View file

@ -41,6 +41,27 @@ export default function ({ getService }) {
.expect(401);
});
it('does not prevent basic login', async () => {
const [username, password] = config.get('servers.elasticsearch.auth').split(':');
const response = await supertest
.post('/api/security/v1/login')
.set('kbn-xsrf', 'xxx')
.send({ username, password })
.expect(204);
const cookies = response.headers['set-cookie'];
expect(cookies).to.have.length(1);
const { body: user } = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', request.cookie(cookies[0]).cookieString())
.expect(200);
expect(user.username).to.eql(username);
expect(user.authentication_realm).to.eql({ name: 'reserved', type: 'reserved' });
});
describe('initiating handshake', () => {
it('should properly set cookie and redirect user', async () => {
const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three')

View file

@ -47,7 +47,7 @@ export default async function ({ readConfigFile }) {
...xPackAPITestsConfig.get('kbnTestServer.serverArgs'),
'--optimize.enabled=false',
'--server.xsrf.whitelist=[\"/api/security/v1/saml\"]',
'--xpack.security.authProviders=[\"saml\"]',
`--xpack.security.authProviders=${JSON.stringify(['saml', 'basic'])}`,
],
},
};