Skip to content

Commit

Permalink
Introduce Kerberos authentication provider. (#36112)
Browse files Browse the repository at this point in the history
  • Loading branch information
azasypkin committed May 29, 2019
1 parent 963152f commit 580edcd
Show file tree
Hide file tree
Showing 29 changed files with 1,327 additions and 66 deletions.
4 changes: 4 additions & 0 deletions packages/kbn-es/src/cluster.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
});

Expand Down
3 changes: 2 additions & 1 deletion packages/kbn-test/src/es/es_test_cluster.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -87,6 +87,7 @@ export function createEsTestCluster(options = {}) {
'discovery.type=single-node',
...esArgs,
],
esEnvVars,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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'), [
Expand Down
1 change: 1 addition & 0 deletions src/functional_test_runner/lib/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
25 changes: 25 additions & 0 deletions x-pack/plugins/security/server/lib/__tests__/auth_redirect.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
27 changes: 20 additions & 7 deletions x-pack/plugins/security/server/lib/auth_redirect.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* Represents status that `AuthenticationResult` can be in.
*/
import { AuthenticatedUser } from '../../../common/model';
import { getErrorStatusCode } from '../errors';

enum AuthenticationResultStatus {
/**
Expand Down Expand Up @@ -40,6 +41,7 @@ enum AuthenticationResultStatus {
*/
interface AuthenticationOptions {
error?: Error;
challenges?: string[];
redirectURL?: string;
state?: unknown;
user?: AuthenticatedUser;
Expand Down Expand Up @@ -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 });
}

/**
Expand Down Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
AuthenticationProviderOptions,
BaseAuthenticationProvider,
BasicAuthenticationProvider,
KerberosAuthenticationProvider,
RequestWithLoginAttempt,
SAMLAuthenticationProvider,
TokenAuthenticationProvider,
OIDCAuthenticationProvider,
Expand All @@ -37,6 +39,7 @@ const providerMap = new Map<
) => BaseAuthenticationProvider
>([
['basic', BasicAuthenticationProvider],
['kerberos', KerberosAuthenticationProvider],
['saml', SAMLAuthenticationProvider],
['token', TokenAuthenticationProvider],
['oidc', OIDCAuthenticationProvider],
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -48,10 +47,6 @@ export class BasicCredentials {
}
}

type RequestWithLoginAttempt = Legacy.Request & {
loginAttempt: () => LoginAttempt;
};

/**
* The state supported by the provider.
*/
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading

0 comments on commit 580edcd

Please sign in to comment.