|
@@ -0,0 +1,339 @@
|
|
|
+/**
|
|
|
+ * Licensed 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. See accompanying LICENSE file.
|
|
|
+ */
|
|
|
+package org.apache.hadoop.security.authentication.server;
|
|
|
+
|
|
|
+import java.io.IOException;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.util.Hashtable;
|
|
|
+import java.util.Properties;
|
|
|
+
|
|
|
+import javax.naming.Context;
|
|
|
+import javax.naming.NamingException;
|
|
|
+import javax.naming.directory.InitialDirContext;
|
|
|
+import javax.naming.ldap.InitialLdapContext;
|
|
|
+import javax.naming.ldap.LdapContext;
|
|
|
+import javax.naming.ldap.StartTlsRequest;
|
|
|
+import javax.naming.ldap.StartTlsResponse;
|
|
|
+import javax.net.ssl.HostnameVerifier;
|
|
|
+import javax.net.ssl.SSLSession;
|
|
|
+import javax.servlet.ServletException;
|
|
|
+import javax.servlet.http.HttpServletRequest;
|
|
|
+import javax.servlet.http.HttpServletResponse;
|
|
|
+
|
|
|
+import org.apache.commons.codec.binary.Base64;
|
|
|
+import org.apache.hadoop.classification.InterfaceAudience;
|
|
|
+import org.apache.hadoop.classification.InterfaceStability;
|
|
|
+import org.apache.hadoop.security.authentication.client.AuthenticationException;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+
|
|
|
+import com.google.common.annotations.VisibleForTesting;
|
|
|
+import com.google.common.base.Preconditions;
|
|
|
+
|
|
|
+/**
|
|
|
+ * The {@link LdapAuthenticationHandler} implements the BASIC authentication
|
|
|
+ * mechanism for HTTP using LDAP back-end.
|
|
|
+ *
|
|
|
+ * The supported configuration properties are:
|
|
|
+ * <ul>
|
|
|
+ * <li>ldap.providerurl: The url of the LDAP server. It does not have a default
|
|
|
+ * value.</li>
|
|
|
+ * <li>ldap.basedn: the base distinguished name (DN) to be used with the LDAP
|
|
|
+ * server. This value is appended to the provided user id for authentication
|
|
|
+ * purpose. It does not have a default value.</li>
|
|
|
+ * <li>ldap.binddomain: the LDAP bind domain value to be used with the LDAP
|
|
|
+ * server. This property is optional and useful only in case of Active
|
|
|
+ * Directory server.
|
|
|
+ * <li>ldap.enablestarttls: A boolean value used to define if the LDAP server
|
|
|
+ * supports 'StartTLS' extension.</li>
|
|
|
+ * </ul>
|
|
|
+ */
|
|
|
+@InterfaceAudience.Private
|
|
|
+@InterfaceStability.Evolving
|
|
|
+public class LdapAuthenticationHandler implements AuthenticationHandler {
|
|
|
+ private static Logger logger = LoggerFactory
|
|
|
+ .getLogger(LdapAuthenticationHandler.class);
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Constant that identifies the authentication mechanism.
|
|
|
+ */
|
|
|
+ public static final String TYPE = "ldap";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Constant that identifies the authentication mechanism to be used with the
|
|
|
+ * LDAP server.
|
|
|
+ */
|
|
|
+ public static final String SECURITY_AUTHENTICATION = "simple";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Constant for the configuration property that indicates the url of the LDAP
|
|
|
+ * server.
|
|
|
+ */
|
|
|
+ public static final String PROVIDER_URL = TYPE + ".providerurl";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Constant for the configuration property that indicates the base
|
|
|
+ * distinguished name (DN) to be used with the LDAP server. This value is
|
|
|
+ * appended to the provided user id for authentication purpose.
|
|
|
+ */
|
|
|
+ public static final String BASE_DN = TYPE + ".basedn";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Constant for the configuration property that indicates the LDAP bind
|
|
|
+ * domain value to be used with the LDAP server.
|
|
|
+ */
|
|
|
+ public static final String LDAP_BIND_DOMAIN = TYPE + ".binddomain";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Constant for the configuration property that indicates the base
|
|
|
+ * distinguished name (DN) to be used with the LDAP server. This value is
|
|
|
+ * appended to the provided user id for authentication purpose.
|
|
|
+ */
|
|
|
+ public static final String ENABLE_START_TLS = TYPE + ".enablestarttls";
|
|
|
+
|
|
|
+ private String ldapDomain;
|
|
|
+ private String baseDN;
|
|
|
+ private String providerUrl;
|
|
|
+ private Boolean enableStartTls;
|
|
|
+ private Boolean disableHostNameVerification;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Configure StartTLS LDAP extension for this handler.
|
|
|
+ *
|
|
|
+ * @param enableStartTls true If the StartTLS LDAP extension is to be enabled
|
|
|
+ * false otherwise
|
|
|
+ */
|
|
|
+ @VisibleForTesting
|
|
|
+ public void setEnableStartTls(Boolean enableStartTls) {
|
|
|
+ this.enableStartTls = enableStartTls;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Configure the Host name verification for this handler. This method is
|
|
|
+ * introduced only for unit testing and should never be used in production.
|
|
|
+ *
|
|
|
+ * @param disableHostNameVerification true to disable host-name verification
|
|
|
+ * false otherwise
|
|
|
+ */
|
|
|
+ @VisibleForTesting
|
|
|
+ public void setDisableHostNameVerification(
|
|
|
+ Boolean disableHostNameVerification) {
|
|
|
+ this.disableHostNameVerification = disableHostNameVerification;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public String getType() {
|
|
|
+ return TYPE;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void init(Properties config) throws ServletException {
|
|
|
+ this.baseDN = config.getProperty(BASE_DN);
|
|
|
+ this.providerUrl = config.getProperty(PROVIDER_URL);
|
|
|
+ this.ldapDomain = config.getProperty(LDAP_BIND_DOMAIN);
|
|
|
+ this.enableStartTls =
|
|
|
+ Boolean.valueOf(config.getProperty(ENABLE_START_TLS, "false"));
|
|
|
+
|
|
|
+ Preconditions
|
|
|
+ .checkNotNull(this.providerUrl, "The LDAP URI can not be null");
|
|
|
+ Preconditions.checkArgument((this.baseDN == null)
|
|
|
+ ^ (this.ldapDomain == null),
|
|
|
+ "Either LDAP base DN or LDAP domain value needs to be specified");
|
|
|
+ if (this.enableStartTls) {
|
|
|
+ String tmp = this.providerUrl.toLowerCase();
|
|
|
+ Preconditions.checkArgument(!tmp.startsWith("ldaps"),
|
|
|
+ "Can not use ldaps and StartTLS option at the same time");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void destroy() {
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public boolean managementOperation(AuthenticationToken token,
|
|
|
+ HttpServletRequest request, HttpServletResponse response)
|
|
|
+ throws IOException, AuthenticationException {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public AuthenticationToken authenticate(HttpServletRequest request,
|
|
|
+ HttpServletResponse response)
|
|
|
+ throws IOException, AuthenticationException {
|
|
|
+ AuthenticationToken token = null;
|
|
|
+ String authorization =
|
|
|
+ request.getHeader(HttpConstants.AUTHORIZATION_HEADER);
|
|
|
+
|
|
|
+ if (authorization == null
|
|
|
+ || !AuthenticationHandlerUtil.matchAuthScheme(HttpConstants.BASIC,
|
|
|
+ authorization)) {
|
|
|
+ response.setHeader(WWW_AUTHENTICATE, HttpConstants.BASIC);
|
|
|
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
|
|
+ if (authorization == null) {
|
|
|
+ logger.trace("Basic auth starting");
|
|
|
+ } else {
|
|
|
+ logger.warn("'" + HttpConstants.AUTHORIZATION_HEADER
|
|
|
+ + "' does not start with '" + HttpConstants.BASIC + "' : {}",
|
|
|
+ authorization);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ authorization =
|
|
|
+ authorization.substring(HttpConstants.BASIC.length()).trim();
|
|
|
+ final Base64 base64 = new Base64(0);
|
|
|
+ // As per RFC7617, UTF-8 charset should be used for decoding.
|
|
|
+ String[] credentials = new String(base64.decode(authorization),
|
|
|
+ StandardCharsets.UTF_8).split(":", 2);
|
|
|
+ if (credentials.length == 2) {
|
|
|
+ token = authenticateUser(credentials[0], credentials[1]);
|
|
|
+ response.setStatus(HttpServletResponse.SC_OK);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return token;
|
|
|
+ }
|
|
|
+
|
|
|
+ private AuthenticationToken authenticateUser(String userName,
|
|
|
+ String password) throws AuthenticationException {
|
|
|
+ if (userName == null || userName.isEmpty()) {
|
|
|
+ throw new AuthenticationException("Error validating LDAP user:"
|
|
|
+ + " a null or blank username has been provided");
|
|
|
+ }
|
|
|
+
|
|
|
+ // If the domain is available in the config, then append it unless domain
|
|
|
+ // is already part of the username. LDAP providers like Active Directory
|
|
|
+ // use a fully qualified user name like foo@bar.com.
|
|
|
+ if (!hasDomain(userName) && ldapDomain != null) {
|
|
|
+ userName = userName + "@" + ldapDomain;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (password == null || password.isEmpty() ||
|
|
|
+ password.getBytes(StandardCharsets.UTF_8)[0] == 0) {
|
|
|
+ throw new AuthenticationException("Error validating LDAP user:"
|
|
|
+ + " a null or blank password has been provided");
|
|
|
+ }
|
|
|
+
|
|
|
+ // setup the security principal
|
|
|
+ String bindDN;
|
|
|
+ if (baseDN == null) {
|
|
|
+ bindDN = userName;
|
|
|
+ } else {
|
|
|
+ bindDN = "uid=" + userName + "," + baseDN;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.enableStartTls) {
|
|
|
+ authenticateWithTlsExtension(bindDN, password);
|
|
|
+ } else {
|
|
|
+ authenticateWithoutTlsExtension(bindDN, password);
|
|
|
+ }
|
|
|
+
|
|
|
+ return new AuthenticationToken(userName, userName, TYPE);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void authenticateWithTlsExtension(String userDN, String password)
|
|
|
+ throws AuthenticationException {
|
|
|
+ LdapContext ctx = null;
|
|
|
+ Hashtable<String, Object> env = new Hashtable<String, Object>();
|
|
|
+ env.put(Context.INITIAL_CONTEXT_FACTORY,
|
|
|
+ "com.sun.jndi.ldap.LdapCtxFactory");
|
|
|
+ env.put(Context.PROVIDER_URL, providerUrl);
|
|
|
+
|
|
|
+ try {
|
|
|
+ // Create initial context
|
|
|
+ ctx = new InitialLdapContext(env, null);
|
|
|
+ // Establish TLS session
|
|
|
+ StartTlsResponse tls =
|
|
|
+ (StartTlsResponse) ctx.extendedOperation(new StartTlsRequest());
|
|
|
+
|
|
|
+ if (disableHostNameVerification) {
|
|
|
+ tls.setHostnameVerifier(new HostnameVerifier() {
|
|
|
+ @Override
|
|
|
+ public boolean verify(String hostname, SSLSession session) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ tls.negotiate();
|
|
|
+
|
|
|
+ // Initialize security credentials & perform read operation for
|
|
|
+ // verification.
|
|
|
+ ctx.addToEnvironment(Context.SECURITY_AUTHENTICATION,
|
|
|
+ SECURITY_AUTHENTICATION);
|
|
|
+ ctx.addToEnvironment(Context.SECURITY_PRINCIPAL, userDN);
|
|
|
+ ctx.addToEnvironment(Context.SECURITY_CREDENTIALS, password);
|
|
|
+ ctx.lookup(userDN);
|
|
|
+ logger.debug("Authentication successful for {}", userDN);
|
|
|
+
|
|
|
+ } catch (NamingException | IOException ex) {
|
|
|
+ throw new AuthenticationException("Error validating LDAP user", ex);
|
|
|
+ } finally {
|
|
|
+ if (ctx != null) {
|
|
|
+ try {
|
|
|
+ ctx.close();
|
|
|
+ } catch (NamingException e) { /* Ignore. */
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void authenticateWithoutTlsExtension(String userDN, String password)
|
|
|
+ throws AuthenticationException {
|
|
|
+ Hashtable<String, Object> env = new Hashtable<String, Object>();
|
|
|
+ env.put(Context.INITIAL_CONTEXT_FACTORY,
|
|
|
+ "com.sun.jndi.ldap.LdapCtxFactory");
|
|
|
+ env.put(Context.PROVIDER_URL, providerUrl);
|
|
|
+ env.put(Context.SECURITY_AUTHENTICATION, SECURITY_AUTHENTICATION);
|
|
|
+ env.put(Context.SECURITY_PRINCIPAL, userDN);
|
|
|
+ env.put(Context.SECURITY_CREDENTIALS, password);
|
|
|
+
|
|
|
+ try {
|
|
|
+ // Create initial context
|
|
|
+ Context ctx = new InitialDirContext(env);
|
|
|
+ ctx.close();
|
|
|
+ logger.debug("Authentication successful for {}", userDN);
|
|
|
+
|
|
|
+ } catch (NamingException e) {
|
|
|
+ throw new AuthenticationException("Error validating LDAP user", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean hasDomain(String userName) {
|
|
|
+ return (indexOfDomainMatch(userName) > 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ /*
|
|
|
+ * Get the index separating the user name from domain name (the user's name
|
|
|
+ * up to the first '/' or '@').
|
|
|
+ *
|
|
|
+ * @param userName full user name.
|
|
|
+ *
|
|
|
+ * @return index of domain match or -1 if not found
|
|
|
+ */
|
|
|
+ private static int indexOfDomainMatch(String userName) {
|
|
|
+ if (userName == null) {
|
|
|
+ return -1;
|
|
|
+ }
|
|
|
+
|
|
|
+ int idx = userName.indexOf('/');
|
|
|
+ int idx2 = userName.indexOf('@');
|
|
|
+ int endIdx = Math.min(idx, idx2); // Use the earlier match.
|
|
|
+ // Unless at least one of '/' or '@' was not found, in
|
|
|
+ // which case, user the latter match.
|
|
|
+ if (endIdx == -1) {
|
|
|
+ endIdx = Math.max(idx, idx2);
|
|
|
+ }
|
|
|
+ return endIdx;
|
|
|
+ }
|
|
|
+
|
|
|
+}
|