1 /*
2  * Copyright (C) 2004-2008 Jive Software. All rights reserved.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *     http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package org.jivesoftware.openfire.ldap;
18 
19 import org.jivesoftware.admin.LdapUserTester;
20 import org.jivesoftware.openfire.XMPPServer;
21 import org.jivesoftware.openfire.group.Group;
22 import org.jivesoftware.openfire.group.GroupManager;
23 import org.jivesoftware.openfire.user.*;
24 import org.jivesoftware.util.JiveGlobals;
25 import org.slf4j.Logger;
26 import org.slf4j.LoggerFactory;
27 import org.xmpp.packet.JID;
28 
29 import javax.naming.NamingEnumeration;
30 import javax.naming.directory.Attribute;
31 import javax.naming.directory.Attributes;
32 import javax.naming.directory.DirContext;
33 import javax.naming.ldap.Rdn;
34 import java.text.MessageFormat;
35 import java.text.SimpleDateFormat;
36 import java.time.Instant;
37 import java.time.temporal.ChronoUnit;
38 import java.util.*;
39 import java.util.stream.Collectors;
40 
41 /**
42  * LDAP implementation of the UserProvider interface. All data in the directory is
43  * treated as read-only so any set operations will result in an exception.
44  *
45  * @author Matt Tucker
46  */
47 public class LdapUserProvider implements UserProvider {
48 
49     private static final Logger Log = LoggerFactory.getLogger(LdapUserProvider.class);
50 
51     // LDAP date format parser.
52     private static final SimpleDateFormat ldapDateFormat = new SimpleDateFormat("yyyyMMddHHmmss");
53 
54     private final LdapManager manager;
55     private Map<String, String> searchFields;
56     private Instant allUserCacheExpires = Instant.now();
57     private int userCount = -1;
58     private List<String> allUsernames = null;
59     private Collection<User> allUsers = null;
60 
LdapUserProvider()61     public LdapUserProvider() {
62         // Convert XML based provider setup to Database based
63         JiveGlobals.migrateProperty("ldap.searchFields");
64 
65         manager = LdapManager.getInstance();
66         searchFields = new LinkedHashMap<>();
67         String fieldList = JiveGlobals.getProperty("ldap.searchFields");
68         // If the value isn't present, default to to username, name, and email.
69         if (fieldList == null) {
70             searchFields.put("Username", manager.getUsernameField());
71             int i = 0;
72             for ( final String nameField : manager.getNameField().getFields() ) {
73                 searchFields.put((i == 0 ? "Name" : "Name (" + i + ")"), nameField);
74                 i++;
75             }
76             searchFields.put("Email", manager.getEmailField());
77         }
78         else {
79             try {
80                 for (StringTokenizer i=new StringTokenizer(fieldList, ","); i.hasMoreTokens(); ) {
81                     String[] field = i.nextToken().split("/");
82                     searchFields.put(field[0], field[1]);
83                 }
84             }
85             catch (Exception e) {
86                 Log.error("Error parsing LDAP search fields: " + fieldList, e);
87             }
88         }
89     }
90 
91     @Override
loadUser(String username)92     public User loadUser(String username) throws UserNotFoundException {
93         if(username.contains("@")) {
94             if (!XMPPServer.getInstance().isLocal(new JID(username))) {
95                 throw new UserNotFoundException("Cannot load user of remote server: " + username);
96             }
97             username = username.substring(0,username.lastIndexOf("@"));
98         }
99         // Un-escape username.
100         username = JID.unescapeNode(username);
101         DirContext ctx = null;
102         try {
103             Rdn[] userRDN = manager.findUserRDN(username);
104             // Load record.
105             final List<String> attributes = new ArrayList<>();
106             attributes.add( manager.getUsernameField() );
107             attributes.addAll( manager.getNameField().getFields() );
108             attributes.add( manager.getEmailField() );
109             attributes.add( "createTimestamp" );
110             attributes.add( "modifyTimestamp" );
111 
112             ctx = manager.getContext(manager.getUsersBaseDN(username));
113             Attributes attrs = ctx.getAttributes(LdapManager.escapeForJNDI(userRDN), attributes.toArray(new String[0]));
114             String name = LdapUserTester.getPropertyValue(manager.getNameField(), attrs);
115             String email = null;
116             Attribute emailField = attrs.get(manager.getEmailField());
117             if (emailField != null) {
118                 email = (String)emailField.get();
119             }
120             Date creationDate = new Date();
121             Attribute creationDateField = attrs.get("createTimestamp");
122             if (creationDateField != null && "".equals(((String) creationDateField.get()).trim())) {
123                 creationDate = parseLDAPDate((String) creationDateField.get());
124             }
125             Date modificationDate = new Date();
126             Attribute modificationDateField = attrs.get("modifyTimestamp");
127             if (modificationDateField != null && "".equals(((String) modificationDateField.get()).trim())) {
128                 modificationDate = parseLDAPDate((String)modificationDateField.get());
129             }
130             // Escape the username so that it can be used as a JID.
131             username = JID.escapeNode(username);
132 
133             // As defined by RFC5803.
134             Attribute authPassword = attrs.get("authPassword");
135             User user = new User(username, name, email, creationDate, modificationDate);
136             if (manager.isFindUsersFromGroupsEnabled() && GroupManager.getInstance().getGroups(user).isEmpty()) {
137                 throw new UserNotFoundException("User exists in LDAP but is not a member of any Openfire groups");
138             }
139             if (authPassword != null) {
140                 // The authPassword attribute can be multivalued.
141                 // Not sure if this is the right API to loop through them.
142                 NamingEnumeration values = authPassword.getAll();
143                 while (values.hasMore()) {
144                     Attribute authPasswordValue = (Attribute) values.next();
145                     String[] parts = ((String) authPasswordValue.get()).split("$");
146                     String[] authInfo = parts[1].split(":");
147                     String[] authValue = parts[2].split(":");
148 
149                     String scheme = parts[0].trim();
150 
151                     // We only support SCRAM-SHA-1 at the moment.
152                     if ("SCRAM-SHA-1".equals(scheme)) {
153                         int iterations = Integer.valueOf(authInfo[0].trim());
154                         String salt = authInfo[1].trim();
155                         String storedKey = authValue[0].trim();
156                         String serverKey = authValue[1].trim();
157 
158                         user.setSalt(salt);
159                         user.setStoredKey(storedKey);
160                         user.setServerKey(serverKey);
161                         user.setIterations(iterations);
162 
163                         break;
164                     }
165                 }
166             }
167             return user;
168         }
169         catch (Exception e) {
170             throw new UserNotFoundException(e);
171         }
172         finally {
173             try {
174                 if (ctx != null) {
175                     ctx.close();
176                 }
177             }
178             catch (Exception ex) {
179                 Log.debug( "An exception occurred while closing the LDAP context after attempting to load user {}", username, ex);
180             }
181         }
182     }
183 
184     @Override
createUser(String username, String password, String name, String email)185     public User createUser(String username, String password, String name, String email)
186             throws UserAlreadyExistsException
187     {
188         throw new UnsupportedOperationException();
189     }
190 
191     @Override
deleteUser(String username)192     public void deleteUser(String username) {
193         throw new UnsupportedOperationException();
194     }
195 
196     @Override
getUserCount()197     public int getUserCount() {
198         // Cache user count for 5 minutes.
199         if (userCount != -1 && allUserCacheExpires.isAfter(Instant.now())) {
200             return userCount;
201         }
202         // Refresh the cache
203         getUsers();
204         return this.userCount;
205     }
206 
207     @Override
getUsernames()208     public Collection<String> getUsernames() {
209         // Cache usernames for 5 minutes.
210         if (allUsernames != null && allUserCacheExpires.isAfter(Instant.now())) {
211             return allUsernames;
212         }
213         // Refresh the cache
214         getUsers();
215         return this.allUsernames;
216     }
217 
218     @Override
getUsers()219     public synchronized Collection<User> getUsers() {
220         if (allUsers != null && allUserCacheExpires.isAfter(Instant.now())) {
221             return allUsers;
222         }
223         this.allUsers = getUsers( -1, -1 );
224         // When all user have been fetched, we can update various other cached values.
225         this.userCount = this.allUsers.size();
226         this.allUsernames = allUsers.stream().map(User::getUsername).collect(Collectors.toList());
227         this.allUserCacheExpires = Instant.now().plus(5, ChronoUnit.MINUTES);
228         return allUsers;
229     }
230 
231     @Override
getUsers(int startIndex, int numResults)232     public Collection<User> getUsers(int startIndex, int numResults) {
233         final List<String> userlist;
234         if (manager.isFindUsersFromGroupsEnabled()) {
235             final Set<String> allUsers = GroupManager.getInstance().getGroups()
236                 .stream()
237                 .map(Group::getAll)
238                 .flatMap(Collection::stream)
239                 .map(JID::getNode)
240                 .collect(Collectors.toSet());
241             userlist = LdapManager.sortAndPaginate(allUsers, startIndex, numResults);
242         } else {
243             userlist = manager.retrieveList(
244                 manager.getUsernameField(),
245                 MessageFormat.format(manager.getSearchFilter(), "*"),
246                 startIndex,
247                 numResults,
248                 manager.getUsernameSuffix(),
249                 true
250             );
251         }
252         return new UserCollection(userlist.toArray(new String[userlist.size()]));
253     }
254 
255     @Override
setName(String username, String name)256     public void setName(String username, String name) throws UserNotFoundException {
257         throw new UnsupportedOperationException();
258     }
259 
260     @Override
setEmail(String username, String email)261     public void setEmail(String username, String email) throws UserNotFoundException {
262         throw new UnsupportedOperationException();
263     }
264 
265     @Override
setCreationDate(String username, Date creationDate)266     public void setCreationDate(String username, Date creationDate) throws UserNotFoundException {
267         throw new UnsupportedOperationException();
268     }
269 
270     @Override
setModificationDate(String username, Date modificationDate)271     public void setModificationDate(String username, Date modificationDate) throws UserNotFoundException {
272         throw new UnsupportedOperationException();
273     }
274 
275     @Override
getSearchFields()276     public Set<String> getSearchFields() throws UnsupportedOperationException {
277         return Collections.unmodifiableSet(searchFields.keySet());
278     }
279 
setSearchFields(String fieldList)280     public void setSearchFields(String fieldList) {
281         this.searchFields = new LinkedHashMap<>();
282         // If the value isn't present, default to to username, name, and email.
283         if (fieldList == null) {
284             searchFields.put("Username", manager.getUsernameField());
285             int i = 0;
286             for ( final String nameField : manager.getNameField().getFields() ) {
287                 searchFields.put((i == 0 ? "Name" : "Name (" + i + ")"), nameField);
288                 i++;
289             }
290             searchFields.put("Email", manager.getEmailField());
291         }
292         else {
293             try {
294                 for (StringTokenizer i=new StringTokenizer(fieldList, ","); i.hasMoreTokens(); ) {
295                     String[] field = i.nextToken().split("/");
296                     searchFields.put(field[0], field[1]);
297                 }
298             }
299             catch (Exception e) {
300                 Log.error("Error parsing LDAP search fields: " + fieldList, e);
301             }
302         }
303         JiveGlobals.setProperty("ldap.searchFields", fieldList);
304     }
305 
306     @Override
findUsers(Set<String> fields, String query)307     public Collection<User> findUsers(Set<String> fields, String query)
308             throws UnsupportedOperationException
309     {
310         return findUsers(fields, query, -1, -1);
311     }
312 
313     @Override
findUsers(Set<String> fields, String query, int startIndex, int numResults)314     public Collection<User> findUsers(Set<String> fields, String query, int startIndex,
315             int numResults) throws UnsupportedOperationException
316     {
317         if (fields.isEmpty() || query == null || "".equals(query)) {
318             return Collections.emptyList();
319         }
320 
321         query = LdapManager.sanitizeSearchFilter(query, true);
322 
323         // Make the query be a wildcard search by default. So, if the user searches for
324         // "John", make the search be "John*" instead.
325         if (!query.endsWith("*")) {
326             query = query + "*";
327         }
328 
329         if (!searchFields.keySet().containsAll(fields)) {
330             throw new IllegalArgumentException("Search fields " + fields + " are not valid.");
331         }
332         StringBuilder filter = new StringBuilder();
333         //Add the global search filter so only those users the directory administrator wants to include
334         //are returned from the directory
335         filter.append("(&(");
336         filter.append(MessageFormat.format(manager.getSearchFilter(),"*"));
337         filter.append(')');
338         if (fields.size() > 1) {
339             filter.append("(|");
340         }
341         for (String field:fields) {
342             String attribute = searchFields.get(field);
343             filter.append('(').append(attribute).append('=')
344                 .append( query ).append(")");
345         }
346         if (fields.size() > 1) {
347             filter.append(')');
348         }
349         filter.append(')');
350         List<String> userlist = manager.retrieveList(
351                 manager.getUsernameField(),
352                 filter.toString(),
353                 startIndex,
354                 numResults,
355                 manager.getUsernameSuffix(),
356                 true
357         );
358         if (manager.isFindUsersFromGroupsEnabled()) {
359             userlist = userlist.stream()
360                 .filter(user ->
361                     !GroupManager.getInstance().getGroups(
362                         XMPPServer.getInstance().createJID(user, null))
363                     .isEmpty())
364                 .collect(Collectors.toList());
365         }
366         return new UserCollection(userlist.toArray(new String[userlist.size()]));
367     }
368 
369     @Override
isReadOnly()370     public boolean isReadOnly() {
371         return true;
372     }
373 
374     @Override
isNameRequired()375     public boolean isNameRequired() {
376         return false;
377     }
378 
379     @Override
isEmailRequired()380     public boolean isEmailRequired() {
381         return false;
382     }
383 
384     /**
385      * Parses dates/time stamps stored in LDAP. Some possible values:
386      *
387      * <ul>
388      *      <li>20020228150820</li>
389      *      <li>20030228150820Z</li>
390      *      <li>20050228150820.12</li>
391      *      <li>20060711011740.0Z</li>
392      * </ul>
393      *
394      * @param dateText the date string.
395      * @return the Date.
396      */
parseLDAPDate(String dateText)397     private static Date parseLDAPDate(String dateText) {
398         // If the date ends with a "Z", that means that it's in the UTC time zone. Otherwise,
399         // Use the default time zone.
400         boolean useUTC = false;
401         if (dateText.endsWith("Z")) {
402             useUTC = true;
403         }
404         Date date = new Date();
405         try {
406             if (useUTC) {
407                 ldapDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
408             }
409             else {
410                 ldapDateFormat.setTimeZone(TimeZone.getDefault());
411             }
412             date = ldapDateFormat.parse(dateText);
413         }
414         catch (Exception e) {
415             Log.error(e.getMessage(), e);
416         }
417         return date;
418     }
419 }
420