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

[skip ci] feedback on approach for token provider #25431

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all 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
215 changes: 215 additions & 0 deletions x-pack/plugins/security/server/lib/authentication/providers/token.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/*
* 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 { canRedirectRequest } from '../../can_redirect_request';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';

/**
* Object that represents available provider options.
* @typedef {{
* protocol: string,
* hostname: string,
* port: string,
* basePath: string,
* client: Client,
* log: Function
* }} ProviderOptions
*/

/**
* Checks the error returned by Elasticsearch as the result of `authenticate` call and returns `true` if request
* has been rejected because of expired token, otherwise returns `false`.
* @param {Object} err Error returned from Elasticsearch.
* @returns {boolean}
*/
function isAccessTokenExpiredError(err) {
return err.body
&& err.body.error
&& err.body.error.reason === 'token expired';
}

/**
* Provider that supports request authentication via Basic HTTP Authentication.
*/
export class TokenAuthenticationProvider {
/**
* Server options that may be needed by authentication provider.
* @type {?ProviderOptions}
* @protected
*/
_options = null;

/**
* Instantiates BasicAuthenticationProvider.
* @param {ProviderOptions} options Provider options object.
*/
constructor(options) {
this._options = options;
}

/**
* Performs request authentication using Basic HTTP Authentication.
* @param {Hapi.Request} request HapiJS request instance.
* @param {Object} [state] Optional state object associated with the provider.
* @returns {Promise.<AuthenticationResult>}
*/
async authenticate(request, state) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the entire proposed authentication flow for the token provider. It is sort of a merging of logic between the basic and saml providers.

It supports true basic auth headers, similar to both basic and saml.

It supports login from credentials, similar to the basic provider.

It supports token auth and token refresh similar to saml, but it does not include the handshake process handling that saml does.

I'd appreciate feedback on the high level logic in the flow itself.

this._options.log(['debug', 'security', 'token'], `Trying to authenticate user request to ${request.url.path}.`);

// first try from login payload
let authenticationResult = await this._authenticateViaLoginAttempt(request);

// if there isn't a payload, try basic auth
if (authenticationResult.notHandled()) {
authenticationResult = await this._authenticateViaHeader(request);
}

// if we still can't attempt auth, try authenticating via state (session token)
if (authenticationResult.notHandled()) {
authenticationResult = await this._authenticateViaState(request, state);
if (authenticationResult.failed() && isAccessTokenExpiredError(authenticationResult.error)) {
authenticationResult = this._authenticateViaRefreshToken(request, state);
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not seeing the _authenticateViaRefreshToken implementation yet. It's likely this is known and intentional, but I wanted to note it just in case it wasn't.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah yeah, I meant to call that out. It's not implemented yet, but it's essentially a copy+paste job from saml.

}
}

// finally, if authentication still can not be handled for this
// request/state combination, redirect to the login page if appropriate
if (authenticationResult.notHandled() && canRedirectRequest(request)) {
const nextURL = encodeURIComponent(`${this._options.basePath}${request.url.path}`);
return AuthenticationResult.redirectTo(
`${this._options.basePath}/login?next=${nextURL}`
);
}

return authenticationResult;
}

/**
* Redirects user to the login page preserving query string parameters.
* @param {Hapi.Request} request HapiJS request instance.
* @returns {Promise.<DeauthenticationResult>}
*/
async deauthenticate(request) {
Copy link
Contributor

Choose a reason for hiding this comment

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

We'll want to also invalidate the access/refresh tokens when they deauthenticate, like SAML does here: https://github.com/elastic/kibana/blob/master/x-pack/plugins/security/server/lib/authentication/providers/saml.js#L374

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch

// Query string may contain the path where logout has been called or
// logout reason that login page may need to know.
return DeauthenticationResult.redirectTo(
`${this._options.basePath}/login${request.url.search}`
);
}

/**
* Validates whether request contains `Basic ***` Authorization header and just passes it
* forward to Elasticsearch backend.
* @param {Hapi.Request} request HapiJS request instance.
* @returns {Promise.<AuthenticationResult>}
* @private
*/
async _authenticateViaHeader(request) {
this._options.log(['debug', 'security', 'token'], 'Trying to authenticate via header.');

const authorization = request.headers.authorization;
if (!authorization) {
this._options.log(['debug', 'security', 'token'], 'Authorization header is not presented.');
return AuthenticationResult.notHandled();
}

const authenticationSchema = authorization.split(/\s+/)[0];
if (authenticationSchema.toLowerCase() !== 'basic') {
Copy link
Member

Choose a reason for hiding this comment

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

nit: basic ---> bearer or you want this provider to support both or just basic for some reason? If someone is using Basic ... header directly I think they are supposed to use basic auth provider. Using Bearer here would allow us to use any ES realm that understands tokens (e.g custom OAuth realms + OAuth proxy in front of Kibana).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I double checked the SAML provider logic and sure enough it uses bearer. I was under the impression that we had hardcoded true basic auth header support into each provider to make sure people could still do API calls programmatically while also supporting UI-based auth workflows, but clearly that's not the case.

Unfortunately, this gets more tricky with basic/token providers. The login form itself can only work with one of those two providers, so I worry about the subtly in ordering of those providers in the configuration accidentally exposing people to issues they didn't realize. Like when you switch between token and basic auth for the login form, they will appear to work in exactly the same way, and you'd need to examine the contents of the encrypted cookie (not possible for end users) to even know which provider was handling login.

I wonder if it makes sense to introduce an explicit login handling configuration so you can configure things like basic and token providers at the same time while simultaneously ensuring that the login form only works with one specific provider.

Thoughts?

Copy link
Member

Choose a reason for hiding this comment

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

Just a couple of thoughts:

The order of auth providers in [basic, token] or [token, basic] only influences login page, for programmatic API it won't matter (basic intentionally fails if it notices non-empty Authorization header that it doesn't understand to allow next provider to handle that, token should do the same). For [saml, token] the order will matter though. But as far as I understand the order of realms can be tricky for ES as well and administrators should know what they do when they configure the order of realms.

Having said that, I agree, "the first auth provider always manages login" rule isn't obvious, we can introduce config option to make it explicit which auth provider should handle login and then we can "sign" loginAttempt with provider name/id (like we do for session) based on this config. Otherwise there may be an issue when provider can try to use loginAttempt to authenticate user just because its shape looks like what provider expects even though it was supposed to be used by another provider (idk, like ldap-based auth provider that expects username and password and basic that expects the same).

Copy link
Contributor

Choose a reason for hiding this comment

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

I feel like I'm missing something. I thought this provider was going to essentially be a "basic auth provider using tokens for sessions internally". In what situations would the requests from the end-users browser be presenting "bearer" authorization headers?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@kobelb That was how I was thinking about it originally, but I'm coming around to the idea that the token provider is not a special basic auth provider at all. So the idea would be that if you want to support both token auth and basic auth, you'd have to enable both providers. You could lock out basic auth entirely if you wanted.

With this in mind, we don't have to support header-based auth at all with the token provider. The only real use case I can think of would be people building custom SSO solutions (ldap perhaps?) that leverage the token provider rather than the basic provider as they do today. Correct me if I'm wrong about that possibility, though.

Copy link
Contributor

Choose a reason for hiding this comment

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

@epixa gotcha, I'm with you all now, thanks for catching me up.

Allowing custom SSO using the header makes sense conceptually, I don't see any real harm in including it if it opens up this possibility. I honestly haven't played around with various intelligent reverse proxies that would make this type of workflow possible enough to know the feasibility, but it'd be a ton nicer than the run-as approach that we have now.

this._options.log(['debug', 'security', 'token'], `Unsupported authentication schema: ${authenticationSchema}`);

// It's essential that we fail if non-empty, but unsupported authentication schema
// is provided to allow authenticator to consult other authentication providers
// that may support that schema.
return AuthenticationResult.failed(
Boom.badRequest(`Unsupported authentication schema: ${authenticationSchema}`)
);
}

try {
const user = await this._options.client.callWithRequest(request, 'shield.authenticate');

this._options.log(['debug', 'security', 'token'], 'Request has been authenticated via header.');

return AuthenticationResult.succeeded(user, { authorization });
Copy link
Contributor

Choose a reason for hiding this comment

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

When we authenticate using the basic auth headers, I don't think that we want to put the user into the session, as this would be putting the username/password encrypted in the session cookie, which can't be invalidated with the logout. We'll likely want to perform the same flow as the "authenticate via login attempt"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll have to double check, but I think passing user to succeeded isn't the problem here as that shouldn't include the credentials (at least not the password). But I think passing authorization here will have the effect you describe, so we definitely don't want that.

Copy link
Member

@azasypkin azasypkin Nov 9, 2018

Choose a reason for hiding this comment

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

I'll have to double check, but I think passing user to succeeded isn't the problem here as that shouldn't include the credentials (at least not the password)

It shouldn't be a problem, and moreover it's required for succeeded and it's not used for session.

which can't be invalidated with the logout. We'll likely want to perform the same flow as the "authenticate via login attempt"

Hmm, correct me if I'm wrong, but if it's in cookie, the deauthenticate that is called on logout should clear it as well. But having said that I agree that we don't want to store Bearer xxxx in the cookie when we authenticate user via header (and should remove this "feature" from basic provider auth via header as well since now we have login attempt thing), but if we don't store it in the session cookie during "login attempt", where do we get access/refresh tokens from for the next request?

Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed on the user vs authenticated point, I was wrong.

But having said that I agree that we don't want to store Bearer xxxx in the cookie when we authenticate user via header (and should remove this "feature" from basic provider auth via header as well since now we have login attempt thing), but if we don't store it in the session cookie during "login attempt", where do we get access/refresh tokens from for the next request?

So, just to be clear, we're talking about not using tokens when the user authenticates using basic auth headers, and additionally not storing the username/password in the session cookie itself; but instead requiring subsequent requests to provide the basic auth headers themselves?

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, I was talking about two cases:

  1. _authenticateViaHeader - here we expect to receive Authorization: Bearer xxxx and hence we shouldn't store anything in the cookie since we expect every request to have this header (e.g. they have some OAuth proxy in front of Kibana that supplies it). Currently basic auth provider stores it in the cookie, but with loginAttempt we can finally fix that.

  2. _authenticateViaLoginAttempt - here we expect to receive credentials that we exchange for the token pair. It's probably one time action (e.g. initiated from the login page) so we should store token pair in the cookie (and "forget" credentials) and allow consequent requests to rely on generated tokens from the cookie instead.

Or I'm misunderstanding something? :)

Copy link
Contributor

Choose a reason for hiding this comment

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

I believe we're on the same page, I forgot that we could see either the Authorization: Bearer xxxx or the Authorization: Basic xxxx headers when using the _authenticateViaHeader method, but I believe it makes sense to not store these in the cookie in either situation.

Copy link
Member

Choose a reason for hiding this comment

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

I believe we're on the same page

Great!

, but I believe it makes sense to not store these in the cookie in either situation.

Yes, that's where loginAttempt that we didn't have before can help us.

} catch(err) {
this._options.log(['debug', 'security', 'token'], `Failed to authenticate request via header: ${err.message}`);
return AuthenticationResult.failed(err);
}
}

/**
* @param {Hapi.Request} request HapiJS request instance.
* @returns {Promise.<AuthenticationResult>}
* @private
*/
async _authenticateViaLoginAttempt(request) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the key function that is invoked when login credentials exist for a request, which it retrieves from the loginAttempt object on the request.

The basic provider would be updated to use this mechanism as well. The only real difference between this function in the token provider and the basic provider is the interaction with Elasticsearch using those credentials.

this._options.log(['debug', 'security', 'token'], 'Trying to authenticate via login attempt.');

const credentials = request.loginAttempt.getCredentials();
if (!credentials) {
this._options.log(['debug', 'security', 'token'], 'Username and password not found in payload.');
return AuthenticationResult.notHandled();
}

request.payload = {
...credentials,
grant_type: 'password',
};

try {
// todo: actual token auth logic
const user = await this._options.client.callWithRequest(request, 'shield.token');

this._options.log(['debug', 'security', 'token'], 'Request has been authenticated via request payload.');

return AuthenticationResult.succeeded(user);
} catch(err) {
this._options.log(['debug', 'security', 'token'], `Failed to authenticate request via state: ${err.message}`);

return AuthenticationResult.failed(err);
}
}

/**
* Tries to extract authorization header from the state and adds it to the request before
* it's forwarded to Elasticsearch backend.
* @param {Hapi.Request} request HapiJS request instance.
* @param {Object} state State value previously stored by the provider.
* @returns {Promise.<AuthenticationResult>}
* @private
*/
async _authenticateViaState(request, { accessToken }) {
this._options.log(['debug', 'security', 'token'], 'Trying to authenticate via state.');

if (!accessToken) {
this._options.log(['debug', 'security', 'token'], '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._options.log(['debug', 'security', 'token'], 'Request has been authenticated via state.');

return AuthenticationResult.succeeded(user);
} catch(err) {
this._options.log(['debug', 'security', 'token'], `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 crash 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);
}
}
}
21 changes: 17 additions & 4 deletions x-pack/plugins/security/server/routes/api/v1/authenticate.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,25 @@
import Boom from 'boom';
import Joi from 'joi';
import { wrapError } from '../../../lib/errors';
import { BasicCredentials } from '../../../../server/lib/authentication/providers/basic';
import { canRedirectRequest } from '../../../lib/can_redirect_request';

export function initAuthenticateApi(server) {

server.decorate('request', 'loginAttempt', (route, options) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Feedback on the loginAttempt implementation is welcome, but it's not critical at this point. The internal details of this decorator are not yet my priority.

Copy link
Member

@azasypkin azasypkin Nov 12, 2018

Choose a reason for hiding this comment

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

question: I'm wondering if we want to make this a bit more generic/opaque (without hardcoding of username and password parameters, like we do state - every providers knows how to decode its state) so that we can use loginAttempt for SAML Response payload as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think that's a good thing to consider, but I'm not sure if I'll implement it that way initially. I'll ponder it more, but my current thinking is that broader refactoring across all authentication providers should wait until we have one more two more SSO-based providers like kerberos and oauth2. SAML's working pretty well at the moment, and at least currently this loginAttempt logic only matters to providers that explicitly need to power the login form.

let credentials;
return {
getCredentials() {
return credentials;
},
setCredentials(username, password) {
if (credentials) {
throw new Error('Credentials for login attempt have already been set');
}
credentials = { username, password };
}
};
});

server.route({
method: 'POST',
path: '/api/security/v1/login',
Expand All @@ -31,9 +45,8 @@ export function initAuthenticateApi(server) {
const { username, password } = request.payload;

try {
const authenticationResult = await server.plugins.security.authenticate(
BasicCredentials.decorateRequest(request, username, password)
);
request.loginAttempt.setCredentials(username, password);
const authenticationResult = await server.plugins.security.authenticate(request);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Rather than extracting the username and password from the payload and faking a basic auth header on the request to force a new login during authenticate, login attempts in authenticate will now be handled exclusively through the new loginAttempt object on the request.

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 this much more


if (!authenticationResult.succeeded()) {
throw Boom.unauthorized(authenticationResult.error);
Expand Down