diff --git a/x-pack/plugins/security/server/lib/authentication/authenticator.js b/x-pack/plugins/security/server/lib/authentication/authenticator.js index e731c6724e9d4..2139eaaccf1bf 100644 --- a/x-pack/plugins/security/server/lib/authentication/authenticator.js +++ b/x-pack/plugins/security/server/lib/authentication/authenticator.js @@ -7,6 +7,7 @@ import { getClient } from '../../../../../server/lib/get_client_shield'; import { AuthScopeService } from '../auth_scope_service'; import { BasicAuthenticationProvider } from './providers/basic'; +import { TokenAuthenticationProvider } from './providers/token'; import { SAMLAuthenticationProvider } from './providers/saml'; import { AuthenticationResult } from './authentication_result'; import { DeauthenticationResult } from './deauthentication_result'; @@ -16,6 +17,7 @@ import { Session } from './session'; // provider class that can handle specific authentication mechanism. const providerMap = new Map([ ['basic', BasicAuthenticationProvider], + ['token', TokenAuthenticationProvider], ['saml', SAMLAuthenticationProvider] ]); @@ -169,6 +171,10 @@ class Authenticator { } } + if (authenticationResult.failed()) { + return authenticationResult; + } + if (authenticationResult.succeeded()) { // we have to do this here, as the auth scope's could be dependent on this await this._authorizationMode.initialize(request); diff --git a/x-pack/plugins/security/server/lib/authentication/providers/basic.js b/x-pack/plugins/security/server/lib/authentication/providers/basic.js index 87b193c9e053e..64ef9fcbdefc9 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/basic.js +++ b/x-pack/plugins/security/server/lib/authentication/providers/basic.js @@ -4,11 +4,41 @@ * 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'; +/** + * Utility class that knows how to decorate request with proper Basic authentication headers. + */ +export class BasicCredentials { + /** + * Takes provided `username` and `password`, transforms them into proper `Basic ***` authorization + * header and decorates passed request with it. + * @param {Hapi.Request} request HapiJS request instance. + * @param {string} username User name. + * @param {string} password User password. + * @returns {Hapi.Request} HapiJS request instance decorated with the proper header. + */ + static decorateRequest(request, username, password) { + if (!request || typeof request !== 'object') { + throw new Error('Request should be a valid object.'); + } + + if (!username || typeof username !== 'string') { + throw new Error('Username should be a valid non-empty string.'); + } + + if (!password || typeof password !== 'string') { + throw new Error('Password should be a valid non-empty string.'); + } + + const basicCredentials = new Buffer(`${username}:${password}`).toString('base64'); + request.headers.authorization = `Basic ${basicCredentials}`; + return request; + } +} + /** * Object that represents available provider options. * @typedef {{ @@ -49,8 +79,15 @@ export class BasicAuthenticationProvider { async authenticate(request, state) { this._options.log(['debug', 'security', 'basic'], `Trying to authenticate user request to ${request.url.path}.`); - let authenticationResult = await this._authenticateViaHeader(request); + // first try from login payload + let authenticationResult = await this._authenticateViaLoginAttempt(request); + + // if there isn't a payload, try header-based token 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() && state) { authenticationResult = await this._authenticateViaState(request, state); } else if (authenticationResult.notHandled() && canRedirectRequest(request)) { @@ -96,13 +133,7 @@ export class BasicAuthenticationProvider { const authenticationSchema = authorization.split(/\s+/)[0]; if (authenticationSchema.toLowerCase() !== 'basic') { this._options.log(['debug', 'security', 'basic'], `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}`) - ); + return AuthenticationResult.notHandled(); } try { @@ -110,13 +141,50 @@ export class BasicAuthenticationProvider { this._options.log(['debug', 'security', 'basic'], 'Request has been authenticated via header.'); - return AuthenticationResult.succeeded(user, { authorization }); + return AuthenticationResult.succeeded(user); } catch(err) { this._options.log(['debug', 'security', 'basic'], `Failed to authenticate request via header: ${err.message}`); return AuthenticationResult.failed(err); } } + /** + * @param {Hapi.Request} request HapiJS request instance. + * @returns {Promise.} + * @private + */ + async _authenticateViaLoginAttempt(request) { + this._options.log(['debug', 'security', 'basic'], 'Trying to authenticate via login attempt.'); + + const credentials = request.loginAttempt(request).getCredentials(); + if (!credentials) { + this._options.log(['debug', 'security', 'basic'], 'Username and password not found in payload.'); + return AuthenticationResult.notHandled(); + } + + try { + const { username, password } = credentials; + + BasicCredentials.decorateRequest(request, username, password); + const user = await this._options.client.callWithRequest(request, 'shield.authenticate'); + + this._options.log(['debug', 'security', 'basic'], 'Request has been authenticated via login attempt.'); + + return AuthenticationResult.succeeded(user, { authorization: request.headers.authorization }); + } catch(err) { + this._options.log(['debug', 'security', 'basic'], `Failed to authenticate request via login attempt: ${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 extract authorization header from the state and adds it to the request before * it's forwarded to Elasticsearch backend. @@ -155,34 +223,3 @@ export class BasicAuthenticationProvider { } } } - -/** - * Utility class that knows how to decorate request with proper Basic authentication headers. - */ -export class BasicCredentials { - /** - * Takes provided `username` and `password`, transforms them into proper `Basic ***` authorization - * header and decorates passed request with it. - * @param {Hapi.Request} request HapiJS request instance. - * @param {string} username User name. - * @param {string} password User password. - * @returns {Hapi.Request} HapiJS request instance decorated with the proper header. - */ - static decorateRequest(request, username, password) { - if (!request || typeof request !== 'object') { - throw new Error('Request should be a valid object.'); - } - - if (!username || typeof username !== 'string') { - throw new Error('Username should be a valid non-empty string.'); - } - - if (!password || typeof password !== 'string') { - throw new Error('Password should be a valid non-empty string.'); - } - - const basicCredentials = new Buffer(`${username}:${password}`).toString('base64'); - request.headers.authorization = `Basic ${basicCredentials}`; - return request; - } -} diff --git a/x-pack/plugins/security/server/lib/authentication/providers/saml.js b/x-pack/plugins/security/server/lib/authentication/providers/saml.js index fc364736e395b..c8518f0f63966 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/saml.js +++ b/x-pack/plugins/security/server/lib/authentication/providers/saml.js @@ -34,7 +34,7 @@ function isAccessTokenExpiredError(err) { } /** - * Checks the error returned by Elasticsearch as the result of `samlRefreshAccessToken` call and returns `true` if + * Checks the error returned by Elasticsearch as the result of `getAccessToken` call and returns `true` if * request has been rejected because of invalid refresh token (expired after 24 hours or have been used already), * otherwise returns `false`. * @param {Object} err Error returned from Elasticsearch. @@ -114,12 +114,7 @@ export class SAMLAuthenticationProvider { if (authenticationSchema.toLowerCase() !== 'bearer') { this._options.log(['debug', 'security', 'saml'], `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}`) - ); + return AuthenticationResult.notHandled(); } try { @@ -269,7 +264,7 @@ export class SAMLAuthenticationProvider { access_token: newAccessToken, refresh_token: newRefreshToken } = await this._options.client.callWithInternalUser( - 'shield.samlRefreshAccessToken', + 'shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: refreshToken } } ); diff --git a/x-pack/plugins/security/server/lib/authentication/providers/token.js b/x-pack/plugins/security/server/lib/authentication/providers/token.js new file mode 100644 index 0000000000000..0d15d9dbef6f5 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authentication/providers/token.js @@ -0,0 +1,334 @@ +/* + * 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 { 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 token-based request authentication. + */ +export class TokenAuthenticationProvider { + /** + * Server options that may be needed by authentication provider. + * @type {?ProviderOptions} + * @protected + */ + _options = null; + + /** + * Instantiates TokenAuthenticationProvider. + * @param {ProviderOptions} options Provider options object. + */ + constructor(options) { + this._options = options; + } + + /** + * Performs token-based request authentication + * @param {Hapi.Request} request HapiJS request instance. + * @param {Object} [state] Optional state object associated with the provider. + * @returns {Promise.} + */ + 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 header-based token 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() && state) { + authenticationResult = await this._authenticateViaState(request, state); + if (authenticationResult.failed() && isAccessTokenExpiredError(authenticationResult.error)) { + authenticationResult = await this._authenticateViaRefreshToken(request, state); + } + } + + // 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. + * @param {Object} state State value previously stored by the provider. + * @returns {Promise.} + */ + async deauthenticate(request, state) { + this._options.log(['debug', 'security', 'token'], `Trying to deauthenticate user via ${request.url.path}.`); + + if (!state || !state.accessToken || !state.refreshToken) { + this._options.log(['debug', 'security', 'token'], 'There are no access and refresh tokens to invalidate.'); + return DeauthenticationResult.notHandled(); + } + + this._options.log(['debug', 'security', 'token'], 'Token-based logout has been initiated by the user.'); + + + try { + // First invalidate the access token. + const { created: deletedAccessToken } = await this._options.client.callWithInternalUser( + 'shield.deleteAccessToken', + { body: { token: state.accessToken } } + ); + + if (deletedAccessToken) { + this._options.log(['debug', 'security', 'token'], 'User access token has been successfully invalidated.'); + } else { + this._options.log(['debug', 'security', 'token'], 'User access token was already invalidated.'); + } + + // Then invalidate the refresh token. + const { created: deletedRefreshToken } = await this._options.client.callWithInternalUser( + 'shield.deleteAccessToken', + { body: { refresh_token: state.refreshToken } } + ); + + if (deletedRefreshToken) { + this._options.log(['debug', 'security', 'token'], 'User refresh token has been successfully invalidated.'); + } else { + this._options.log(['debug', 'security', 'token'], 'User refresh token was already invalidated.'); + } + + return DeauthenticationResult.redirectTo( + `${this._options.basePath}/login${request.url.search}` + ); + } catch(err) { + this._options.log(['debug', 'security', 'token'], `Failed invalidating user's access token: ${err.message}`); + return DeauthenticationResult.failed(err); + } + } + + /** + * Validates whether request contains `Bearer ***` Authorization header and just passes it + * forward to Elasticsearch backend. + * @param {Hapi.Request} request HapiJS request instance. + * @returns {Promise.} + * @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() !== 'bearer') { + this._options.log(['debug', 'security', 'token'], `Unsupported authentication schema: ${authenticationSchema}`); + + return AuthenticationResult.notHandled(); + } + + try { + const user = await this._options.client.callWithRequest(request, 'shield.authenticate'); + + this._options.log(['debug', 'security', 'token'], 'Request has been authenticated via header.'); + + // We intentionally do not store anything in session state because token + // header auth can only be used on a request by request basis. + return AuthenticationResult.succeeded(user); + } 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.} + * @private + */ + async _authenticateViaLoginAttempt(request) { + 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(); + } + + try { + // First attempt to exchange login credentials for an access token + const { username, password } = credentials; + const { + access_token: accessToken, + refresh_token: refreshToken, + } = await this._options.client.callWithInternalUser( + 'shield.getAccessToken', + { body: { grant_type: 'password', username, password } } + ); + + this._options.log(['debug', 'security', 'token'], 'Get token API request to Elasticsearch successful'); + + // We validate that both access and refresh tokens exist in the response + // so other private methods in this class can rely on them both existing. + if (!accessToken) { + throw new Error('Unexpected response from get token API - no access token present'); + } + if (!refreshToken) { + throw new Error('Unexpected response from get token API - no refresh token present'); + } + + // Then attempt to query for the user details using the new token + request.headers.authorization = `Bearer ${accessToken}`; + const user = await this._options.client.callWithRequest(request, 'shield.authenticate'); + + this._options.log(['debug', 'security', 'token'], 'User has been authenticated with new access token'); + + return AuthenticationResult.succeeded(user, { accessToken, refreshToken }); + } catch(err) { + this._options.log(['debug', 'security', 'token'], `Failed to authenticate request via login attempt: ${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 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.} + * @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(); + } + + try { + request.headers.authorization = `Bearer ${accessToken}`; + 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); + } + } + + /** + * This method is only called when authentication via access token stored in the state failed because of expired + * token. So we should use refresh token, that is also stored in the state, to extend expired access token and + * authenticate user with it. + * @param {Hapi.Request} request HapiJS request instance. + * @param {Object} state State value previously stored by the provider. + * @returns {Promise.} + * @private + */ + async _authenticateViaRefreshToken(request, { refreshToken }) { + this._options.log(['debug', 'security', 'token'], 'Trying to refresh access token.'); + + if (!refreshToken) { + this._options.log(['debug', 'security', 'token'], 'Refresh token is not found in state.'); + return AuthenticationResult.notHandled(); + } + + try { + // Token must be refreshed by the same user that obtained that token, the + // kibana system user. + const { + access_token: newAccessToken, + refresh_token: newRefreshToken + } = await this._options.client.callWithInternalUser( + 'shield.getAccessToken', + { body: { grant_type: 'refresh_token', refresh_token: refreshToken } } + ); + + this._options.log(['debug', 'security', 'token'], `Request to refresh token via Elasticsearch's get token API successful`); + + // We validate that both access and refresh tokens exist in the response + // so other private methods in this class can rely on them both existing. + if (!newAccessToken) { + throw new Error('Unexpected response from get token API - no access token present'); + } + if (!newRefreshToken) { + throw new Error('Unexpected response from get token API - no refresh token present'); + } + + request.headers.authorization = `Bearer ${newAccessToken}`; + const user = await this._options.client.callWithRequest(request, 'shield.authenticate'); + + this._options.log(['debug', 'security', 'token'], 'Request has been authenticated via refreshed token.'); + + return AuthenticationResult.succeeded( + user, + { accessToken: newAccessToken, refreshToken: newRefreshToken } + ); + } catch (err) { + this._options.log(['debug', 'security', 'token'], `Failed to refresh access token: ${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); + } + } +} diff --git a/x-pack/plugins/security/server/routes/api/v1/authenticate.js b/x-pack/plugins/security/server/routes/api/v1/authenticate.js index 61abc38d3b0de..eb89eaae8638d 100644 --- a/x-pack/plugins/security/server/routes/api/v1/authenticate.js +++ b/x-pack/plugins/security/server/routes/api/v1/authenticate.js @@ -7,11 +7,26 @@ 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) { + const loginAttempts = new WeakMap(); + server.decorate('request', 'loginAttempt', function () { + const request = this; + return { + getCredentials() { + return loginAttempts.get(request); + }, + setCredentials(username, password) { + if (loginAttempts.has(request)) { + throw new Error('Credentials for login attempt have already been set'); + } + loginAttempts.set(request, { username, password }); + } + }; + }); + server.route({ method: 'POST', path: '/api/security/v1/login', @@ -31,9 +46,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); if (!authenticationResult.succeeded()) { throw Boom.unauthorized(authenticationResult.error); diff --git a/x-pack/plugins/security/server/routes/api/v1/users.js b/x-pack/plugins/security/server/routes/api/v1/users.js index 17b0cfe7e5567..0e2719e87707d 100644 --- a/x-pack/plugins/security/server/routes/api/v1/users.js +++ b/x-pack/plugins/security/server/routes/api/v1/users.js @@ -105,9 +105,8 @@ export function initUsersApi(server) { // Now we authenticate user with the new password again updating current session if any. if (isCurrentUser) { - const authenticationResult = await server.plugins.security.authenticate( - BasicCredentials.decorateRequest(request, username, newPassword) - ); + request.loginAttempt().setCredentials(username, newPassword); + const authenticationResult = await server.plugins.security.authenticate(request); if (!authenticationResult.succeeded()) { throw Boom.unauthorized((authenticationResult.error)); diff --git a/x-pack/server/lib/esjs_shield_plugin.js b/x-pack/server/lib/esjs_shield_plugin.js index bcab31b554c9d..4234e55d43278 100644 --- a/x-pack/server/lib/esjs_shield_plugin.js +++ b/x-pack/server/lib/esjs_shield_plugin.js @@ -360,14 +360,14 @@ }); /** - * Refreshes SAML access token. + * Refreshes an access token. * * @param {string} grant_type Currently only "refresh_token" grant type is supported. * @param {string} refresh_token One-time refresh token that will be exchanged to the new access/refresh token pair. * * @returns {{access_token: string, type: string, expires_in: number, refresh_token: string}} */ - shield.samlRefreshAccessToken = ca({ + shield.getAccessToken = ca({ method: 'POST', needBody: true, url: { @@ -375,6 +375,26 @@ } }); + /** + * Invalidates an access token. + * + * @param {string} token The access token to invalidate + * + * @returns {{created: boolean}} + */ + shield.deleteAccessToken = ca({ + method: 'DELETE', + needBody: true, + params: { + token: { + type: 'string' + } + }, + url: { + fmt: '/_xpack/security/oauth2/token' + } + }); + shield.getPrivilege = ca({ method: 'GET', urls: [{