diff --git a/x-pack/plugins/security/public/authentication/authentication_service.ts b/x-pack/plugins/security/public/authentication/authentication_service.ts index 6657f5c0a900cf9..9e445dc4e40eb29 100644 --- a/x-pack/plugins/security/public/authentication/authentication_service.ts +++ b/x-pack/plugins/security/public/authentication/authentication_service.ts @@ -13,6 +13,7 @@ import { loginApp } from './login'; import { logoutApp } from './logout'; import { loggedOutApp } from './logged_out'; import { overwrittenSessionApp } from './overwritten_session'; +import { captureURLApp } from './capture_url'; interface SetupParams { application: ApplicationSetup; @@ -48,6 +49,7 @@ export class AuthenticationService { .apiKeysEnabled; accessAgreementApp.create({ application, getStartServices }); + captureURLApp.create({ application, http }); loginApp.create({ application, config, getStartServices, http }); logoutApp.create({ application, http }); loggedOutApp.create({ application, getStartServices, http }); diff --git a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts new file mode 100644 index 000000000000000..039d9620a072462 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts @@ -0,0 +1,51 @@ +/* + * 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 { CoreSetup, HttpSetup } from 'src/core/public'; +import { SessionInfo } from '../../../common/types'; + +interface CreateDeps { + application: CoreSetup['application']; + http: HttpSetup; +} + +export const captureURLApp = Object.freeze({ + id: 'security_capture_url', + create({ application, http }: CreateDeps) { + http.anonymousPaths.register('/internal/security/capture-url'); + application.register({ + id: this.id, + title: '', + chromeless: true, + appRoute: '/internal/security/capture-url', + async mount() { + try { + const { provider } = await http.get('/internal/security/session'); + if (!provider) { + throw new Error('Cannot retrieve current provider.'); + } + + const { location } = await http.post<{ location: string }>( + '/internal/security/login_with', + { + body: JSON.stringify({ + providerType: provider.type, + providerName: provider.name, + currentURL: window.location.href, + }), + } + ); + + window.location.href = location; + } catch (err) { + console.error(err); + } + + return () => {}; + }, + }); + }, +}); diff --git a/x-pack/plugins/security/public/authentication/capture_url/index.ts b/x-pack/plugins/security/public/authentication/capture_url/index.ts new file mode 100644 index 000000000000000..6dc1c2f7e2c27f5 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/capture_url/index.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { captureURLApp } from './capture_url_app'; diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index f8e6ac0f9b5d082..6d10bd1bcfa74dd 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -32,7 +32,7 @@ export enum OIDCLogin { * Describes the parameters that are required by the provider to process the initial login request. */ export type ProviderLoginAttempt = - | { type: OIDCLogin.LoginInitiatedByUser; redirectURLPath: string } + | { type: OIDCLogin.LoginInitiatedByUser; redirectURL: string } | { type: OIDCLogin.LoginWithImplicitFlow | OIDCLogin.LoginWithAuthorizationCodeFlow; authenticationResponseURI: string; @@ -58,7 +58,7 @@ interface ProviderState extends Partial { /** * URL to redirect user to after successful OpenID Connect handshake. */ - nextURL?: string; + redirectURL?: string; /** * The name of the OpenID Connect realm that was used to establish session. @@ -143,11 +143,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { if (attempt.type === OIDCLogin.LoginInitiatedByUser) { this.logger.debug(`Login has been initiated by a user.`); - return this.initiateOIDCAuthentication( - request, - { realm: this.realm }, - attempt.redirectURLPath - ); + return this.initiateOIDCAuthentication(request, { realm: this.realm }, attempt.redirectURL); } if (attempt.type === OIDCLogin.LoginWithImplicitFlow) { @@ -200,7 +196,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in // another tab) return authenticationResult.notHandled() && canStartNewSession(request) - ? await this.initiateOIDCAuthentication(request, { realm: this.realm }) + ? await this.captureRedirectURL(request) : authenticationResult; } @@ -231,8 +227,11 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // 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. - const { nonce: stateNonce = '', state: stateOIDCState = '', nextURL: stateRedirectURL = '' } = - sessionState || {}; + const { + nonce: stateNonce = '', + state: stateOIDCState = '', + redirectURL: stateRedirectURL = '', + } = sessionState || {}; if (!stateNonce || !stateOIDCState || !stateRedirectURL) { const message = 'Response session state does not have corresponding state or nonce parameters or redirect URL.'; @@ -272,13 +271,12 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * * @param request Request instance. * @param params OIDC authentication parameters. - * @param [redirectURLPath] Optional URL user is supposed to be redirected to after successful - * login. If not provided the URL of the specified request is used. + * @param redirectURL URL user is supposed to be redirected to after successful login. */ private async initiateOIDCAuthentication( request: KibanaRequest, params: { realm: string } | { iss: string; login_hint?: string }, - redirectURLPath = `${this.options.basePath.get(request)}${request.url.path}` + redirectURL: string ) { this.logger.debug('Trying to initiate OpenID Connect authentication.'); @@ -295,7 +293,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.redirectTo( redirect, // Store the state and nonce parameters in the session state of the user - { state: { state, nonce, nextURL: redirectURLPath, realm: this.realm } } + { state: { state, nonce, redirectURL, realm: this.realm } } ); } catch (err) { this.logger.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`); @@ -367,7 +365,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug( 'Both elasticsearch access and refresh tokens are expired. Re-initiating OpenID Connect authentication.' ); - return this.initiateOIDCAuthentication(request, { realm: this.realm }); + return this.captureRedirectURL(request); } return AuthenticationResult.failed( @@ -449,4 +447,19 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { public getHTTPAuthenticationScheme() { return 'bearer'; } + + /** + * Tries to capture full redirect URL (both path and fragment) and initiate OIDC handshake. + * @param request Request instance. + */ + private captureRedirectURL(request: KibanaRequest) { + return AuthenticationResult.redirectTo( + `${ + this.options.basePath.serverBasePath + }/internal/security/capture-url?next=${encodeURIComponent( + `${this.options.basePath.get(request)}${request.url.path}` + )}`, + { state: { realm: this.realm } } + ); + } } diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index 3161144023c1f34..b1d91439c15616c 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -5,7 +5,6 @@ */ import Boom from 'boom'; -import { ByteSizeValue } from '@kbn/config-schema'; import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -27,6 +26,7 @@ interface ProviderState extends Partial { * Unique identifier of the SAML request initiated the handshake. */ requestId?: string; + /** * Stores path component of the URL only or in a combination with URL fragment that was used to * initiate SAML handshake and where we should redirect user after successful authentication. @@ -58,7 +58,7 @@ export enum SAMLLogin { * Describes the parameters that are required by the provider to process the initial login request. */ type ProviderLoginAttempt = - | { type: SAMLLogin.LoginInitiatedByUser; redirectURLPath?: string; redirectURLFragment?: string } + | { type: SAMLLogin.LoginInitiatedByUser; redirectURL: string } | { type: SAMLLogin.LoginWithSAMLResponse; samlResponse: string }; /** @@ -93,14 +93,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { */ private readonly realm: string; - /** - * Maximum size of the URL we store in the session during SAML handshake. - */ - private readonly maxRedirectURLSize: ByteSizeValue; - constructor( protected readonly options: Readonly, - samlOptions?: Readonly<{ realm?: string; maxRedirectURLSize?: ByteSizeValue }> + samlOptions?: Readonly<{ realm?: string }> ) { super(options); @@ -108,12 +103,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { throw new Error('Realm name must be specified'); } - if (!samlOptions.maxRedirectURLSize) { - throw new Error('Maximum redirect URL size must be specified'); - } - this.realm = samlOptions.realm; - this.maxRedirectURLSize = samlOptions.maxRedirectURLSize; } /** @@ -138,14 +128,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } if (attempt.type === SAMLLogin.LoginInitiatedByUser) { - const redirectURLPath = attempt.redirectURLPath || state?.redirectURL; - if (!redirectURLPath) { - const message = 'State or login attempt does not include URL path to redirect to.'; - this.logger.debug(message); - return AuthenticationResult.failed(Boom.badRequest(message)); - } - - return this.captureRedirectURL(request, redirectURLPath, attempt.redirectURLFragment); + return this.authenticateViaHandshake(request, attempt.redirectURL); } const { samlResponse } = attempt; @@ -579,52 +562,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { /** * Tries to capture full redirect URL (both path and fragment) and initiate SAML handshake. * @param request Request instance. - * @param [redirectURLPath] Optional URL path user is supposed to be redirected to after successful - * login. If not provided the URL path of the specified request is used. - * @param [redirectURLFragment] Optional URL fragment of the URL user is supposed to be redirected - * to after successful login. If not provided user will be redirected to the client-side page that - * will grab it and redirect user back to Kibana to initiate SAML handshake. */ - private captureRedirectURL( - request: KibanaRequest, - redirectURLPath = `${this.options.basePath.get(request)}${request.url.path}`, - redirectURLFragment?: string - ) { - // If the size of the path already exceeds the maximum allowed size of the URL to store in the - // session there is no reason to try to capture URL fragment and we start handshake immediately. - // In this case user will be redirected to the Kibana home/root after successful login. - let redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURLPath)); - if (this.maxRedirectURLSize.isLessThan(redirectURLSize)) { - this.logger.warn( - `Max URL path size should not exceed ${this.maxRedirectURLSize.toString()} but it was ${redirectURLSize.toString()}. URL is not captured.` - ); - return this.authenticateViaHandshake(request, ''); - } - - // If URL fragment wasn't specified at all, let's try to capture it. - if (redirectURLFragment === undefined) { - return AuthenticationResult.redirectTo( - `${this.options.basePath.serverBasePath}/internal/security/saml/capture-url-fragment`, - { state: { redirectURL: redirectURLPath, realm: this.realm } } - ); - } - - if (redirectURLFragment.length > 0 && !redirectURLFragment.startsWith('#')) { - this.logger.warn('Redirect URL fragment does not start with `#`.'); - redirectURLFragment = `#${redirectURLFragment}`; - } - - let redirectURL = `${redirectURLPath}${redirectURLFragment}`; - redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURL)); - if (this.maxRedirectURLSize.isLessThan(redirectURLSize)) { - this.logger.warn( - `Max URL size should not exceed ${this.maxRedirectURLSize.toString()} but it was ${redirectURLSize.toString()}. Only URL path is captured.` - ); - redirectURL = redirectURLPath; - } else { - this.logger.debug('Captured redirect URL.'); - } - - return this.authenticateViaHandshake(request, redirectURL); + private captureRedirectURL(request: KibanaRequest) { + return AuthenticationResult.redirectTo( + `${ + this.options.basePath.serverBasePath + }/internal/security/capture-url?next=${encodeURIComponent( + `${this.options.basePath.get(request)}${request.url.path}` + )}`, + { state: { realm: this.realm } } + ); } } diff --git a/x-pack/plugins/security/server/routes/authentication/common.ts b/x-pack/plugins/security/server/routes/authentication/common.ts index 91783140539a5b5..84f20a493cd8a21 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.ts @@ -84,18 +84,12 @@ export function defineCommonRoutes({ } function getLoginAttemptForProviderType(providerType: string, redirectURL: string) { - const [redirectURLPath] = redirectURL.split('#'); - const redirectURLFragment = - redirectURL.length > redirectURLPath.length - ? redirectURL.substring(redirectURLPath.length) - : ''; - if (providerType === SAMLAuthenticationProvider.type) { - return { type: SAMLLogin.LoginInitiatedByUser, redirectURLPath, redirectURLFragment }; + return { type: SAMLLogin.LoginInitiatedByUser, redirectURL }; } if (providerType === OIDCAuthenticationProvider.type) { - return { type: OIDCLogin.LoginInitiatedByUser, redirectURLPath }; + return { type: OIDCLogin.LoginInitiatedByUser, redirectURL }; } return undefined; diff --git a/x-pack/plugins/security/server/routes/authentication/index.ts b/x-pack/plugins/security/server/routes/authentication/index.ts index d09f65525f44e0d..e6ab3e7c76890d0 100644 --- a/x-pack/plugins/security/server/routes/authentication/index.ts +++ b/x-pack/plugins/security/server/routes/authentication/index.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { defineSessionRoutes } from './session'; import { defineSAMLRoutes } from './saml'; import { defineBasicRoutes } from './basic'; import { defineCommonRoutes } from './common'; @@ -12,7 +11,6 @@ import { defineOIDCRoutes } from './oidc'; import { RouteDefinitionParams } from '..'; export function defineAuthenticationRoutes(params: RouteDefinitionParams) { - defineSessionRoutes(params); defineCommonRoutes(params); if (params.authc.isProviderTypeEnabled('basic') || params.authc.isProviderTypeEnabled('token')) { diff --git a/x-pack/plugins/security/server/routes/authentication/saml.ts b/x-pack/plugins/security/server/routes/authentication/saml.ts index 30e1f6f336bdd3c..40dfb00ffa1e612 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.ts @@ -12,87 +12,15 @@ import { RouteDefinitionParams } from '..'; /** * Defines routes required for SAML authentication. */ -export function defineSAMLRoutes({ - router, - httpResources, - logger, - authc, - basePath, -}: RouteDefinitionParams) { - httpResources.register( - { - path: '/internal/security/saml/capture-url-fragment', - validate: false, - options: { authRequired: false }, - }, - (context, request, response) => { - // We're also preventing `favicon.ico` request since it can cause new SAML handshake. - return response.renderHtml({ - body: ` - - Kibana SAML Login - - - `, - }); - } - ); - httpResources.register( - { - path: '/internal/security/saml/capture-url-fragment.js', - validate: false, - options: { authRequired: false }, - }, - (context, request, response) => { - return response.renderJs({ - body: ` - window.location.replace( - '${basePath.serverBasePath}/internal/security/saml/start?redirectURLFragment=' + encodeURIComponent(window.location.hash) - ); - `, - }); - } - ); - - router.get( - { - path: '/internal/security/saml/start', - validate: { - query: schema.object({ redirectURLFragment: schema.string() }), - }, - options: { authRequired: false }, - }, - async (context, request, response) => { - try { - const authenticationResult = await authc.login(request, { - provider: { type: SAMLAuthenticationProvider.type }, - value: { - type: SAMLLogin.LoginInitiatedByUser, - redirectURLFragment: request.query.redirectURLFragment, - }, - }); - - // When authenticating using SAML we _expect_ to redirect to the SAML Identity provider. - if (authenticationResult.redirected()) { - return response.redirected({ headers: { location: authenticationResult.redirectURL! } }); - } - - return response.unauthorized(); - } catch (err) { - logger.error(err); - return response.internalError(); - } - } - ); - +export function defineSAMLRoutes({ router, logger, authc }: RouteDefinitionParams) { router.post( { path: '/api/security/saml/callback', validate: { - body: schema.object({ - SAMLResponse: schema.string(), - RelayState: schema.maybe(schema.string()), - }), + body: schema.object( + { SAMLResponse: schema.string(), RelayState: schema.maybe(schema.string()) }, + { unknowns: 'ignore' } + ), }, options: { authRequired: false, xsrfRequired: false }, }, diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index b0d961afde12924..83daa0f2efdeaba 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -6,8 +6,8 @@ import { Feature } from '../../../features/server'; import { - CoreSetup, HttpResources, + IBasePath, IClusterClient, IRouter, Logger, @@ -23,6 +23,7 @@ import { defineApiKeysRoutes } from './api_keys'; import { defineIndicesRoutes } from './indices'; import { defineUsersRoutes } from './users'; import { defineRoleMappingRoutes } from './role_mapping'; +import { defineSessionManagementRoutes } from './session_management'; import { defineViewRoutes } from './views'; import { SecurityFeatureUsageServiceStart } from '../feature_usage'; import { Session } from '../session_management'; @@ -32,7 +33,7 @@ import { Session } from '../session_management'; */ export interface RouteDefinitionParams { router: IRouter; - basePath: CoreSetup['http']['basePath']; + basePath: IBasePath; httpResources: HttpResources; logger: Logger; clusterClient: IClusterClient; @@ -48,6 +49,7 @@ export interface RouteDefinitionParams { export function defineRoutes(params: RouteDefinitionParams) { defineAuthenticationRoutes(params); defineAuthorizationRoutes(params); + defineSessionManagementRoutes(params); defineApiKeysRoutes(params); defineIndicesRoutes(params); defineUsersRoutes(params); diff --git a/x-pack/plugins/security/server/routes/session_management/extend.ts b/x-pack/plugins/security/server/routes/session_management/extend.ts new file mode 100644 index 000000000000000..722636aa9934a0f --- /dev/null +++ b/x-pack/plugins/security/server/routes/session_management/extend.ts @@ -0,0 +1,29 @@ +/* + * 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 { RouteDefinitionParams } from '..'; + +/** + * Defines routes required for the session extension. + */ +export function defineSessionExtendRoutes({ router, basePath }: RouteDefinitionParams) { + router.post( + { + path: '/internal/security/session', + validate: false, + }, + async (_context, _request, response) => { + // We can't easily return updated session info in a single HTTP call, because session data is obtained from + // the HTTP request, not the response. So the easiest way to facilitate this is to redirect the client to GET + // the session endpoint after the client's session has been extended. + return response.redirected({ + headers: { + location: `${basePath.serverBasePath}/internal/security/session`, + }, + }); + } + ); +} diff --git a/x-pack/plugins/security/server/routes/session_management/index.ts b/x-pack/plugins/security/server/routes/session_management/index.ts new file mode 100644 index 000000000000000..f335a272e72d9f2 --- /dev/null +++ b/x-pack/plugins/security/server/routes/session_management/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { defineSessionInfoRoutes } from './info'; +import { defineSessionExtendRoutes } from './extend'; +import { RouteDefinitionParams } from '..'; + +export function defineSessionManagementRoutes(params: RouteDefinitionParams) { + defineSessionInfoRoutes(params); + defineSessionExtendRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/authentication/session.ts b/x-pack/plugins/security/server/routes/session_management/info.ts similarity index 63% rename from x-pack/plugins/security/server/routes/authentication/session.ts rename to x-pack/plugins/security/server/routes/session_management/info.ts index e1deed16ae449c2..0c6d173b80d77a1 100644 --- a/x-pack/plugins/security/server/routes/authentication/session.ts +++ b/x-pack/plugins/security/server/routes/session_management/info.ts @@ -8,13 +8,14 @@ import { SessionInfo } from '../../../common/types'; import { RouteDefinitionParams } from '..'; /** - * Defines routes required for all authentication realms. + * Defines routes required for the session info. */ -export function defineSessionRoutes({ router, logger, basePath, session }: RouteDefinitionParams) { +export function defineSessionInfoRoutes({ router, logger, session }: RouteDefinitionParams) { router.get( { path: '/internal/security/session', validate: false, + options: { authRequired: 'optional' }, }, async (_context, request, response) => { try { @@ -39,21 +40,4 @@ export function defineSessionRoutes({ router, logger, basePath, session }: Route } } ); - - router.post( - { - path: '/internal/security/session', - validate: false, - }, - async (_context, _request, response) => { - // We can't easily return updated session info in a single HTTP call, because session data is obtained from - // the HTTP request, not the response. So the easiest way to facilitate this is to redirect the client to GET - // the session endpoint after the client's session has been extended. - return response.redirected({ - headers: { - location: `${basePath.serverBasePath}/internal/security/session`, - }, - }); - } - ); } diff --git a/x-pack/plugins/security/server/routes/views/capture_url.ts b/x-pack/plugins/security/server/routes/views/capture_url.ts new file mode 100644 index 000000000000000..eb24221f9d0261d --- /dev/null +++ b/x-pack/plugins/security/server/routes/views/capture_url.ts @@ -0,0 +1,17 @@ +/* + * 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 { RouteDefinitionParams } from '..'; + +/** + * Defines routes required for the Capture URL view. + */ +export function defineCaptureURLRoutes({ httpResources }: RouteDefinitionParams) { + httpResources.register( + { path: '/internal/security/capture-url', validate: false, options: { authRequired: false } }, + (context, request, response) => response.renderAnonymousCoreApp() + ); +} diff --git a/x-pack/plugins/security/server/routes/views/index.ts b/x-pack/plugins/security/server/routes/views/index.ts index b9de58d47fe4077..64d288dfc7c7d65 100644 --- a/x-pack/plugins/security/server/routes/views/index.ts +++ b/x-pack/plugins/security/server/routes/views/index.ts @@ -10,6 +10,7 @@ import { defineLoggedOutRoutes } from './logged_out'; import { defineLoginRoutes } from './login'; import { defineLogoutRoutes } from './logout'; import { defineOverwrittenSessionRoutes } from './overwritten_session'; +import { defineCaptureURLRoutes } from './capture_url'; import { RouteDefinitionParams } from '..'; export function defineViewRoutes(params: RouteDefinitionParams) { @@ -26,4 +27,5 @@ export function defineViewRoutes(params: RouteDefinitionParams) { defineLoggedOutRoutes(params); defineLogoutRoutes(params); defineOverwrittenSessionRoutes(params); + defineCaptureURLRoutes(params); } diff --git a/x-pack/plugins/security/server/session_management/index.ts b/x-pack/plugins/security/server/session_management/index.ts index 990ddac53881bfb..ee7ed914947a048 100644 --- a/x-pack/plugins/security/server/session_management/index.ts +++ b/x-pack/plugins/security/server/session_management/index.ts @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export { Session } from './session'; -export { SessionValue } from './session_value'; +export { Session, SessionValue } from './session'; export { SessionManagementServiceSetup, SessionManagementService, diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts index 44edad773999d93..9507e9287712009 100644 --- a/x-pack/plugins/security/server/session_management/session.ts +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -8,20 +8,75 @@ import nodeCrypto from '@elastic/node-crypto'; import { promisify } from 'util'; import { randomBytes } from 'crypto'; import { KibanaRequest, Logger } from '../../../../../src/core/server'; +import { AuthenticationProvider } from '../../common/types'; import { SecurityAuditLogger } from '../audit'; import { ConfigType } from '../config'; -import { SessionValue } from './session_value'; -import { SessionIndex } from './session_index'; +import { SessionIndex, SessionIndexValue } from './session_index'; import { SessionCookie } from './session_cookie'; +/** + * The shape of the value that represents user's session information. + */ +export interface SessionValue { + /** + * Unique session ID. + */ + sid: string; + + /** + * Username this session belongs. It's defined only if session is authenticated, otherwise session + * is considered unauthenticated (e.g. intermediate session used during SSO handshake). + */ + username?: string; + + /** + * Name and type of the provider this session belongs to. + */ + provider: AuthenticationProvider; + + /** + * The Unix time in ms when the session should be considered expired. If `null`, session will stay + * active until the browser is closed. + */ + idleTimeoutExpiration: number | null; + + /** + * The Unix time in ms which is the max total lifespan of the session. If `null`, session expire + * time can be extended indefinitely. + */ + lifespanExpiration: number | null; + + /** + * Kibana server base path the session was created for. + */ + path: string; + + /** + * 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; + + /** + * Indicates whether user acknowledged access agreement or not. + */ + accessAgreementAcknowledged?: boolean; +} + export interface SessionOptions { auditLogger: SecurityAuditLogger; + serverBasePath: string; logger: Logger; sessionIndex: SessionIndex; sessionCookie: SessionCookie; config: Pick; } +interface SessionValueContentToEncrypt { + username?: string; + state: unknown; +} + export class Session { /** * Type/name mappings of the currently configured authentication providers. @@ -58,7 +113,7 @@ export class Session { */ private readonly randomBytes = promisify(randomBytes); - constructor(private readonly options: SessionOptions) {} + constructor(private readonly options: Readonly) {} /** * Extracts session value for the specified request. Under the hood it can clear session if it is @@ -101,10 +156,7 @@ export class Session { } try { - return { - ...sessionIndexValue, - state: await this.decryptState(sessionIndexValue.state as string, sessionCookieValue.aad), - }; + return await this.decryptSessionValue(sessionIndexValue, sessionCookieValue.aad); } catch (err) { await this.clear(request); return null; @@ -118,7 +170,9 @@ export class Session { */ async create( request: KibanaRequest, - sessionValue: Omit + sessionValue: Readonly< + Omit + > ) { // Do we want to partition these calls or merge in a single 512 call instead? Technically 512 // will be faster, and we'll occupy just one thread. @@ -128,22 +182,23 @@ export class Session { ]); const sessionExpirationInfo = this.calculateExpiry(); - const createdSessionValue: SessionValue = { + const path = this.options.serverBasePath; + + const createdSessionValue: Readonly = { ...sessionValue, ...sessionExpirationInfo, sid, - state: await this.encryptState(sessionValue.state, aad), + path, }; // First try to store session in the index and only then in the cookie to make sure cookie is // only updated if server side session is created successfully. - await this.options.sessionIndex.create(createdSessionValue); - await this.options.sessionCookie.set(request, { ...sessionExpirationInfo, sid, aad }); + await this.options.sessionIndex.create( + await this.encryptSessionValue(createdSessionValue, aad) + ); + await this.options.sessionCookie.set(request, { ...sessionExpirationInfo, sid, aad, path }); - return { - ...createdSessionValue, - state: sessionValue.state, - }; + return createdSessionValue; } /** @@ -151,28 +206,33 @@ export class Session { * @param request Request instance to set session value for. * @param sessionValue Session value parameters. */ - async update(request: KibanaRequest, sessionValue: SessionValue) { + async update(request: KibanaRequest, sessionValue: Readonly) { const sessionCookieValue = await this.options.sessionCookie.get(request); if (!sessionCookieValue) { throw new Error('Session cannot be update since it doesnt exist.'); } const sessionExpirationInfo = this.calculateExpiry(sessionCookieValue.lifespanExpiration); + const path = this.options.serverBasePath; - // First try to store session in the index and only then in the cookie to make sure cookie is - // only updated if server side session is created successfully. - await this.options.sessionIndex.update({ + const updatedSessionValue: Readonly = { ...sessionValue, ...sessionExpirationInfo, - state: await this.encryptState(sessionValue.state, sessionCookieValue.aad), - }); + path, + }; + + // First try to store session in the index and only then in the cookie to make sure cookie is + // only updated if server side session is created successfully. + await this.options.sessionIndex.update( + await this.encryptSessionValue(updatedSessionValue, sessionCookieValue.aad) + ); await this.options.sessionCookie.set(request, { ...sessionCookieValue, ...sessionExpirationInfo, }); - return { ...sessionValue, ...sessionExpirationInfo }; + return updatedSessionValue; } /** @@ -180,7 +240,7 @@ export class Session { * @param request Request instance to set session value for. * @param sessionValue Session value parameters. */ - async extend(request: KibanaRequest, sessionValue: SessionValue) { + async extend(request: KibanaRequest, sessionValue: Readonly) { const sessionCookieValue = await this.options.sessionCookie.get(request); if (!sessionCookieValue) { throw new Error('Session cannot be extended since it doesnt exist.'); @@ -246,7 +306,7 @@ export class Session { ...sessionExpirationInfo, }); - return { ...sessionValue, ...sessionExpirationInfo }; + return { ...sessionValue, ...sessionExpirationInfo } as Readonly; } /** @@ -266,37 +326,42 @@ export class Session { } /** - * Encrypts specified state. - * @param state State to encrypt. - * @param aad Additional authenticated data (AAD) to use for encryption. + * Encrypts session value content and converts to a value stored in the session index. + * @param sessionValue Session value. + * @param aad Additional authenticated data (AAD) used for encryption. */ - private async encryptState(state: unknown, aad: string) { - if (state == null) { - return state; - } + private async encryptSessionValue(sessionValue: Readonly, aad: string) { + // Extract values that shouldn't be directly included into session index value. + const { username, state, ...sessionIndexValue } = sessionValue; try { - return await this.crypto.encrypt(JSON.stringify(state), aad); + const encryptedContent = await this.crypto.encrypt( + JSON.stringify({ username, state } as SessionValueContentToEncrypt), + aad + ); + return { ...sessionIndexValue, content: encryptedContent } as Readonly; } catch (err) { - this.options.logger.error(`Failed to encrypt session: ${err.message}`); + this.options.logger.error(`Failed to encrypt session value: ${err.message}`); throw err; } } /** - * Decrypts specified state. - * @param encryptedState State to decrypt. - * @param aad Additional authenticated data (AAD) used for encryption. + * Decrypts session value content from the value stored in the session index. + * @param sessionIndexValue Session value retrieved from the session index. + * @param aad Additional authenticated data (AAD) used for decryption. */ - private async decryptState(encryptedState: string, aad: string) { - if (encryptedState == null) { - return encryptedState; - } + private async decryptSessionValue(sessionIndexValue: Readonly, aad: string) { + // Extract values that are specific to session index value. + const { username_hash, content, ...sessionValue } = sessionIndexValue; try { - return JSON.parse((await this.crypto.decrypt(encryptedState, aad)) as string); + const decryptedContent = JSON.parse( + (await this.crypto.decrypt(content, aad)) as string + ) as SessionValueContentToEncrypt; + return { ...sessionValue, ...decryptedContent } as Readonly; } catch (err) { - this.options.logger.error(`Failed to decrypt session: ${err.message}`); + this.options.logger.error(`Failed to decrypt session value: ${err.message}`); throw err; } } diff --git a/x-pack/plugins/security/server/session_management/session_cookie.ts b/x-pack/plugins/security/server/session_management/session_cookie.ts index 3ff2d3be855e0ce..4538031ec9f3148 100644 --- a/x-pack/plugins/security/server/session_management/session_cookie.ts +++ b/x-pack/plugins/security/server/session_management/session_cookie.ts @@ -6,7 +6,6 @@ import { HttpServiceSetup, - IBasePath, KibanaRequest, Logger, SessionStorageFactory, @@ -20,52 +19,49 @@ export interface SessionCookieValue { /** * Unique session ID. */ - readonly sid: string; + sid: string; /** * Unique random value used as Additional authenticated data (AAD) while encrypting/decrypting * sensitive or PII session content stored in the Elasticsearch index. This value is only stored * in the user cookie. */ - readonly aad: string; + aad: string; /** - * Cookie "Path" attribute that is validated against the current Kibana server configuration. + * Kibana server base path the session was created for. */ - readonly path: string; + path: string; /** * The Unix time in ms when the session should be considered expired. If `null`, session will stay * active until the browser is closed. */ - readonly idleTimeoutExpiration: number | null; + idleTimeoutExpiration: number | null; /** * The Unix time in ms which is the max total lifespan of the session. If `null`, session expire * time can be extended indefinitely. */ - readonly lifespanExpiration: number | null; + lifespanExpiration: number | null; } export interface SessionCookieOptions { logger: Logger; - basePath: IBasePath; + serverBasePath: string; createCookieSessionStorageFactory: HttpServiceSetup['createCookieSessionStorageFactory']; config: Pick; } export class SessionCookie { - /** - * Which base path the HTTP server is hosted on. - */ - private readonly serverBasePath = this.options.basePath.serverBasePath || '/'; - /** * Promise containing initialized cookie session storage factory. */ - private readonly cookieSessionValueStorage: Promise>; + private readonly cookieSessionValueStorage: Promise< + SessionStorageFactory> + >; - constructor(private readonly options: SessionCookieOptions) { + constructor(private readonly options: Readonly) { this.cookieSessionValueStorage = options.createCookieSessionStorageFactory({ encryptionKey: options.config.encryptionKey, isSecure: options.config.secureCookies, @@ -75,9 +71,8 @@ export class SessionCookie { const invalidSessionValue = (Array.isArray(sessionValue) ? sessionValue : [sessionValue] - ).find((sess) => sess.path !== undefined && sess.path !== this.serverBasePath); + ).find((sess) => sess.path !== undefined && sess.path !== this.options.serverBasePath); - // TODO: We should notify session index about that too. if (invalidSessionValue) { options.logger.debug(`Outdated session value with path "${invalidSessionValue.path}"`); return { isValid: false, path: invalidSessionValue.path }; @@ -111,11 +106,8 @@ export class SessionCookie { * @param request Request instance to set session value for. * @param sessionValue Session value parameters. */ - async set(request: KibanaRequest, sessionValue: Omit) { - (await this.cookieSessionValueStorage).asScoped(request).set({ - ...sessionValue, - path: this.serverBasePath, - }); + async set(request: KibanaRequest, sessionValue: Readonly) { + (await this.cookieSessionValueStorage).asScoped(request).set(sessionValue); } /** diff --git a/x-pack/plugins/security/server/session_management/session_index.ts b/x-pack/plugins/security/server/session_management/session_index.ts index f6c70deed29af96..6f9647b0b03b527 100644 --- a/x-pack/plugins/security/server/session_management/session_index.ts +++ b/x-pack/plugins/security/server/session_management/session_index.ts @@ -5,13 +5,59 @@ */ import { IClusterClient, Logger } from '../../../../../src/core/server'; +import { AuthenticationProvider } from '../../common/types'; import { ConfigType } from '../config'; -import { SessionValue } from './session_value'; export interface SessionIndexOptions { - readonly clusterClient: IClusterClient; - readonly config: Pick; - readonly logger: Logger; + clusterClient: IClusterClient; + serverBasePath: string; + config: Pick; + logger: Logger; +} + +export interface SessionIndexValue { + /** + * Unique session ID. + */ + sid: string; + + /** + * Hash of the username. It's defined only if session is authenticated, otherwise session + * is considered unauthenticated (e.g. intermediate session used during SSO handshake). + */ + username_hash?: string; + + /** + * Name and type of the provider this session belongs to. + */ + provider: AuthenticationProvider; + + /** + * The Unix time in ms when the session should be considered expired. If `null`, session will stay + * active until the browser is closed. + */ + idleTimeoutExpiration: number | null; + + /** + * The Unix time in ms which is the max total lifespan of the session. If `null`, session expire + * time can be extended indefinitely. + */ + lifespanExpiration: number | null; + + /** + * Kibana server base path the session was created for. + */ + path: string; + + /** + * Indicates whether user acknowledged access agreement or not. + */ + accessAgreementAcknowledged?: boolean; + + /** + * Content of the session value represented in a encrypted JSON string. + */ + content: string; } export class SessionIndex { @@ -29,13 +75,13 @@ export class SessionIndex { ? this.options.config.session.idleTimeout.asMilliseconds() * 3 : null; - constructor(private readonly options: SessionIndexOptions) {} + constructor(private readonly options: Readonly) {} /** * Creates new document for the session value. * @param sessionValue Session value. */ - async create(sessionValue: SessionValue) { + async create(sessionValue: Readonly) { const { sid, ...sessionValueToStore } = sessionValue; try { await this.options.clusterClient.callAsInternalUser('create', { @@ -45,7 +91,7 @@ export class SessionIndex { refresh: 'wait_for', }); } catch (err) { - this.options.logger.error(`Failed to create session: ${err.message}`); + this.options.logger.error(`Failed to create session value: ${err.message}`); throw err; } } @@ -54,7 +100,7 @@ export class SessionIndex { * Makes a partial update of the existing session value. * @param sessionValue Session value. */ - async update(sessionValue: { sid: string } & Partial) { + async update(sessionValue: Readonly<{ sid: string } & Partial>) { const { sid, ...sessionValueToStore } = sessionValue; try { await this.options.clusterClient.callAsInternalUser('update', { @@ -64,7 +110,7 @@ export class SessionIndex { refresh: 'wait_for', }); } catch (err) { - this.options.logger.error(`Failed to update session: ${err.message}`); + this.options.logger.error(`Failed to update session value: ${err.message}`); throw err; } } @@ -86,9 +132,9 @@ export class SessionIndex { return null; } - return { sid, ...response._source } as SessionValue; + return { sid, ...response._source } as Readonly; } catch (err) { - this.options.logger.error(`Failed to retrieve session: ${err.message}`); + this.options.logger.error(`Failed to retrieve session value: ${err.message}`); throw err; } } @@ -101,11 +147,12 @@ export class SessionIndex { try { const now = Date.now(); - // Always try to delete session with the specified ID and with expired lifespan (even if it's - // not configured right now). + // Always try to delete session with the specified ID, with expired lifespan (even if it's + // not configured right now) and with a path that is different from the current one. const deleteQueries: object[] = [ { term: { _id: sid } }, { range: { lifespanExpiration: { lte: now } } }, + { bool: { must_not: { term: { path: this.options.serverBasePath } } } }, ]; // If lifespan is configured we should remove sessions that were created without it if any. @@ -132,7 +179,7 @@ export class SessionIndex { body: { conflicts: 'proceed', query: { bool: { should: deleteQueries } } }, }); } catch (err) { - this.options.logger.error(`Failed to clear session: ${err.message}`); + this.options.logger.error(`Failed to clear session value: ${err.message}`); throw err; } } @@ -172,20 +219,22 @@ export class SessionIndex { dynamic: 'strict', properties: { username_hash: { type: 'keyword' }, - roles: { type: 'keyword' }, provider: { properties: { type: { type: 'keyword' }, name: { type: 'keyword' } } }, idleTimeoutExpiration: { type: 'date' }, lifespanExpiration: { type: 'date' }, + path: { type: 'keyword' }, accessAgreementAcknowledged: { type: 'boolean' }, - state: { type: 'binary' }, + content: { type: 'binary' }, }, }, settings: { - number_of_shards: 1, - number_of_replicas: 0, - auto_expand_replicas: '0-1', - 'index.priority': 1000, - 'index.refresh_interval': '1s', + index: { + number_of_shards: 1, + number_of_replicas: 0, + auto_expand_replicas: '0-1', + priority: 1000, + refresh_interval: '1s', + }, }, }, index: SessionIndex.INDEX_NAME, diff --git a/x-pack/plugins/security/server/session_management/session_management_service.ts b/x-pack/plugins/security/server/session_management/session_management_service.ts index 29a38a59103d513..90f6a7b2bdb868c 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.ts @@ -40,22 +40,26 @@ export class SessionManagementService { clusterClient, http, }: SessionManagementServiceSetupParams): SessionManagementServiceSetup { + const serverBasePath = http.basePath.serverBasePath || '/'; + const sessionCookie = new SessionCookie({ config, createCookieSessionStorageFactory: http.createCookieSessionStorageFactory, - basePath: http.basePath, + serverBasePath, logger: this.logger.get('cookie'), }); this.sessionIndex = new SessionIndex({ config, clusterClient, + serverBasePath, logger: this.logger.get('index'), }); return { session: new Session({ auditLogger, + serverBasePath, logger: this.logger, sessionCookie, sessionIndex: this.sessionIndex, diff --git a/x-pack/plugins/security/server/session_management/session_value.ts b/x-pack/plugins/security/server/session_management/session_value.ts deleted file mode 100644 index d305f95306ffb61..000000000000000 --- a/x-pack/plugins/security/server/session_management/session_value.ts +++ /dev/null @@ -1,45 +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 { AuthenticationProvider } from '../../common/types'; - -/** - * The shape of the session value stored in the Elasticsearch index. - */ -export interface SessionValue { - /** - * Unique session ID. - */ - readonly sid: string; - - /** - * Name and type of the provider this session belongs to. - */ - readonly provider: AuthenticationProvider; - - /** - * The Unix time in ms when the session should be considered expired. If `null`, session will stay - * active until the browser is closed. - */ - readonly idleTimeoutExpiration: number | null; - - /** - * The Unix time in ms which is the max total lifespan of the session. If `null`, session expire - * time can be extended indefinitely. - */ - readonly lifespanExpiration: 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. - */ - readonly state: unknown; - - /** - * Indicates whether user acknowledged access agreement or not. - */ - readonly accessAgreementAcknowledged?: boolean; -}