/** * Copyright 2015 StreamSets Inc. * * Licensed under the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF 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 com.streamsets.datacollector.http; import com.google.common.annotations.VisibleForTesting; import com.streamsets.datacollector.util.Configuration; import org.apache.commons.lang3.StringUtils; import org.eclipse.jetty.jaas.callback.ObjectCallback; import org.eclipse.jetty.jaas.spi.AbstractLoginModule; import org.eclipse.jetty.jaas.spi.UserInfo; import org.eclipse.jetty.util.security.Credential; import org.ldaptive.*; import org.ldaptive.auth.AuthenticationRequest; import org.ldaptive.auth.AuthenticationResponse; import org.ldaptive.auth.Authenticator; import org.ldaptive.auth.BindAuthenticationHandler; import org.ldaptive.auth.SearchDnResolver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.security.auth.Subject; import javax.security.auth.callback.Callback; import javax.security.auth.callback.CallbackHandler; import javax.security.auth.callback.NameCallback; import javax.security.auth.callback.UnsupportedCallbackException; import javax.security.auth.login.LoginException; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Locale; import java.util.Map; /** * This is copy of class org.eclipse.jetty.server.server.plus.jaas.spi.LdapLoginModule * Only change is bindingLogin method to use bindDn/bindPassword for fetching roles and * removing 'debug' argument (we'll use LOG.debug() instead). * * A LdapLoginModule for use with JAAS setups * <p/> * The jvm should be started with the following parameter: * <br><br> * <code> * -Djava.security.auth.login.config=etc/ldap-loginModule.conf * </code> * <br><br> * and an example of the ldap-loginModule.conf would be: * <br><br> * <pre> * ldaploginmodule { * com.streamsets.datacollector.http.LdapLoginModule required * useLdaps="false" * useStartTLS="false" * contextFactory="com.sun.jndi.ldap.LdapCtxFactory" * hostname="ldap.example.com" * port="389" * bindDn="cn=Directory Manager" * bindPassword="directory" * authenticationMethod="simple" * forceBindingLogin="false" * userBaseDn="ou=people,dc=alcatel" * userRdnAttribute="uid" * userIdAttribute="uid" * userPasswordAttribute="userPassword" * userObjectClass="inetOrgPerson" * useFilter="uid={user}" * roleBaseDn="ou=groups,dc=example,dc=com" * roleNameAttribute="cn" * roleMemberAttribute="uniqueMember" * roleObjectClass="groupOfUniqueNames"; * }; * </pre> * * * * */ public class LdapLoginModule extends AbstractLoginModule { private static final Logger LOG = LoggerFactory.getLogger(LdapLoginModule.class); /** * hostname of the ldap server */ private String _hostname; /** * port of the ldap server */ private int _port; /** * root DN used to connect to */ private String _bindDn; /** * password used to connect to the root ldap context */ private String _bindPassword; /** * object class of a user */ private String _userObjectClass = "inetOrgPerson"; /** * attribute that the principal is located */ private String _userRdnAttribute = "uid"; /** * attribute that the principal is located */ private String _userIdAttribute = "cn"; /** * name of the attribute that a users password is stored under * <p/> * NOTE: not always accessible, see force binding login */ private String _userPasswordAttribute = "userPassword"; /** * base DN where users are to be searched from */ private String _userBaseDn; /** * base DN where role membership is to be searched from */ private String _roleBaseDn; /** * object class of roles */ private String _roleObjectClass = "groupOfUniqueNames"; /** * name of the attribute that a username would be under a role class */ private String _roleMemberAttribute = "uniqueMember"; /** * the name of the attribute that a role would be stored under */ private String _roleNameAttribute = "roleName"; /** * if the getUserInfo can pull a password off of the user then * password comparison is an option for authn, to force binding * login checks, set this to true */ private boolean _forceBindingLogin = false; /** * When true changes the protocol to ldaps */ private boolean _useLdaps = false; /** * When true changes the protocol to ldaps */ private boolean _useStarttls = false; /** * Default filter to do the user search. */ private String _userFilter = "%s={user}"; /** * Default filter to do the role search. */ private String _roleFilter = "%s={dn})"; private static final String filterFormat = "(&(objectClass=%s)%s)"; private static final String DN = "{dn}"; private static final String USER= "{user}"; /** * LDAP configuration. */ private ConnectionConfig connConfig; /** * Connection to Ldap server. */ private Connection conn; /** * get the available information about the user * <p/> * for this LoginModule, the credential can be null which will result in a * binding ldap authentication scenario * <p/> * roles are also an optional concept if required * * @param username * @return the userinfo for the username * @throws Exception */ @Override public UserInfo getUserInfo(String username) throws Exception { LdapEntry entry = getEntryWithCredential(username); if (entry == null) { return null; } String pwdCredential = getUserCredential(entry); pwdCredential = convertCredentialLdapToJetty(pwdCredential); Credential credential = Credential.getCredential(pwdCredential); List<String> roles = getUserRoles(username, entry.getDn()); return new UserInfo(username, credential, roles); } /** * attempts to get the users credentials from the users context * <p/> * NOTE: this is not an user authenticated operation * * @param username * @return * @throws LoginException */ private LdapEntry getEntryWithCredential(String username) throws LdapException { if (StringUtils.isBlank(_userObjectClass)|| StringUtils.isBlank(_userIdAttribute) || StringUtils.isBlank(_userBaseDn) || StringUtils.isBlank(_userPasswordAttribute)){ LOG.error("Failed to get user because at least one of the following is null : " + "[_userObjectClass, _userIdAttribute, _userBaseDn, _userPasswordAttribute ]"); return null; } // Create the format of &(objectClass=_userObjectClass)(_userIdAttribute={user})) String userFilter = buildFilter(_userFilter, _userObjectClass, _userIdAttribute); if (userFilter.contains("{user}")){ userFilter = userFilter.replace("{user}", username); } LOG.debug("Searching user using the filter {} on user baseDn {}", userFilter, _userBaseDn); // Get the group names from each group, which is obtained from roleNameAttribute attribute. SearchRequest request = new SearchRequest(_userBaseDn, userFilter, _userPasswordAttribute); request.setSearchScope(SearchScope.SUBTREE); request.setSizeLimit(1); try { SearchOperation search = new SearchOperation(conn); org.ldaptive.SearchResult result = search.execute(request).getResult(); LdapEntry entry = result.getEntry(); LOG.info("Found user?: {}", entry != null); return entry; } catch (LdapException ex) { LOG.error("{}", ex.toString(), ex); return null; } } public String getUserCredential(LdapEntry entry) { String credential = null; if (entry != null) { LdapAttribute attr = entry.getAttribute(_userPasswordAttribute); if (attr == null){ LOG.error("Failed to receive user password from LDAP server. Possibly userPasswordAttribute is wrong"); return null; } byte[] value = attr.getBinaryValue(); credential = new String(value, StandardCharsets.UTF_8); } return credential; } /** * attempts to get the users roles from the root context * <p/> * NOTE: this is not an user authenticated operation * * @param dirContext * @param username * @return * @throws LoginException */ /** * attempts to get the users roles * <p/> * NOTE: this is not an user authenticated operation * * @param username * @return * @throws LoginException */ private List<String> getUserRoles(String username, String userDn) { List<String> roleList = new ArrayList<>(); if (StringUtils.isBlank(_roleBaseDn)|| StringUtils.isBlank(_roleObjectClass) || StringUtils.isBlank(_roleNameAttribute) || StringUtils.isBlank(_roleMemberAttribute)){ LOG.debug("Failed to get roles because at least one of the following is null : " + "[_roleBaseDn, _roleObjectClass, _roleNameAttribute, _roleMemberAttribute ]"); return roleList; } String roleFilter = buildFilter(_roleFilter, _roleObjectClass, _roleMemberAttribute); if (_roleFilter.contains(DN)) { userDn = userDn.replace("\\", "\\\\\\"); roleFilter = roleFilter.replace(DN, userDn); } else if (_roleFilter.contains(USER)){ roleFilter = roleFilter.replace(USER, username); } else { LOG.error("roleFilter contains invalid filter {}. Check the roleFilter option"); return roleList; } LOG.debug("Searching roles using the filter {} on role baseDn {}", roleFilter, _roleBaseDn); // Get the group names from each group, which is obtained from roleNameAttribute attribute. SearchRequest request = new SearchRequest(_roleBaseDn, roleFilter, _roleNameAttribute); request.setSearchScope(SearchScope.SUBTREE); try { SearchOperation search = new SearchOperation(conn); org.ldaptive.SearchResult result = search.execute(request).getResult(); Collection<LdapEntry> entries = result.getEntries(); LOG.info("Found roles?: {}", !(entries == null || entries.isEmpty())); if (entries != null) { for (LdapEntry entry : entries) { roleList.add(entry.getAttribute().getStringValue()); } } } catch (LdapException ex) { LOG.error(ex.getMessage(), ex); } LOG.info("Found roles: {}", roleList); return roleList; } /** * Given a filter(user/role filter), replace attributes using given information from config. * This will create complete filter, which will look like &(objectClass=inetOrgPerson)(uid={user})) * @param attrFilter We obtain this part from config * @param objClass objectClass * @param attrName attribute name * @return Complete filter */ @VisibleForTesting static String buildFilter(String attrFilter, String objClass, String attrName){ // check if the filter has surrounding "()" if(!attrFilter.startsWith("(")){ attrFilter = "(" + attrFilter; } if (!attrFilter.endsWith(")")) { attrFilter = attrFilter + ")"; } return String.format(filterFormat, objClass, String.format(attrFilter, attrName)); } /** * since ldap uses a context bind for valid authentication checking, we override login() * <p/> * if credentials are not available from the users context or if we are forcing the binding check * then we try a binding authentication check, otherwise if we have the users encoded password then * we can try authentication via that mechanic * * @return true if authenticated, false otherwise * @throws LoginException */ @Override public boolean login() throws LoginException { try { if (getCallbackHandler() == null) { throw new LoginException("No callback handler"); } if (conn == null){ return false; } Callback[] callbacks = configureCallbacks(); getCallbackHandler().handle(callbacks); String webUserName = ((NameCallback) callbacks[0]).getName(); Object webCredential = ((ObjectCallback) callbacks[1]).getObject(); if (webUserName == null || webCredential == null) { setAuthenticated(false); return isAuthenticated(); } // Please see the following stackoverflow article // http://security.stackexchange.com/questions/6713/ldap-security-problems // Some LDAP implementation "MAY" accept empty password as a sign of anonymous connection and thus // return "true" for the authentication request. if((webCredential instanceof String) && ((String)webCredential).isEmpty()) { LOG.info("Ignoring login request for user {} as the password is empty.", webUserName); setAuthenticated(false); return isAuthenticated(); } if (_forceBindingLogin) { return bindingLogin(webUserName, webCredential); } // This sets read and the credential UserInfo userInfo = getUserInfo(webUserName); if (userInfo == null) { setAuthenticated(false); return false; } JAASUserInfo jaasUserInfo = new JAASUserInfo(userInfo); jaasUserInfo.fetchRoles(); setCurrentUser(jaasUserInfo); if (webCredential instanceof String) { return credentialLogin(Credential.getCredential((String) webCredential)); } return credentialLogin(webCredential); } catch (UnsupportedCallbackException e) { throw new LoginException("Error obtaining callback information."); } catch (IOException e) { LOG.error("IO Error performing login", e); } catch (Exception e) { LOG.error("IO Error performing login", e); } return false; } /** * password supplied authentication check * * @param webCredential * @return true if authenticated * @throws LoginException */ protected boolean credentialLogin(Object webCredential) throws LoginException { boolean credResult = getCurrentUser().checkCredential(webCredential); setAuthenticated(credResult); if (!credResult){ LOG.warn("Authentication failed - Possibly the user password is wrong"); } return isAuthenticated(); } /** * binding authentication check * This method of authentication works only if the user branch of the DIT (ldap tree) * has an ACI (access control instruction) that allow the access to any user or at least * for the user that logs in. * * @param username * @param password * @return true always * @throws LoginException */ public boolean bindingLogin(String username, Object password) throws Exception { if (StringUtils.isBlank(_userObjectClass)|| StringUtils.isBlank(_userIdAttribute) || StringUtils.isBlank(_userBaseDn)){ LOG.error("Failed to get user because at least one of the following is null : " + "[_userObjectClass, _userIdAttribute, _userBaseDn ]"); return false; } LdapEntry userEntry = authenticate(username, password); if (userEntry == null) { return false; } // If authenticated by LDAP server, the returned LdapEntry contains full DN of the user String userDn = userEntry.getDn(); if(userDn == null){ // This shouldn't happen if LDAP server is configured properly. LOG.error("userDn is found null for the user {}", username); return false; } List<String> roles = getUserRoles(username, userDn); UserInfo userInfo = new UserInfo(username, null, roles); JAASUserInfo jaasUserInfo = new JAASUserInfo(userInfo); jaasUserInfo.fetchRoles(); setCurrentUser(jaasUserInfo); setAuthenticated(true); return true; } /** * Perform authentication with given username and password. * Receive the result from Ldap server * @param username Username that user entered to login * @param password Password that user entered to login * @return LdapEntry which contains all user attributes */ private LdapEntry authenticate(String username,Object password) { try { SearchDnResolver dnResolver = new SearchDnResolver(new DefaultConnectionFactory(connConfig)); dnResolver.setBaseDn(_userBaseDn); dnResolver.setSubtreeSearch(true); String userFilter = buildFilter(_userFilter, _userObjectClass, _userIdAttribute); LOG.debug("Searching a user with filter {} where user is {}", userFilter, username); dnResolver.setUserFilter(userFilter); // Set Authenticator with username and password. It will return the user if username/password matches. BindAuthenticationHandler authHandler = new BindAuthenticationHandler(new DefaultConnectionFactory(connConfig)); Authenticator auth = new Authenticator(dnResolver, authHandler); AuthenticationRequest authRequest = new AuthenticationRequest(); authRequest.setUser(username); if (password instanceof char[]) { authRequest.setCredential(new org.ldaptive.Credential(new String((char[]) password))); } else if (password instanceof String){ authRequest.setCredential(new org.ldaptive.Credential((String)password)); } else { LOG.error("Unexpected type for password '{}'", (password != null) ? password.getClass() : "NULL"); return null; } String[] userRoleAttribute = ReturnAttributes.ALL.value(); authRequest.setReturnAttributes(userRoleAttribute); LOG.debug("Retrieved authenticator from factory: {}", auth); LOG.debug("Retrieved authentication request from factory: {}", authRequest); AuthenticationResponse response = auth.authenticate(authRequest); LOG.info("Found user?: {}", response.getResult()); if (response.getResult()) { LdapEntry entry = response.getLdapEntry(); return entry; } else { // User not found. Most likely username/password didn't match. Log the reason. LOG.error("Result code: {} - {}", response.getResultCode(), response.getMessage()); } } catch (LdapException e) { LOG.warn(e.getMessage()); } return null; } /** * Init LoginModule. * Called once by JAAS after new instance is created. * * @param subject * @param callbackHandler * @param sharedState * @param options */ @Override public void initialize(Subject subject, CallbackHandler callbackHandler, Map<String,?> sharedState, Map<String,?> options) { super.initialize(subject, callbackHandler, sharedState, options); LOG.debug("Initializing Ldap configuration"); _hostname = (String) options.get("hostname"); _port = Integer.parseInt((String) options.get("port")); _bindDn = (String) options.get("bindDn"); _bindPassword = (String) options.get("bindPassword"); _userBaseDn = (String) options.get("userBaseDn"); _roleBaseDn = (String) options.get("roleBaseDn"); if (options.containsKey("forceBindingLogin")) { _forceBindingLogin = Boolean.parseBoolean((String) options.get("forceBindingLogin")); } if (options.containsKey("useLdaps")) { _useLdaps = Boolean.parseBoolean((String) options.get("useLdaps")); } if (options.containsKey("useStartTLS")) { _useStarttls = Boolean.parseBoolean((String) options.get("useStartTLS")); } _userObjectClass = getOption(options, "userObjectClass", _userObjectClass); _userRdnAttribute = getOption(options, "userRdnAttribute", _userRdnAttribute); //depricated _userIdAttribute = getOption(options, "userIdAttribute", _userIdAttribute); _userPasswordAttribute = getOption(options, "userPasswordAttribute", _userPasswordAttribute); _roleObjectClass = getOption(options, "roleObjectClass", _roleObjectClass); _roleMemberAttribute = getOption(options, "roleMemberAttribute", _roleMemberAttribute); _roleNameAttribute = getOption(options, "roleNameAttribute", _roleNameAttribute); _userFilter = getOption(options, "userFilter", _userFilter); _roleFilter = getOption(options, "roleFilter", _roleFilter); if (Configuration.FileRef.isValueMyRef(_bindPassword)) { Configuration.FileRef fileRef = new Configuration.FileRef(_bindPassword); _bindPassword = fileRef.getValue(); if (_bindPassword != null) { _bindPassword = _bindPassword.trim(); } } // Setup environment. If both useLdaps and useStartTLS are set to true, apply useStartTLS String ldapUrl; if (_useStarttls){ ldapUrl = String.format("ldap://%s:%s", _hostname, _port); } else { ldapUrl = String.format("%s://%s:%s", _useLdaps ? "ldaps" : "ldap", _hostname, _port); } LOG.info("Accessing LDAP Server: {} startTLS: {}", ldapUrl, _useStarttls); connConfig = new ConnectionConfig(ldapUrl); connConfig.setUseStartTLS(_useStarttls); connConfig.setConnectionInitializer( new BindConnectionInitializer(_bindDn, new org.ldaptive.Credential(_bindPassword)) ); conn = DefaultConnectionFactory.getConnection(connConfig); try { conn.open(); } catch (LdapException ex){ LOG.error("Failed to establish connection to the LDAP server {}. {}", ldapUrl, ex); // We don't throw exception here because there might be multiple LDAP servers configured } } @Override public boolean commit() throws LoginException { if (conn != null && conn.isOpen()) { conn.close(); } return super.commit(); } @Override public boolean abort() throws LoginException { if (conn != null && conn.isOpen()) { conn.close(); } return super.abort(); } private String getOption(Map<String,?> options, String key, String defaultValue) { Object value = options.get(key); if (value == null) { return defaultValue; } return (String) value; } public static String convertCredentialLdapToJetty(String encryptedPassword) { if (encryptedPassword == null) { return encryptedPassword; } if ("{MD5}".startsWith(encryptedPassword.toUpperCase(Locale.ENGLISH))) { return "MD5:" + encryptedPassword.substring("{MD5}".length(), encryptedPassword.length()); } if ("{CRYPT}".startsWith(encryptedPassword.toUpperCase(Locale.ENGLISH))) { return "CRYPT:" + encryptedPassword.substring("{CRYPT}".length(), encryptedPassword.length()); } return encryptedPassword; } }