mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Introduce Kerberos authentication provider. (#36112)
This commit is contained in:
parent
963152f3c9
commit
580edcd1c1
29 changed files with 1327 additions and 66 deletions
|
@ -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'],
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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'), [
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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' });
|
||||
|
||||
|
|
|
@ -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.');
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
@ -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'),
|
||||
|
|
15
x-pack/test/kerberos_api_integration/apis/index.ts
Normal file
15
x-pack/test/kerberos_api_integration/apis/index.ts
Normal 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'));
|
||||
});
|
||||
}
|
14
x-pack/test/kerberos_api_integration/apis/security/index.ts
Normal file
14
x-pack/test/kerberos_api_integration/apis/security/index.ts
Normal 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'));
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
58
x-pack/test/kerberos_api_integration/config.ts
Normal file
58
x-pack/test/kerberos_api_integration/config.ts
Normal 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'])}`,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
10
x-pack/test/kerberos_api_integration/fixtures/README.md
Normal file
10
x-pack/test/kerberos_api_integration/fixtures/README.md
Normal 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`.
|
11
x-pack/test/kerberos_api_integration/fixtures/krb5.conf
Executable file
11
x-pack/test/kerberos_api_integration/fixtures/krb5.conf
Executable 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
|
BIN
x-pack/test/kerberos_api_integration/fixtures/krb5.keytab
Executable file
BIN
x-pack/test/kerberos_api_integration/fixtures/krb5.keytab
Executable file
Binary file not shown.
|
@ -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')
|
||||
|
|
|
@ -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'])}`,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue