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

io.druid.security.kerberos.KerberosAuthenticator Maven / Gradle / Ivy

There is a newer version: 0.12.3
Show newest version
/*
 * Licensed to Metamarkets Group Inc. (Metamarkets) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. Metamarkets licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License. You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied. See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

package io.druid.security.kerberos;

import com.fasterxml.jackson.annotation.JacksonInject;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonTypeName;
import com.google.common.base.Throwables;
import io.druid.guice.annotations.Self;
import io.druid.java.util.common.StringUtils;
import io.druid.java.util.common.logger.Logger;
import io.druid.server.DruidNode;
import io.druid.server.security.AuthConfig;
import io.druid.server.security.AuthenticationResult;
import io.druid.server.security.Authenticator;
import org.apache.commons.codec.binary.Base64;
import org.apache.hadoop.security.SecurityUtil;
import org.apache.hadoop.security.authentication.client.AuthenticatedURL;
import org.apache.hadoop.security.authentication.client.AuthenticationException;
import org.apache.hadoop.security.authentication.server.AuthenticationFilter;
import org.apache.hadoop.security.authentication.server.AuthenticationToken;
import org.apache.hadoop.security.authentication.util.KerberosUtil;
import org.apache.hadoop.security.authentication.util.Signer;
import org.apache.hadoop.security.authentication.util.SignerException;
import org.apache.hadoop.security.authentication.util.SignerSecretProvider;
import sun.security.krb5.EncryptedData;
import sun.security.krb5.EncryptionKey;
import sun.security.krb5.internal.APReq;
import sun.security.krb5.internal.EncTicketPart;
import sun.security.krb5.internal.Krb5;
import sun.security.krb5.internal.Ticket;
import sun.security.krb5.internal.crypto.KeyUsage;
import sun.security.util.DerInputStream;
import sun.security.util.DerValue;

import javax.security.auth.Subject;
import javax.security.auth.kerberos.KerberosKey;
import javax.security.auth.kerberos.KerberosPrincipal;
import javax.security.auth.kerberos.KeyTab;
import javax.security.auth.login.AppConfigurationEntry;
import javax.security.auth.login.Configuration;
import javax.security.auth.login.LoginContext;
import javax.servlet.DispatcherType;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.security.Principal;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Random;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@JsonTypeName("kerberos")
public class KerberosAuthenticator implements Authenticator
{
  private static final Logger log = new Logger(KerberosAuthenticator.class);
  private static final Pattern HADOOP_AUTH_COOKIE_REGEX = Pattern.compile(".*p=(\\S+)&t=.*");
  public static final List DEFAULT_EXCLUDED_PATHS = Collections.emptyList();

  private final DruidNode node;
  private final String serverPrincipal;
  private final String serverKeytab;
  private final String internalClientPrincipal;
  private final String internalClientKeytab;
  private final String authToLocal;
  private final List excludedPaths;
  private final String cookieSignatureSecret;
  private final String authorizerName;
  private LoginContext loginContext;

  @JsonCreator
  public KerberosAuthenticator(
      @JsonProperty("serverPrincipal") String serverPrincipal,
      @JsonProperty("serverKeytab") String serverKeytab,
      @JsonProperty("internalClientPrincipal") String internalClientPrincipal,
      @JsonProperty("internalClientKeytab") String internalClientKeytab,
      @JsonProperty("authToLocal") String authToLocal,
      @JsonProperty("excludedPaths") List excludedPaths,
      @JsonProperty("cookieSignatureSecret") String cookieSignatureSecret,
      @JsonProperty("authorizerName") String authorizerName,
      @JacksonInject @Self DruidNode node
  )
  {
    this.node = node;
    this.serverPrincipal = serverPrincipal;
    this.serverKeytab = serverKeytab;
    this.internalClientPrincipal = internalClientPrincipal;
    this.internalClientKeytab = internalClientKeytab;
    this.authToLocal = authToLocal == null ? "DEFAULT" : authToLocal;
    this.excludedPaths = excludedPaths == null ? DEFAULT_EXCLUDED_PATHS : excludedPaths;
    this.cookieSignatureSecret = cookieSignatureSecret;
    this.authorizerName = authorizerName;
  }

  @Override
  public Filter getFilter()
  {
    return new AuthenticationFilter()
    {
      private Signer mySigner;

      @Override
      public void init(FilterConfig filterConfig) throws ServletException
      {
        ClassLoader prevLoader = Thread.currentThread().getContextClassLoader();
        try {
          // AuthenticationHandler is created during Authenticationfilter.init using reflection with thread context class loader.
          // In case of druid since the class is actually loaded as an extension and filter init is done in main thread.
          // We need to set the classloader explicitly to extension class loader.
          Thread.currentThread().setContextClassLoader(AuthenticationFilter.class.getClassLoader());
          super.init(filterConfig);
          String configPrefix = filterConfig.getInitParameter(CONFIG_PREFIX);
          configPrefix = (configPrefix != null) ? configPrefix + "." : "";
          Properties config = getConfiguration(configPrefix, filterConfig);
          String signatureSecret = config.getProperty(configPrefix + SIGNATURE_SECRET);
          if (signatureSecret == null) {
            signatureSecret = Long.toString(new Random().nextLong());
            log.warn("'signature.secret' configuration not set, using a random value as secret");
          }
          final byte[] secretBytes = StringUtils.toUtf8(signatureSecret);
          SignerSecretProvider signerSecretProvider = new SignerSecretProvider()
          {
            @Override
            public void init(Properties config, ServletContext servletContext, long tokenValidity) throws Exception
            {

            }

            @Override
            public byte[] getCurrentSecret()
            {
              return secretBytes;
            }

            @Override
            public byte[][] getAllSecrets()
            {
              return new byte[][]{secretBytes};
            }
          };
          mySigner = new Signer(signerSecretProvider);
        }
        finally {
          Thread.currentThread().setContextClassLoader(prevLoader);
        }
      }

      // Copied from hadoop-auth's AuthenticationFilter, to allow us to change error response handling in doFilterSuper
      @Override
      protected AuthenticationToken getToken(HttpServletRequest request) throws IOException, AuthenticationException
      {
        AuthenticationToken token = null;
        String tokenStr = null;
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
          for (Cookie cookie : cookies) {
            if (cookie.getName().equals(AuthenticatedURL.AUTH_COOKIE)) {
              tokenStr = cookie.getValue();
              try {
                tokenStr = mySigner.verifyAndExtract(tokenStr);
              }
              catch (SignerException ex) {
                throw new AuthenticationException(ex);
              }
              break;
            }
          }
        }
        if (tokenStr != null) {
          token = AuthenticationToken.parse(tokenStr);
          if (!token.getType().equals(getAuthenticationHandler().getType())) {
            throw new AuthenticationException("Invalid AuthenticationToken type");
          }
          if (token.isExpired()) {
            throw new AuthenticationException("AuthenticationToken expired");
          }
        }
        return token;
      }

      @Override
      public void doFilter(
          ServletRequest request, ServletResponse response, FilterChain filterChain
      ) throws IOException, ServletException
      {
        HttpServletRequest httpReq = (HttpServletRequest) request;

        // If there's already an auth result, then we have authenticated already, skip this.
        if (request.getAttribute(AuthConfig.DRUID_AUTHENTICATION_RESULT) != null) {
          filterChain.doFilter(request, response);
          return;
        }

        if (loginContext == null) {
          initializeKerberosLogin();
        }

        String path = ((HttpServletRequest) request).getRequestURI();
        if (isExcluded(path)) {
          filterChain.doFilter(request, response);
        } else {
          String clientPrincipal = null;
          try {
            Cookie[] cookies = httpReq.getCookies();
            if (cookies == null) {
              clientPrincipal = getPrincipalFromRequestNew((HttpServletRequest) request);
            } else {
              clientPrincipal = null;
              for (Cookie cookie : cookies) {
                if ("hadoop.auth".equals(cookie.getName())) {
                  Matcher matcher = HADOOP_AUTH_COOKIE_REGEX.matcher(cookie.getValue());
                  if (matcher.matches()) {
                    clientPrincipal = matcher.group(1);
                    break;
                  }
                }
              }
            }
          }
          catch (Exception ex) {
            clientPrincipal = null;
          }

          if (clientPrincipal != null) {
            request.setAttribute(
                AuthConfig.DRUID_AUTHENTICATION_RESULT,
                new AuthenticationResult(clientPrincipal, authorizerName, null)
            );
          }
        }

        doFilterSuper(request, response, filterChain);
      }

      // Copied from hadoop-auth's AuthenticationFilter, to allow us to change error response handling
      private void doFilterSuper(ServletRequest request, ServletResponse response, FilterChain filterChain)
          throws IOException, ServletException
      {
        boolean unauthorizedResponse = true;
        int errCode = HttpServletResponse.SC_UNAUTHORIZED;
        AuthenticationException authenticationEx = null;
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        boolean isHttps = "https".equals(httpRequest.getScheme());
        try {
          boolean newToken = false;
          AuthenticationToken token;
          try {
            token = getToken(httpRequest);
          }
          catch (AuthenticationException ex) {
            log.warn("AuthenticationToken ignored: " + ex.getMessage());
            // will be sent back in a 401 unless filter authenticates
            authenticationEx = ex;
            token = null;
          }
          if (getAuthenticationHandler().managementOperation(token, httpRequest, httpResponse)) {
            if (token == null) {
              if (log.isDebugEnabled()) {
                log.debug("Request [{%s}] triggering authentication", getRequestURL(httpRequest));
              }
              token = getAuthenticationHandler().authenticate(httpRequest, httpResponse);
              if (token != null && token.getExpires() != 0 &&
                  token != AuthenticationToken.ANONYMOUS) {
                token.setExpires(System.currentTimeMillis() + getValidity() * 1000);
              }
              newToken = true;
            }
            if (token != null) {
              unauthorizedResponse = false;
              if (log.isDebugEnabled()) {
                log.debug("Request [{%s}] user [{%s}] authenticated", getRequestURL(httpRequest), token.getUserName());
              }
              final AuthenticationToken authToken = token;
              httpRequest = new HttpServletRequestWrapper(httpRequest)
              {

                @Override
                public String getAuthType()
                {
                  return authToken.getType();
                }

                @Override
                public String getRemoteUser()
                {
                  return authToken.getUserName();
                }

                @Override
                public Principal getUserPrincipal()
                {
                  return (authToken != AuthenticationToken.ANONYMOUS) ? authToken : null;
                }
              };
              if (newToken && !token.isExpired() && token != AuthenticationToken.ANONYMOUS) {
                String signedToken = mySigner.sign(token.toString());
                createAuthCookie(httpResponse, signedToken, getCookieDomain(),
                                 getCookiePath(), token.getExpires(), isHttps
                );
              }
              doFilter(filterChain, httpRequest, httpResponse);
            }
          } else {
            unauthorizedResponse = false;
          }
        }
        catch (AuthenticationException ex) {
          // exception from the filter itself is fatal
          errCode = HttpServletResponse.SC_FORBIDDEN;
          authenticationEx = ex;
          if (log.isDebugEnabled()) {
            log.debug("Authentication exception: " + ex.getMessage(), ex);
          } else {
            log.warn("Authentication exception: " + ex.getMessage());
          }
        }
        if (unauthorizedResponse) {
          if (!httpResponse.isCommitted()) {
            createAuthCookie(httpResponse, "", getCookieDomain(),
                             getCookiePath(), 0, isHttps
            );
            // If response code is 401. Then WWW-Authenticate Header should be
            // present.. reset to 403 if not found..
            if ((errCode == HttpServletResponse.SC_UNAUTHORIZED)
                && (!httpResponse.containsHeader(
                org.apache.hadoop.security.authentication.client.KerberosAuthenticator.WWW_AUTHENTICATE))) {
              errCode = HttpServletResponse.SC_FORBIDDEN;
            }
            if (authenticationEx == null) {
              // Don't send an error response here, unlike the base AuthenticationFilter implementation.
              // This request did not use Kerberos auth.
              // Instead, we will send an error response in PreResponseAuthorizationCheckFilter to allow
              // other Authenticator implementations to check the request.
              filterChain.doFilter(request, response);
            } else {
              // Do send an error response here, we attempted Kerberos authentication and failed.
              httpResponse.sendError(errCode, authenticationEx.getMessage());
            }
          }
        }
      }
    };
  }

  @Override
  public Class getFilterClass()
  {
    return null;
  }

  @Override
  public Map getInitParameters()
  {
    Map params = new HashMap();
    try {
      params.put(
          "kerberos.principal",
          SecurityUtil.getServerPrincipal(serverPrincipal, node.getHost())
      );
      params.put("kerberos.keytab", serverKeytab);
      params.put(AuthenticationFilter.AUTH_TYPE, DruidKerberosAuthenticationHandler.class.getName());
      params.put("kerberos.name.rules", authToLocal);
      if (cookieSignatureSecret != null) {
        params.put("signature.secret", cookieSignatureSecret);
      }
    }
    catch (IOException e) {
      Throwables.propagate(e);
    }
    return params;
  }

  @Override
  public String getPath()
  {
    return "/*";
  }

  @Override
  public EnumSet getDispatcherType()
  {
    return null;
  }

  @Override
  public String getAuthChallengeHeader()
  {
    return "Negotiate";
  }

  @Override
  public AuthenticationResult authenticateJDBCContext(Map context)
  {
    throw new UnsupportedOperationException("JDBC Kerberos auth not supported yet");
  }

  private boolean isExcluded(String path)
  {
    for (String excluded : excludedPaths) {
      if (path.startsWith(excluded)) {
        return true;
      }
    }
    return false;
  }


  /**
   * Kerberos context configuration for the JDK GSS library. Copied from hadoop-auth's KerberosAuthenticationHandler.
   */
  public static class DruidKerberosConfiguration extends Configuration
  {
    private String keytab;
    private String principal;

    public DruidKerberosConfiguration(String keytab, String principal)
    {
      this.keytab = keytab;
      this.principal = principal;
    }

    @Override
    public AppConfigurationEntry[] getAppConfigurationEntry(String name)
    {
      Map options = new HashMap();
      if (System.getProperty("java.vendor").contains("IBM")) {
        options.put(
            "useKeytab",
            keytab.startsWith("file://") ? keytab : "file://" + keytab
        );
        options.put("principal", principal);
        options.put("credsType", "acceptor");
      } else {
        options.put("keyTab", keytab);
        options.put("principal", principal);
        options.put("useKeyTab", "true");
        options.put("storeKey", "true");
        options.put("doNotPrompt", "true");
        options.put("useTicketCache", "true");
        options.put("renewTGT", "true");
        options.put("isInitiator", "false");
      }
      options.put("refreshKrb5Config", "true");
      String ticketCache = System.getenv("KRB5CCNAME");
      if (ticketCache != null) {
        if (System.getProperty("java.vendor").contains("IBM")) {
          options.put("useDefaultCcache", "true");
          // The first value searched when "useDefaultCcache" is used.
          System.setProperty("KRB5CCNAME", ticketCache);
          options.put("renewTGT", "true");
          options.put("credsType", "both");
        } else {
          options.put("ticketCache", ticketCache);
        }
      }
      if (log.isDebugEnabled()) {
        options.put("debug", "true");
      }

      return new AppConfigurationEntry[]{
          new AppConfigurationEntry(
              KerberosUtil.getKrb5LoginModuleName(),
              AppConfigurationEntry.LoginModuleControlFlag.REQUIRED,
              options
          ),
          };
    }
  }

  private String getPrincipalFromRequestNew(HttpServletRequest req)
  {
    String authorization = req.getHeader(org.apache.hadoop.security.authentication.client.KerberosAuthenticator.AUTHORIZATION);
    if (authorization == null
        || !authorization.startsWith(org.apache.hadoop.security.authentication.client.KerberosAuthenticator.NEGOTIATE)) {
      return null;
    } else {
      authorization = authorization.substring(org.apache.hadoop.security.authentication.client.KerberosAuthenticator.NEGOTIATE
                                                  .length()).trim();
      final Base64 base64 = new Base64(0);
      final byte[] clientToken = base64.decode(authorization);
      try {
        DerInputStream ticketStream = new DerInputStream(clientToken);
        DerValue[] values = ticketStream.getSet(clientToken.length, true);

        // see this link for AP-REQ format: https://tools.ietf.org/html/rfc1510#section-5.5.1
        for (DerValue value : values) {
          if (isValueAPReq(value)) {
            APReq apReq = new APReq(value);
            Ticket ticket = apReq.ticket;
            EncryptedData encData = ticket.encPart;
            int eType = encData.getEType();

            // find the server's key
            EncryptionKey finalKey = null;
            Subject serverSubj = loginContext.getSubject();
            Set serverCreds = serverSubj.getPrivateCredentials(Object.class);
            for (Object cred : serverCreds) {
              if (cred instanceof KeyTab) {
                KeyTab serverKeyTab = (KeyTab) cred;
                KerberosPrincipal serverPrincipal = new KerberosPrincipal(this.serverPrincipal);
                KerberosKey[] serverKeys = serverKeyTab.getKeys(serverPrincipal);
                for (KerberosKey key : serverKeys) {
                  if (key.getKeyType() == eType) {
                    finalKey = new EncryptionKey(key.getKeyType(), key.getEncoded());
                    break;
                  }
                }
              }
            }

            if (finalKey == null) {
              log.error("Could not find matching key from server creds.");
              return null;
            }

            // decrypt the ticket with the server's key
            byte[] decryptedBytes = encData.decrypt(finalKey, KeyUsage.KU_TICKET);
            decryptedBytes = encData.reset(decryptedBytes);
            EncTicketPart decrypted = new EncTicketPart(decryptedBytes);
            String clientPrincipal = decrypted.cname.toString();
            return clientPrincipal;
          }
        }
      }
      catch (Exception ex) {
        Throwables.propagate(ex);
      }
    }

    return null;
  }

  private boolean isValueAPReq(DerValue value)
  {
    return value.isConstructed((byte) Krb5.KRB_AP_REQ);
  }

  private void initializeKerberosLogin() throws ServletException
  {
    String principal;
    String keytab;

    try {
      principal = SecurityUtil.getServerPrincipal(serverPrincipal, node.getHost());
      if (principal == null || principal.trim().length() == 0) {
        throw new ServletException("Principal not defined in configuration");
      }
      keytab = serverKeytab;
      if (keytab == null || keytab.trim().length() == 0) {
        throw new ServletException("Keytab not defined in configuration");
      }
      if (!new File(keytab).exists()) {
        throw new ServletException("Keytab does not exist: " + keytab);
      }

      Set principals = new HashSet();
      principals.add(new KerberosPrincipal(principal));
      Subject subject = new Subject(false, principals, new HashSet(), new HashSet());

      DruidKerberosConfiguration kerberosConfiguration = new DruidKerberosConfiguration(keytab, principal);

      log.info("Login using keytab " + keytab + ", for principal " + principal);
      loginContext = new LoginContext("", subject, null, kerberosConfiguration);
      loginContext.login();

      log.info("Initialized, principal %s from keytab %s", principal, keytab);
    }
    catch (Exception ex) {
      throw new ServletException(ex);
    }
  }
}