Java tutorial
/**************************************************************** * Licensed to 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 org.apache.james.user.ldap; import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.Set; import javax.annotation.PostConstruct; import javax.naming.Context; import javax.naming.NamingEnumeration; import javax.naming.NamingException; import javax.naming.directory.Attribute; import javax.naming.directory.Attributes; import javax.naming.directory.SearchControls; import javax.naming.directory.SearchResult; import javax.naming.ldap.InitialLdapContext; import javax.naming.ldap.LdapContext; import org.apache.commons.configuration.ConfigurationException; import org.apache.commons.configuration.HierarchicalConfiguration; import org.apache.commons.lang.StringUtils; import org.apache.james.lifecycle.api.Configurable; import org.apache.james.lifecycle.api.LogEnabled; import org.apache.james.user.api.UsersRepository; import org.apache.james.user.api.UsersRepositoryException; import org.apache.james.user.api.model.User; import org.apache.james.user.ldap.api.LdapConstants; import org.apache.james.util.retry.DoublingRetrySchedule; import org.apache.james.util.retry.api.RetrySchedule; import org.apache.james.util.retry.naming.ldap.RetryingLdapContext; import org.slf4j.Logger; /** * <p> * This repository implementation serves as a bridge between Apache James and * LDAP. It allows James to authenticate users against an LDAP compliant server * such as Apache DS or Microsoft AD. It also enables role/group based access * restriction based on LDAP groups. * </p> * <p> * It is intended for organisations that already have a user-authentication and * authorisation mechanism in place, and want to leverage this when deploying * James. The assumption inherent here is that such organisations would not want * to manage user details via James, but will do so externally using whatever * mechanism provided by, or built on top off, their LDAP implementation. * </p> * <p> * Based on this assumption, this repository is strictly <b>read-only</b>. As a * consequence, user modification, deletion and creation requests will be * ignored when using this repository. * </p> * <p> * The following fragment of XML provides an example configuration to enable * this repository: </br> * * <pre> * <users-store> * <repository name="LDAPUsers" * class="org.apache.james.userrepository.ReadOnlyUsersLDAPRepository" * ldapHost="ldap://myldapserver:389" * principal="uid=ldapUser,ou=system" * credentials="password" * userBase="ou=People,o=myorg.com,ou=system" * userIdAttribute="uid" * userObjectClass="inetOrgPerson" * maxRetries="20" * retryStartInterval="0" * retryMaxInterval="30" * retryIntervalScale="1000" * </users-store> * </pre> * * </br> * * Its constituent attributes are defined as follows: * <ul> * <li><b>ldapHost:</b> The URL of the LDAP server to connect to.</li> * <li> * <b>principal:</b> (optional) The name (DN) of the user with which to * initially bind to the LDAP server.</li> * <li> * <b>credentials:</b> (optional) The password with which to initially bind to * the LDAP server.</li> * <li> * <b>userBase:</b>The context within which to search for user entities.</li> * <li> * <b>userIdAttribute:</b>The name of the LDAP attribute which holds user ids. * For example "uid" for Apache DS, or "sAMAccountName" for * Microsoft Active Directory.</li> * <li> * <b>userObjectClass:</b>The objectClass value for user nodes below the * userBase. For example "inetOrgPerson" for Apache DS, or * "user" for Microsoft Active Directory.</li> ** * <li> * <b>maxRetries:</b> (optional, default = 0) The maximum number of times to * retry a failed operation. -1 means retry forever.</li> * <li> * <b>retryStartInterval:</b> (optional, default = 0) The interval in * milliseconds to wait before the first retry. If > 0, subsequent retries are * made at double the proceeding one up to the <b>retryMaxInterval</b> described * below. If = 0, the next retry is 1 and subsequent retries proceed as above.</li> * <li> * <b>retryMaxInterval:</b> (optional, default = 60) The maximum interval in * milliseconds to wait between retries</li> * <li> * <b>retryIntervalScale:</b> (optional, default = 1000) The amount by which to * multiply each retry interval. The default value of 1000 (milliseconds) is 1 * second, so the default <b>retryMaxInterval</b> of 60 is 60 seconds, or 1 * minute. * </ul> * </p> * <p> * <em>Example Schedules</em> * <ul> * <li> * Retry after 1000 milliseconds, doubling the interval for each retry up to * 30000 milliseconds, subsequent retry intervals are 30000 milliseconds until * 10 retries have been attempted, after which the <code>Exception</code> * causing the fault is thrown: * <ul> * <li>maxRetries = 10 * <li>retryStartInterval = 1000 * <li>retryMaxInterval = 30000 * <li>retryIntervalScale = 1 * </ul> * <li> * Retry immediately, then retry after 1 * 1000 milliseconds, doubling the * interval for each retry up to 30 * 1000 milliseconds, subsequent retry * intervals are 30 * 1000 milliseconds until 20 retries have been attempted, * after which the <code>Exception</code> causing the fault is thrown: * <ul> * <li>maxRetries = 20 * <li>retryStartInterval = 0 * <li>retryMaxInterval = 30 * <li>retryIntervalScale = 1000 * </ul> * <li> * Retry after 5000 milliseconds, subsequent retry intervals are 5000 * milliseconds. Retry forever: * <ul> * <li>maxRetries = -1 * <li>retryStartInterval = 5000 * <li>retryMaxInterval = 5000 * <li>retryIntervalScale = 1 * </ul> * </ul> * </p> * * <p> * In order to enable group/role based access restrictions, you can use the * "<restriction>" configuration element. An example of this is * shown below: <br> * * <pre> * <restriction * memberAttribute="uniqueMember"> * <group>cn=PermanentStaff,ou=Groups,o=myorg.co.uk,ou=system</group> * <group>cn=TemporaryStaff,ou=Groups,o=myorg.co.uk,ou=system</group> * </restriction> * </pre> * * Its constituent attributes and elements are defined as follows: * <ul> * <li> * <b>memberAttribute:</b> The LDAP attribute whose values indicate the DNs of * the users which belong to the group or role.</li> * <li> * <b>group:</b> A valid group or role DN. A user is only authenticated * (permitted access) if they belong to at least one of the groups listed under * the "<restriction>" sections.</li> * </ul> * </p> * * <p> * The following parameters may be used to adjust the underlying * <code>com.sun.jndi.ldap.LdapCtxFactory</code>. See <a href= * "http://docs.oracle.com/javase/1.5.0/docs/guide/jndi/jndi-ldap.html#SPIPROPS" * > LDAP Naming Service Provider for the Java Naming and Directory InterfaceTM * (JNDI) : Provider-specific Properties</a> for details. * <ul> * <li> * <b>useConnectionPool:</b> (optional, default = true) Sets property * <code>com.sun.jndi.ldap.connect.pool</code> to the specified boolean value * <li> * <b>connectionTimeout:</b> (optional) Sets property * <code>com.sun.jndi.ldap.connect.timeout</code> to the specified integer value * <li> * <b>readTimeout:</b> (optional) Sets property * <code>com.sun.jndi.ldap.read.timeout</code> to the specified integer value. * Applicable to Java 6 and above. * </ul> * * @see ReadOnlyLDAPUser * @see ReadOnlyLDAPGroupRestriction * */ public class ReadOnlyUsersLDAPRepository implements UsersRepository, Configurable, LogEnabled { // The name of the factory class which creates the initial context // for the LDAP service provider private static final String INITIAL_CONTEXT_FACTORY = "com.sun.jndi.ldap.LdapCtxFactory"; private static final String PROPERTY_NAME_CONNECTION_POOL = "com.sun.jndi.ldap.connect.pool"; private static final String PROPERTY_NAME_CONNECT_TIMEOUT = "com.sun.jndi.ldap.connect.timeout"; private static final String PROPERTY_NAME_READ_TIMEOUT = "com.sun.jndi.ldap.read.timeout"; /** * The URL of the LDAP server against which users are to be authenticated. * Note that users are actually authenticated by binding against the LDAP * server using the users "dn" and "credentials".The * value of this field is taken from the value of the configuration * attribute "ldapHost". */ private String ldapHost; /** * The value of this field is taken from the configuration attribute * "userIdAttribute". This is the LDAP attribute type which holds * the userId value. Note that this is not the same as the email address * attribute. */ private String userIdAttribute; /** * The value of this field is taken from the configuration attribute * "userObjectClass". This is the LDAP object class to use in the * search filter for user nodes under the userBase value. */ private String userObjectClass; /** * The value of this field is taken from the configuration attribute "filter". * This is the search filter to use to find the desired user. */ private String filter; /** * This is the LDAP context/sub-context within which to search for user * entities. The value of this field is taken from the configuration * attribute "userBase". */ private String userBase; /** * The user with which to initially bind to the LDAP server. The value of * this field is taken from the configuration attribute * "principal". */ private String principal; /** * The password/credentials with which to initially bind to the LDAP server. * The value of this field is taken from the configuration attribute * "credentials". */ private String credentials; /** * Encapsulates the information required to restrict users to LDAP groups or * roles. This object is populated from the contents of the configuration * element <restriction>. */ private ReadOnlyLDAPGroupRestriction restriction; /** * The context for the LDAP server. This is the connection that is built * from the configuration attributes "ldapHost", * "principal" and "credentials". */ private LdapContext ldapContext; // Use a connection pool. Default is true. private boolean useConnectionPool = true; // The connection timeout in milliseconds. // A value of less than or equal to zero means to use the network protocol's // (i.e., TCP's) timeout value. private int connectionTimeout = -1; // The LDAP read timeout in milliseconds. private int readTimeout = -1; // The schedule for retry attempts private RetrySchedule schedule = null; // Maximum number of times to retry a connection attempts. Default is no // retries. private int maxRetries = 0; private Logger log; /** * Creates a new instance of ReadOnlyUsersLDAPRepository. * */ public ReadOnlyUsersLDAPRepository() { super(); } /** * Extracts the parameters required by the repository instance from the * James server configuration data. The fields extracted include * {@link #ldapHost}, {@link #userIdAttribute}, {@link #userBase}, * {@link #principal}, {@link #credentials} and {@link #restriction}. * * @param configuration * An encapsulation of the James server configuration data. */ public void configure(HierarchicalConfiguration configuration) throws ConfigurationException { ldapHost = configuration.getString("[@ldapHost]", ""); principal = configuration.getString("[@principal]", ""); credentials = configuration.getString("[@credentials]", ""); userBase = configuration.getString("[@userBase]"); userIdAttribute = configuration.getString("[@userIdAttribute]"); userObjectClass = configuration.getString("[@userObjectClass]"); // Default is to use connection pooling useConnectionPool = configuration.getBoolean("[@useConnectionPool]", true); connectionTimeout = configuration.getInt("[@connectionTimeout]", -1); readTimeout = configuration.getInt("[@readTimeout]", -1); // Default maximum retries is 1, which allows an alternate connection to // be found in a multi-homed environment maxRetries = configuration.getInt("[@maxRetries]", 1); // Default retry start interval is 0 second long retryStartInterval = configuration.getLong("[@retryStartInterval]", 0); // Default maximum retry interval is 60 seconds long retryMaxInterval = configuration.getLong("[@retryMaxInterval]", 60); int scale = configuration.getInt("[@retryIntervalScale]", 1000); // seconds schedule = new DoublingRetrySchedule(retryStartInterval, retryMaxInterval, scale); HierarchicalConfiguration restrictionConfig = null; // Check if we have a restriction we can use // See JAMES-1204 if (configuration.containsKey("restriction[@memberAttribute]")) { restrictionConfig = configuration.configurationAt("restriction"); } restriction = new ReadOnlyLDAPGroupRestriction(restrictionConfig); //see if there is a filter argument filter = configuration.getString("[@filter]"); } /** * Initialises the user-repository instance. It will create a connection to * the LDAP host using the supplied configuration. * * @throws Exception * If an error occurs authenticating or connecting to the * specified LDAP host. */ @PostConstruct public void init() throws Exception { if (log.isDebugEnabled()) { log.debug(this.getClass().getName() + ".init()" + '\n' + "LDAP host: " + ldapHost + '\n' + "User baseDN: " + userBase + '\n' + "userIdAttribute: " + userIdAttribute + '\n' + "Group restriction: " + restriction + '\n' + "UseConnectionPool: " + useConnectionPool + '\n' + "connectionTimeout: " + connectionTimeout + '\n' + "readTimeout: " + readTimeout + '\n' + "retrySchedule: " + schedule + '\n' + "maxRetries: " + maxRetries + '\n'); } // Setup the initial LDAP context updateLdapContext(); } /** * Answer the LDAP context used to connect with the LDAP server. * * @return an <code>LdapContext</code> * @throws NamingException */ protected LdapContext getLdapContext() throws NamingException { if (null == ldapContext) { updateLdapContext(); } return ldapContext; } protected void updateLdapContext() throws NamingException { ldapContext = computeLdapContext(); } /** * Answers a new LDAP/JNDI context using the specified user credentials. * * @return an LDAP directory context * @throws NamingException * Propagated from underlying LDAP communication API. */ protected LdapContext computeLdapContext() throws NamingException { return new RetryingLdapContext(schedule, maxRetries, log) { @Override public Context newDelegate() throws NamingException { return new InitialLdapContext(getContextEnvironment(), null); } }; } protected Properties getContextEnvironment() { final Properties props = new Properties(); props.put(Context.INITIAL_CONTEXT_FACTORY, INITIAL_CONTEXT_FACTORY); props.put(Context.PROVIDER_URL, null == ldapHost ? "" : ldapHost); if (null == credentials || credentials.isEmpty()) { props.put(Context.SECURITY_AUTHENTICATION, LdapConstants.SECURITY_AUTHENTICATION_NONE); } else { props.put(Context.SECURITY_AUTHENTICATION, LdapConstants.SECURITY_AUTHENTICATION_SIMPLE); props.put(Context.SECURITY_PRINCIPAL, null == principal ? "" : principal); props.put(Context.SECURITY_CREDENTIALS, credentials); } // The following properties are specific to com.sun.jndi.ldap.LdapCtxFactory props.put(PROPERTY_NAME_CONNECTION_POOL, Boolean.toString(useConnectionPool)); if (connectionTimeout > -1) { props.put(PROPERTY_NAME_CONNECT_TIMEOUT, Integer.toString(connectionTimeout)); } if (readTimeout > -1) { props.put(PROPERTY_NAME_READ_TIMEOUT, Integer.toString(readTimeout)); } return props; } /** * Indicates if the user with the specified DN can be found in the group * membership map-as encapsulated by the specified parameter map. * * @param userDN * The DN of the user to search for. * @param groupMembershipList * A map containing the entire group membership lists for the * configured groups. This is organised as a map of * * <code>"<groupDN>=<[userDN1,userDN2,...,userDNn]>"</code> * pairs. In essence, each <code>groupDN</code> string is * associated to a list of <code>userDNs</code>. * @return <code>True</code> if the specified userDN is associated with at * least one group in the parameter map, and <code>False</code> * otherwise. */ private boolean userInGroupsMembershipList(String userDN, Map<String, Collection<String>> groupMembershipList) { boolean result = false; Collection<Collection<String>> memberLists = groupMembershipList.values(); Iterator<Collection<String>> memberListsIterator = memberLists.iterator(); while (memberListsIterator.hasNext() && !result) { Collection<String> groupMembers = memberListsIterator.next(); result = groupMembers.contains(userDN); } return result; } /** * Gets all the user entities taken from the LDAP server, as taken from the * search-context given by the value of the attribute {@link #userBase}. * * @return A set containing all the relevant users found in the LDAP * directory. * @throws NamingException * Propagated from the LDAP communication layer. */ private Set<String> getAllUsersFromLDAP() throws NamingException { Set<String> result = new HashSet<String>(); SearchControls sc = new SearchControls(); sc.setSearchScope(SearchControls.SUBTREE_SCOPE); sc.setReturningAttributes(new String[] { "distinguishedName" }); NamingEnumeration<SearchResult> sr = ldapContext.search(userBase, "(objectClass=" + userObjectClass + ")", sc); while (sr.hasMore()) { SearchResult r = sr.next(); result.add(r.getNameInNamespace()); } return result; } /** * Extract the user attributes for the given collection of userDNs, and * encapsulates the user list as a collection of {@link ReadOnlyLDAPUser}s. * This method delegates the extraction of a single user's details to the * method {@link #buildUser(String)}. * * @param userDNs * The distinguished-names (DNs) of the users whose information * is to be extracted from the LDAP repository. * @return A collection of {@link ReadOnlyLDAPUser}s as taken from the LDAP * server. * @throws NamingException * Propagated from the underlying LDAP communication layer. */ private Collection<ReadOnlyLDAPUser> buildUserCollection(Collection<String> userDNs) throws NamingException { List<ReadOnlyLDAPUser> results = new ArrayList<ReadOnlyLDAPUser>(); for (String userDN : userDNs) { ReadOnlyLDAPUser user = buildUser(userDN); results.add(user); } return results; } /** * For a given name, this method makes ldap search in userBase with filter {@link #userIdAttribute}=name and objectClass={@link #userObjectClass} * and builds {@link User} based on search result. * * @param name * The userId which should be value of the field {@link #userIdAttribute} * @return A {@link ReadOnlyLDAPUser} instance which is initialized with the * userId of this user and ldap connection information with which * the user was searched. Return null if such a user was not found. * @throws NamingException * Propagated by the underlying LDAP communication layer. */ private ReadOnlyLDAPUser searchAndBuildUser(String name) throws NamingException { SearchControls sc = new SearchControls(); sc.setSearchScope(SearchControls.SUBTREE_SCOPE); sc.setReturningAttributes(new String[] { userIdAttribute }); sc.setCountLimit(1); StringBuilder builderFilter = new StringBuilder("(&("); builderFilter.append(userIdAttribute).append("=").append(name).append(")").append("(objectClass=") .append(userObjectClass).append(")"); if (StringUtils.isNotEmpty(filter)) { builderFilter.append(filter).append(")"); } else { builderFilter.append(")"); } NamingEnumeration<SearchResult> sr = ldapContext.search(userBase, builderFilter.toString(), sc); if (!sr.hasMore()) return null; SearchResult r = sr.next(); Attribute userName = r.getAttributes().get(userIdAttribute); if (!restriction.isActivated() || userInGroupsMembershipList(r.getNameInNamespace(), restriction.getGroupMembershipLists(ldapContext))) return new ReadOnlyLDAPUser(userName.get().toString(), r.getNameInNamespace(), ldapContext); return null; } /** * Given a userDN, this method retrieves the user attributes from the LDAP * server, so as to extract the items that are of interest to James. * Specifically it extracts the userId, which is extracted from the LDAP * attribute whose name is given by the value of the field * {@link #userIdAttribute}. * * @param userDN * The distinguished-name of the user whose details are to be * extracted from the LDAP repository. * @return A {@link ReadOnlyLDAPUser} instance which is initialized with the * userId of this user and ldap connection information with which * the userDN and attributes were obtained. * @throws NamingException * Propagated by the underlying LDAP communication layer. */ private ReadOnlyLDAPUser buildUser(String userDN) throws NamingException { Attributes userAttributes = ldapContext.getAttributes(userDN); Attribute userName = userAttributes.get(userIdAttribute); return new ReadOnlyLDAPUser(userName.get().toString(), userDN, ldapContext); } /** * @see UsersRepository#contains(java.lang.String) */ public boolean contains(String name) throws UsersRepositoryException { return getUserByName(name) != null; } /* * TODO Should this be deprecated? At least the method isn't declared in the * interface anymore * * @see UsersRepository#containsCaseInsensitive(java.lang.String) */ public boolean containsCaseInsensitive(String name) throws UsersRepositoryException { return getUserByNameCaseInsensitive(name) != null; } /** * @see UsersRepository#countUsers() */ public int countUsers() throws UsersRepositoryException { try { return getValidUsers().size(); } catch (NamingException e) { log.error("Unable to retrieve user count from ldap", e); throw new UsersRepositoryException("Unable to retrieve user count from ldap", e); } } /* * TODO Should this be deprecated? At least the method isn't declared in the * interface anymore * * @see UsersRepository#getRealName(java.lang.String) */ public String getRealName(String name) throws UsersRepositoryException { User u = getUserByNameCaseInsensitive(name); if (u != null) { return u.getUserName(); } return null; } /** * @see UsersRepository#getUserByName(java.lang.String) */ public User getUserByName(String name) throws UsersRepositoryException { try { return searchAndBuildUser(name); } catch (NamingException e) { log.error("Unable to retrieve user from ldap", e); throw new UsersRepositoryException("Unable to retrieve user from ldap", e); } } /* * TODO Should this be deprecated? At least the method isn't declared in the * interface anymore * * @see UsersRepository#getUserByNameCaseInsensitive(java.lang.String) */ public User getUserByNameCaseInsensitive(String name) throws UsersRepositoryException { try { for (ReadOnlyLDAPUser u : buildUserCollection(getValidUsers())) { if (u.getUserName().equalsIgnoreCase(name)) { return u; } } } catch (NamingException e) { log.error("Unable to retrieve user from ldap", e); throw new UsersRepositoryException("Unable to retrieve user from ldap", e); } return null; } /** * @see UsersRepository#list() */ public Iterator<String> list() throws UsersRepositoryException { List<String> result = new ArrayList<String>(); try { for (ReadOnlyLDAPUser readOnlyLDAPUser : buildUserCollection(getValidUsers())) { result.add(readOnlyLDAPUser.getUserName()); } } catch (NamingException namingException) { throw new UsersRepositoryException( "Unable to retrieve users list from LDAP due to unknown naming error.", namingException); } return result.iterator(); } private Collection<String> getValidUsers() throws NamingException { Set<String> userDNs = getAllUsersFromLDAP(); Collection<String> validUserDNs; if (restriction.isActivated()) { Map<String, Collection<String>> groupMembershipList = restriction.getGroupMembershipLists(ldapContext); validUserDNs = new ArrayList<String>(); Iterator<String> userDNIterator = userDNs.iterator(); String userDN; while (userDNIterator.hasNext()) { userDN = userDNIterator.next(); if (userInGroupsMembershipList(userDN, groupMembershipList)) validUserDNs.add(userDN); } } else { validUserDNs = userDNs; } return validUserDNs; } /** * @see UsersRepository#removeUser(java.lang.String) */ public void removeUser(String name) throws UsersRepositoryException { log.warn("This user-repository is read-only. Modifications are not permitted."); throw new UsersRepositoryException("This user-repository is read-only. Modifications are not permitted."); } /** * @see UsersRepository#test(java.lang.String, java.lang.String) */ public boolean test(String name, String password) throws UsersRepositoryException { User u = getUserByName(name); return u != null && u.verifyPassword(password); } /** * @see UsersRepository#addUser(java.lang.String, java.lang.String) */ public void addUser(String username, String password) throws UsersRepositoryException { log.error("This user-repository is read-only. Modifications are not permitted."); throw new UsersRepositoryException("This user-repository is read-only. Modifications are not permitted."); } /** */ public void updateUser(User user) throws UsersRepositoryException { log.error("This user-repository is read-only. Modifications are not permitted."); throw new UsersRepositoryException("This user-repository is read-only. Modifications are not permitted."); } /** * @see org.apache.james.lifecycle.api.LogEnabled#setLog(org.slf4j.Logger) */ public void setLog(Logger log) { this.log = log; } /** * VirtualHosting not supported */ public boolean supportVirtualHosting() { return false; } }