From 3cff787f7169575058c7649c6700ddbee0a1fbbe Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 14 Jun 2019 18:39:38 +0200 Subject: [PATCH] Migrate authentication subsystem to new platform. --- kibana.d.ts | 1 + .../core_plugins/elasticsearch/lib/cluster.ts | 4 +- x-pack/legacy/plugins/security/index.js | 13 +- .../security/server/lib/auth_redirect.js | 63 ---- .../auth_redirect.test.ts} | 4 +- .../authentication/authentication_result.ts | 22 +- .../lib/authentication/authenticator.test.ts | 18 +- .../lib/authentication/authenticator.ts | 350 +++++++++++------- .../can_redirect_request.test.ts | 2 + .../can_redirect_request.ts | 12 +- .../server/lib/authentication/index.ts | 149 +++++++- .../lib/authentication/login_attempt.test.ts | 38 -- .../lib/authentication/login_attempt.ts | 42 --- .../lib/authentication/providers/base.ts | 41 +- .../authentication/providers/basic.test.ts | 6 +- .../lib/authentication/providers/basic.ts | 169 ++++----- .../lib/authentication/providers/index.ts | 6 +- .../authentication/providers/kerberos.test.ts | 11 +- .../lib/authentication/providers/kerberos.ts | 174 ++++----- .../lib/authentication/providers/oidc.test.ts | 14 +- .../lib/authentication/providers/oidc.ts | 231 +++++------- .../lib/authentication/providers/saml.test.ts | 28 +- .../lib/authentication/providers/saml.ts | 285 +++++++------- .../authentication/providers/token.test.ts | 11 +- .../lib/authentication/providers/token.ts | 234 ++++++------ .../server/lib/authentication/session.test.ts | 191 ---------- .../server/lib/authentication/session.ts | 163 -------- .../server/lib/authentication/tokens.ts | 41 +- .../routes/api/v1/__tests__/authenticate.js | 12 +- .../server/routes/api/v1/authenticate.js | 41 +- 30 files changed, 1015 insertions(+), 1361 deletions(-) delete mode 100644 x-pack/legacy/plugins/security/server/lib/auth_redirect.js rename x-pack/legacy/plugins/security/server/lib/{__tests__/auth_redirect.js => authentication/auth_redirect.test.ts} (99%) rename x-pack/legacy/plugins/security/server/lib/{ => authentication}/can_redirect_request.test.ts (99%) rename x-pack/legacy/plugins/security/server/lib/{ => authentication}/can_redirect_request.ts (65%) delete mode 100644 x-pack/legacy/plugins/security/server/lib/authentication/login_attempt.test.ts delete mode 100644 x-pack/legacy/plugins/security/server/lib/authentication/login_attempt.ts delete mode 100644 x-pack/legacy/plugins/security/server/lib/authentication/session.test.ts delete mode 100644 x-pack/legacy/plugins/security/server/lib/authentication/session.ts diff --git a/kibana.d.ts b/kibana.d.ts index 45cf4405a8edc03..67055c86a4b8635 100644 --- a/kibana.d.ts +++ b/kibana.d.ts @@ -53,6 +53,7 @@ export namespace Legacy { export namespace elasticsearch { export type Plugin = LegacyElasticsearch.ElasticsearchPlugin; export type Cluster = LegacyElasticsearch.Cluster; + export type CallClusterWithRequest = LegacyElasticsearch.CallClusterWithRequest; export type ClusterConfig = LegacyElasticsearch.ClusterConfig; export type CallClusterOptions = LegacyElasticsearch.CallClusterOptions; } diff --git a/src/legacy/core_plugins/elasticsearch/lib/cluster.ts b/src/legacy/core_plugins/elasticsearch/lib/cluster.ts index a595fffb3c23508..f7bff02f0281492 100644 --- a/src/legacy/core_plugins/elasticsearch/lib/cluster.ts +++ b/src/legacy/core_plugins/elasticsearch/lib/cluster.ts @@ -19,7 +19,7 @@ import { Request } from 'hapi'; import { errors } from 'elasticsearch'; -import { CallAPIOptions, ClusterClient, FakeRequest } from 'kibana/server'; +import { CallAPIOptions, ClusterClient, FakeRequest, KibanaRequest } from 'kibana/server'; export class Cluster { public readonly errors = errors; @@ -27,7 +27,7 @@ export class Cluster { constructor(private readonly clusterClient: ClusterClient) {} public callWithRequest = async ( - req: Request | FakeRequest, + req: Request | FakeRequest | KibanaRequest, endpoint: string, clientParams?: Record, options?: CallAPIOptions diff --git a/x-pack/legacy/plugins/security/index.js b/x-pack/legacy/plugins/security/index.js index 250c8729b5466c3..395e5d603ba86f6 100644 --- a/x-pack/legacy/plugins/security/index.js +++ b/x-pack/legacy/plugins/security/index.js @@ -16,9 +16,8 @@ import { initLoginView } from './server/routes/views/login'; import { initLogoutView } from './server/routes/views/logout'; import { initLoggedOutView } from './server/routes/views/logged_out'; import { validateConfig } from './server/lib/validate_config'; -import { authenticateFactory } from './server/lib/auth_redirect'; +import { initAuthentication } from './server/lib/authentication'; import { checkLicense } from './server/lib/check_license'; -import { initAuthenticator } from './server/lib/authentication/authenticator'; import { SecurityAuditLogger } from './server/lib/audit_logger'; import { AuditLogger } from '../../server/lib/audit_logger'; import { @@ -151,14 +150,7 @@ export const security = (kibana) => new kibana.Plugin({ validateConfig(config, message => server.log(['security', 'warning'], message)); - // Create a Hapi auth scheme that should be applied to each request. - server.auth.scheme('login', () => ({ authenticate: authenticateFactory(server) })); - - server.auth.strategy('session', 'login'); - - // The default means that the `session` strategy that is based on `login` schema defined above will be - // automatically assigned to all routes that don't contain an auth config. - server.auth.default('session'); + await initAuthentication(this.kbnServer, server); const { savedObjects } = server; @@ -204,7 +196,6 @@ export const security = (kibana) => new kibana.Plugin({ getUserProvider(server); - await initAuthenticator(server); initAuthenticateApi(server); initAPIAuthorization(server, authorization); initAppAuthorization(server, xpackMainPlugin, authorization); diff --git a/x-pack/legacy/plugins/security/server/lib/auth_redirect.js b/x-pack/legacy/plugins/security/server/lib/auth_redirect.js deleted file mode 100644 index cbcd5ecaeb4790a..000000000000000 --- a/x-pack/legacy/plugins/security/server/lib/auth_redirect.js +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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 { wrapError } from './errors'; - -/** - * Creates a hapi authenticate function that conditionally redirects - * on auth failure. - * @param {Hapi.Server} server HapiJS Server instance. - * @returns {Function} Authentication function that will be called by Hapi for every - * request that needs to be authenticated. - */ -export function authenticateFactory(server) { - return async function authenticate(request, h) { - // If security is disabled continue with no user credentials - // and delete the client cookie as well. - const xpackInfo = server.plugins.xpack_main.info; - if (xpackInfo.isAvailable() && !xpackInfo.feature('security').isEnabled()) { - return h.authenticated({ credentials: {} }); - } - - let authenticationResult; - try { - authenticationResult = await server.plugins.security.authenticate(request); - } catch (err) { - server.log(['error', 'authentication'], err); - return wrapError(err); - } - - if (authenticationResult.succeeded()) { - return h.authenticated({ credentials: authenticationResult.user }); - } - - 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(); - } - - 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(); - }; -} diff --git a/x-pack/legacy/plugins/security/server/lib/__tests__/auth_redirect.js b/x-pack/legacy/plugins/security/server/lib/authentication/auth_redirect.test.ts similarity index 99% rename from x-pack/legacy/plugins/security/server/lib/__tests__/auth_redirect.js rename to x-pack/legacy/plugins/security/server/lib/authentication/auth_redirect.test.ts index a96d4b5a008cc6a..55556b97058eec7 100644 --- a/x-pack/legacy/plugins/security/server/lib/__tests__/auth_redirect.js +++ b/x-pack/legacy/plugins/security/server/lib/authentication/auth_redirect.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; +/* import Boom from 'boom'; import expect from '@kbn/expect'; import sinon from 'sinon'; @@ -151,4 +151,4 @@ describe('lib/auth_redirect', function () { sinon.assert.notCalled(h.redirect); }); -}); +});*/ diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/authentication_result.ts b/x-pack/legacy/plugins/security/server/lib/authentication/authentication_result.ts index be443462688be98..62a430f14aba4c5 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/authentication_result.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/authentication_result.ts @@ -45,6 +45,7 @@ interface AuthenticationOptions { redirectURL?: string; state?: unknown; user?: AuthenticatedUser; + authHeaders?: Record; } /** @@ -62,14 +63,23 @@ export class AuthenticationResult { /** * Produces `AuthenticationResult` for the case when authentication succeeds. * @param user User information retrieved as a result of successful authentication attempt. + * @param authHeaders The dictionary of headers with authentication information. * @param [state] Optional state to be stored and reused for the next request. */ - public static succeeded(user: AuthenticatedUser, state?: unknown) { + public static succeeded( + user: AuthenticatedUser, + authHeaders: Record = {}, + state?: unknown + ) { if (!user) { throw new Error('User should be specified.'); } - return new AuthenticationResult(AuthenticationResultStatus.Succeeded, { user, state }); + return new AuthenticationResult(AuthenticationResultStatus.Succeeded, { + user, + authHeaders, + state, + }); } /** @@ -112,6 +122,14 @@ export class AuthenticationResult { return this.options.user; } + /** + * Headers that include authentication information that should be used to authenticate user for any + * future requests (only available for `succeeded` result). + */ + public get authHeaders() { + return this.options.authHeaders; + } + /** * State associated with the authenticated user (only available for `succeeded` * and `redirected` results). diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.test.ts b/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.test.ts index 622d84dbc543a61..beea55569b014d9 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.test.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import sinon from 'sinon'; +/*import sinon from 'sinon'; import Boom from 'boom'; import { Legacy } from 'kibana'; @@ -467,8 +467,8 @@ describe('Authenticator', () => { }); }); - describe('`deauthenticate` method', () => { - let deauthenticate: ( + describe('`logout` method', () => { + let logout: ( request: ReturnType ) => Promise; beforeEach(async () => { @@ -478,11 +478,11 @@ describe('Authenticator', () => { await initAuthenticator(server as any); // Second argument will be a method we'd like to test. - deauthenticate = server.expose.withArgs('deauthenticate').firstCall.args[1]; + logout = server.expose.withArgs('logout').firstCall.args[1]; }); it('fails if request is not provided.', async () => { - await expect(deauthenticate(undefined as any)).rejects.toThrowError( + await expect(logout(undefined as any)).rejects.toThrowError( 'Request should be a valid object, was [undefined].' ); }); @@ -491,7 +491,7 @@ describe('Authenticator', () => { const request = requestFixture(); session.get.withArgs(request).resolves(null); - const deauthenticationResult = await deauthenticate(request); + const deauthenticationResult = await logout(request); expect(deauthenticationResult.notHandled()).toBe(true); sinon.assert.notCalled(session.clear); @@ -504,7 +504,7 @@ describe('Authenticator', () => { provider: 'basic', }); - const deauthenticationResult = await deauthenticate(request); + const deauthenticationResult = await logout(request); sinon.assert.calledOnce(session.clear); sinon.assert.calledWithExactly(session.clear, request); @@ -521,7 +521,7 @@ describe('Authenticator', () => { provider: 'token', }); - const deauthenticationResult = await deauthenticate(request); + const deauthenticationResult = await logout(request); sinon.assert.calledOnce(session.clear); sinon.assert.calledWithExactly(session.clear, request); @@ -570,4 +570,4 @@ describe('Authenticator', () => { await expect(isAuthenticated(request)).rejects.toThrowError(non401Error); }); }); -}); +});*/ diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.ts b/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.ts index 177c76b56ff6356..c8d8613e56aca00 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.ts @@ -5,30 +5,62 @@ */ import { Legacy } from 'kibana'; -import { getClient } from '../../../../../server/lib/get_client_shield'; +import { + SessionStorageFactory, + SessionStorage, + KibanaRequest, + LoggerFactory, + Logger, +} from '../../../../../../../src/core/server'; import { getErrorStatusCode } from '../errors'; +import { SecurityClusterClient } from '.'; + import { AuthenticationProviderOptions, BaseAuthenticationProvider, BasicAuthenticationProvider, KerberosAuthenticationProvider, - RequestWithLoginAttempt, SAMLAuthenticationProvider, TokenAuthenticationProvider, OIDCAuthenticationProvider, } from './providers'; import { AuthenticationResult } from './authentication_result'; import { DeauthenticationResult } from './deauthentication_result'; -import { Session } from './session'; -import { LoginAttempt } from './login_attempt'; import { AuthenticationProviderSpecificOptions } from './providers/base'; import { Tokens } from './tokens'; -interface ProviderSession { +/** + * The shape of the session that is actually stored in the cookie. + */ +export interface ProviderSession { provider: string; + + /** + * The Unix time in ms when the session should be considered expired. If `null`, session will stay + * active until the browser is closed. + */ + expires: number | null; + + /** + * Session value that is fed to the authentication provider. The shape is unknown upfront and + * entirely determined by the authentication provider that owns the current session. + */ state: unknown; } +export interface ProviderLoginAttempt { + provider: string; + value: unknown; +} + +interface AuthenticatorOptions { + config: Legacy.KibanaConfig; + log: LoggerFactory; + clusterClient: SecurityClusterClient; + sessionStorageFactory: SessionStorageFactory; + isSystemAPIRequest: (request: KibanaRequest) => boolean; +} + // Mapping between provider key defined in the config and authentication // provider class that can handle specific authentication mechanism. const providerMap = new Map< @@ -45,43 +77,15 @@ const providerMap = new Map< ['oidc', OIDCAuthenticationProvider], ]); -function assertRequest(request: Legacy.Request) { - if (!request || typeof request !== 'object') { - throw new Error(`Request should be a valid object, was [${typeof request}].`); +function assertRequest(request: KibanaRequest) { + if (!request || !(request instanceof KibanaRequest)) { + throw new Error(`Request should be a valid "KibanaRequest" instance, was [${typeof request}].`); } } -/** - * Prepares options object that is shared among all authentication providers. - * @param server Server instance. - */ -function getProviderOptions(server: Legacy.Server) { - const config = server.config(); - const client = getClient(server); - const log = server.log.bind(server); - - return { - client, - log, - basePath: config.get('server.basePath'), - tokens: new Tokens({ client, log }), - }; -} - -/** - * Prepares options object that is specific only to an authentication provider. - * @param server Server instance. - * @param providerType the type of the provider to get the options for. - */ -function getProviderSpecificOptions( - server: Legacy.Server, - providerType: string -): AuthenticationProviderSpecificOptions | undefined { - const config = server.config(); - - const providerOptionsConfigKey = `xpack.security.authc.${providerType}`; - if (config.has(providerOptionsConfigKey)) { - return config.get(providerOptionsConfigKey); +function assertLoginAttempt(attempt: ProviderLoginAttempt) { + if (!attempt || !attempt.provider || typeof attempt.provider !== 'string') { + throw new Error('Login attempt should be an object with non-empty "provider" property.'); } } @@ -117,50 +121,129 @@ function instantiateProvider( * the authentication is then considered to be unsuccessful and an authentication error * will be returned. */ -class Authenticator { +export class Authenticator { /** * List of configured and instantiated authentication providers. */ private readonly providers: Map; + /** + * Session duration in ms. If `null` session will stay active until the browser is closed. + */ + private readonly ttl: number | null = null; + + /** + * Internal authenticator logger. + */ + private readonly log: Logger; + /** * Instantiates Authenticator and bootstrap configured providers. - * @param server Server instance. - * @param session Session instance. + * @param options Authenticator options. */ - constructor(private readonly server: Legacy.Server, private readonly session: Session) { - const config = this.server.config(); - const authProviders = config.get('xpack.security.authc.providers'); + constructor(private readonly options: Readonly) { + this.log = options.log.get('security', 'authenticator'); + + const authProviders = this.options.config.get('xpack.security.authc.providers'); if (authProviders.length === 0) { throw new Error( 'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.' ); } - const providerOptions = Object.freeze(getProviderOptions(server)); + const providerCommonOptions = { + client: this.options.clusterClient, + basePath: this.options.config.get('server.basePath'), + tokens: new Tokens({ + client: this.options.clusterClient, + log: this.options.log.get('tokens'), + }), + }; this.providers = new Map( authProviders.map(providerType => { - const providerSpecificOptions = getProviderSpecificOptions(server, providerType); + const providerOptionsConfigKey = `xpack.security.authc.${providerType}`; + const providerSpecificOptions = this.options.config.has(providerOptionsConfigKey) + ? this.options.config.get(providerOptionsConfigKey) + : undefined; + return [ providerType, - instantiateProvider(providerType, providerOptions, providerSpecificOptions), + instantiateProvider( + providerType, + Object.freeze({ + ...providerCommonOptions, + log: this.options.log.get('security', providerType), + }), + providerSpecificOptions + ), ] as [string, BaseAuthenticationProvider]; }) ); + + this.ttl = this.options.config.get('xpack.security.sessionTimeout'); + } + + /** + * Performs the initial login request using the provider login attempt description. + * @param request Request instance. + * @param loginAttempt Login attempt description. + */ + async login(request: KibanaRequest, loginAttempt: ProviderLoginAttempt) { + assertRequest(request); + assertLoginAttempt(loginAttempt); + + // If there is an attempt to login with a provider that isn't enabled, we should fail. + const provider = this.providers.get(loginAttempt.provider); + if (provider === undefined) { + this.log.debug( + `Login attempt for provider "${loginAttempt.provider}" is detected, but it isn't enabled.` + ); + return AuthenticationResult.notHandled(); + } + + this.log.debug(`Performing login using "${loginAttempt.provider}" provider.`); + + const sessionStorage = this.options.sessionStorageFactory.asScoped(request); + + // If we detect an existing session that belongs to a different provider than the one request to + // perform a login we should clear such session. + let existingSession = await this.getSessionValue(sessionStorage); + if (existingSession && existingSession.provider !== loginAttempt.provider) { + this.log.debug( + `Clearing existing session of another ("${existingSession.provider}") provider.` + ); + await sessionStorage.clear(); + existingSession = null; + } + + const authenticationResult = await provider.login( + request, + loginAttempt.value, + existingSession ? existingSession.state : null + ); + + this.updateSessionValue(sessionStorage, { + providerType: loginAttempt.provider, + isSystemAPIRequest: this.options.isSystemAPIRequest(request), + authenticationResult, + existingSession, + }); + + return authenticationResult; } /** * Performs request authentication using configured chain of authentication providers. * @param request Request instance. */ - async authenticate(request: RequestWithLoginAttempt) { + async authenticate(request: KibanaRequest) { assertRequest(request); - const isSystemApiRequest = this.server.plugins.kibana.systemApi.isSystemApiRequest(request); - const existingSession = await this.getSessionValue(request); + const sessionStorage = this.options.sessionStorageFactory.asScoped(request); + const existingSession = await this.getSessionValue(sessionStorage); - let authenticationResult; + let authenticationResult = AuthenticationResult.notHandled(); for (const [providerType, provider] of this.providerIterator(existingSession)) { // Check if current session has been set by this provider. const ownsSession = existingSession && existingSession.provider === providerType; @@ -170,39 +253,19 @@ class Authenticator { ownsSession ? existingSession!.state : null ); - if (ownsSession || authenticationResult.shouldUpdateState()) { - // If authentication succeeds or requires redirect we should automatically extend existing user session, - // unless authentication has been triggered by a system API request. In case provider explicitly returns new - // state we should store it in the session regardless of whether it's a system API request or not. - const sessionCanBeUpdated = - (authenticationResult.succeeded() || authenticationResult.redirected()) && - (authenticationResult.shouldUpdateState() || !isSystemApiRequest); - - // If provider owned the session, but failed to authenticate anyway, that likely means that - // session is not valid and we should clear it. Also provider can specifically ask to clear - // session by setting it to `null` even if authentication attempt didn't fail. - if ( - authenticationResult.shouldClearState() || - (authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401) - ) { - await this.session.clear(request); - } else if (sessionCanBeUpdated) { - await this.session.set( - request, - authenticationResult.shouldUpdateState() - ? { state: authenticationResult.state, provider: providerType } - : existingSession - ); - } - } - - if (authenticationResult.failed()) { - return authenticationResult; - } - - if (authenticationResult.succeeded()) { - return AuthenticationResult.succeeded(authenticationResult.user!); - } else if (authenticationResult.redirected()) { + this.updateSessionValue(sessionStorage, { + providerType, + isSystemAPIRequest: this.options.isSystemAPIRequest(request), + authenticationResult, + existingSession: + existingSession && existingSession.provider === providerType ? existingSession : null, + }); + + if ( + authenticationResult.failed() || + authenticationResult.succeeded() || + authenticationResult.redirected() + ) { return authenticationResult; } } @@ -214,24 +277,25 @@ class Authenticator { * Deauthenticates current request. * @param request Request instance. */ - async deauthenticate(request: RequestWithLoginAttempt) { + async logout(request: KibanaRequest) { assertRequest(request); - const sessionValue = await this.getSessionValue(request); + const sessionStorage = this.options.sessionStorageFactory.asScoped(request); + const sessionValue = await this.getSessionValue(sessionStorage); if (sessionValue) { - await this.session.clear(request); + sessionStorage.clear(); - return this.providers.get(sessionValue.provider)!.deauthenticate(request, sessionValue.state); + return this.providers.get(sessionValue.provider)!.logout(request, sessionValue.state); } - // Normally when there is no active session in Kibana, `deauthenticate` method shouldn't do anything + // Normally when there is no active session in Kibana, `logout` method shouldn't do anything // and user will eventually be redirected to the home page to log in. But if SAML is supported there // is a special case when logout is initiated by the IdP or another SP, then IdP will request _every_ // SP associated with the current user session to do the logout. So if Kibana (without active session) // receives such a request it shouldn't redirect user to the home page, but rather redirect back to IdP // with correct logout response and only Elasticsearch knows how to do that. if ((request.query as Record).SAMLRequest && this.providers.has('saml')) { - return this.providers.get('saml')!.deauthenticate(request); + return this.providers.get('saml')!.logout(request); } return DeauthenticationResult.notHandled(); @@ -240,20 +304,25 @@ class Authenticator { /** * Returns provider iterator where providers are sorted in the order of priority (based on the session ownership). * @param sessionValue Current session value. + * @param [loginAttempt] Optional provider login attempt. If present, login attempt always has a higher + * priority comparing to the existing session. */ - *providerIterator( - sessionValue: ProviderSession | null + private *providerIterator( + sessionValue: ProviderSession | null, + loginAttempt?: ProviderLoginAttempt ): IterableIterator<[string, BaseAuthenticationProvider]> { - // If there is no session to predict which provider to use first, let's use the order - // providers are configured in. Otherwise return provider that owns session first, and only then the rest - // of providers. - if (!sessionValue) { + // If there is no session or login attempt to predict which provider to use first, let's use the order + // providers are configured in. Otherwise return provider that owns login attempt/session first, and + // only then the rest of providers. Login attempt always takes precedence over session. + const preferredProvider = + (loginAttempt && loginAttempt.provider) || (sessionValue && sessionValue.provider); + if (!preferredProvider) { yield* this.providers; } else { - yield [sessionValue.provider, this.providers.get(sessionValue.provider)!]; + yield [preferredProvider, this.providers.get(preferredProvider)!]; - for (const [providerType, provider] of this.providers) { - if (providerType !== sessionValue.provider) { + for (const [providerType, provider] of this.providers.entries()) { + if (providerType !== preferredProvider) { yield [providerType, provider]; } } @@ -263,54 +332,65 @@ class Authenticator { /** * Extracts session value for the specified request. Under the hood it can * clear session if it belongs to the provider that is not available. - * @param request Request to extract session value for. + * @param sessionStorage Session storage instance. */ - private async getSessionValue(request: Legacy.Request) { - let sessionValue = await this.session.get(request); + private async getSessionValue(sessionStorage: SessionStorage) { + let sessionValue = await sessionStorage.get(); // If for some reason we have a session stored for the provider that is not available // (e.g. when user was logged in with one provider, but then configuration has changed // and that provider is no longer available), then we should clear session entirely. if (sessionValue && !this.providers.has(sessionValue.provider)) { - await this.session.clear(request); + await sessionStorage.clear(); sessionValue = null; } return sessionValue; } -} -export async function initAuthenticator(server: Legacy.Server) { - const session = await Session.create(server); - const authenticator = new Authenticator(server, session); - - const loginAttempts = new WeakMap(); - server.decorate('request', 'loginAttempt', function(this: Legacy.Request) { - const request = this; - if (!loginAttempts.has(request)) { - loginAttempts.set(request, new LoginAttempt()); + private updateSessionValue( + sessionStorage: SessionStorage, + { + providerType, + authenticationResult, + existingSession, + isSystemAPIRequest, + }: { + providerType: string; + authenticationResult: AuthenticationResult; + existingSession: ProviderSession | null; + isSystemAPIRequest: boolean; } - return loginAttempts.get(request); - }); - - server.expose('authenticate', (request: RequestWithLoginAttempt) => - authenticator.authenticate(request) - ); - server.expose('deauthenticate', (request: RequestWithLoginAttempt) => - authenticator.deauthenticate(request) - ); - - server.expose('isAuthenticated', async (request: Legacy.Request) => { - try { - await server.plugins.security!.getUser(request); - return true; - } catch (err) { - // Don't swallow server errors. - if (!err.isBoom || err.output.statusCode !== 401) { - throw err; - } + ) { + if (!existingSession && !authenticationResult.shouldUpdateState()) { + return; } - return false; - }); + // If authentication succeeds or requires redirect we should automatically extend existing user session, + // unless authentication has been triggered by a system API request. In case provider explicitly returns new + // state we should store it in the session regardless of whether it's a system API request or not. + const sessionCanBeUpdated = + (authenticationResult.succeeded() || authenticationResult.redirected()) && + (authenticationResult.shouldUpdateState() || !isSystemAPIRequest); + + // If provider owned the session, but failed to authenticate anyway, that likely means that + // session is not valid and we should clear it. Also provider can specifically ask to clear + // session by setting it to `null` even if authentication attempt didn't fail. + if ( + authenticationResult.shouldClearState() || + (authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401) + ) { + sessionStorage.clear(); + } else if (sessionCanBeUpdated) { + sessionStorage.set( + authenticationResult.shouldUpdateState() + ? { + state: authenticationResult.state, + provider: providerType, + expires: this.ttl && Date.now() + this.ttl, + } + : existingSession! + ); + } + } } diff --git a/x-pack/legacy/plugins/security/server/lib/can_redirect_request.test.ts b/x-pack/legacy/plugins/security/server/lib/authentication/can_redirect_request.test.ts similarity index 99% rename from x-pack/legacy/plugins/security/server/lib/can_redirect_request.test.ts rename to x-pack/legacy/plugins/security/server/lib/authentication/can_redirect_request.test.ts index b132b39c7ae7e97..0328e88648ec5ee 100644 --- a/x-pack/legacy/plugins/security/server/lib/can_redirect_request.test.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/can_redirect_request.test.ts @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +/* import { requestFixture } from './__tests__/__fixtures__/request'; import { canRedirectRequest } from './can_redirect_request'; @@ -26,3 +27,4 @@ describe('lib/can_redirect_request', () => { expect(canRedirectRequest(request)).toBe(false); }); }); +*/ diff --git a/x-pack/legacy/plugins/security/server/lib/can_redirect_request.ts b/x-pack/legacy/plugins/security/server/lib/authentication/can_redirect_request.ts similarity index 65% rename from x-pack/legacy/plugins/security/server/lib/can_redirect_request.ts rename to x-pack/legacy/plugins/security/server/lib/authentication/can_redirect_request.ts index c87d31f8ff0c240..d1a233b29500726 100644 --- a/x-pack/legacy/plugins/security/server/lib/can_redirect_request.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/can_redirect_request.ts @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Request } from 'hapi'; -import { contains, get, has } from 'lodash'; +import { KibanaRequest } from 'src/core/server/http/router'; const ROUTE_TAG_API = 'api'; const KIBANA_XSRF_HEADER = 'kbn-xsrf'; @@ -16,11 +15,12 @@ const KIBANA_VERSION_HEADER = 'kbn-version'; * only for non-AJAX and non-API requests. * @param request HapiJS request instance to check redirection possibility for. */ -export function canRedirectRequest(request: Request) { - const hasVersionHeader = has(request.raw.req.headers, KIBANA_VERSION_HEADER); - const hasXsrfHeader = has(request.raw.req.headers, KIBANA_XSRF_HEADER); +export function canRedirectRequest(request: KibanaRequest) { + const headers = request.headers; + const hasVersionHeader = headers.hasOwnProperty(KIBANA_VERSION_HEADER); + const hasXsrfHeader = headers.hasOwnProperty(KIBANA_XSRF_HEADER); - const isApiRoute = contains(get(request, 'route.settings.tags'), ROUTE_TAG_API); + const isApiRoute = request.route.options.tags.includes(ROUTE_TAG_API); const isAjaxRequest = hasVersionHeader || hasXsrfHeader; return !isApiRoute && !isAjaxRequest; diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/index.ts b/x-pack/legacy/plugins/security/server/lib/authentication/index.ts index 1a70fdf879da589..4d22aeade435c3f 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/index.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/index.ts @@ -4,5 +4,152 @@ * you may not use this file except in compliance with the Elastic License. */ +import { schema } from '@kbn/config-schema'; +import KbnServer from 'src/legacy/server/kbn_server'; +import Boom from 'boom'; +import { Legacy } from 'kibana'; +import { FakeRequest, KibanaRequest } from '../../../../../../../src/core/server'; +import { getClient } from '../../../../../server/lib/get_client_shield'; +import { AuthenticatedUser } from '../../../common/model'; +import { wrapError } from '../errors'; +import { Authenticator, ProviderLoginAttempt, ProviderSession } from './authenticator'; + +export { canRedirectRequest } from './can_redirect_request'; export { AuthenticationResult } from './authentication_result'; -export { DeauthenticationResult } from './deauthentication_result'; + +interface SecurityCallWithRequest extends Legacy.Plugins.elasticsearch.CallClusterWithRequest { + ( + request: KibanaRequest | FakeRequest, + endpoint: 'shield.authenticate', + params?: Record + ): Promise; + ( + request: KibanaRequest, + endpoint: 'shield.getAccessToken', + params: { body: { grant_type: 'client_credentials' } } + ): Promise<{ access_token: string }>; +} + +export interface SecurityClusterClient { + callWithRequest: SecurityCallWithRequest; + callWithInternalUser: Legacy.Plugins.elasticsearch.Cluster['callWithInternalUser']; +} + +export async function initAuthentication(kbnServer: KbnServer, ser: KbnServer['server']) { + const config = kbnServer.server.config(); + const server = kbnServer.server; + + const logger = kbnServer.newPlatform.coreContext.logger; + const core = kbnServer.newPlatform.setup.core; + + const clusterClient = getClient(server) as SecurityClusterClient; + const xpackInfo = (ser as any).plugins.xpack_main.info; + + const authRegistration = await core.http.registerAuth( + async (request, t) => { + // If security is disabled continue with no user credentials + // and delete the client cookie as well. + if (xpackInfo.isAvailable() && !xpackInfo.feature('security').isEnabled()) { + return t.authenticated(); + } + + let authenticationResult; + try { + authenticationResult = await authenticator.authenticate(request); + } catch (err) { + logger.get('authentication').error(err); + return t.rejected(wrapError(err)); + } + + if (authenticationResult.succeeded()) { + return t.authenticated({ + state: authenticationResult.user, + headers: authenticationResult.authHeaders, + }); + } + + 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 t.redirected(authenticationResult.redirectURL!); + } + + if (authenticationResult.failed()) { + kbnServer.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 as any; + } + + return t.rejected(error); + } + + return t.rejected(Boom.unauthorized()); + }, + { + encryptionKey: config.get('xpack.security.encryptionKey'), + isSecure: config.get('xpack.security.secureCookies'), + name: config.get('xpack.security.cookieName'), + validate: (sessionValue: ProviderSession) => + !(sessionValue.expires && sessionValue.expires < Date.now()), + } + ); + + const authenticator: Authenticator = new Authenticator({ + clusterClient, + config, + isSystemAPIRequest: server.plugins.kibana.systemApi.isSystemApiRequest.bind( + server.plugins.kibana.systemApi + ), + log: logger, + sessionStorageFactory: authRegistration.sessionStorageFactory, + }); + + const getUser = async (request: Legacy.Request | KibanaRequest) => { + return xpackInfo && xpackInfo.isAvailable() && !xpackInfo.feature('security').isEnabled() + ? null + : // bad types, it actually accepts KibanaRequest. + clusterClient.callWithRequest(request as any, 'shield.authenticate'); + }; + + ser.expose({ + login: async (request: Legacy.Request, attempt: ProviderLoginAttempt) => + await authenticator.login(KibanaRequest.from(request), attempt), + authenticate: async (request: Legacy.Request) => + await authenticator.authenticate(KibanaRequest.from(request)), + logout: async (request: Legacy.Request) => + await authenticator.logout( + // HACK: remove once https://github.com/elastic/kibana/pull/39448 is merged. + KibanaRequest.from(request, { + query: schema.object({ + msg: schema.maybe(schema.string()), + next: schema.maybe(schema.string()), + SAMLRequest: schema.maybe(schema.any()), + SigAlg: schema.maybe(schema.any()), + Signature: schema.maybe(schema.any()), + }), + }) + ), + isAuthenticated: async (request: Legacy.Request) => { + try { + await getUser(request); + return true; + } catch (err) { + // Don't swallow server errors. + if (!err.isBoom || err.output.statusCode !== 401) { + throw err; + } + } + + return false; + }, + getUser, + }); +} diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/login_attempt.test.ts b/x-pack/legacy/plugins/security/server/lib/authentication/login_attempt.test.ts deleted file mode 100644 index ba3f29ddd491f47..000000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authentication/login_attempt.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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 { LoginAttempt } from './login_attempt'; - -describe('LoginAttempt', () => { - describe('getCredentials()', () => { - it('returns null by default', () => { - const attempt = new LoginAttempt(); - expect(attempt.getCredentials()).toBe(null); - }); - - it('returns a credentials object after credentials are set', () => { - const attempt = new LoginAttempt(); - attempt.setCredentials('foo', 'bar'); - expect(attempt.getCredentials()).toEqual({ username: 'foo', password: 'bar' }); - }); - }); - - describe('setCredentials()', () => { - it('sets the credentials for this login attempt', () => { - const attempt = new LoginAttempt(); - attempt.setCredentials('foo', 'bar'); - expect(attempt.getCredentials()).toEqual({ username: 'foo', password: 'bar' }); - }); - - it('throws if credentials have already been set', () => { - const attempt = new LoginAttempt(); - attempt.setCredentials('foo', 'bar'); - expect(() => attempt.setCredentials('some', 'some')).toThrowError( - 'Credentials for login attempt have already been set' - ); - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/login_attempt.ts b/x-pack/legacy/plugins/security/server/lib/authentication/login_attempt.ts deleted file mode 100644 index 642a3cd1f293470..000000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authentication/login_attempt.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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. - */ - -/** - * Represents login credentials. - */ -interface LoginCredentials { - username: string; - password: string; -} - -/** - * A LoginAttempt represents a single attempt to provide login credentials. - * Once credentials are set, they cannot be changed. - */ -export class LoginAttempt { - /** - * Username and password for login. - */ - private credentials: LoginCredentials | null = null; - - /** - * Gets the username and password for this login. - */ - public getCredentials() { - return this.credentials; - } - - /** - * Sets the username and password for this login. - */ - public setCredentials(username: string, password: string) { - if (this.credentials) { - throw new Error('Credentials for login attempt have already been set'); - } - - this.credentials = Object.freeze({ username, password }); - } -} diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/base.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/base.ts index af4028185381c28..fc97472d088f3f0 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/base.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/providers/base.ts @@ -4,26 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; +import { KibanaRequest, Logger } from '../../../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { LoginAttempt } from '../login_attempt'; +import { SecurityClusterClient } from '../index'; import { Tokens } from '../tokens'; -/** - * Describes a request complemented with `loginAttempt` method. - */ -export interface RequestWithLoginAttempt extends Legacy.Request { - loginAttempt: () => LoginAttempt; -} - /** * Represents available provider options. */ export interface AuthenticationProviderOptions { basePath: string; - client: Legacy.Plugins.elasticsearch.Cluster; - log: (tags: string[], message: string) => void; + client: SecurityClusterClient; + log: Logger; tokens: PublicMethodsOf; } @@ -43,22 +36,32 @@ export abstract class BaseAuthenticationProvider { constructor(protected readonly options: Readonly) {} /** - * Performs request authentication. + * Performs initial login request and creates user session. Provider isn't required to implement + * this method if it doesn't support initial login request. * @param request Request instance. + * @param loginAttempt Login attempt associated with the provider. * @param [state] Optional state object associated with the provider. */ - abstract authenticate( - request: RequestWithLoginAttempt, + async login( + request: KibanaRequest, + loginAttempt: unknown, state?: unknown - ): Promise; + ): Promise { + return AuthenticationResult.notHandled(); + } + + /** + * Performs request authentication based on the session created during login or other information + * associated with the request (e.g. `Authorization` HTTP header). + * @param request Request instance. + * @param [state] Optional state object associated with the provider. + */ + abstract authenticate(request: KibanaRequest, state?: unknown): Promise; /** * Invalidates user session associated with the request. * @param request Request instance. * @param [state] Optional state object associated with the provider that needs to be invalidated. */ - abstract deauthenticate( - request: Legacy.Request, - state?: unknown - ): Promise; + abstract logout(request: KibanaRequest, state?: unknown): Promise; } diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.test.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.test.ts index 88ae1d76f5b5794..594475c654a6a33 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.test.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.test.ts @@ -169,7 +169,7 @@ describe('BasicAuthenticationProvider', () => { }); }); - describe('`deauthenticate` method', () => { + describe('`logout` method', () => { let provider: BasicAuthenticationProvider; beforeEach(() => { provider = new BasicAuthenticationProvider(mockAuthenticationProviderOptions()); @@ -177,14 +177,14 @@ describe('BasicAuthenticationProvider', () => { it('always redirects to the login page.', async () => { const request = requestFixture(); - const deauthenticateResult = await provider.deauthenticate(request); + const deauthenticateResult = await provider.logout(request); expect(deauthenticateResult.redirected()).toBe(true); expect(deauthenticateResult.redirectURL).toBe('/base-path/login?msg=LOGGED_OUT'); }); it('passes query string parameters to the login page.', async () => { const request = requestFixture({ search: '?next=%2Fapp%2Fml&msg=SESSION_EXPIRED' }); - const deauthenticateResult = await provider.deauthenticate(request); + const deauthenticateResult = await provider.logout(request); expect(deauthenticateResult.redirected()).toBe(true); expect(deauthenticateResult.redirectURL).toBe( '/base-path/login?next=%2Fapp%2Fml&msg=SESSION_EXPIRED' diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.ts index bac711b86807123..cca60ff8acb102b 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.ts @@ -6,11 +6,11 @@ /* eslint-disable max-classes-per-file */ -import { Legacy } from 'kibana'; -import { canRedirectRequest } from '../../can_redirect_request'; +import { KibanaRequest } from '../../../../../../../../src/core/server'; +import { canRedirectRequest } from '../can_redirect_request'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base'; +import { BaseAuthenticationProvider } from './base'; /** * Utility class that knows how to decorate request with proper Basic authentication headers. @@ -23,7 +23,7 @@ export class BasicCredentials { * @param username User name. * @param password User password. */ - public static decorateRequest( + public static decorateRequest( request: T, username: string, password: string @@ -47,6 +47,14 @@ export class BasicCredentials { } } +/** + * Describes the parameters that are required by the provider to process the initial login request. + */ +interface ProviderLoginAttempt { + username: string; + password: string; +} + /** * The state supported by the provider. */ @@ -63,34 +71,65 @@ interface ProviderState { * Provider that supports request authentication via Basic HTTP Authentication. */ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { + /** + * Performs initial login request using username and password. + * @param request Request instance. + * @param attempt User credentials. + * @param [state] Optional state object associated with the provider. + */ + public async login( + request: KibanaRequest, + attempt: ProviderLoginAttempt, + state?: ProviderState | null + ) { + this.options.log.debug('Trying to perform a login.'); + + if (!attempt || !attempt.username || !attempt.password) { + this.options.log.debug('Username and/or password not provided.'); + return AuthenticationResult.notHandled(); + } + + try { + const authorization = `Basic ${Buffer.from( + `${attempt.username}:${attempt.password}` + ).toString('base64')}`; + + const user = await this.options.client.callWithRequest( + { headers: { ...request.headers, authorization } }, + 'shield.authenticate' + ); + + this.options.log.debug('Login has been successfully performed.'); + return AuthenticationResult.succeeded(user, { authorization }, { authorization }); + } catch (err) { + this.options.log.debug(`Failed to perform a login: ${err.message}`); + return AuthenticationResult.failed(err); + } + } + /** * Performs request authentication using Basic HTTP Authentication. * @param request Request instance. * @param [state] Optional state object associated with the provider. */ - public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) { - this.debug(`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 auth - if (authenticationResult.notHandled()) { - const { - authenticationResult: headerAuthResult, - headerNotRecognized, - } = await this.authenticateViaHeader(request); - if (headerNotRecognized) { - return headerAuthResult; - } - authenticationResult = headerAuthResult; + public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + this.options.log.debug(`Trying to authenticate user request to ${request.url.path}.`); + + // try header-based auth + const { + authenticationResult: headerAuthResult, + headerNotRecognized, + } = await this.authenticateViaHeader(request); + if (headerNotRecognized) { + return headerAuthResult; } + let authenticationResult = headerAuthResult; if (authenticationResult.notHandled() && state) { authenticationResult = await this.authenticateViaState(request, state); } else if (authenticationResult.notHandled() && canRedirectRequest(request)) { // If we couldn't handle authentication let's redirect user to the login page. - const nextURL = encodeURIComponent(`${request.getBasePath()}${request.url.path}`); + const nextURL = encodeURIComponent(`${this.options.basePath}${request.url.path}`); authenticationResult = AuthenticationResult.redirectTo( `${this.options.basePath}/login?next=${nextURL}` ); @@ -103,62 +142,30 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { * Redirects user to the login page preserving query string parameters. * @param request Request instance. */ - public async deauthenticate(request: Legacy.Request) { + public async logout(request: KibanaRequest) { // Query string may contain the path where logout has been called or // logout reason that login page may need to know. const queryString = request.url.search || `?msg=LOGGED_OUT`; return DeauthenticationResult.redirectTo(`${this.options.basePath}/login${queryString}`); } - /** - * Validates whether request contains a login payload and authenticates the - * user if necessary. - * @param request Request instance. - */ - private async authenticateViaLoginAttempt(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate via login attempt.'); - - const credentials = request.loginAttempt().getCredentials(); - if (!credentials) { - this.debug('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.debug('Request has been authenticated via login attempt.'); - return AuthenticationResult.succeeded(user, { authorization: request.headers.authorization }); - } catch (err) { - this.debug(`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); - } - } - /** * Validates whether request contains `Basic ***` Authorization header and just passes it * forward to Elasticsearch backend. * @param request Request instance. */ - private async authenticateViaHeader(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate via header.'); + private async authenticateViaHeader(request: KibanaRequest) { + this.options.log.debug('Trying to authenticate via header.'); const authorization = request.headers.authorization; - if (!authorization) { - this.debug('Authorization header is not presented.'); + if (!authorization || typeof authorization !== 'string') { + this.options.log.debug('Authorization header is not presented.'); return { authenticationResult: AuthenticationResult.notHandled() }; } const authenticationSchema = authorization.split(/\s+/)[0]; if (authenticationSchema.toLowerCase() !== 'basic') { - this.debug(`Unsupported authentication schema: ${authenticationSchema}`); + this.options.log.debug(`Unsupported authentication schema: ${authenticationSchema}`); return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true, @@ -168,11 +175,10 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { try { const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - this.debug('Request has been authenticated via header.'); - + this.options.log.debug('Request has been authenticated via header.'); return { authenticationResult: AuthenticationResult.succeeded(user) }; } catch (err) { - this.debug(`Failed to authenticate request via header: ${err.message}`); + this.options.log.debug(`Failed to authenticate request via header: ${err.message}`); return { authenticationResult: AuthenticationResult.failed(err) }; } } @@ -183,44 +189,25 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - private async authenticateViaState( - request: RequestWithLoginAttempt, - { authorization }: ProviderState - ) { - this.debug('Trying to authenticate via state.'); + private async authenticateViaState(request: KibanaRequest, { authorization }: ProviderState) { + this.options.log.debug('Trying to authenticate via state.'); if (!authorization) { - this.debug('Access token is not found in state.'); + this.options.log.debug('Access token is not found in state.'); return AuthenticationResult.notHandled(); } - request.headers.authorization = authorization; - try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.debug('Request has been authenticated via state.'); + const user = await this.options.client.callWithRequest( + { headers: { ...request.headers, authorization } }, + 'shield.authenticate' + ); - return AuthenticationResult.succeeded(user); + this.options.log.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authorization }); } catch (err) { - this.debug(`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; - + this.options.log.debug(`Failed to authenticate request via state: ${err.message}`); return AuthenticationResult.failed(err); } } - - /** - * Logs message with `debug` level and saml/security related tags. - * @param message Message to log. - */ - private debug(message: string) { - this.options.log(['debug', 'security', 'basic'], message); - } } diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/index.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/index.ts index a3a0c6192baa429..61fda21c1703a5a 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/index.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/providers/index.ts @@ -4,11 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export { - BaseAuthenticationProvider, - AuthenticationProviderOptions, - RequestWithLoginAttempt, -} from './base'; +export { BaseAuthenticationProvider, AuthenticationProviderOptions } from './base'; export { BasicAuthenticationProvider, BasicCredentials } from './basic'; export { KerberosAuthenticationProvider } from './kerberos'; export { SAMLAuthenticationProvider } from './saml'; diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.test.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.test.ts index bbfa1b9f75d0e4f..cab56a063be4981 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.test.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.test.ts @@ -8,7 +8,6 @@ import Boom from 'boom'; import sinon from 'sinon'; import { requestFixture } from '../../__tests__/__fixtures__/request'; -import { LoginAttempt } from '../login_attempt'; import { mockAuthenticationProviderOptions } from './base.mock'; import { KerberosAuthenticationProvider } from './kerberos'; @@ -367,14 +366,14 @@ describe('KerberosAuthenticationProvider', () => { }); }); - describe('`deauthenticate` method', () => { + describe('`logout` method', () => { it('returns `notHandled` if state is not presented.', async () => { const request = requestFixture(); - let deauthenticateResult = await provider.deauthenticate(request); + let deauthenticateResult = await provider.logout(request); expect(deauthenticateResult.notHandled()).toBe(true); - deauthenticateResult = await provider.deauthenticate(request, null); + deauthenticateResult = await provider.logout(request, null); expect(deauthenticateResult.notHandled()).toBe(true); sinon.assert.notCalled(tokens.invalidate); @@ -387,7 +386,7 @@ describe('KerberosAuthenticationProvider', () => { const failureReason = new Error('failed to delete token'); tokens.invalidate.withArgs(tokenPair).rejects(failureReason); - const authenticationResult = await provider.deauthenticate(request, tokenPair); + const authenticationResult = await provider.logout(request, tokenPair); sinon.assert.calledOnce(tokens.invalidate); sinon.assert.calledWithExactly(tokens.invalidate, tokenPair); @@ -405,7 +404,7 @@ describe('KerberosAuthenticationProvider', () => { tokens.invalidate.withArgs(tokenPair).resolves(); - const authenticationResult = await provider.deauthenticate(request, tokenPair); + const authenticationResult = await provider.logout(request, tokenPair); sinon.assert.calledOnce(tokens.invalidate); sinon.assert.calledWithExactly(tokens.invalidate, tokenPair); diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.ts index af6122e06e25156..8feb24af699f98f 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.ts @@ -6,12 +6,12 @@ import Boom from 'boom'; import { get } from 'lodash'; -import { Legacy } from 'kibana'; +import { KibanaRequest } from '../../../../../../../../src/core/server'; import { getErrorStatusCode } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; +import { BaseAuthenticationProvider } from './base'; import { Tokens, TokenPair } from '../tokens'; -import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base'; /** * The state supported by the provider. @@ -22,9 +22,9 @@ type ProviderState = TokenPair; * Parses request's `Authorization` HTTP header if present and extracts authentication scheme. * @param request Request instance to extract authentication scheme for. */ -function getRequestAuthenticationScheme(request: RequestWithLoginAttempt) { +function getRequestAuthenticationScheme(request: KibanaRequest) { const authorization = request.headers.authorization; - if (!authorization) { + if (!authorization || typeof authorization !== 'string') { return ''; } @@ -40,20 +40,15 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param [state] Optional state object associated with the provider. */ - public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) { - this.debug(`Trying to authenticate user request to ${request.url.path}.`); + public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + this.options.log.debug(`Trying to authenticate user request to ${request.url.path}.`); const authenticationScheme = getRequestAuthenticationScheme(request); if ( authenticationScheme && (authenticationScheme !== 'negotiate' && authenticationScheme !== 'bearer') ) { - this.debug(`Unsupported authentication scheme: ${authenticationScheme}`); - return AuthenticationResult.notHandled(); - } - - if (request.loginAttempt().getCredentials() != null) { - this.debug('Login attempt is detected, but it is not supported by the provider'); + this.options.log.debug(`Unsupported authentication scheme: ${authenticationScheme}`); return AuthenticationResult.notHandled(); } @@ -88,18 +83,18 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - public async deauthenticate(request: Legacy.Request, state?: ProviderState | null) { - this.debug(`Trying to deauthenticate user via ${request.url.path}.`); + public async logout(request: KibanaRequest, state?: ProviderState | null) { + this.options.log.debug(`Trying to log user out via ${request.url.path}.`); if (!state) { - this.debug('There is no access token invalidate.'); + this.options.log.debug('There is no access token invalidate.'); return DeauthenticationResult.notHandled(); } try { await this.options.tokens.invalidate(state); } catch (err) { - this.debug(`Failed invalidating access and/or refresh tokens: ${err.message}`); + this.options.log.debug(`Failed invalidating access and/or refresh tokens: ${err.message}`); return DeauthenticationResult.failed(err); } @@ -111,10 +106,12 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * get an access token in exchange. * @param request Request instance. */ - private async authenticateWithNegotiateScheme(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate request using "Negotiate" authentication scheme.'); + private async authenticateWithNegotiateScheme(request: KibanaRequest) { + this.options.log.debug( + 'Trying to authenticate request using "Negotiate" authentication scheme.' + ); - const [, kerberosTicket] = request.headers.authorization.split(/\s+/); + const [, kerberosTicket] = (request.headers.authorization as string).split(/\s+/); // First attempt to exchange SPNEGO token for an access token. let tokens: { access_token: string; refresh_token: string }; @@ -123,34 +120,28 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { body: { grant_type: '_kerberos', kerberos_ticket: kerberosTicket }, }); } catch (err) { - this.debug(`Failed to exchange SPNEGO token for an access token: ${err.message}`); + this.options.log.debug(`Failed to exchange SPNEGO token for an access token: ${err.message}`); return AuthenticationResult.failed(err); } - this.debug('Get token API request to Elasticsearch successful'); - - // Then attempt to query for the user details using the new token - const originalAuthorizationHeader = request.headers.authorization; - request.headers.authorization = `Bearer ${tokens.access_token}`; + this.options.log.debug('Get token API request to Elasticsearch successful'); try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.debug('User has been authenticated with new access token'); + // Then attempt to query for the user details using the new token + const authorization = `Bearer ${tokens.access_token}`; + const user = await this.options.client.callWithRequest( + { headers: { ...request.headers, authorization } }, + 'shield.authenticate' + ); - return AuthenticationResult.succeeded(user, { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - }); + this.options.log.debug('User has been authenticated with new access token'); + return AuthenticationResult.succeeded( + user, + { authorization }, + { accessToken: tokens.access_token, refreshToken: tokens.refresh_token } + ); } catch (err) { - this.debug(`Failed to authenticate request via access token: ${err.message}`); - - // Restore `Authorization` header we've just set. We can end up here only if newly generated - // access token was rejected by Elasticsearch for some reason and it doesn't make any sense to - // keep it in the request object since it can confuse other consumers of the request down the - // line (e.g. in the next authentication provider). - request.headers.authorization = originalAuthorizationHeader; - + this.options.log.debug(`Failed to authenticate request via access token: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -159,20 +150,20 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * Tries to authenticate request with `Bearer ***` Authorization header by passing it to the Elasticsearch backend. * @param request Request instance. */ - private async authenticateWithBearerScheme(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate request using "Bearer" authentication scheme.'); + private async authenticateWithBearerScheme(request: KibanaRequest) { + this.options.log.debug('Trying to authenticate request using "Bearer" authentication scheme.'); try { const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - this.debug('Request has been authenticated using "Bearer" authentication scheme.'); - + this.options.log.debug( + 'Request has been authenticated using "Bearer" authentication scheme.' + ); return AuthenticationResult.succeeded(user); } catch (err) { - this.debug( + this.options.log.debug( `Failed to authenticate request using "Bearer" authentication scheme: ${err.message}` ); - return AuthenticationResult.failed(err); } } @@ -183,34 +174,25 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - private async authenticateViaState( - request: RequestWithLoginAttempt, - { accessToken }: ProviderState - ) { - this.debug('Trying to authenticate via state.'); + private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) { + this.options.log.debug('Trying to authenticate via state.'); if (!accessToken) { - this.debug('Access token is not found in state.'); + this.options.log.debug('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'); + const authorization = `Bearer ${accessToken}`; + const user = await this.options.client.callWithRequest( + { headers: { ...request.headers, authorization } }, + 'shield.authenticate' + ); - this.debug('Request has been authenticated via state.'); - return AuthenticationResult.succeeded(user); + this.options.log.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authorization }); } catch (err) { - this.debug(`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 fail if - // it's called with this request once again down the line (e.g. in the next authentication provider). - delete request.headers.authorization; - + this.options.log.debug(`Failed to authenticate request via state: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -222,11 +204,8 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - private async authenticateViaRefreshToken( - request: RequestWithLoginAttempt, - state: ProviderState - ) { - this.debug('Trying to refresh access token.'); + private async authenticateViaRefreshToken(request: KibanaRequest, state: ProviderState) { + this.options.log.debug('Trying to refresh access token.'); let refreshedTokenPair: TokenPair | null; try { @@ -237,27 +216,25 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { // If refresh token is no longer valid, then we should clear session and renegotiate using SPNEGO. if (refreshedTokenPair === null) { - this.debug('Both access and refresh tokens are expired. Re-initiating SPNEGO handshake.'); + this.options.log.debug( + 'Both access and refresh tokens are expired. Re-initiating SPNEGO handshake.' + ); return this.authenticateViaSPNEGO(request, state); } try { - request.headers.authorization = `Bearer ${refreshedTokenPair.accessToken}`; - - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const authorization = `Bearer ${refreshedTokenPair.accessToken}`; + const user = await this.options.client.callWithRequest( + { headers: { ...request.headers, authorization } }, + 'shield.authenticate' + ); - this.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, refreshedTokenPair); + this.options.log.debug('Request has been authenticated via refreshed token.'); + return AuthenticationResult.succeeded(user, { authorization }, refreshedTokenPair); } catch (err) { - this.debug(`Failed to authenticate user using newly refreshed 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; - + this.options.log.debug( + `Failed to authenticate user using newly refreshed access token: ${err.message}` + ); return AuthenticationResult.failed(err); } } @@ -267,17 +244,14 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param [state] Optional state object associated with the provider. */ - private async authenticateViaSPNEGO( - request: RequestWithLoginAttempt, - state?: ProviderState | null - ) { - this.debug('Trying to authenticate request via SPNEGO.'); + private async authenticateViaSPNEGO(request: KibanaRequest, state?: ProviderState | null) { + this.options.log.debug('Trying to authenticate request via SPNEGO.'); // Try to authenticate current request with Elasticsearch to see whether it supports SPNEGO. let authenticationError: Error; try { await this.options.client.callWithRequest(request, 'shield.authenticate'); - this.debug('Request was not supposed to be authenticated, ignoring result.'); + this.options.log.debug('Request was not supposed to be authenticated, ignoring result.'); return AuthenticationResult.notHandled(); } catch (err) { // Fail immediately if we get unexpected error (e.g. ES isn't available). We should not touch @@ -294,11 +268,15 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { ); if (challenges.some(challenge => challenge.toLowerCase() === 'negotiate')) { - this.debug(`SPNEGO is supported by the backend, challenges are: [${challenges}].`); + this.options.log.debug( + `SPNEGO is supported by the backend, challenges are: [${challenges}].` + ); return AuthenticationResult.failed(Boom.unauthorized(), ['Negotiate']); } - this.debug(`SPNEGO is not supported by the backend, challenges are: [${challenges}].`); + this.options.log.debug( + `SPNEGO is not supported by the backend, challenges are: [${challenges}].` + ); // If we failed to do SPNEGO and have a session with expired token that belongs to Kerberos // authentication provider then it means Elasticsearch isn't configured to use Kerberos anymore. @@ -308,12 +286,4 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { ? AuthenticationResult.failed(Boom.unauthorized()) : AuthenticationResult.notHandled(); } - - /** - * Logs message with `debug` level and kerberos/security related tags. - * @param message Message to log. - */ - private debug(message: string) { - this.options.log(['debug', 'security', 'kerberos'], message); - } } diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.test.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.test.ts index 78a2eee0e540882..86f5d227bf537d7 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.test.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.test.ts @@ -502,17 +502,17 @@ describe('OIDCAuthenticationProvider', () => { }); }); - describe('`deauthenticate` method', () => { + describe('`logout` method', () => { it('returns `notHandled` if state is not presented or does not include access token.', async () => { const request = requestFixture(); - let deauthenticateResult = await provider.deauthenticate(request, {}); + let deauthenticateResult = await provider.logout(request, {}); expect(deauthenticateResult.notHandled()).toBe(true); - deauthenticateResult = await provider.deauthenticate(request, {}); + deauthenticateResult = await provider.logout(request, {}); expect(deauthenticateResult.notHandled()).toBe(true); - deauthenticateResult = await provider.deauthenticate(request, { nonce: 'x' }); + deauthenticateResult = await provider.logout(request, { nonce: 'x' }); expect(deauthenticateResult.notHandled()).toBe(true); sinon.assert.notCalled(callWithInternalUser); @@ -526,7 +526,7 @@ describe('OIDCAuthenticationProvider', () => { const failureReason = new Error('Realm is misconfigured!'); callWithInternalUser.withArgs('shield.oidcLogout').returns(Promise.reject(failureReason)); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); @@ -547,7 +547,7 @@ describe('OIDCAuthenticationProvider', () => { callWithInternalUser.withArgs('shield.oidcLogout').resolves({ redirect: null }); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); @@ -570,7 +570,7 @@ describe('OIDCAuthenticationProvider', () => { .withArgs('shield.oidcLogout') .resolves({ redirect: 'http://fake-idp/logout&id_token_hint=thehint' }); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.ts index 358c0322bc3ff4c..16bfb24dd8cbbfc 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.ts @@ -6,8 +6,8 @@ import Boom from 'boom'; import type from 'type-detect'; -import { Legacy } from 'kibana'; -import { canRedirectRequest } from '../../can_redirect_request'; +import { canRedirectRequest } from '../'; +import { KibanaRequest } from '../../../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { Tokens, TokenPair } from '../tokens'; @@ -15,9 +15,17 @@ import { AuthenticationProviderOptions, BaseAuthenticationProvider, AuthenticationProviderSpecificOptions, - RequestWithLoginAttempt, } from './base'; +/** + * Describes the parameters that are required by the provider to process the initial login request. + */ +interface ProviderLoginAttempt { + code?: string; + iss?: string; + loginHint?: string; +} + /** * The state supported by the provider (for the OpenID Connect handshake or established session). */ @@ -40,49 +48,13 @@ interface ProviderState extends Partial { nextURL?: string; } -/** - * Defines the shape of an incoming OpenID Connect Request - */ -type OIDCIncomingRequest = RequestWithLoginAttempt & { - payload: { - iss?: string; - login_hint?: string; - }; - query: { - iss?: string; - code?: string; - state?: string; - login_hint?: string; - error?: string; - error_description?: string; - }; -}; - -/** - * Checks if the Request object represents an HTTP request regarding authentication with OpenID - * Connect. This can be - * - An HTTP GET request with a query parameter named `iss` as part of a 3rd party initiated authentication - * - An HTTP POST request with a parameter named `iss` as part of a 3rd party initiated authentication - * - An HTTP GET request with a query parameter named `code` as the response to a successful authentication from - * an OpenID Connect Provider - * - An HTTP GET request with a query parameter named `error` as the response to a failed authentication from - * an OpenID Connect Provider - * @param request Request instance. - */ -function isOIDCIncomingRequest(request: RequestWithLoginAttempt): request is OIDCIncomingRequest { - return ( - (request.payload != null && !!(request.payload as Record).iss) || - (request.query != null && - (!!(request.query as any).iss || - !!(request.query as any).code || - !!(request.query as any).error)) - ); -} - /** * Provider that supports authentication using an OpenID Connect realm in Elasticsearch. */ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { + /** + * Specifies Elasticsearch OIDC realm name that Kibana should use. + */ private readonly realm: string; constructor( @@ -104,10 +76,28 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { /** * Performs OpenID Connect request authentication. * @param request Request instance. + * @param attempt Login attempt description. * @param [state] Optional state object associated with the provider. */ - public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) { - this.debug(`Trying to authenticate user request to ${request.url.path}.`); + public async login( + request: KibanaRequest, + attempt: ProviderLoginAttempt, + state?: ProviderState | null + ) { + this.options.log.debug('Trying to perform a login.'); + + // This might be the OpenID Connect Provider redirecting the user to `redirect_uri` after authentication or + // a third party initiating an authentication + return await this.loginWithOIDCPayload(request, attempt, state); + } + + /** + * Performs OpenID Connect request authentication. + * @param request Request instance. + * @param [state] Optional state object associated with the provider. + */ + public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + this.options.log.debug(`Trying to authenticate user request to ${request.url.path}.`); // We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore. let { @@ -118,11 +108,6 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { return authenticationResult; } - if (request.loginAttempt().getCredentials() != null) { - this.debug('Login attempt is detected, but it is not supported by the provider'); - return AuthenticationResult.notHandled(); - } - if (state && authenticationResult.notHandled()) { authenticationResult = await this.authenticateViaState(request, state); if ( @@ -133,12 +118,6 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } } - if (isOIDCIncomingRequest(request) && authenticationResult.notHandled()) { - // This might be the OpenID Connect Provider redirecting the user to `redirect_uri` after authentication or - // a third party initiating an authentication - authenticationResult = await this.authenticateViaResponseUrl(request, state); - } - // If we couldn't authenticate by means of all methods above, let's try to // initiate an OpenID Connect based authentication, otherwise just return the authentication result we have. // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in @@ -161,30 +140,31 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * to the URL that was requested before authentication flow started or to default Kibana location in case of a third * party initiated login * @param request Request instance. + * @param attempt Login attempt description. * @param [sessionState] Optional state object associated with the provider. */ - private async authenticateViaResponseUrl( - request: OIDCIncomingRequest, + private async loginWithOIDCPayload( + request: KibanaRequest, + { iss, loginHint, code }: ProviderLoginAttempt, sessionState?: ProviderState | null ) { - this.debug('Trying to authenticate via OpenID Connect response query.'); - // First check to see if this is a Third Party initiated authentication (which can happen via POST or GET) - const iss = (request.query && request.query.iss) || (request.payload && request.payload.iss); - const loginHint = - (request.query && request.query.login_hint) || - (request.payload && request.payload.login_hint); + this.options.log.debug('Trying to authenticate via OpenID Connect response query.'); + + // First check to see if this is a Third Party initiated authentication. if (iss) { - this.debug('Authentication has been initiated by a Third Party.'); + this.options.log.debug('Authentication has been initiated by a Third Party.'); + // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in // another tab) const oidcPrepareParams = loginHint ? { iss, login_hint: loginHint } : { iss }; return this.initiateOIDCAuthentication(request, oidcPrepareParams); } - if (!request.query || !request.query.code) { - this.debug('OpenID Connect Authentication response is not found.'); + if (!code) { + this.options.log.debug('OpenID Connect Authentication response is not found.'); return AuthenticationResult.notHandled(); } + // If it is an authentication response and the users' session state doesn't contain all the necessary information, // then something unexpected happened and we should fail because Elasticsearch won't be able to validate the // response. @@ -193,7 +173,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { if (!stateNonce || !stateOIDCState || !stateRedirectURL) { const message = 'Response session state does not have corresponding state or nonce parameters or redirect URL.'; - this.debug(message); + this.options.log.debug(message); return AuthenticationResult.failed(Boom.badRequest(message)); } @@ -215,14 +195,14 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { }, }); - this.debug('Request has been authenticated via OpenID Connect.'); + this.options.log.debug('Request has been authenticated via OpenID Connect.'); return AuthenticationResult.redirectTo(stateRedirectURL, { accessToken, refreshToken, }); } catch (err) { - this.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); + this.options.log.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -235,15 +215,17 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param [sessionState] Optional state object associated with the provider. */ private async initiateOIDCAuthentication( - request: RequestWithLoginAttempt, + request: KibanaRequest, params: { realm: string } | { iss: string; login_hint?: string }, sessionState?: ProviderState | null ) { - this.debug('Trying to initiate OpenID Connect authentication.'); + this.options.log.debug('Trying to initiate OpenID Connect authentication.'); // If client can't handle redirect response, we shouldn't initiate OpenID Connect authentication. if (!canRedirectRequest(request)) { - this.debug('OpenID Connect authentication can not be initiated by AJAX requests.'); + this.options.log.debug( + 'OpenID Connect authentication can not be initiated by AJAX requests.' + ); return AuthenticationResult.notHandled(); } @@ -266,9 +248,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } ); - this.debug('Redirecting to OpenID Connect Provider with authentication request.'); + this.options.log.debug('Redirecting to OpenID Connect Provider with authentication request.'); // If this is a third party initiated login, redirect to the base path - const redirectAfterLogin = `${request.getBasePath()}${ + const redirectAfterLogin = `${this.options.basePath}${ 'iss' in params ? '/' : request.url.path }`; return AuthenticationResult.redirectTo( @@ -277,7 +259,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { { state, nonce, nextURL: redirectAfterLogin } ); } catch (err) { - this.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`); + this.options.log.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -287,12 +269,12 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * forward to Elasticsearch backend. * @param request Request instance. */ - private async authenticateViaHeader(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate via header.'); + private async authenticateViaHeader(request: KibanaRequest) { + this.options.log.debug('Trying to authenticate via header.'); const authorization = request.headers.authorization; - if (!authorization) { - this.debug('Authorization header is not presented.'); + if (!authorization || typeof authorization !== 'string') { + this.options.log.debug('Authorization header is not presented.'); return { authenticationResult: AuthenticationResult.notHandled(), }; @@ -300,7 +282,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { const authenticationSchema = authorization.split(/\s+/)[0]; if (authenticationSchema.toLowerCase() !== 'bearer') { - this.debug(`Unsupported authentication schema: ${authenticationSchema}`); + this.options.log.debug(`Unsupported authentication schema: ${authenticationSchema}`); return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true, @@ -310,13 +292,12 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { try { const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - this.debug('Request has been authenticated via header.'); - + this.options.log.debug('Request has been authenticated via header.'); return { authenticationResult: AuthenticationResult.succeeded(user), }; } catch (err) { - this.debug(`Failed to authenticate request via header: ${err.message}`); + this.options.log.debug(`Failed to authenticate request via header: ${err.message}`); return { authenticationResult: AuthenticationResult.failed(err), }; @@ -329,35 +310,25 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - private async authenticateViaState( - request: RequestWithLoginAttempt, - { accessToken }: ProviderState - ) { - this.debug('Trying to authenticate via state.'); + private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) { + this.options.log.debug('Trying to authenticate via state.'); if (!accessToken) { - this.debug('Elasticsearch access token is not found in state.'); + this.options.log.debug('Elasticsearch 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.debug('Request has been authenticated via state.'); + const authorization = `Bearer ${accessToken}`; + const user = await this.options.client.callWithRequest( + { headers: { ...request.headers, authorization } }, + 'shield.authenticate' + ); - return AuthenticationResult.succeeded(user); + this.options.log.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authorization }); } catch (err) { - this.debug(`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 fail if - // it's called with this request once again down the line (e.g. in the next authentication provider). - delete request.headers.authorization; - + this.options.log.debug(`Failed to authenticate request via state: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -370,13 +341,13 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ private async authenticateViaRefreshToken( - request: RequestWithLoginAttempt, + request: KibanaRequest, { refreshToken }: ProviderState ) { - this.debug('Trying to refresh elasticsearch access token.'); + this.options.log.debug('Trying to refresh elasticsearch access token.'); if (!refreshToken) { - this.debug('Refresh token is not found in state.'); + this.options.log.debug('Refresh token is not found in state.'); return AuthenticationResult.notHandled(); } @@ -395,7 +366,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // supported. if (refreshedTokenPair === null) { if (canRedirectRequest(request)) { - this.debug( + this.options.log.debug( 'Both elasticsearch access and refresh tokens are expired. Re-initiating OpenID Connect authentication.' ); return this.initiateOIDCAuthentication(request, { realm: this.realm }); @@ -407,22 +378,16 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } try { - request.headers.authorization = `Bearer ${refreshedTokenPair.accessToken}`; - - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const authorization = `Bearer ${refreshedTokenPair.accessToken}`; + const user = await this.options.client.callWithRequest( + { headers: { ...request.headers, authorization } }, + 'shield.authenticate' + ); - this.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, refreshedTokenPair); + this.options.log.debug('Request has been authenticated via refreshed token.'); + return AuthenticationResult.succeeded(user, { authorization }, refreshedTokenPair); } catch (err) { - this.debug(`Failed to authenticate user using newly refreshed 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; - + this.options.log.debug(`Failed to refresh elasticsearch access token: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -433,11 +398,11 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - public async deauthenticate(request: Legacy.Request, state: ProviderState) { - this.debug(`Trying to deauthenticate user via ${request.url.path}.`); + public async logout(request: KibanaRequest, state: ProviderState) { + this.options.log.debug(`Trying to log user out via ${request.url.path}.`); if (!state || !state.accessToken) { - this.debug('There is no elasticsearch access token to invalidate.'); + this.options.log.debug('There is no elasticsearch access token to invalidate.'); return DeauthenticationResult.notHandled(); } @@ -455,28 +420,22 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { logoutBody ); - this.debug('User session has been successfully invalidated.'); + this.options.log.debug('User session has been successfully invalidated.'); // Having non-null `redirect` field within logout response means that the OpenID Connect realm configuration // supports RP initiated Single Logout and we should redirect user to the specified location in the OpenID Connect // Provider to properly complete logout. if (redirect != null) { - this.debug('Redirecting user to the OpenID Connect Provider to complete logout.'); + this.options.log.debug( + 'Redirecting user to the OpenID Connect Provider to complete logout.' + ); return DeauthenticationResult.redirectTo(redirect); } return DeauthenticationResult.redirectTo(`${this.options.basePath}/logged_out`); } catch (err) { - this.debug(`Failed to deauthenticate user: ${err.message}`); + this.options.log.debug(`Failed to deauthenticate user: ${err.message}`); return DeauthenticationResult.failed(err); } } - - /** - * Logs message with `debug` level and oidc/security related tags. - * @param message Message to log. - */ - private debug(message: string) { - this.options.log(['debug', 'security', 'oidc'], message); - } } diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.test.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.test.ts index 7029ddcc1781775..46209f95a20a1c1 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.test.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.test.ts @@ -655,17 +655,17 @@ describe('SAMLAuthenticationProvider', () => { }); }); - describe('`deauthenticate` method', () => { + describe('`logout` method', () => { it('returns `notHandled` if state is not presented or does not include access token.', async () => { const request = requestFixture(); - let deauthenticateResult = await provider.deauthenticate(request); + let deauthenticateResult = await provider.logout(request); expect(deauthenticateResult.notHandled()).toBe(true); - deauthenticateResult = await provider.deauthenticate(request, {} as any); + deauthenticateResult = await provider.logout(request, {} as any); expect(deauthenticateResult.notHandled()).toBe(true); - deauthenticateResult = await provider.deauthenticate(request, { somethingElse: 'x' } as any); + deauthenticateResult = await provider.logout(request, { somethingElse: 'x' } as any); expect(deauthenticateResult.notHandled()).toBe(true); sinon.assert.notCalled(callWithInternalUser); @@ -679,7 +679,7 @@ describe('SAMLAuthenticationProvider', () => { const failureReason = new Error('Realm is misconfigured!'); callWithInternalUser.withArgs('shield.samlLogout').rejects(failureReason); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); @@ -699,7 +699,7 @@ describe('SAMLAuthenticationProvider', () => { const failureReason = new Error('Realm is misconfigured!'); callWithInternalUser.withArgs('shield.samlInvalidate').rejects(failureReason); - const authenticationResult = await provider.deauthenticate(request); + const authenticationResult = await provider.logout(request); sinon.assert.calledOnce(callWithInternalUser); sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlInvalidate', { @@ -720,7 +720,7 @@ describe('SAMLAuthenticationProvider', () => { callWithInternalUser.withArgs('shield.samlLogout').resolves({ redirect: null }); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); @@ -741,7 +741,7 @@ describe('SAMLAuthenticationProvider', () => { callWithInternalUser.withArgs('shield.samlLogout').resolves({ redirect: undefined }); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); @@ -762,7 +762,7 @@ describe('SAMLAuthenticationProvider', () => { callWithInternalUser.withArgs('shield.samlLogout').resolves({ redirect: null }); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); @@ -781,7 +781,7 @@ describe('SAMLAuthenticationProvider', () => { callWithInternalUser.withArgs('shield.samlInvalidate').resolves({ redirect: null }); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken: 'x-saml-token', refreshToken: 'x-saml-refresh-token', }); @@ -803,7 +803,7 @@ describe('SAMLAuthenticationProvider', () => { callWithInternalUser.withArgs('shield.samlInvalidate').resolves({ redirect: null }); - const authenticationResult = await provider.deauthenticate(request); + const authenticationResult = await provider.logout(request); sinon.assert.calledOnce(callWithInternalUser); sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlInvalidate', { @@ -822,7 +822,7 @@ describe('SAMLAuthenticationProvider', () => { callWithInternalUser.withArgs('shield.samlInvalidate').resolves({ redirect: undefined }); - const authenticationResult = await provider.deauthenticate(request); + const authenticationResult = await provider.logout(request); sinon.assert.calledOnce(callWithInternalUser); sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlInvalidate', { @@ -845,7 +845,7 @@ describe('SAMLAuthenticationProvider', () => { .withArgs('shield.samlLogout') .resolves({ redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' }); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); @@ -862,7 +862,7 @@ describe('SAMLAuthenticationProvider', () => { .withArgs('shield.samlInvalidate') .resolves({ redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' }); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken: 'x-saml-token', refreshToken: 'x-saml-refresh-token', }); diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.ts index c6209abc26bb97a..df59d7c8ed24f3c 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.ts @@ -5,17 +5,13 @@ */ import Boom from 'boom'; -import { Legacy } from 'kibana'; -import { canRedirectRequest } from '../../can_redirect_request'; +import { canRedirectRequest } from '..'; +import { KibanaRequest } from '../../../../../../../../src/core/server'; import { AuthenticatedUser } from '../../../../common/model'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; +import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base'; import { Tokens, TokenPair } from '../tokens'; -import { - AuthenticationProviderOptions, - BaseAuthenticationProvider, - RequestWithLoginAttempt, -} from './base'; /** * The state supported by the provider (for the SAML handshake or established session). @@ -33,34 +29,17 @@ interface ProviderState extends Partial { } /** - * Defines the shape of the request query containing SAML request. + * Describes the parameters that are required by the provider to process the initial login request. */ -interface SAMLRequestQuery { - SAMLRequest: string; -} - -/** - * Defines the shape of the request with a body containing SAML response. - */ -type RequestWithSAMLPayload = RequestWithLoginAttempt & { - payload: { SAMLResponse: string; RelayState?: string }; -}; - -/** - * Checks whether request payload contains SAML response from IdP. - * @param request Request instance. - */ -function isRequestWithSAMLResponsePayload( - request: RequestWithLoginAttempt -): request is RequestWithSAMLPayload { - return request.payload != null && !!(request.payload as any).SAMLResponse; +interface ProviderLoginAttempt { + samlResponse: string; } /** * Checks whether request query includes SAML request from IdP. * @param query Parsed HTTP request query. */ -function isSAMLRequestQuery(query: any): query is SAMLRequestQuery { +function isSAMLRequestQuery(query: any): query is { SAMLRequest: string } { return query && query.SAMLRequest; } @@ -86,13 +65,59 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.realm = samlOptions.realm; } + /** + * Performs initial login request using SAMLResponse payload. + * @param request Request instance. + * @param attempt Login attempt description. + * @param [state] Optional state object associated with the provider. + */ + public async login( + request: KibanaRequest, + { samlResponse }: ProviderLoginAttempt, + state?: ProviderState | null + ) { + this.options.log.debug('Trying to perform a login.'); + + const authenticationResult = state + ? await this.authenticateViaState(request, state) + : AuthenticationResult.notHandled(); + + // Let's check if user is redirected to Kibana from IdP with valid SAMLResponse. + if (authenticationResult.notHandled()) { + return await this.loginWithSAMLResponse(samlResponse, state); + } + + if (authenticationResult.succeeded()) { + // If user has been authenticated via session, but request also includes SAML payload + // we should check whether this payload is for the exactly same user and if not + // we'll re-authenticate user and forward to a page with the respective warning. + return await this.loginWithNewSAMLResponse( + request, + samlResponse, + (authenticationResult.state || state) as ProviderState, + authenticationResult.user as AuthenticatedUser + ); + } + + if (authenticationResult.succeeded() || authenticationResult.redirected()) { + this.options.log.debug('Login has been successfully performed.'); + } else { + this.options.log.debug( + `Failed to perform a login: ${authenticationResult.error && + authenticationResult.error.message}` + ); + } + + return authenticationResult; + } + /** * Performs SAML request authentication. * @param request Request instance. * @param [state] Optional state object associated with the provider. */ - public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) { - this.debug(`Trying to authenticate user request to ${request.url.path}.`); + public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + this.options.log.debug(`Trying to authenticate user request to ${request.url.path}.`); // We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore. let { @@ -104,11 +129,6 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return authenticationResult; } - if (request.loginAttempt().getCredentials() != null) { - this.debug('Login attempt is detected, but it is not supported by the provider'); - return AuthenticationResult.notHandled(); - } - if (state && authenticationResult.notHandled()) { authenticationResult = await this.authenticateViaState(request, state); if ( @@ -119,22 +139,6 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } } - // Let's check if user is redirected to Kibana from IdP with valid SAMLResponse. - if (isRequestWithSAMLResponsePayload(request)) { - if (authenticationResult.notHandled()) { - authenticationResult = await this.authenticateViaPayload(request, state); - } else if (authenticationResult.succeeded()) { - // If user has been authenticated via session, but request also includes SAML payload - // we should check whether this payload is for the exactly same user and if not - // we'll re-authenticate user and forward to a page with the respective warning. - authenticationResult = await this.authenticateViaNewPayload( - request, - (authenticationResult.state || state) as ProviderState, - authenticationResult.user as AuthenticatedUser - ); - } - } - // If we couldn't authenticate by means of all methods above, let's try to // initiate SAML handshake, otherwise just return authentication result we have. return authenticationResult.notHandled() @@ -147,11 +151,11 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - public async deauthenticate(request: Legacy.Request, state?: ProviderState) { - this.debug(`Trying to deauthenticate user via ${request.url.path}.`); + public async logout(request: KibanaRequest, state?: ProviderState) { + this.options.log.debug(`Trying to log user out via ${request.url.path}.`); if ((!state || !state.accessToken) && !isSAMLRequestQuery(request.query)) { - this.debug('There is neither access token nor SAML session to invalidate.'); + this.options.log.debug('There is neither access token nor SAML session to invalidate.'); return DeauthenticationResult.notHandled(); } @@ -164,13 +168,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // supports SAML Single Logout and we should redirect user to the specified // location to properly complete logout. if (redirect != null) { - this.debug('Redirecting user to Identity Provider to complete logout.'); + this.options.log.debug('Redirecting user to Identity Provider to complete logout.'); return DeauthenticationResult.redirectTo(redirect); } return DeauthenticationResult.redirectTo('/logged_out'); } catch (err) { - this.debug(`Failed to deauthenticate user: ${err.message}`); + this.options.log.debug(`Failed to deauthenticate user: ${err.message}`); return DeauthenticationResult.failed(err); } } @@ -180,18 +184,18 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * forward to Elasticsearch backend. * @param request Request instance. */ - private async authenticateViaHeader(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate via header.'); + private async authenticateViaHeader(request: KibanaRequest) { + this.options.log.debug('Trying to authenticate via header.'); const authorization = request.headers.authorization; - if (!authorization) { - this.debug('Authorization header is not presented.'); + if (!authorization || typeof authorization !== 'string') { + this.options.log.debug('Authorization header is not presented.'); return { authenticationResult: AuthenticationResult.notHandled() }; } const authenticationSchema = authorization.split(/\s+/)[0]; if (authenticationSchema.toLowerCase() !== 'bearer') { - this.debug(`Unsupported authentication schema: ${authenticationSchema}`); + this.options.log.debug(`Unsupported authentication schema: ${authenticationSchema}`); return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true, @@ -201,10 +205,10 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { try { const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - this.debug('Request has been authenticated via header.'); + this.options.log.debug('Request has been authenticated via header.'); return { authenticationResult: AuthenticationResult.succeeded(user) }; } catch (err) { - this.debug(`Failed to authenticate request via header: ${err.message}`); + this.options.log.debug(`Failed to authenticate request via header: ${err.message}`); return { authenticationResult: AuthenticationResult.failed(err) }; } } @@ -221,14 +225,11 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * When login succeeds access token is stored in the state and user is redirected to the URL * that was requested before SAML handshake or to default Kibana location in case of IdP * initiated login. - * @param request Request instance. + * @param samlResponse SAMLResponse payload string. * @param [state] Optional state object associated with the provider. */ - private async authenticateViaPayload( - request: RequestWithSAMLPayload, - state?: ProviderState | null - ) { - this.debug('Trying to authenticate via SAML response payload.'); + private async loginWithSAMLResponse(samlResponse: string, state?: ProviderState | null) { + this.options.log.debug('Trying to log in with SAML response payload.'); // If we have a `SAMLResponse` and state, but state doesn't contain all the necessary information, // then something unexpected happened and we should fail. @@ -238,16 +239,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { }; if (state && (!stateRequestId || !stateRedirectURL)) { const message = 'SAML response state does not have corresponding request id or redirect URL.'; - this.debug(message); - + this.options.log.debug(message); return AuthenticationResult.failed(Boom.badRequest(message)); } // When we don't have state and hence request id we assume that SAMLResponse came from the IdP initiated login. - this.debug( + this.options.log.debug( stateRequestId - ? 'Authentication has been previously initiated by Kibana.' - : 'Authentication has been initiated by Identity Provider.' + ? 'Login has been previously initiated by Kibana.' + : 'Login has been initiated by Identity Provider.' ); try { @@ -259,17 +259,17 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } = await this.options.client.callWithInternalUser('shield.samlAuthenticate', { body: { ids: stateRequestId ? [stateRequestId] : [], - content: request.payload.SAMLResponse, + content: samlResponse, }, }); - this.debug('Request has been authenticated via SAML response.'); + this.options.log.debug('Login has been performed with SAML response.'); return AuthenticationResult.redirectTo(stateRedirectURL || `${this.options.basePath}/`, { accessToken, refreshToken, }); } catch (err) { - this.debug(`Failed to authenticate request via SAML response: ${err.message}`); + this.options.log.debug(`Failed to log in with SAML response: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -283,24 +283,30 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * we detect that user from existing session isn't the same as defined in SAML payload. In this case * we'll forward user to a page with the respective warning. * @param request Request instance. + * @param samlResponse SAMLResponse payload string. * @param existingState State existing user session is based on. * @param user User returned for the existing session. */ - private async authenticateViaNewPayload( - request: RequestWithSAMLPayload, + private async loginWithNewSAMLResponse( + request: KibanaRequest, + samlResponse: string, existingState: ProviderState, user: AuthenticatedUser ) { - this.debug('Trying to authenticate via SAML response payload with existing valid session.'); + this.options.log.debug( + 'Trying to log in with SAML response payload and existing valid session.' + ); // First let's try to authenticate via SAML Response payload. - const payloadAuthenticationResult = await this.authenticateViaPayload(request); + const payloadAuthenticationResult = await this.loginWithSAMLResponse(samlResponse); if (payloadAuthenticationResult.failed()) { return payloadAuthenticationResult; - } else if (!payloadAuthenticationResult.shouldUpdateState()) { + } + + if (!payloadAuthenticationResult.shouldUpdateState()) { // Should never happen, but if it does - it's a bug. return AuthenticationResult.failed( - new Error('Authentication via SAML payload did not produce access and refresh tokens.') + new Error('Login with SAML payload did not produce access and refresh tokens.') ); } @@ -311,7 +317,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { const newUserAuthenticationResult = await this.authenticateViaState(request, newState); if (newUserAuthenticationResult.failed()) { return newUserAuthenticationResult; - } else if (newUserAuthenticationResult.user === undefined) { + } + + if (newUserAuthenticationResult.user === undefined) { // Should never happen, but if it does - it's a bug. return AuthenticationResult.failed( new Error('Could not retrieve user information using tokens produced for the SAML payload.') @@ -320,13 +328,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // Now let's invalidate tokens from the existing session. try { - this.debug('Perform IdP initiated local logout.'); + this.options.log.debug('Perform IdP initiated local logout.'); await this.options.tokens.invalidate({ accessToken: existingState.accessToken!, refreshToken: existingState.refreshToken!, }); } catch (err) { - this.debug(`Failed to perform IdP initiated local logout: ${err.message}`); + this.options.log.debug(`Failed to perform IdP initiated local logout: ${err.message}`); return AuthenticationResult.failed(err); } @@ -334,18 +342,17 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { newUserAuthenticationResult.user.username !== user.username || newUserAuthenticationResult.user.authentication_realm.name !== user.authentication_realm.name ) { - this.debug( - 'Authentication initiated by Identity Provider is for a different user than currently authenticated.' + this.options.log.debug( + 'Login initiated by Identity Provider is for a different user than currently authenticated.' ); - return AuthenticationResult.redirectTo( `${this.options.basePath}/overwritten_session`, newState ); } - this.debug( - 'Authentication initiated by Identity Provider is for currently authenticated user.' + this.options.log.debug( + 'Login initiated by Identity Provider is for currently authenticated user.' ); return payloadAuthenticationResult; } @@ -356,34 +363,25 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - private async authenticateViaState( - request: RequestWithLoginAttempt, - { accessToken }: ProviderState - ) { - this.debug('Trying to authenticate via state.'); + private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) { + this.options.log.debug('Trying to authenticate via state.'); if (!accessToken) { - this.debug('Access token is not found in state.'); + this.options.log.debug('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'); + const authorization = `Bearer ${accessToken}`; + const user = await this.options.client.callWithRequest( + { headers: { ...request.headers, authorization } }, + 'shield.authenticate' + ); - this.debug('Request has been authenticated via state.'); - return AuthenticationResult.succeeded(user); + this.options.log.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authorization }); } catch (err) { - this.debug(`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 fail if - // it's called with this request once again down the line (e.g. in the next authentication provider). - delete request.headers.authorization; - + this.options.log.debug(`Failed to authenticate request via state: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -396,13 +394,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ private async authenticateViaRefreshToken( - request: RequestWithLoginAttempt, + request: KibanaRequest, { refreshToken }: ProviderState ) { - this.debug('Trying to refresh access token.'); + this.options.log.debug('Trying to refresh access token.'); if (!refreshToken) { - this.debug('Refresh token is not found in state.'); + this.options.log.debug('Refresh token is not found in state.'); return AuthenticationResult.notHandled(); } @@ -420,7 +418,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // to do the same on Kibana side and `401` would force user to logout and do full SLO if it's supported. if (refreshedTokenPair === null) { if (canRedirectRequest(request)) { - this.debug('Both access and refresh tokens are expired. Re-initiating SAML handshake.'); + this.options.log.debug( + 'Both access and refresh tokens are expired. Re-initiating SAML handshake.' + ); return this.authenticateViaHandshake(request); } @@ -430,22 +430,18 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } try { - request.headers.authorization = `Bearer ${refreshedTokenPair.accessToken}`; - - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const authorization = `Bearer ${refreshedTokenPair.accessToken}`; + const user = await this.options.client.callWithRequest( + { headers: { ...request.headers, authorization } }, + 'shield.authenticate' + ); - this.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, refreshedTokenPair); + this.options.log.debug('Request has been authenticated via refreshed token.'); + return AuthenticationResult.succeeded(user, { authorization }, refreshedTokenPair); } catch (err) { - this.debug(`Failed to authenticate user using newly refreshed 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; - + this.options.log.debug( + `Failed to authenticate user using newly refreshed access token: ${err.message}` + ); return AuthenticationResult.failed(err); } } @@ -454,12 +450,12 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * Tries to start SAML handshake and eventually receive a token. * @param request Request instance. */ - private async authenticateViaHandshake(request: RequestWithLoginAttempt) { - this.debug('Trying to initiate SAML handshake.'); + private async authenticateViaHandshake(request: KibanaRequest) { + this.options.log.debug('Trying to initiate SAML handshake.'); // If client can't handle redirect response, we shouldn't initiate SAML handshake. if (!canRedirectRequest(request)) { - this.debug('SAML handshake can not be initiated by AJAX requests.'); + this.options.log.debug('SAML handshake can not be initiated by AJAX requests.'); return AuthenticationResult.notHandled(); } @@ -471,15 +467,14 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { { body: { realm: this.realm } } ); - this.debug('Redirecting to Identity Provider with SAML request.'); - + this.options.log.debug('Redirecting to Identity Provider with SAML request.'); return AuthenticationResult.redirectTo( redirect, // Store request id in the state so that we can reuse it once we receive `SAMLResponse`. - { requestId, nextURL: `${request.getBasePath()}${request.url.path}` } + { requestId, nextURL: `${this.options.basePath}${request.url.path}` } ); } catch (err) { - this.debug(`Failed to initiate SAML handshake: ${err.message}`); + this.options.log.debug(`Failed to initiate SAML handshake: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -490,7 +485,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param refreshToken Refresh token to invalidate. */ private async performUserInitiatedSingleLogout(accessToken: string, refreshToken: string) { - this.debug('Single logout has been initiated by the user.'); + this.options.log.debug('Single logout has been initiated by the user.'); // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/logout`. @@ -498,7 +493,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { body: { token: accessToken, refresh_token: refreshToken }, }); - this.debug('User session has been successfully invalidated.'); + this.options.log.debug('User session has been successfully invalidated.'); return redirect; } @@ -508,8 +503,8 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * Provider and redirects user back to the Identity Provider if needed. * @param request Request instance. */ - private async performIdPInitiatedSingleLogout(request: Legacy.Request) { - this.debug('Single logout has been initiated by the Identity Provider.'); + private async performIdPInitiatedSingleLogout(request: KibanaRequest) { + this.options.log.debug('Single logout has been initiated by the Identity Provider.'); // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/invalidate`. @@ -521,16 +516,8 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { }, }); - this.debug('User session has been successfully invalidated.'); + this.options.log.debug('User session has been successfully invalidated.'); return redirect; } - - /** - * Logs message with `debug` level and saml/security related tags. - * @param message Message to log. - */ - private debug(message: string) { - this.options.log(['debug', 'security', 'saml'], message); - } } diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.test.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.test.ts index 4cb088aa00d0bc4..43cdb8014645248 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.test.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.test.ts @@ -8,7 +8,6 @@ import Boom from 'boom'; import { errors } from 'elasticsearch'; import sinon from 'sinon'; import { requestFixture } from '../../__tests__/__fixtures__/request'; -import { LoginAttempt } from '../login_attempt'; import { mockAuthenticationProviderOptions } from './base.mock'; import { TokenAuthenticationProvider } from './token'; @@ -427,20 +426,20 @@ describe('TokenAuthenticationProvider', () => { }); }); - describe('`deauthenticate` method', () => { + describe('`logout` method', () => { it('returns `notHandled` if state is not presented.', async () => { const request = requestFixture(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - let deauthenticateResult = await provider.deauthenticate(request); + let deauthenticateResult = await provider.logout(request); expect(deauthenticateResult.notHandled()).toBe(true); - deauthenticateResult = await provider.deauthenticate(request, null); + deauthenticateResult = await provider.logout(request, null); expect(deauthenticateResult.notHandled()).toBe(true); sinon.assert.notCalled(tokens.invalidate); - deauthenticateResult = await provider.deauthenticate(request, tokenPair); + deauthenticateResult = await provider.logout(request, tokenPair); expect(deauthenticateResult.notHandled()).toBe(false); }); @@ -451,7 +450,7 @@ describe('TokenAuthenticationProvider', () => { const failureReason = new Error('failed to delete token'); tokens.invalidate.withArgs(tokenPair).rejects(failureReason); - const authenticationResult = await provider.deauthenticate(request, tokenPair); + const authenticationResult = await provider.logout(request, tokenPair); sinon.assert.calledOnce(tokens.invalidate); sinon.assert.calledWithExactly(tokens.invalidate, tokenPair); diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.ts index 73e757b71d51d65..8e325f9a53034ba 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.ts @@ -5,12 +5,20 @@ */ import Boom from 'boom'; -import { Legacy } from 'kibana'; -import { canRedirectRequest } from '../../can_redirect_request'; +import { KibanaRequest } from '../../../../../../../../src/core/server'; +import { canRedirectRequest } from '..'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; +import { BaseAuthenticationProvider } from './base'; import { Tokens, TokenPair } from '../tokens'; -import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base'; + +/** + * Describes the parameters that are required by the provider to process the initial login request. + */ +interface ProviderLoginAttempt { + username: string; + password: string; +} /** * The state supported by the provider. @@ -22,28 +30,77 @@ type ProviderState = TokenPair; */ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { /** - * Performs token-based request authentication + * Performs initial login request using username and password. * @param request Request instance. + * @param loginAttempt Login attempt description. * @param [state] Optional state object associated with the provider. */ - public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) { - this.debug(`Trying to authenticate user request to ${request.url.path}.`); + /** + * Performs initial login request using username and password. + * @param request Request instance. + * @param credentials User credentials. + * @param [state] Optional state object associated with the provider. + */ + public async login( + request: KibanaRequest, + credentials: ProviderLoginAttempt, + state?: ProviderState | null + ) { + this.options.log.debug('Trying to perform a login.'); - // first try from login payload - let authenticationResult = await this.authenticateViaLoginAttempt(request); + if (!credentials || !credentials.username || !credentials.password) { + this.options.log.debug('Username and/or password not provided.'); + return AuthenticationResult.notHandled(); + } - // if there isn't a payload, try header-based token auth - if (authenticationResult.notHandled()) { + try { + // First attempt to exchange login credentials for an access token const { - authenticationResult: headerAuthResult, - headerNotRecognized, - } = await this.authenticateViaHeader(request); - if (headerNotRecognized) { - return headerAuthResult; - } - authenticationResult = headerAuthResult; + access_token: accessToken, + refresh_token: refreshToken, + } = await this.options.client.callWithInternalUser('shield.getAccessToken', { + body: { + grant_type: 'password', + username: credentials.username, + password: credentials.password, + }, + }); + + this.options.log.debug('Get token API request to Elasticsearch successful'); + + // Then attempt to query for the user details using the new token + const authorization = `Bearer ${accessToken}`; + const user = await this.options.client.callWithRequest( + { headers: { ...request.headers, authorization } }, + 'shield.authenticate' + ); + + this.options.log.debug('Login has been successfully performed.'); + return AuthenticationResult.succeeded(user, { authorization }, { accessToken, refreshToken }); + } catch (err) { + this.options.log.debug(`Failed to perform a login: ${err.message}`); + return AuthenticationResult.failed(err); + } + } + + /** + * Performs token-based request authentication + * @param request Request instance. + * @param [state] Optional state object associated with the provider. + */ + public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + this.options.log.debug(`Trying to authenticate user request to ${request.url.path}.`); + + // if there isn't a payload, try header-based token auth + const { + authenticationResult: headerAuthResult, + headerNotRecognized, + } = await this.authenticateViaHeader(request); + if (headerNotRecognized) { + return headerAuthResult; } + let authenticationResult = headerAuthResult; // if we still can't attempt auth, try authenticating via state (session token) if (authenticationResult.notHandled() && state) { authenticationResult = await this.authenticateViaState(request, state); @@ -69,20 +126,20 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - public async deauthenticate(request: Legacy.Request, state?: ProviderState | null) { - this.debug(`Trying to deauthenticate user via ${request.url.path}.`); + public async logout(request: KibanaRequest, state?: ProviderState | null) { + this.options.log.debug(`Trying to log user out via ${request.url.path}.`); if (!state) { - this.debug('There are no access and refresh tokens to invalidate.'); + this.options.log.debug('There are no access and refresh tokens to invalidate.'); return DeauthenticationResult.notHandled(); } - this.debug('Token-based logout has been initiated by the user.'); + this.options.log.debug('Token-based logout has been initiated by the user.'); try { await this.options.tokens.invalidate(state); } catch (err) { - this.debug(`Failed invalidating user's access token: ${err.message}`); + this.options.log.debug(`Failed invalidating user's access token: ${err.message}`); return DeauthenticationResult.failed(err); } @@ -95,111 +152,55 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * forward to Elasticsearch backend. * @param request Request instance. */ - private async authenticateViaHeader(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate via header.'); + private async authenticateViaHeader(request: KibanaRequest) { + this.options.log.debug('Trying to authenticate via header.'); const authorization = request.headers.authorization; - if (!authorization) { - this.debug('Authorization header is not presented.'); + if (!authorization || typeof authorization !== 'string') { + this.options.log.debug('Authorization header is not presented.'); return { authenticationResult: AuthenticationResult.notHandled() }; } const authenticationSchema = authorization.split(/\s+/)[0]; if (authenticationSchema.toLowerCase() !== 'bearer') { - this.debug(`Unsupported authentication schema: ${authenticationSchema}`); + this.options.log.debug(`Unsupported authentication schema: ${authenticationSchema}`); return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true }; } try { const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - this.debug('Request has been authenticated via header.'); + this.options.log.debug('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: AuthenticationResult.succeeded(user) }; } catch (err) { - this.debug(`Failed to authenticate request via header: ${err.message}`); + this.options.log.debug(`Failed to authenticate request via header: ${err.message}`); return { authenticationResult: AuthenticationResult.failed(err) }; } } - /** - * Validates whether request contains a login payload and authenticates the - * user if necessary. - * @param request Request instance. - */ - private async authenticateViaLoginAttempt(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate via login attempt.'); - - const credentials = request.loginAttempt().getCredentials(); - if (!credentials) { - this.debug('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.debug('Get token API request to Elasticsearch successful'); - - // 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.debug('User has been authenticated with new access token'); - - return AuthenticationResult.succeeded(user, { accessToken, refreshToken }); - } catch (err) { - this.debug(`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 request Request instance. * @param state State value previously stored by the provider. */ - private async authenticateViaState( - request: RequestWithLoginAttempt, - { accessToken }: ProviderState - ) { - this.debug('Trying to authenticate via state.'); + private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) { + this.options.log.debug('Trying to authenticate via state.'); try { - request.headers.authorization = `Bearer ${accessToken}`; - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.debug('Request has been authenticated via state.'); + const authorization = `Bearer ${accessToken}`; + const user = await this.options.client.callWithRequest( + { headers: { ...request.headers, authorization } }, + 'shield.authenticate' + ); - return AuthenticationResult.succeeded(user); + this.options.log.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authorization }); } catch (err) { - this.debug(`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; - + this.options.log.debug(`Failed to authenticate request via state: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -212,10 +213,10 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ private async authenticateViaRefreshToken( - request: RequestWithLoginAttempt, + request: KibanaRequest, { refreshToken }: ProviderState ) { - this.debug('Trying to refresh access token.'); + this.options.log.debug('Trying to refresh access token.'); let refreshedTokenPair: TokenPair | null; try { @@ -228,7 +229,9 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { // login page to re-authenticate, or fail if redirect isn't possible. if (refreshedTokenPair === null) { if (canRedirectRequest(request)) { - this.debug('Clearing session since both access and refresh tokens are expired.'); + this.options.log.debug( + 'Clearing session since both access and refresh tokens are expired.' + ); // Set state to `null` to let `Authenticator` know that we want to clear current session. return AuthenticationResult.redirectTo(this.getLoginPageURL(request), null); @@ -240,21 +243,18 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { } try { - request.headers.authorization = `Bearer ${refreshedTokenPair.accessToken}`; - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const authorization = `Bearer ${refreshedTokenPair.accessToken}`; + const user = await this.options.client.callWithRequest( + { headers: { ...request.headers, authorization } }, + 'shield.authenticate' + ); - this.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, refreshedTokenPair); + this.options.log.debug('Request has been authenticated via refreshed token.'); + return AuthenticationResult.succeeded(user, { authorization }, refreshedTokenPair); } catch (err) { - this.debug(`Failed to authenticate user using newly refreshed 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; - + this.options.log.debug( + `Failed to authenticate user using newly refreshed access token: ${err.message}` + ); return AuthenticationResult.failed(err); } } @@ -263,16 +263,8 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * Constructs login page URL using current url path as `next` query string parameter. * @param request Request instance. */ - private getLoginPageURL(request: RequestWithLoginAttempt) { - const nextURL = encodeURIComponent(`${request.getBasePath()}${request.url.path}`); + private getLoginPageURL(request: KibanaRequest) { + const nextURL = encodeURIComponent(`${this.options.basePath}${request.url.path}`); return `${this.options.basePath}/login?next=${nextURL}`; } - - /** - * Logs message with `debug` level and token/security related tags. - * @param message Message to log. - */ - private debug(message: string) { - this.options.log(['debug', 'security', 'token'], message); - } } diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/session.test.ts b/x-pack/legacy/plugins/security/server/lib/authentication/session.test.ts deleted file mode 100644 index fd42cbcce7c9d6d..000000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authentication/session.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -/* - * 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 sinon from 'sinon'; -import { requestFixture } from '../__tests__/__fixtures__/request'; -import { serverFixture } from '../__tests__/__fixtures__/server'; -import { Session } from './session'; - -describe('Session', () => { - const sandbox = sinon.createSandbox(); - - let server: ReturnType; - let config: { get: sinon.SinonStub }; - - beforeEach(() => { - server = serverFixture(); - config = { get: sinon.stub() }; - - server.config.returns(config); - - sandbox.useFakeTimers(); - }); - - afterEach(() => { - sandbox.restore(); - }); - - describe('constructor', () => { - it('correctly setups Hapi plugin.', async () => { - config.get.withArgs('xpack.security.cookieName').returns('cookie-name'); - config.get.withArgs('xpack.security.encryptionKey').returns('encryption-key'); - config.get.withArgs('xpack.security.secureCookies').returns('secure-cookies'); - config.get.withArgs('server.basePath').returns('base/path'); - - await Session.create(server as any); - - sinon.assert.calledOnce(server.auth.strategy); - sinon.assert.calledWithExactly(server.auth.strategy, 'security-cookie', 'cookie', { - cookie: 'cookie-name', - password: 'encryption-key', - clearInvalid: true, - validateFunc: sinon.match.func, - isHttpOnly: true, - isSecure: 'secure-cookies', - isSameSite: false, - path: 'base/path/', - }); - }); - }); - - describe('`get` method', () => { - let session: Session; - beforeEach(async () => { - session = await Session.create(server as any); - }); - - it('fails if request is not provided.', async () => { - await expect(session.get(undefined as any)).rejects.toThrowError( - 'Request should be a valid object, was [undefined].' - ); - }); - - it('logs the reason of validation function failure.', async () => { - const request = requestFixture(); - const failureReason = new Error('Invalid cookie.'); - server.auth.test.withArgs('security-cookie', request).rejects(failureReason); - - await expect(session.get(request)).resolves.toBeNull(); - sinon.assert.calledOnce(server.log); - sinon.assert.calledWithExactly( - server.log, - ['debug', 'security', 'auth', 'session'], - failureReason - ); - }); - - it('returns session if single session cookie is in an array.', async () => { - const request = requestFixture(); - const sessionValue = { token: 'token' }; - const sessions = [{ value: sessionValue }]; - server.auth.test.withArgs('security-cookie', request).resolves(sessions); - - await expect(session.get(request)).resolves.toBe(sessionValue); - }); - - it('returns null if multiple session cookies are detected.', async () => { - const request = requestFixture(); - const sessions = [{ value: { token: 'token' } }, { value: { token: 'token' } }]; - server.auth.test.withArgs('security-cookie', request).resolves(sessions); - - await expect(session.get(request)).resolves.toBeNull(); - }); - - it('returns what validation function returns', async () => { - const request = requestFixture(); - const rawSessionValue = { value: { token: 'token' } }; - server.auth.test.withArgs('security-cookie', request).resolves(rawSessionValue); - - await expect(session.get(request)).resolves.toEqual(rawSessionValue.value); - }); - - it('correctly process session expiration date', async () => { - const { validateFunc } = server.auth.strategy.firstCall.args[2]; - const currentTime = 100; - - sandbox.clock.tick(currentTime); - - const sessionWithoutExpires = { token: 'token' }; - let result = validateFunc({}, sessionWithoutExpires); - - expect(result.valid).toBe(true); - - const notExpiredSession = { token: 'token', expires: currentTime + 1 }; - result = validateFunc({}, notExpiredSession); - - expect(result.valid).toBe(true); - - const expiredSession = { token: 'token', expires: currentTime - 1 }; - result = validateFunc({}, expiredSession); - - expect(result.valid).toBe(false); - }); - }); - - describe('`set` method', () => { - let session: Session; - beforeEach(async () => { - session = await Session.create(server as any); - }); - - it('fails if request is not provided.', async () => { - await expect(session.set(undefined as any, undefined as any)).rejects.toThrowError( - 'Request should be a valid object, was [undefined].' - ); - }); - - it('does not set expires if corresponding config value is not specified.', async () => { - const sessionValue = { token: 'token' }; - const request = requestFixture(); - - await session.set(request, sessionValue); - - sinon.assert.calledOnce(request.cookieAuth.set); - sinon.assert.calledWithExactly(request.cookieAuth.set, { - value: sessionValue, - expires: undefined, - }); - }); - - it('sets expires based on corresponding config value.', async () => { - const sessionValue = { token: 'token' }; - const request = requestFixture(); - - config.get.withArgs('xpack.security.sessionTimeout').returns(100); - sandbox.clock.tick(1000); - - const sessionWithTimeout = await Session.create(server as any); - await sessionWithTimeout.set(request, sessionValue); - - sinon.assert.calledOnce(request.cookieAuth.set); - sinon.assert.calledWithExactly(request.cookieAuth.set, { - value: sessionValue, - expires: 1100, - }); - }); - }); - - describe('`clear` method', () => { - let session: Session; - beforeEach(async () => { - session = await Session.create(server as any); - }); - - it('fails if request is not provided.', async () => { - await expect(session.clear(undefined as any)).rejects.toThrowError( - 'Request should be a valid object, was [undefined].' - ); - }); - - it('correctly clears cookie', async () => { - const request = requestFixture(); - - await session.clear(request); - - sinon.assert.calledOnce(request.cookieAuth.clear); - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/session.ts b/x-pack/legacy/plugins/security/server/lib/authentication/session.ts deleted file mode 100644 index 89b256ad2f68c7f..000000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authentication/session.ts +++ /dev/null @@ -1,163 +0,0 @@ -/* - * 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 hapiAuthCookie from 'hapi-auth-cookie'; -import { Legacy } from 'kibana'; - -const HAPI_STRATEGY_NAME = 'security-cookie'; -// Forbid applying of Hapi authentication strategies to routes automatically. -const HAPI_STRATEGY_MODE = false; - -/** - * The shape of the session that is actually stored in the cookie. - */ -interface InternalSession { - /** - * Session value that is fed to the authentication provider. The shape is unknown upfront and - * entirely determined by the authentication provider that owns the current session. - */ - value: unknown; - - /** - * The Unix time in ms when the session should be considered expired. If `null`, session will stay - * active until the browser is closed. - */ - expires: number | null; -} - -function assertRequest(request: Legacy.Request) { - if (!request || typeof request !== 'object') { - throw new Error(`Request should be a valid object, was [${typeof request}].`); - } -} - -/** - * Manages Kibana user session. - */ -export class Session { - /** - * Session duration in ms. If `null` session will stay active until the browser is closed. - */ - private readonly ttl: number | null = null; - - /** - * Instantiates Session. Constructor is not supposed to be used directly. To make sure that all - * `Session` dependencies/plugins are properly initialized one should use static `Session.create` instead. - * @param server Server instance. - */ - constructor(private readonly server: Legacy.Server) { - this.ttl = this.server.config().get('xpack.security.sessionTimeout'); - } - - /** - * Retrieves session value from the session storage (e.g. cookie). - * @param request Request instance. - */ - async get(request: Legacy.Request) { - assertRequest(request); - - try { - const session = await this.server.auth.test(HAPI_STRATEGY_NAME, request); - - // If it's not an array, just return the session value - if (!Array.isArray(session)) { - return session.value as T; - } - - // If we have an array with one value, we're good also - if (session.length === 1) { - return session[0].value as T; - } - - // Otherwise, we have more than one and won't be authing the user because we don't - // know which session identifies the actual user. There's potential to change this behavior - // to ensure all valid sessions identify the same user, or choose one valid one, but this - // is the safest option. - const warning = `Found ${session.length} auth sessions when we were only expecting 1.`; - this.server.log(['warning', 'security', 'auth', 'session'], warning); - return null; - } catch (err) { - this.server.log(['debug', 'security', 'auth', 'session'], err); - return null; - } - } - - /** - * Puts current session value into the session storage. - * @param request Request instance. - * @param value Any object that will be associated with the request. - */ - async set(request: Legacy.Request, value: unknown) { - assertRequest(request); - - request.cookieAuth.set({ - value, - expires: this.ttl && Date.now() + this.ttl, - } as InternalSession); - } - - /** - * Clears current session. - * @param request Request instance. - */ - async clear(request: Legacy.Request) { - assertRequest(request); - - request.cookieAuth.clear(); - } - - /** - * Prepares and creates a session instance. - * @param server Server instance. - */ - static async create(server: Legacy.Server) { - // Register HAPI plugin that manages session cookie and delegate parsing of the session cookie to it. - await server.register({ - plugin: hapiAuthCookie, - }); - - const config = server.config(); - const httpOnly = true; - const name = config.get('xpack.security.cookieName'); - const password = config.get('xpack.security.encryptionKey'); - const path = `${config.get('server.basePath')}/`; - const secure = config.get('xpack.security.secureCookies'); - - server.auth.strategy(HAPI_STRATEGY_NAME, 'cookie', { - cookie: name, - password, - clearInvalid: true, - validateFunc: Session.validateCookie, - isHttpOnly: httpOnly, - isSecure: secure, - isSameSite: false, - path, - }); - - if (HAPI_STRATEGY_MODE) { - server.auth.default({ - strategy: HAPI_STRATEGY_NAME, - mode: 'required', - }); - } - - return new Session(server); - } - - /** - * Validation function that is passed to hapi-auth-cookie plugin and is responsible - * only for cookie expiration time validation. - * @param request Request instance. - * @param session Session value object retrieved from cookie. - */ - private static validateCookie(request: Legacy.Request, session: InternalSession) { - if (session.expires && session.expires < Date.now()) { - return { valid: false }; - } - - return { valid: true }; - } -} diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/tokens.ts b/x-pack/legacy/plugins/security/server/lib/authentication/tokens.ts index 15702036ce6d564..6a57104fac7ed68 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/tokens.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/tokens.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; +import { Logger } from 'src/core/server'; import { getErrorStatusCode } from '../errors'; +import { SecurityClusterClient } from './index'; /** * Represents a pair of access and refresh tokens. @@ -31,8 +32,8 @@ export interface TokenPair { export class Tokens { constructor( private readonly options: Readonly<{ - client: Legacy.Plugins.elasticsearch.Cluster; - log: (tags: string[], message: string) => void; + client: SecurityClusterClient; + log: Logger; }> ) {} @@ -50,11 +51,11 @@ export class Tokens { body: { grant_type: 'refresh_token', refresh_token: existingRefreshToken }, }); - this.debug('Access token has been successfully refreshed.'); + this.options.log.debug('Access token has been successfully refreshed.'); return { accessToken, refreshToken }; } catch (err) { - this.debug(`Failed to refresh access token: ${err.message}`); + this.options.log.debug(`Failed to refresh access token: ${err.message}`); // There are at least two common cases when refresh token request can fail: // 1. Refresh token is valid only for 24 hours and if it hasn't been used it expires. @@ -73,7 +74,7 @@ export class Tokens { // same refresh token multiple times yielding the same refreshed access/refresh token pair it's still possible // to hit the case when refresh token is no longer valid. if (getErrorStatusCode(err) === 400) { - this.debug('Refresh token is either expired or already used.'); + this.options.log.debug('Refresh token is either expired or already used.'); return null; } @@ -88,7 +89,7 @@ export class Tokens { * @param [refreshToken] Optional refresh token to invalidate. */ public async invalidate({ accessToken, refreshToken }: Partial) { - this.debug('Invalidating access/refresh token pair.'); + this.options.log.debug('Invalidating access/refresh token pair.'); let invalidationError; if (refreshToken) { @@ -99,17 +100,17 @@ export class Tokens { { body: { refresh_token: refreshToken } } )).invalidated_tokens; } catch (err) { - this.debug(`Failed to invalidate refresh token: ${err.message}`); + this.options.log.debug(`Failed to invalidate refresh token: ${err.message}`); // We don't re-throw the error here to have a chance to invalidate access token if it's provided. invalidationError = err; } if (invalidatedTokensCount === 0) { - this.debug('Refresh token was already invalidated.'); + this.options.log.debug('Refresh token was already invalidated.'); } else if (invalidatedTokensCount === 1) { - this.debug('Refresh token has been successfully invalidated.'); + this.options.log.debug('Refresh token has been successfully invalidated.'); } else if (invalidatedTokensCount > 1) { - this.debug( + this.options.log.debug( `${invalidatedTokensCount} refresh tokens were invalidated, this is unexpected.` ); } @@ -123,16 +124,18 @@ export class Tokens { { body: { token: accessToken } } )).invalidated_tokens; } catch (err) { - this.debug(`Failed to invalidate access token: ${err.message}`); + this.options.log.debug(`Failed to invalidate access token: ${err.message}`); invalidationError = err; } if (invalidatedTokensCount === 0) { - this.debug('Access token was already invalidated.'); + this.options.log.debug('Access token was already invalidated.'); } else if (invalidatedTokensCount === 1) { - this.debug('Access token has been successfully invalidated.'); + this.options.log.debug('Access token has been successfully invalidated.'); } else if (invalidatedTokensCount > 1) { - this.debug(`${invalidatedTokensCount} access tokens were invalidated, this is unexpected.`); + this.options.log.debug( + `${invalidatedTokensCount} access tokens were invalidated, this is unexpected.` + ); } } @@ -161,12 +164,4 @@ export class Tokens { )) ); } - - /** - * Logs message with `debug` level and tokens/security related tags. - * @param message Message to log. - */ - private debug(message: string) { - this.options.log(['debug', 'security', 'tokens'], message); - } } diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/authenticate.js b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/authenticate.js index 49bbdee39b917c0..5b83881c9772cdd 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/authenticate.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/authenticate.js @@ -155,7 +155,7 @@ describe('Authentication routes', () => { const request = requestFixture(); const unhandledException = new Error('Something went wrong.'); - serverStub.plugins.security.deauthenticate + serverStub.plugins.security.logout .withArgs(request) .returns(Promise.reject(unhandledException)); @@ -167,11 +167,11 @@ describe('Authentication routes', () => { }); }); - it('returns 500 if authenticator fails to deauthenticate.', async () => { + it('returns 500 if authenticator fails to logout.', async () => { const request = requestFixture(); const failureReason = Boom.forbidden(); - serverStub.plugins.security.deauthenticate + serverStub.plugins.security.logout .withArgs(request) .returns(Promise.resolve(DeauthenticationResult.failed(failureReason))); @@ -199,7 +199,7 @@ describe('Authentication routes', () => { it('redirects user to the URL returned by authenticator.', async () => { const request = requestFixture(); - serverStub.plugins.security.deauthenticate + serverStub.plugins.security.logout .withArgs(request) .returns( Promise.resolve(DeauthenticationResult.redirectTo('https://custom.logout')) @@ -214,7 +214,7 @@ describe('Authentication routes', () => { it('redirects user to the base path if deauthentication succeeds.', async () => { const request = requestFixture(); - serverStub.plugins.security.deauthenticate + serverStub.plugins.security.logout .withArgs(request) .returns(Promise.resolve(DeauthenticationResult.succeeded())); @@ -227,7 +227,7 @@ describe('Authentication routes', () => { it('redirects user to the base path if deauthentication is not handled.', async () => { const request = requestFixture(); - serverStub.plugins.security.deauthenticate + serverStub.plugins.security.logout .withArgs(request) .returns(Promise.resolve(DeauthenticationResult.notHandled())); diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js b/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js index e24b31039b9cb81..424e21ca3ece0eb 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js @@ -7,7 +7,8 @@ import Boom from 'boom'; import Joi from 'joi'; import { wrapError } from '../../../lib/errors'; -import { canRedirectRequest } from '../../../lib/can_redirect_request'; +import { canRedirectRequest } from '../../../lib/authentication'; +import { KibanaRequest } from '../../../../../../../../src/core/server'; export function initAuthenticateApi(server) { @@ -30,8 +31,11 @@ export function initAuthenticateApi(server) { const { username, password } = request.payload; try { - request.loginAttempt().setCredentials(username, password); - const authenticationResult = await server.plugins.security.authenticate(request); + // We should prefer `token` over `basic` iа possible. + const authenticationResult = await server.plugins.security.login(request, { + provider: server.config().get('xpack.security.authc.providers').includes('token') ? 'token' : 'basic', + value: { username, password } + }); if (!authenticationResult.succeeded()) { throw Boom.unauthorized(authenticationResult.error); @@ -53,13 +57,17 @@ export function initAuthenticateApi(server) { payload: Joi.object({ SAMLResponse: Joi.string().required(), RelayState: Joi.string().allow('') - }) + }).required() } }, async handler(request, h) { try { // When authenticating using SAML we _expect_ to redirect to the SAML Identity provider. - const authenticationResult = await server.plugins.security.authenticate(request); + const authenticationResult = await server.plugins.security.login(request, { + provider: 'saml', + value: { samlResponse: request.payload.SAMLResponse } + }); + if (authenticationResult.redirected()) { return h.redirect(authenticationResult.redirectURL); } @@ -94,7 +102,24 @@ export function initAuthenticateApi(server) { try { // We handle the fact that the user might get redirected to Kibana while already having an session // Return an error notifying the user they are already logged in. - const authenticationResult = await server.plugins.security.authenticate(request); + const authenticationResult = await server.plugins.security.login(request, { + provider: 'oidc', + // Checks if the request object represents an HTTP request regarding authentication with OpenID Connect. + // This can be + // - An HTTP GET request with a query parameter named `iss` as part of a 3rd party initiated authentication + // - An HTTP POST request with a parameter named `iss` as part of a 3rd party initiated authentication + // - An HTTP GET request with a query parameter named `code` as the response to a successful authentication from + // an OpenID Connect Provider + // - An HTTP GET request with a query parameter named `error` as the response to a failed authentication from + // an OpenID Connect Provider + value: { + code: request.query && request.query.code, + iss: (request.query && request.query.iss) || (request.payload && request.payload.iss), + loginHint: + (request.query && request.query.login_hint) || + (request.payload && request.payload.login_hint), + }, + }); if (authenticationResult.succeeded()) { return Boom.forbidden( 'Sorry, you already have an active Kibana session. ' + @@ -120,12 +145,12 @@ export function initAuthenticateApi(server) { auth: false }, async handler(request, h) { - if (!canRedirectRequest(request)) { + if (!canRedirectRequest(KibanaRequest.from(request))) { throw Boom.badRequest('Client should be able to process redirect response.'); } try { - const deauthenticationResult = await server.plugins.security.deauthenticate(request); + const deauthenticationResult = await server.plugins.security.logout(request); if (deauthenticationResult.failed()) { throw wrapError(deauthenticationResult.error); }