Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce Kerberos authentication provider. #36112

Merged
merged 6 commits into from
May 29, 2019
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 || {}),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: @elastic/kibana-operations how do you feel about this change? We need to specify some JVM options for the test ES instance (namely -Djava.security.krb5.conf and -Dsun.security.krb5.rcache) and I'm aware of only two ways of doing that, either via config/jvm.options or ES_JAVA_OPTS env variable.

Dealing with ES_JAVA_OPTS seemed easier to me, so I picked that option, but happy to hear about any better ways of doing that. With this change test config would look like this:

{
    esTestCluster: {
      ...xPackAPITestsConfig.get('esTestCluster'),
      serverArgs: [
        ...xPackAPITestsConfig.get('esTestCluster.serverArgs'),
        `xpack.security.authc.realms.kerberos.kerb1.keytab.path=${kerberosKeytabPath}`,
      ],
      serverEnvVars: {
        ES_JAVA_OPTS: `-Djava.security.krb5.conf=${kerberosConfigPath} -Dsun.security.krb5.rcache=none`,
      },
    },
    ...
  };

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works for me, though I think you might run into problems with paths containing spaces based on your snippet.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great!

I think you might run into problems with paths containing spaces based on your snippet.

Thanks for pointing out! We shouldn't have spaces there, but yeah, I pretend this problem doesn't exist yet 🙈

},
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);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: we could potentially move this if and optional challenges to wrapError, but I'm leaning towards targeted usage at this stage.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the explicitness of how it's done currently.

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[]) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: If I understand that correctly challenges makes sense only with 401 error so I made it an optional argument for failed, but based on the way you handled it in your WIP I'd be glad to hear your thoughts or concerns!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand the current implementation correctly, when the Kerberos provider returns with AuthenticationResult.failed(Boom.unauthorized(), ['Negotiate']), this will immediately cause the response to be sent to the user. Assuming the user's browser doesn't support Kerberos, will they still eventually get to the login screen?

The previous WIP approach which I implemented was intending to allow the kerberos provider to behave more seamlessly with the basic provider, which is why the challenges weren't necessarily terminating the way we iterate through the auth providers within the authenticator.

Other internals might have changed where the previous approach is no longer relevant.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand the current implementation correctly, when the Kerberos provider returns with AuthenticationResult.failed(Boom.unauthorized(), ['Negotiate']), this will immediately cause the response to be sent to the user. Assuming the user's browser doesn't support Kerberos, will they still eventually get to the login screen?

Right, the only way user will be able to login using basic auth provider in [kerberos, basic] setup would be to go directly to /login page. Like we do for saml.

The previous WIP approach which I implemented was intending to allow the kerberos provider to behave more seamlessly with the basic provider, which is why the challenges weren't necessarily terminating the way we iterate through the auth providers within the authenticator.

Thanks for pointing out! I've played with this approach last week, it looks interesting, but I'd rather tackle this separately if find out that current approach is annoying for our users. Would that work for you? There are three things that make me think about handling this separately:

  • Same experience (okay, ^experience^ 🙂 ) even if basic is not enabled
  • basic in [kerberos, basic] scenario feels more like admin-only and "normal" users that can't exchange Kerberos tickets to access tokens wouldn't benefit from login page too much, and admins know where to go
  • Authenticator will have to handle challenges in a special manner, that I'd rather avoid unless it's really needed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think what you have is perfectly fine :) I think at some point, we'll likely need to add the "choose your login method" when we have multiple auth providers configured.

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind explaining the significance of changing the deauthenticate methods to use RequestWithLoginAttempt? It feels like we'd only want to use these for authenticate, but I might be missing something.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'm on the fence here. There were two reasons to use RequestWithLoginAttempt everywhere:

  • When security is enabled request always includes loginAttempt, so technically it's more correct to have RequestWithLoginAttempt here
  • We don't need to import any additional type from kibana (import { Legacy.Request } from 'kibana') if we only use RequestWithLoginAttempt.

But none of these reasons are strong enough and since it confused you it will confuse others too, I'll switch back to Legacy.Request in deauthenticate just to not give a false impression that we may use loginAttempt where we shouldn't.

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