-
Notifications
You must be signed in to change notification settings - Fork 8.2k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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) { | ||
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not seeing the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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') { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I double checked the SAML provider logic and sure enough it uses 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just a couple of thoughts: The order of auth providers in 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" There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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 }); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll have to double check, but I think passing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
It shouldn't be a problem, and moreover it's required for
Hmm, correct me if I'm wrong, but if it's in cookie, the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed on the
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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, I was talking about two cases:
Or I'm misunderstanding something? :) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Great!
Yes, that's where |
||
} 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Feedback on the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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', | ||
|
@@ -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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
There was a problem hiding this comment.
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.