Java tutorial
/* * This file is part of Alpine. * * 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. * * Copyright (c) Steve Springett. All Rights Reserved. */ package alpine.auth; import alpine.Config; import alpine.logging.Logger; import alpine.model.LdapUser; import alpine.validation.LdapStringSanitizer; import org.apache.commons.lang3.StringUtils; import javax.naming.CommunicationException; import javax.naming.Context; import javax.naming.NamingEnumeration; import javax.naming.NamingException; import javax.naming.PartialResultException; import javax.naming.directory.Attribute; import javax.naming.directory.Attributes; import javax.naming.directory.DirContext; import javax.naming.directory.InitialDirContext; import javax.naming.directory.SearchControls; import javax.naming.directory.SearchResult; import javax.naming.ldap.InitialLdapContext; import javax.naming.ldap.LdapContext; import java.util.ArrayList; import java.util.Collections; import java.util.Hashtable; import java.util.List; /** * A convenience wrapper for LDAP connections and commons LDAP tasks. * * @since 1.4.0 */ public class LdapConnectionWrapper { private static final Logger LOGGER = Logger.getLogger(LdapConnectionWrapper.class); private static final String BIND_USERNAME = Config.getInstance() .getProperty(Config.AlpineKey.LDAP_BIND_USERNAME); private static final String BIND_PASSWORD = Config.getInstance() .getProperty(Config.AlpineKey.LDAP_BIND_PASSWORD); private static final String LDAP_SECURITY_AUTH = Config.getInstance() .getProperty(Config.AlpineKey.LDAP_SECURITY_AUTH); private static final String LDAP_AUTH_USERNAME_FMT = Config.getInstance() .getProperty(Config.AlpineKey.LDAP_AUTH_USERNAME_FMT); private static final String USER_GROUPS_FILTER = Config.getInstance() .getProperty(Config.AlpineKey.LDAP_USER_GROUPS_FILTER); private static final String GROUPS_FILTER = Config.getInstance() .getProperty(Config.AlpineKey.LDAP_GROUPS_FILTER); public static final boolean LDAP_ENABLED = Config.getInstance() .getPropertyAsBoolean(Config.AlpineKey.LDAP_ENABLED); public static final String LDAP_URL = Config.getInstance().getProperty(Config.AlpineKey.LDAP_SERVER_URL); public static final String BASE_DN = Config.getInstance().getProperty(Config.AlpineKey.LDAP_BASEDN); public static final String ATTRIBUTE_MAIL = Config.getInstance() .getProperty(Config.AlpineKey.LDAP_ATTRIBUTE_MAIL); public static final String ATTRIBUTE_NAME = Config.getInstance() .getProperty(Config.AlpineKey.LDAP_ATTRIBUTE_NAME); public static final boolean USER_PROVISIONING = Config.getInstance() .getPropertyAsBoolean(Config.AlpineKey.LDAP_USER_PROVISIONING); public static final boolean TEAM_SYNCHRONIZATION = Config.getInstance() .getPropertyAsBoolean(Config.AlpineKey.LDAP_TEAM_SYNCHRONIZATION); public static final boolean LDAP_CONFIGURED = (LDAP_ENABLED && StringUtils.isNotBlank(LDAP_URL)); private static final boolean IS_LDAP_SSLTLS = (StringUtils.isNotBlank(LDAP_URL) && LDAP_URL.startsWith("ldaps:")); /** * Asserts a users credentials. Returns an LdapContext if assertion is successful * or an exception for any other reason. * * @param userDn the users DN to assert * @param password the password to assert * @return the LdapContext upon a successful connection * @throws NamingException when unable to establish a connection * @since 1.4.0 */ public LdapContext createLdapContext(String userDn, String password) throws NamingException { if (StringUtils.isEmpty(userDn) || StringUtils.isEmpty(password)) { throw new NamingException("Username or password cannot be empty or null"); } final Hashtable<String, String> env = new Hashtable<>(); if (StringUtils.isNotBlank(LDAP_SECURITY_AUTH)) { env.put(Context.SECURITY_AUTHENTICATION, LDAP_SECURITY_AUTH); } env.put(Context.SECURITY_PRINCIPAL, userDn); env.put(Context.SECURITY_CREDENTIALS, password); env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory"); env.put(Context.PROVIDER_URL, LDAP_URL); if (IS_LDAP_SSLTLS) { env.put("java.naming.ldap.factory.socket", "alpine.crypto.RelaxedSSLSocketFactory"); } try { return new InitialLdapContext(env, null); } catch (CommunicationException e) { LOGGER.error("Failed to connect to directory server", e); throw (e); } catch (NamingException e) { throw new NamingException("Failed to authenticate user"); } } /** * Creates a DirContext with the applications configuration settings. * @return a DirContext * @throws NamingException if an exception is thrown * @since 1.4.0 */ public DirContext createDirContext() throws NamingException { final Hashtable<String, String> env = new Hashtable<>(); env.put(Context.SECURITY_PRINCIPAL, BIND_USERNAME); env.put(Context.SECURITY_CREDENTIALS, BIND_PASSWORD); env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory"); env.put(Context.PROVIDER_URL, LDAP_URL); if (IS_LDAP_SSLTLS) { env.put("java.naming.ldap.factory.socket", "alpine.crypto.RelaxedSSLSocketFactory"); } return new InitialDirContext(env); } /** * Retrieves a list of all groups the user is a member of. * @param dirContext a DirContext * @param ldapUser the LdapUser to retrieve group membership for * @return A list of Strings representing the fully qualified DN of each group * @throws NamingException if an exception is thrown * @since 1.4.0 */ public List<String> getGroups(DirContext dirContext, LdapUser ldapUser) throws NamingException { final List<String> groupDns = new ArrayList<>(); final String searchFilter = variableSubstitution(USER_GROUPS_FILTER, ldapUser); final SearchControls sc = new SearchControls(); sc.setSearchScope(SearchControls.SUBTREE_SCOPE); final NamingEnumeration<SearchResult> ne = dirContext.search(BASE_DN, searchFilter, sc); while (hasMoreEnum(ne)) { final SearchResult result = ne.next(); groupDns.add(result.getNameInNamespace()); } closeQuietly(ne); return groupDns; } /** * Retrieves a list of all the groups in the directory. * @param dirContext a DirContext * @return A list of Strings representing the fully qualified DN of each group * @throws NamingException if an exception if thrown * @since 1.4.0 */ public List<String> getGroups(DirContext dirContext) throws NamingException { final List<String> groupDns = new ArrayList<>(); final SearchControls sc = new SearchControls(); sc.setSearchScope(SearchControls.SUBTREE_SCOPE); final NamingEnumeration<SearchResult> ne = dirContext.search(BASE_DN, GROUPS_FILTER, sc); while (hasMoreEnum(ne)) { final SearchResult result = ne.next(); groupDns.add(result.getNameInNamespace()); } closeQuietly(ne); return groupDns; } /** * Performs a search for the specified username. Internally, this method queries on * the attribute defined by {@link Config.AlpineKey#LDAP_ATTRIBUTE_NAME}. * @param ctx the DirContext to use * @param username the username to query on * @return a list of SearchResult objects. If the username is found, the list should typically only contain one result. * @throws NamingException if an exception is thrown * @since 1.4.0 */ public List<SearchResult> searchForUsername(DirContext ctx, String username) throws NamingException { final String[] attributeFilter = {}; final SearchControls sc = new SearchControls(); sc.setReturningAttributes(attributeFilter); sc.setSearchScope(SearchControls.SUBTREE_SCOPE); final String searchFor = LdapConnectionWrapper.ATTRIBUTE_NAME + "=" + LdapStringSanitizer.sanitize(formatPrincipal(username)); return Collections.list(ctx.search(LdapConnectionWrapper.BASE_DN, searchFor, sc)); } /** * Performs a search for the specified username. Internally, this method queries on * the attribute defined by {@link Config.AlpineKey#LDAP_ATTRIBUTE_NAME}. * @param ctx the DirContext to use * @param username the username to query on * @return a list of SearchResult objects. If the username is found, the list should typically only contain one result. * @throws NamingException if an exception is thrown * @since 1.4.0 */ public SearchResult searchForSingleUsername(DirContext ctx, String username) throws NamingException { final List<SearchResult> results = searchForUsername(ctx, username); if (results == null || results.size() == 0) { return null; } else if (results.size() == 1) { return results.get(0); } else { throw new NamingException( "Multiple entries in the directory contain the same username. This scenario is not supported"); } } /** * Retrieves an attribute by its name for the specified dn. * @param ctx the DirContext to use * @param dn the distinguished name of the entry to obtain the attribute value for * @param attributeName the name of the attribute to return * @return the value of the attribute, or null if not found * @throws NamingException if an exception is thrown * @since 1.4.0 */ public String getAttribute(DirContext ctx, String dn, String attributeName) throws NamingException { final Attributes attributes = ctx.getAttributes(dn); return getAttribute(attributes, attributeName); } /** * Retrieves an attribute by its name for the specified search result. * @param result the search result of the entry to obtain the attribute value for * @param attributeName the name of the attribute to return * @return the value of the attribute, or null if not found * @throws NamingException if an exception is thrown * @since 1.4.0 */ public String getAttribute(SearchResult result, String attributeName) throws NamingException { return getAttribute(result.getAttributes(), attributeName); } /** * Retrieves an attribute by its name. * @param attributes the list of attributes to query on * @param attributeName the name of the attribute to return * @return the value of the attribute, or null if not found * @throws NamingException if an exception is thrown * @since 1.4.0 */ public String getAttribute(Attributes attributes, String attributeName) throws NamingException { if (attributes == null || attributes.size() == 0) { return null; } else { final Attribute attribute = attributes.get(attributeName); if (attribute != null) { final Object o = attribute.get(); if (o instanceof String) { return (String) attribute.get(); } } } return null; } /** * Formats the principal in username@domain format or in a custom format if is specified in the config file. * If LDAP_AUTH_USERNAME_FMT is configured to a non-empty value, the substring %s in this value will be replaced with the entered username. * The recommended format of this value depends on your LDAP server(Active Directory, OpenLDAP, etc.). * Examples: * alpine.ldap.auth.username.format=%s * alpine.ldap.auth.username.format=%s@company.com * @param username the username * @return a formatted user principal * @since 1.4.0 */ private static String formatPrincipal(String username) { if (StringUtils.isNotBlank(LDAP_AUTH_USERNAME_FMT)) { return String.format(LDAP_AUTH_USERNAME_FMT, username); } return username; } private String variableSubstitution(String s, LdapUser user) { if (s == null) { return null; } return s.replace("{USER_DN}", user.getDN()); } /** * Convenience method that wraps {@link NamingEnumeration#hasMore()} but ignores {@link PartialResultException}s * that may be thrown as a result. This is typically an issue with a directory server that does not support * {@link Context#REFERRAL} being set to 'ignore' (which is the default value). * * Issue: https://github.com/stevespringett/Alpine/issues/19 * @since 1.4.3 */ private boolean hasMoreEnum(NamingEnumeration<SearchResult> ne) throws NamingException { if (ne == null) { return false; } boolean hasMore = true; try { if (!ne.hasMore()) { hasMore = false; } } catch (PartialResultException e) { hasMore = false; LOGGER.warn( "Partial results returned. If this is an Active Directory server, try using port 3268 or 3269 in " + Config.AlpineKey.LDAP_SERVER_URL.name()); } return hasMore; } /** * Closes a NamingEnumeration object without throwing any exceptions. * @param object the NamingEnumeration object to close * @since 1.4.0 */ public void closeQuietly(final NamingEnumeration object) { try { if (object != null) { object.close(); } } catch (final NamingException e) { // ignore } } /** * Closes a DirContext object without throwing any exceptions. * @param object the DirContext object to close * @since 1.4.0 */ public void closeQuietly(final DirContext object) { try { if (object != null) { object.close(); } } catch (final NamingException e) { // ignore } } }