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