001/*
002 *  Copyright 2016 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.core.util.ldap;
017
018import java.io.IOException;
019import java.util.ArrayList;
020import java.util.Hashtable;
021import java.util.List;
022import java.util.Map;
023import java.util.regex.Pattern;
024
025import javax.naming.Context;
026import javax.naming.NamingEnumeration;
027import javax.naming.NamingException;
028import javax.naming.directory.Attribute;
029import javax.naming.directory.SearchControls;
030import javax.naming.directory.SearchResult;
031import javax.naming.ldap.Control;
032import javax.naming.ldap.InitialLdapContext;
033import javax.naming.ldap.LdapContext;
034import javax.naming.ldap.PagedResultsControl;
035import javax.naming.ldap.PagedResultsResponseControl;
036import javax.naming.ldap.SortControl;
037
038import org.apache.avalon.framework.configuration.Configuration;
039import org.apache.avalon.framework.configuration.ConfigurationException;
040import org.apache.avalon.framework.service.ServiceException;
041import org.apache.avalon.framework.service.ServiceManager;
042import org.apache.avalon.framework.service.Serviceable;
043
044import org.ametys.core.datasource.AbstractDataSourceManager.DataSourceDefinition;
045import org.ametys.core.datasource.LDAPDataSourceManager;
046import org.ametys.core.datasource.UnknownDataSourceException;
047import org.ametys.core.util.CachingComponent;
048import org.ametys.runtime.config.Config;
049
050/**
051 * This abstract class contains all basic for a ldap connection using config parameters
052 * @param <K> the type of keys for caching objects by this component.
053 * @param <V> the type of objects cached by this component.
054 */
055public abstract class AbstractLDAPConnector<K, V> extends CachingComponent<K, V> implements Serviceable
056{
057    /** The default LDAP search page size */
058    protected static final int __DEFAULT_PAGE_SIZE = 500; // default limit for OpenLdap
059    
060    // Check filter look
061    private static final Pattern __FILTER = Pattern.compile("\\s*\\(.*\\)\\s*");
062
063    /** URL connection to the ldap server. */
064    protected String _ldapUrl; 
065    /** Base DN to the ldap server. */
066    protected String _ldapBaseDN;
067    /** Distinguished name of the admin used for searching. */
068    protected String _ldapAdminRelativeDN;
069    /** Password associated with the admin. */
070    protected String _ldapAdminPassword;
071    /** Authentication method used. */
072    protected String _ldapAuthenticationMethod;
073    /** Use ssl for connecting to ldap server. */
074    protected boolean _ldapUseSSL;
075    /** Enable following referrals. */
076    protected boolean _ldapFollowReferrals;
077    /** Alias dereferencing mode. */
078    protected String _ldapAliasDerefMode;
079    /** True to sort the results on the server side, false to get the results unsorted. */
080    protected boolean _serverSideSorting;
081    
082    /** Indicates if the LDAP server supports paging feature. */
083    protected boolean _pagingSupported;
084
085    /** The LDAP data source manager */
086    private LDAPDataSourceManager _ldapDataSourceManager;
087    
088    /**
089     * Call this method with the datasource id to initialize this component
090     * @param dataSourceId The id of the datasource
091     * @throws Exception If an error occurs.
092     */
093    protected void _delayedInitialize(String dataSourceId) throws Exception
094    {
095        DataSourceDefinition ldapDefinition = _ldapDataSourceManager.getDataSourceDefinition(dataSourceId);
096        if (ldapDefinition != null)
097        {
098            Map<String, Object> ldapParameters = ldapDefinition.getParameters();
099            
100            _ldapUrl = (String) ldapParameters.get(LDAPDataSourceManager.PARAM_BASE_URL);
101            _ldapBaseDN = (String) ldapParameters.get(LDAPDataSourceManager.PARAM_BASE_DN);
102            _ldapAdminRelativeDN = (String) ldapParameters.get(LDAPDataSourceManager.PARAM_ADMIN_DN);
103            _ldapAdminPassword = (String) ldapParameters.get(LDAPDataSourceManager.PARAM_ADMIN_PASSWORD);
104            _ldapAuthenticationMethod = (String) ldapParameters.get(LDAPDataSourceManager.PARAM_AUTHENTICATION_METHOD);
105            _ldapUseSSL = (boolean) ldapParameters.get(LDAPDataSourceManager.PARAM_USE_SSL);
106            _ldapFollowReferrals = (boolean) ldapParameters.get(LDAPDataSourceManager.PARAM_FOLLOW_REFERRALS);
107            _ldapAliasDerefMode = (String) ldapParameters.get(LDAPDataSourceManager.PARAM_ALIAS_DEREFERENCING);
108            _serverSideSorting = (boolean) ldapParameters.get(LDAPDataSourceManager.PARAM_SERVER_SIDE_SORTING);
109            
110            _pagingSupported = _testPagingSupported();
111            if (!_testConnectionsPooled())
112            {
113                getLogger().warn("Warning! LDAP connections for this connector are not pooled. "
114                        + "If you are using SSL, you must set the system property 'com.sun.jndi.ldap.connect.pool.protocol' to the String \"plain ssl\".");
115            }
116        }
117        else
118        {
119            throw new UnknownDataSourceException("The data source of id '" + dataSourceId + "' is still referenced but no longer exists.");
120        }
121    }
122    
123    @Override
124    public void service(ServiceManager serviceManager) throws ServiceException
125    {
126        _ldapDataSourceManager = (LDAPDataSourceManager) serviceManager.lookup(LDAPDataSourceManager.ROLE); 
127    }
128    
129    /**
130     * Get the filter from configuration key and check it
131     * @param configuration The configuration
132     * @param filterKey The name of the child in configuration containing the filter config parameter name
133     * @return The value of the configured filter
134     * @throws ConfigurationException if the filter does not match
135     */
136    protected String _getFilter(Configuration configuration, String filterKey) throws ConfigurationException
137    {
138        String filter = _getConfigParameter(configuration, filterKey);
139        if (!__FILTER.matcher(filter).matches())
140        {
141            String message = "Invalid filter '" + filter + "', missing parenthesis";
142            throw new ConfigurationException(message, configuration);
143        }
144        return filter;
145    }
146    
147    /**
148     * Get the search scope from configuration key
149     * @param configuration The configuration
150     * @param searchScopeKey The name of the child in configuration containing the search scope parameter name
151     * @return The scope between <code>SearchControls.ONELEVEL_SCOPE</code>, <code>SearchControls.SUBTREE_SCOPE</code> and <code>SearchControls.OBJECT_SCOPE</code>.
152     * @throws ConfigurationException if a configuration problem occurs
153     */
154    protected int _getSearchScope(Configuration configuration, String searchScopeKey) throws ConfigurationException
155    {
156        String usersSearchScope = _getConfigParameter(configuration, searchScopeKey);
157        
158        try
159        {
160            return ScopeEnumerator.parseScope(usersSearchScope);
161        }
162        catch (IllegalArgumentException e)
163        {
164            throw new ConfigurationException("Unable to parse scope", e);
165        }
166    }
167
168    /**
169     * Test if paging is supported by the underlying directory server.
170     * @return true if the server supports paging.
171     */
172    public boolean isPagingSupported()
173    {
174        return _pagingSupported;
175    }
176    
177    /**
178     * Get a config parameter value
179     * @param configuration The configuration
180     * @param key The child node of configuration containing the config parameter name
181     * @return The value (can be null)
182     * @throws ConfigurationException if parameter is missing
183     */
184    protected String _getConfigParameter(Configuration configuration, String key) throws ConfigurationException
185    {
186        String parameterName = configuration.getChild(key).getValue(null);
187        if (parameterName == null)
188        {
189            String message = "The parameter '" + key + "' is missing";
190            getLogger().error(message);
191            throw new ConfigurationException(message, configuration);
192        }
193        
194        String valeur = Config.getInstance().getValue(parameterName);
195        return valeur;
196    }
197    
198    /**
199     * Get the parameters for connecting to the ldap server.
200     * 
201     * @return Parameters for connecting.
202     */
203    protected Hashtable<String, String> _getContextEnv()
204    {
205        Hashtable<String, String> env = new Hashtable<>();
206
207        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
208        env.put(Context.PROVIDER_URL, _ldapUrl + "/" + _ldapBaseDN);
209        env.put(Context.SECURITY_AUTHENTICATION, _ldapAuthenticationMethod);
210
211        if (!_ldapAuthenticationMethod.equals("none"))
212        {
213            env.put(Context.SECURITY_PRINCIPAL, _ldapAdminRelativeDN);
214            env.put(Context.SECURITY_CREDENTIALS, _ldapAdminPassword);
215        }
216
217        if (_ldapUseSSL)
218        {
219            // Encrypt the connection to the server with SSL
220            env.put(Context.SECURITY_PROTOCOL, "ssl");
221        }
222        
223        // Default is to ignore.
224        if (_ldapFollowReferrals)
225        {
226            env.put(Context.REFERRAL, "follow");
227        }
228        else
229        {
230            env.put(Context.REFERRAL, "ignore");
231        }
232        
233        env.put("java.naming.ldap.derefAliases", _ldapAliasDerefMode);
234        
235        // Use ldap pool connection
236        env.put("com.sun.jndi.ldap.connect.pool", "true");
237
238        return env;
239    }
240    
241    /**
242     * Get the parameters for connecting to the ldap server, root DN.
243     * @return Parameters for connecting.
244     */
245    protected Hashtable<String, String> _getRootContextEnv()
246    {
247        Hashtable<String, String> env = _getContextEnv();
248        
249        env.put(Context.PROVIDER_URL, _ldapUrl);
250        
251        return env;
252    }
253    
254    /**
255     * Test if paging is supported by the underlying directory server.
256     * @return true if the server supports paging.
257     */
258    protected boolean _testPagingSupported()
259    {
260        boolean supported = false;
261        
262        LdapContext context = null;
263        NamingEnumeration<SearchResult> results = null;
264        
265        try
266        {
267            // Connection to LDAP server
268            context = new InitialLdapContext(_getRootContextEnv(), null);
269            
270            SearchControls controls = new SearchControls();
271            controls.setReturningAttributes(new String[]{"supportedControl"});
272            controls.setSearchScope(SearchControls.OBJECT_SCOPE);
273            
274            // Search for the rootDSE object.
275            results = context.search("", "(objectClass=*)", controls);
276            
277            while (results.hasMore() && !supported)
278            {
279                SearchResult entry = results.next();
280                NamingEnumeration<?> attrs = entry.getAttributes().getAll();
281                while (attrs.hasMore() && !supported)
282                {
283                    Attribute attr = (Attribute) attrs.next();
284                    NamingEnumeration<?> vals = attr.getAll();
285                    while (vals.hasMore() && !supported)
286                    {
287                        String value = (String) vals.next();
288                        if (PagedResultsControl.OID.equals(value))
289                        {
290                            supported = true;
291                        }
292                    }
293                }
294            }
295        }
296        catch (NamingException e)
297        {
298            getLogger().warn("Error while testing the LDAP server for paging feature, assuming false.", e);
299        }
300        finally
301        {
302            // Close resources of connection
303            _cleanup(context, results);
304        }
305        
306        return supported;
307    }
308    
309    /**
310     * Test if connections are pooled
311     * @return true if connections are pooled
312     */
313    protected boolean _testConnectionsPooled()
314    {
315        return !_ldapUseSSL || "plain ssl".equals(System.getProperty("com.sun.jndi.ldap.connect.pool.protocol"));
316    }
317    
318    /**
319     * Clean a connection to an ldap server.
320     * 
321     * @param context The connection to the database to close.
322     * @param result The result to close.
323     */
324    protected void _cleanup(Context context, NamingEnumeration result)
325    {
326        if (result != null)
327        {
328            try
329            {
330                // Close results
331                result.close();
332            }
333            catch (NamingException e)
334            {
335                getLogger().error("Error while closing ldap result", e);
336            }
337        }
338        if (context != null)
339        {
340            try
341            {
342                // Close server connection
343                context.close();
344            }
345            catch (NamingException e)
346            {
347                getLogger().error("Error while closing ldap connection", e);
348            }
349        }
350    }
351
352    /**
353     * Executes a LDAP search
354     * @param pageSize The number of entries in a page
355     * @param name  the name of the context or object to search
356     * @param filter the filter expression to use for the search
357     * @param searchControls the search controls that control the search.
358     * @return The results of the LDAP search
359     * @throws NamingException if a naming exception is encountered
360     */
361    protected List<SearchResult> _search(int pageSize, String name, String filter, SearchControls searchControls) throws NamingException
362    {
363        return _search(pageSize, name, filter, null, searchControls, 0, Integer.MAX_VALUE, false);
364    }
365
366    /**
367     * Executes a LDAP search
368     * @param pageSize The number of entries in a page
369     * @param name  the name of the context or object to search
370     * @param filter the filter expression to use for the search
371     * @param searchControls the search controls that control the search.
372     * @param sorted True to sort the results
373     * @return The results of the LDAP search
374     * @throws NamingException if a naming exception is encountered
375     */
376    protected List<SearchResult> _search(int pageSize, String name, String filter, SearchControls searchControls, boolean sorted) throws NamingException
377    {
378        return _search(pageSize, name, filter, null, searchControls, 0, Integer.MAX_VALUE, sorted);
379    }
380    
381    
382    /**
383     * Executes a LDAP search
384     * @param pageSize The number of entries in a page
385     * @param name  the name of the context or object to search
386     * @param filter the filter expression to use for the search
387     * @param filterArgs the array of arguments to substitute for the variables in filter. Can be null.
388     * @param searchControls the search controls that control the search.
389     * @param offset the start index
390     * @param limit The max number of results
391     * @return The results of the LDAP search
392     * @throws NamingException if a naming exception is encountered
393     */
394    protected List<SearchResult> _search(int pageSize, String name, String filter, Object[] filterArgs, SearchControls searchControls, int offset, int limit) throws NamingException
395    {
396        return _search(pageSize, name, filter, filterArgs, searchControls, offset, limit, false);
397    }
398    
399    /**
400     * Executes a LDAP search
401     * @param pageSize The number of entries in a page
402     * @param name  the name of the context or object to search
403     * @param filter the filter expression to use for the search
404     * @param filterArgs the array of arguments to substitute for the variables in filter. Can be null.
405     * @param searchControls the search controls that control the search.
406     * @param offset the start index
407     * @param limit The max number of results
408     * @param sorted True to sort the results
409     * @return The results of the LDAP search
410     * @throws NamingException if a naming exception is encountered
411     */
412    protected List<SearchResult> _search(int pageSize, String name, String filter, Object[] filterArgs, SearchControls searchControls, int offset, int limit, boolean sorted) throws NamingException
413    {
414        List<SearchResult> allResults = new ArrayList<>();
415        
416        LdapContext context = null;
417        NamingEnumeration<SearchResult> tmpResults = null;
418        
419        try
420        {
421            // Connect to the LDAP server.
422            context = new InitialLdapContext(_getContextEnv(), null);
423            
424            _setResultsControls(pageSize, context, sorted);
425            
426            int index = 0;
427            do
428            {
429                // Perform the search
430                tmpResults = context.search(name, filter, filterArgs, searchControls);
431                
432                // Iterate over a batch of search results
433                while (tmpResults != null && tmpResults.hasMoreElements() && index < offset + limit)
434                {
435                    SearchResult result = tmpResults.nextElement();
436                    if (index >= offset)
437                    {
438                        // Retrieve current entry
439                        allResults.add(result);
440                    }
441                    index++;
442                }
443            }
444            while (index < offset + limit && _hasMoreEntries(pageSize, context));
445        }
446        finally
447        {
448            // Close connection resources
449            _cleanup(context, tmpResults);
450        }
451        
452        return allResults;
453    }
454
455    /**
456     * Determines if there are more entries for the LDAP server to return based on server-generated cookie.
457     * @param pageSize The number of entries to be returned per page 
458     * @param context The ldap context
459     * @return false if there are no more entries.
460     * @throws NamingException If an error occurred while getting/setting the request controls
461     */
462    protected boolean _hasMoreEntries(int pageSize, LdapContext context) throws NamingException
463    {
464        byte[] cookie = null;
465        
466        // Examine the paged results control response
467        Control[] controls = context.getResponseControls();
468        if (controls != null)
469        {
470            for (int i = 0; i < controls.length; i++)
471            {
472                if (controls[i] instanceof PagedResultsResponseControl)
473                {
474                    PagedResultsResponseControl prrc = (PagedResultsResponseControl) controls[i];
475                    cookie = prrc.getCookie();
476                }
477            }
478        }
479        
480        // Re-activate paged results
481        if (isPagingSupported())
482        {
483            try
484            {
485                context.setRequestControls(new Control[]{new PagedResultsControl(pageSize, cookie, Control.NONCRITICAL) });
486            }
487            catch (IOException ioe)
488            {
489                getLogger().error("Error setting the PagedResultsControl in the LDAP context.", ioe);
490            }
491        }
492        
493        return cookie != null;
494    }
495    
496    /**
497     * Set paging on ldap if supported, and set the sort 
498     * @param pageSize The page size to communicate with ldap
499     * @param context The ldap context
500     * @param sorted True add the sort controls
501     * @throws NamingException if an error occurred
502     */
503    protected void _setResultsControls(int pageSize, LdapContext context, boolean sorted) throws NamingException
504    {
505        List<Control> controls = new ArrayList<>();
506        
507        if (isPagingSupported())
508        {
509            try
510            {
511                controls.add(new PagedResultsControl(pageSize, Control.NONCRITICAL));
512            }
513            catch (IOException ioe)
514            {
515                getLogger().error("Error setting the PagingResultsControl in the LDAP context.", ioe);
516            }
517        }
518        
519        String[] sortByFields = getSortByFields();
520        if (sorted && sortByFields != null && sortByFields.length > 0)
521        {
522            try
523            {
524                controls.add(new SortControl(sortByFields, Control.NONCRITICAL));
525            }
526            catch (IOException ioe)
527            {
528                getLogger().error("Error setting the SortControl in the LDAP context.", ioe);
529            }
530        }
531        
532        if (controls.size() > 0)
533        {
534            context.setRequestControls(controls.toArray(new Control[controls.size()]));
535        }
536    }
537
538    /**
539     * Get the fields to sort by if the search is sorted
540     * @return The list of fields to sort by
541     */
542    protected String[] getSortByFields()
543    {
544        return null;
545    }
546}