All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.elasticsearch.xpack.security.authc.AuthenticatorChain Maven / Gradle / Ivy

There is a newer version: 8.16.1
Show newest version
/*
 * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
 * or more contributor license agreements. Licensed under the Elastic License
 * 2.0; you may not use this file except in compliance with the Elastic License
 * 2.0.
 */

package org.elasticsearch.xpack.security.authc;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.elasticsearch.ElasticsearchSecurityException;
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.node.Node;
import org.elasticsearch.xpack.core.common.IteratingActionListener;
import org.elasticsearch.xpack.core.security.authc.Authentication;
import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
import org.elasticsearch.xpack.core.security.authc.AuthenticationServiceField;
import org.elasticsearch.xpack.core.security.authc.AuthenticationToken;
import org.elasticsearch.xpack.core.security.authc.support.AuthenticationContextSerializer;
import org.elasticsearch.xpack.core.security.support.Exceptions;
import org.elasticsearch.xpack.core.security.user.AnonymousUser;
import org.elasticsearch.xpack.core.security.user.SystemUser;
import org.elasticsearch.xpack.core.security.user.User;
import org.elasticsearch.xpack.security.operator.OperatorPrivileges.OperatorPrivilegesService;

import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Function;

import static org.elasticsearch.core.Strings.format;

class AuthenticatorChain {

    private static final Logger logger = LogManager.getLogger(AuthenticatorChain.class);

    private final String nodeName;
    private final boolean runAsEnabled;
    private final OperatorPrivilegesService operatorPrivilegesService;
    private final AnonymousUser anonymousUser;
    private final boolean isAnonymousUserEnabled;
    private final AuthenticationContextSerializer authenticationSerializer;
    private final RealmsAuthenticator realmsAuthenticator;
    private final List allAuthenticators;

    AuthenticatorChain(
        Settings settings,
        OperatorPrivilegesService operatorPrivilegesService,
        AnonymousUser anonymousUser,
        AuthenticationContextSerializer authenticationSerializer,
        ServiceAccountAuthenticator serviceAccountAuthenticator,
        OAuth2TokenAuthenticator oAuth2TokenAuthenticator,
        ApiKeyAuthenticator apiKeyAuthenticator,
        RealmsAuthenticator realmsAuthenticator
    ) {
        this.nodeName = Node.NODE_NAME_SETTING.get(settings);
        this.runAsEnabled = AuthenticationServiceField.RUN_AS_ENABLED.get(settings);
        this.operatorPrivilegesService = operatorPrivilegesService;
        this.anonymousUser = anonymousUser;
        this.isAnonymousUserEnabled = AnonymousUser.isAnonymousEnabled(settings);
        this.authenticationSerializer = authenticationSerializer;
        this.realmsAuthenticator = realmsAuthenticator;
        this.allAuthenticators = List.of(serviceAccountAuthenticator, oAuth2TokenAuthenticator, apiKeyAuthenticator, realmsAuthenticator);
    }

    void authenticate(Authenticator.Context context, ActionListener originalListener) {
        assert false == context.getDefaultOrderedRealmList().isEmpty() : "realm list must not be empty";
        // Check whether authentication is an operator user and mark the threadContext if necessary
        // before returning the authentication object
        final ActionListener listener = originalListener.map(authentication -> {
            assert authentication != null;
            operatorPrivilegesService.maybeMarkOperatorUser(authentication, context.getThreadContext());
            return authentication;
        });
        // If a token is directly provided in the context, authenticate with it
        if (context.getMostRecentAuthenticationToken() != null) {
            doAuthenticate(context, listener);
            return;
        }
        final Authentication authentication;
        try {
            authentication = lookForExistingAuthentication(context);
        } catch (Exception e) {
            listener.onFailure(e);
            return;
        }
        if (authentication != null) {
            logger.trace("Found existing authentication [{}] in request [{}]", authentication, context.getRequest());
            listener.onResponse(authentication);
        } else {
            doAuthenticate(context, ActionListener.runBefore(listener, context::close));
        }
    }

    private void doAuthenticate(Authenticator.Context context, ActionListener listener) {
        // The iterating listener walks through the list of Authenticators and attempts to authenticate using
        // each Authenticator (and optionally asks it to extract the authenticationToken).
        // Depending on the authentication result from each Authenticator, the iteration may stop earlier
        // because of either a successful authentication or a not-continuable failure.
        final IteratingActionListener, Authenticator> iterListener = new IteratingActionListener<>(
            listener.delegateFailureAndWrap((l, result) -> {
                assert result.getStatus() != AuthenticationResult.Status.TERMINATE
                    : "terminate should already be handled by each individual authenticator";
                if (result.getStatus() == AuthenticationResult.Status.SUCCESS) {
                    maybeLookupRunAsUser(context, result.getValue(), l);
                } else {
                    assert result.getStatus() == AuthenticationResult.Status.CONTINUE;
                    if (context.shouldHandleNullToken()) {
                        handleNullToken(context, l);
                    } else {
                        l.onFailure(Exceptions.authenticationError("failed to authenticate", result.getException()));
                    }
                }
            }),
            getAuthenticatorConsumer(context),
            allAuthenticators,
            context.getThreadContext(),
            Function.identity(),
            result -> result.getStatus() == AuthenticationResult.Status.CONTINUE
        );
        iterListener.run();
    }

    private static BiConsumer>> getAuthenticatorConsumer(
        Authenticator.Context context
    ) {
        return (authenticator, listener) -> {
            if (context.shouldExtractCredentials()) {
                final AuthenticationToken authenticationToken;
                try {
                    authenticationToken = authenticator.extractCredentials(context);
                } catch (Exception e) {
                    if (e instanceof ElasticsearchSecurityException) {
                        listener.onFailure(e);
                    } else { // other exceptions like illegal argument
                        context.addUnsuccessfulMessage(authenticator.name() + ": " + e.getMessage());
                        listener.onResponse(AuthenticationResult.unsuccessful(e.getMessage(), e));
                    }
                    return;
                }
                if (authenticationToken == null) {
                    listener.onResponse(AuthenticationResult.notHandled());
                    return;
                }
                context.addAuthenticationToken(authenticationToken);
            }

            final Consumer onFailure = (e) -> {
                assert e != null : "exception cannot be null";
                // Not adding additional metadata if the exception is not security related, e.g. server busy.
                // Because (1) unlike security errors which are intentionally obscure, non-security errors are clear
                // about their nature so that no additional information is needed; (2) Non-security errors may
                // not inherit ElasticsearchException and thus does not have the addMetadata method.
                if (e instanceof final ElasticsearchSecurityException ese) {
                    // Attach any other unsuccessful messages to the final error
                    if (false == context.getUnsuccessfulMessages().isEmpty()) {
                        addMetadata(context, ese);
                    }
                }
                listener.onFailure(e);
            };

            authenticator.authenticate(context, ActionListener.wrap(result -> {
                if (result.getStatus() == AuthenticationResult.Status.TERMINATE) {
                    onFailure.accept(result.getException());
                    return;
                }
                if (result.getStatus() == AuthenticationResult.Status.CONTINUE && result.getMessage() != null) {
                    context.addUnsuccessfulMessage(authenticator.name() + ": " + result.getMessage());
                }
                listener.onResponse(result);
            }, onFailure));
        };
    }

    // Package private for test
    void maybeLookupRunAsUser(Authenticator.Context context, Authentication authentication, ActionListener listener) {
        if (false == runAsEnabled) {
            finishAuthentication(context, authentication, listener);
            return;
        }

        final String runAsUsername = context.getThreadContext().getHeader(AuthenticationServiceField.RUN_AS_USER_HEADER);
        if (runAsUsername == null) {
            finishAuthentication(context, authentication, listener);
            return;
        }

        if (false == authentication.supportsRunAs(anonymousUser)) {
            logger.info("ignore run-as header since it is currently not supported for authentication [{}]", authentication);
            finishAuthentication(context, authentication, listener);
            return;
        }

        // Now we have a valid runAsUsername
        realmsAuthenticator.lookupRunAsUser(context, authentication, listener.delegateFailureAndWrap((l, tuple) -> {
            final Authentication finalAuth;
            if (tuple == null) {
                logger.debug(
                    "Cannot find run-as user [{}] for authenticated user [{}]",
                    runAsUsername,
                    authentication.getAuthenticatingSubject().getUser().principal()
                );
                // the user does not exist, but we still create a User object, which will later be rejected by authz
                finalAuth = authentication.runAs(new User(runAsUsername, null, null, null, Map.of(), true), null);
            } else {
                finalAuth = authentication.runAs(tuple.v1(), tuple.v2().realmRef());
            }
            finishAuthentication(context, finalAuth, l);
        }));
    }

    /**
     * Looks to see if the request contains an existing {@link Authentication} and if so, that authentication will be used. The
     * consumer is called if no exception was thrown while trying to read the authentication and may be called with a {@code null}
     * value
     */
    private Authentication lookForExistingAuthentication(Authenticator.Context context) {
        final Authentication authentication;
        try {
            authentication = authenticationSerializer.readFromContext(context.getThreadContext());
        } catch (Exception e) {
            logger.error(() -> format("caught exception while trying to read authentication from request [%s]", context.getRequest()), e);
            throw context.getRequest().tamperedRequest();
        }
        if (authentication != null && context.getRequest() instanceof AuthenticationService.AuditableHttpRequest) {
            throw context.getRequest().tamperedRequest();
        }
        return authentication;
    }

    /**
     * Handles failed extraction of an authentication token. This can happen in a few different scenarios:
     *
     * 
    *
  • this is an initial request from a client without preemptive authentication, so we must return an authentication * challenge
  • *
  • this is a request that contained an Authorization Header that we can't validate
  • *
  • this is a request made internally within a node and there is a fallback user, which is typically the * {@link SystemUser}
  • *
  • anonymous access is enabled and this will be considered an anonymous request
  • *
*

* Regardless of the scenario, this method will call the listener with either failure or success. */ // TODO: In theory, handleNullToken should not be called at all if any credentials is extracted // pkg-private for tests void handleNullToken(Authenticator.Context context, ActionListener listener) { final Authentication authentication; if (context.getFallbackUser() != null) { // TODO: assert we really haven't extract any token logger.trace( "No valid credentials found in request [{}], using fallback [{}]", context.getRequest(), context.getFallbackUser().principal() ); authentication = Authentication.newInternalFallbackAuthentication(context.getFallbackUser(), nodeName); } else if (shouldFallbackToAnonymous(context)) { logger.trace( "No valid credentials found in request [{}], using anonymous [{}]", context.getRequest(), anonymousUser.principal() ); authentication = Authentication.newAnonymousAuthentication(anonymousUser, nodeName); } else { authentication = null; } if (authentication != null) { // TODO: we can also support run-as for fallback users if needed // TODO: the authentication for fallback user is now serialised in the inner threadContext // instead of at the AuthenticationService level writeAuthToContext(context, authentication, listener); } else { final ElasticsearchSecurityException ese = context.getRequest().anonymousAccessDenied(); if (false == context.getUnsuccessfulMessages().isEmpty()) { logger.debug( "Authenticating with null credentials is unsuccessful in request [{}]" + " after unsuccessful attempts of other credentials", context.getRequest() ); final ElasticsearchSecurityException eseWithPreviousCredentials = new ElasticsearchSecurityException( "unable to authenticate with provided credentials and anonymous access is not allowed for this request", ese.status(), ese.getCause() ); ese.getHeaderKeys().forEach(k -> eseWithPreviousCredentials.addHeader(k, ese.getHeader(k))); addMetadata(context, eseWithPreviousCredentials); listener.onFailure(eseWithPreviousCredentials); } else { logger.debug("No valid credentials found in request [{}], rejecting", context.getRequest()); listener.onFailure(ese); } } } /** * Finishes the authentication process by ensuring the returned user is enabled and that the run as user is enabled if there is * one. If authentication is successful, this method also ensures that the authentication is written to the ThreadContext */ void finishAuthentication(Authenticator.Context context, Authentication authentication, ActionListener listener) { if (authentication.getEffectiveSubject().getUser().enabled() == false || authentication.getAuthenticatingSubject().getUser().enabled() == false) { // TODO: these should be different log messages if the runas vs auth user is disabled? logger.debug("user [{}] is disabled. failing authentication", authentication.getEffectiveSubject().getUser()); listener.onFailure(context.getRequest().authenticationFailed(context.getMostRecentAuthenticationToken())); } else { writeAuthToContext(context, authentication, listener); } } /** * Writes the authentication to the {@link ThreadContext} and then calls the listener if * successful */ void writeAuthToContext(Authenticator.Context context, Authentication authentication, ActionListener listener) { try { authenticationSerializer.writeToContext(authentication, context.getThreadContext()); context.getRequest().authenticationSuccess(authentication); } catch (Exception e) { logger.debug(() -> format("Failed to store authentication [%s] for request [%s]", authentication, context.getRequest()), e); final ElasticsearchSecurityException ese = context.getRequest() .exceptionProcessingRequest(e, context.getMostRecentAuthenticationToken()); addMetadata(context, ese); listener.onFailure(ese); return; } logger.trace("Established authentication [{}] for request [{}]", authentication, context.getRequest()); listener.onResponse(authentication); } private static void addMetadata(Authenticator.Context context, ElasticsearchSecurityException ese) { if (false == context.getUnsuccessfulMessages().isEmpty()) { ese.addMetadata("es.additional_unsuccessful_credentials", context.getUnsuccessfulMessages()); } } /** * Determines whether to support anonymous access for the current request. Returns {@code true} if all of the following are true *

    *
  • The service has anonymous authentication enabled (see {@link #isAnonymousUserEnabled})
  • *
  • Anonymous access is accepted for this request ({@code allowAnonymousOnThisRequest} parameter) *
  • The request does NOT provide API Key or Bearer Token credentials. If any of these are present, * we consider the request as failed to authenticate, and will not fall back to anonymous.
  • *
*/ private boolean shouldFallbackToAnonymous(Authenticator.Context context) { if (isAnonymousUserEnabled == false) { return false; } if (context.isAllowAnonymous() == false) { return false; } if (context.getBearerString() != null || context.getApiKeyString() != null) { return false; } return true; } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy