diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index b8ec6258eb0d593..dd7e2dcdaa69702 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -20,6 +20,7 @@ import { SecurityFeatureUsageServiceStart } from '../feature_usage'; import { SessionValue, Session } from '../session_management'; import { + AnonymousAuthenticationProvider, AuthenticationProviderOptions, AuthenticationProviderSpecificOptions, BaseAuthenticationProvider, @@ -85,6 +86,7 @@ const providerMap = new Map< [TokenAuthenticationProvider.type, TokenAuthenticationProvider], [OIDCAuthenticationProvider.type, OIDCAuthenticationProvider], [PKIAuthenticationProvider.type, PKIAuthenticationProvider], + [AnonymousAuthenticationProvider.type, AnonymousAuthenticationProvider], ]); /** diff --git a/x-pack/plugins/security/server/authentication/providers/anonymous.ts b/x-pack/plugins/security/server/authentication/providers/anonymous.ts new file mode 100644 index 000000000000000..4b68a79025f85bd --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/anonymous.ts @@ -0,0 +1,336 @@ +/* + * 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 { createHash } from 'crypto'; +import { KibanaRequest } from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; +import { HTTPAuthorizationHeader } from '../http_authentication'; +import { Tokens, TokenPair } from '../tokens'; +import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base'; + +/** + * The state supported by the provider. + */ +type ProviderState = { + /** + * Hash of the credentials used to create session. + */ + credentialsHash: string; +} & (({ type: 'bearer' } & TokenPair) | { type: 'apikey' }); + +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication. + return request.route.options.authRequired === true; +} + +/** + * Provider that supports anonymous request authentication. + */ +export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider { + /** + * Type of the provider. + */ + static readonly type = 'anonymous'; + + /** + * Specifies credentials that should be used to authenticate anonymous user. + */ + private readonly credentials: Readonly< + { username: string; password: string } | { apiKey: string } + >; + + /** + * Hash of the credentials that current provider uses, used to check whether existing session was + * created using the same credentials. + */ + private readonly credentialsHash: string; + + constructor( + protected readonly options: Readonly, + anonymousOptions?: Readonly<{ + credentials?: { username: string; password: string } | { apiKey: string }; + }> + ) { + super(options); + + if (!anonymousOptions || !anonymousOptions.credentials) { + throw new Error('Credentials must be specified'); + } + + this.credentials = anonymousOptions.credentials; + this.credentialsHash = createHash('sha3-256') + .update(JSON.stringify(anonymousOptions.credentials)) + .digest('hex'); + } + + /** + * Performs initial login request. + * @param request Request instance. + * @param attempt Login attempt description. + * @param [state] Optional state object associated with the provider. + */ + public async login(request: KibanaRequest, attempt: unknown, state?: ProviderState | null) { + this.logger.debug('Trying to perform a login.'); + + if (state?.credentialsHash && state.credentialsHash !== this.credentialsHash) { + const message = `Provider is configured with different credentials that were used to create anonymous session.`; + this.logger.warn(message); + return AuthenticationResult.failed(Boom.unauthorized(message)); + } + + const authenticationResult = state + ? await this.authenticateViaState(request, state) + : AuthenticationResult.notHandled(); + + if ( + authenticationResult.notHandled() || + (authenticationResult.failed() && + Tokens.isAccessTokenExpiredError(authenticationResult.error)) + ) { + return await this.loginWithCredentials(request); + } + + return authenticationResult; + } + + /** + * Performs request authentication. + * @param request Request instance. + * @param [state] Optional state object associated with the provider. + */ + public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); + + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { + this.logger.debug('Cannot authenticate requests with `Authorization` header.'); + return AuthenticationResult.notHandled(); + } + + // It may happen that Kibana is re-configured to use different credentials for the same provider name, + // we should clear such session an log user out. + if (state?.credentialsHash && state.credentialsHash !== this.credentialsHash) { + const message = `Provider is configured with different credentials that were used to create anonymous session.`; + this.logger.warn(message); + return AuthenticationResult.failed(Boom.unauthorized(message)); + } + + let authenticationResult = AuthenticationResult.notHandled(); + if (state) { + authenticationResult = await this.authenticateViaState(request, state); + if ( + authenticationResult.failed() && + Tokens.isAccessTokenExpiredError(authenticationResult.error) + ) { + authenticationResult = await this.authenticateViaRefreshToken(request, state); + } + } + + // If we couldn't authenticate by means of all methods above, let's try to log in automatically. + return authenticationResult.notHandled() && canStartNewSession(request) + ? this.loginWithCredentials(request) + : authenticationResult; + } + + /** + * Invalidates access and refresh token pair if they exist. + * @param request Request instance. + * @param state State value previously stored by the provider. + */ + public async logout(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug(`Trying to log user out via ${request.url.path}.`); + + if (!state || state.type === 'apikey') { + this.logger.debug('There is no session to invalidate.'); + return DeauthenticationResult.notHandled(); + } + + try { + await this.options.tokens.invalidate(state); + } catch (err) { + this.logger.debug(`Failed invalidating user's access token: ${err.message}`); + return DeauthenticationResult.failed(err); + } + + return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); + } + + /** + * Returns HTTP authentication scheme (`Bearer` or `ApiKey`) that's used within `Authorization` + * HTTP header that provider attaches to all successfully authenticated requests to Elasticsearch. + */ + public getHTTPAuthenticationScheme() { + return 'username' in this.credentials ? 'bearer' : 'apikey'; + } + + /** + * Validates whether request payload contains `SAMLResponse` parameter that can be exchanged + * to a proper access token. If state is presented and includes request id then it means + * that login attempt has been initiated by Kibana itself and request id must be sent to + * Elasticsearch together with corresponding `SAMLResponse`. Not having state at this stage is + * indication of potential IdP initiated login, so we should send only `SAMLResponse` that + * Elasticsearch will decrypt and figure out on its own if it's a legit response from IdP + * initiated login. + * + * 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. + */ + private async loginWithCredentials(request: KibanaRequest) { + return 'username' in this.credentials + ? this.loginWithUsernameAndPassword( + request, + this.credentials.username, + this.credentials.password + ) + : this.loginWithAPIKey(request, this.credentials.apiKey); + } + + /** + * Tries to log user in with configured username and password. + * @param request Request instance. + * @param username Name of the Elasticsearch user that is used for "anonymous" access. + * @param password Password of the Elasticsearch user that is used for "anonymous" access. + */ + private async loginWithUsernameAndPassword( + request: KibanaRequest, + username: string, + password: string + ) { + this.logger.debug('Trying to log in with configured anonymous username and password.'); + + try { + // First attempt to exchange login credentials for an access token + const { + access_token: accessToken, + refresh_token: refreshToken, + } = await this.options.client.callAsInternalUser('shield.getAccessToken', { + body: { grant_type: 'password', username, password }, + }); + + this.logger.debug('Get token API request to Elasticsearch successful'); + + // Then attempt to query for the user details using the new token + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; + const user = await this.getUser(request, authHeaders); + + this.logger.debug('Login has been successfully performed.'); + return AuthenticationResult.succeeded(user, { + authHeaders, + state: { type: 'bearer', accessToken, refreshToken }, + }); + } catch (err) { + this.logger.debug(`Failed to perform a login: ${err.message}`); + return AuthenticationResult.failed(err); + } + } + + /** + * Tries to log user in with configured api key ID. + * @param request Request instance. + * @param apiKey Elasticsearch API key that is used for "anonymous" access. + */ + private async loginWithAPIKey(request: KibanaRequest, apiKey: string) { + this.logger.debug('Trying to log in with configured API key.'); + + try { + // Then attempt to query for the user details using the new token + const authHeaders = { + authorization: new HTTPAuthorizationHeader('ApiKey', apiKey).toString(), + }; + const user = await this.getUser(request, authHeaders); + + this.logger.debug('Login has been successfully performed.'); + return AuthenticationResult.succeeded(user, { authHeaders, state: { type: 'apikey' } }); + } catch (err) { + this.logger.debug(`Failed to perform a login: ${err.message}`); + return AuthenticationResult.failed(err); + } + } + + /** + * Tries to extract access token from 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: KibanaRequest, state: ProviderState) { + this.logger.debug(`Trying to authenticate via state with type ${state.type}.`); + + const authHeaders = { + authorization: (state.type === 'bearer' + ? new HTTPAuthorizationHeader('Bearer', state.accessToken) + : new HTTPAuthorizationHeader('ApiKey', (this.credentials as any).apiKey) + ).toString(), + }; + + try { + const user = await this.getUser(request, authHeaders); + this.logger.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authHeaders }); + } catch (err) { + this.logger.debug(`Failed to authenticate request via state: ${err.message}`); + return AuthenticationResult.failed(err); + } + } + + /** + * This method is only called when authentication via access token stored in the state failed because of expired + * token. So we should use refresh token, that is also stored in the state, to extend expired access token and + * authenticate user with it. + * @param request Request instance. + * @param state State value previously stored by the provider. + */ + private async authenticateViaRefreshToken(request: KibanaRequest, state: ProviderState) { + this.logger.debug('Trying to refresh access token.'); + + if (state.type !== 'bearer') { + this.logger.debug('Refresh token is not found in state.'); + return AuthenticationResult.notHandled(); + } + + let refreshedTokenPair: TokenPair | null; + try { + refreshedTokenPair = await this.options.tokens.refresh(state.refreshToken); + } catch (err) { + return AuthenticationResult.failed(err); + } + + // When user has neither valid access nor refresh token, the only way to resolve this issue is to log user again. + if (refreshedTokenPair === null) { + return this.loginWithCredentials(request); + } + + try { + const authHeaders = { + authorization: new HTTPAuthorizationHeader( + 'Bearer', + refreshedTokenPair.accessToken + ).toString(), + }; + const user = await this.getUser(request, authHeaders); + + this.logger.debug('Request has been authenticated via refreshed token.'); + return AuthenticationResult.succeeded(user, { + authHeaders, + state: { ...state, ...refreshedTokenPair }, + }); + } catch (err) { + this.logger.debug( + `Failed to authenticate user using newly refreshed access token: ${err.message}` + ); + return AuthenticationResult.failed(err); + } + } +} diff --git a/x-pack/plugins/security/server/authentication/providers/index.ts b/x-pack/plugins/security/server/authentication/providers/index.ts index 048afb6190d18c8..cfa9e715050669b 100644 --- a/x-pack/plugins/security/server/authentication/providers/index.ts +++ b/x-pack/plugins/security/server/authentication/providers/index.ts @@ -9,6 +9,7 @@ export { AuthenticationProviderOptions, AuthenticationProviderSpecificOptions, } from './base'; +export { AnonymousAuthenticationProvider } from './anonymous'; export { BasicAuthenticationProvider } from './basic'; export { KerberosAuthenticationProvider } from './kerberos'; export { SAMLAuthenticationProvider, SAMLLogin } from './saml'; diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 9ccbdac5e09f426..40a71c854ebba4e 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -107,6 +107,23 @@ const providersConfigSchema = schema.object( schema.object({ ...getCommonProviderSchemaProperties(), realm: schema.string() }) ) ), + anonymous: schema.maybe( + schema.recordOf( + schema.string(), + schema.object({ + ...getCommonProviderSchemaProperties(), + credentials: schema.oneOf([ + schema.object({ + username: schema.string(), + password: schema.string(), + }), + schema.object({ + apiKey: schema.string(), + }), + ]), + }) + ) + ), }, { validate(config) { @@ -182,6 +199,7 @@ export const ConfigSchema = schema.object({ oidc: undefined, pki: undefined, kerberos: undefined, + anonymous: undefined, }, }), oidc: providerOptionsSchema('oidc', schema.object({ realm: schema.string() })),