]> git.argeo.org Git - lgpl/argeo-commons.git/blob - JcrLdapSynchronizer.java
178785602d5aa7eff0ad8d5583c39210f0350fb0
[lgpl/argeo-commons.git] / JcrLdapSynchronizer.java
1 /*
2 * Copyright (C) 2007-2012 Mathieu Baudier
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 package org.argeo.security.ldap.jcr;
17
18 import java.security.NoSuchAlgorithmException;
19 import java.security.SecureRandom;
20 import java.util.ArrayList;
21 import java.util.Arrays;
22 import java.util.HashMap;
23 import java.util.List;
24 import java.util.Map;
25 import java.util.Random;
26 import java.util.SortedSet;
27
28 import javax.jcr.Node;
29 import javax.jcr.NodeIterator;
30 import javax.jcr.Property;
31 import javax.jcr.Repository;
32 import javax.jcr.RepositoryException;
33 import javax.jcr.Session;
34 import javax.jcr.observation.Event;
35 import javax.jcr.observation.EventIterator;
36 import javax.jcr.observation.EventListener;
37 import javax.jcr.query.Query;
38 import javax.jcr.version.VersionManager;
39 import javax.naming.Binding;
40 import javax.naming.Name;
41 import javax.naming.NamingException;
42 import javax.naming.directory.BasicAttribute;
43 import javax.naming.directory.DirContext;
44 import javax.naming.directory.ModificationItem;
45 import javax.naming.directory.SearchControls;
46 import javax.naming.event.EventDirContext;
47 import javax.naming.event.NamespaceChangeListener;
48 import javax.naming.event.NamingEvent;
49 import javax.naming.event.NamingExceptionEvent;
50 import javax.naming.event.NamingListener;
51 import javax.naming.event.ObjectChangeListener;
52 import javax.naming.ldap.UnsolicitedNotification;
53 import javax.naming.ldap.UnsolicitedNotificationEvent;
54 import javax.naming.ldap.UnsolicitedNotificationListener;
55
56 import org.apache.commons.logging.Log;
57 import org.apache.commons.logging.LogFactory;
58 import org.argeo.ArgeoException;
59 import org.argeo.jcr.ArgeoNames;
60 import org.argeo.jcr.ArgeoTypes;
61 import org.argeo.jcr.JcrUtils;
62 import org.argeo.jcr.security.SecurityJcrUtils;
63 import org.argeo.security.jcr.JcrUserDetails;
64 import org.springframework.ldap.core.ContextExecutor;
65 import org.springframework.ldap.core.ContextMapper;
66 import org.springframework.ldap.core.DirContextAdapter;
67 import org.springframework.ldap.core.DirContextOperations;
68 import org.springframework.ldap.core.DistinguishedName;
69 import org.springframework.ldap.core.LdapTemplate;
70 import org.springframework.security.GrantedAuthority;
71 import org.springframework.security.ldap.LdapUsernameToDnMapper;
72 import org.springframework.security.providers.encoding.PasswordEncoder;
73 import org.springframework.security.userdetails.UserDetails;
74 import org.springframework.security.userdetails.ldap.UserDetailsContextMapper;
75
76 /** Guarantees that LDAP and JCR are in line. */
77 public class JcrLdapSynchronizer implements UserDetailsContextMapper,
78 ArgeoNames {
79 private final static Log log = LogFactory.getLog(JcrLdapSynchronizer.class);
80
81 // LDAP
82 private LdapTemplate ldapTemplate;
83 /**
84 * LDAP template whose context source has an object factory set to null. see
85 * <a href=
86 * "http://forum.springsource.org/showthread.php?55955-Persistent-search-with-spring-ldap"
87 * >this</a>
88 */
89 private LdapTemplate rawLdapTemplate;
90
91 private String userBase;
92 private String usernameAttribute;
93 private String passwordAttribute;
94 private String[] userClasses;
95
96 private NamingListener ldapUserListener;
97 private SearchControls subTreeSearchControls;
98 private LdapUsernameToDnMapper usernameMapper;
99
100 private PasswordEncoder passwordEncoder;
101 private final Random random;
102
103 // JCR
104 /** Admin session on the security workspace */
105 private Session securitySession;
106 private Repository repository;
107
108 private String securityWorkspace = "security";
109
110 private JcrProfileListener jcrProfileListener;
111
112 // Mapping
113 private Map<String, String> propertyToAttributes = new HashMap<String, String>();
114
115 public JcrLdapSynchronizer() {
116 random = createRandom();
117 }
118
119 public void init() {
120 try {
121 securitySession = repository.login(securityWorkspace);
122
123 synchronize();
124
125 // LDAP
126 subTreeSearchControls = new SearchControls();
127 subTreeSearchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
128 // LDAP listener
129 ldapUserListener = new LdapUserListener();
130 rawLdapTemplate.executeReadOnly(new ContextExecutor() {
131 public Object executeWithContext(DirContext ctx)
132 throws NamingException {
133 EventDirContext ectx = (EventDirContext) ctx.lookup("");
134 ectx.addNamingListener(userBase, "(" + usernameAttribute
135 + "=*)", subTreeSearchControls, ldapUserListener);
136 return null;
137 }
138 });
139
140 // JCR
141 String[] nodeTypes = { ArgeoTypes.ARGEO_USER_PROFILE };
142 jcrProfileListener = new JcrProfileListener();
143 // noLocal is used so that we are not notified when we modify JCR
144 // from LDAP
145 securitySession
146 .getWorkspace()
147 .getObservationManager()
148 .addEventListener(jcrProfileListener,
149 Event.PROPERTY_CHANGED | Event.NODE_ADDED, "/",
150 true, null, nodeTypes, true);
151 } catch (Exception e) {
152 JcrUtils.logoutQuietly(securitySession);
153 throw new ArgeoException("Cannot initialize LDAP/JCR synchronizer",
154 e);
155 }
156 }
157
158 public void destroy() {
159 JcrUtils.removeListenerQuietly(securitySession, jcrProfileListener);
160 JcrUtils.logoutQuietly(securitySession);
161 try {
162 rawLdapTemplate.executeReadOnly(new ContextExecutor() {
163 public Object executeWithContext(DirContext ctx)
164 throws NamingException {
165 EventDirContext ectx = (EventDirContext) ctx.lookup("");
166 ectx.removeNamingListener(ldapUserListener);
167 return null;
168 }
169 });
170 } catch (Exception e) {
171 // silent (LDAP server may have been shutdown already)
172 if (log.isTraceEnabled())
173 log.trace("Cannot remove LDAP listener", e);
174 }
175 }
176
177 /*
178 * LDAP TO JCR
179 */
180 /** Full synchronization between LDAP and JCR. LDAP has priority. */
181 protected void synchronize() {
182 try {
183 Name userBaseName = new DistinguishedName(userBase);
184 // TODO subtree search?
185 @SuppressWarnings("unchecked")
186 List<String> userPaths = (List<String>) ldapTemplate.listBindings(
187 userBaseName, new ContextMapper() {
188 public Object mapFromContext(Object ctxObj) {
189 return mapLdapToJcr((DirContextAdapter) ctxObj);
190 }
191 });
192
193 // disable accounts which are not in LDAP
194 Query query = securitySession
195 .getWorkspace()
196 .getQueryManager()
197 .createQuery(
198 "select * from [" + ArgeoTypes.ARGEO_USER_PROFILE
199 + "]", Query.JCR_SQL2);
200 NodeIterator it = query.execute().getNodes();
201 while (it.hasNext()) {
202 Node userProfile = it.nextNode();
203 String path = userProfile.getPath();
204 if (!userPaths.contains(path)) {
205 log.warn("Path "
206 + path
207 + " not found in LDAP, disabling user "
208 + userProfile.getProperty(ArgeoNames.ARGEO_USER_ID)
209 .getString());
210 VersionManager versionManager = securitySession
211 .getWorkspace().getVersionManager();
212 versionManager.checkout(userProfile.getPath());
213 userProfile.setProperty(ArgeoNames.ARGEO_ENABLED, false);
214 securitySession.save();
215 versionManager.checkin(userProfile.getPath());
216 }
217 }
218 } catch (Exception e) {
219 JcrUtils.discardQuietly(securitySession);
220 throw new ArgeoException("Cannot synchronized LDAP and JCR", e);
221 }
222 }
223
224 /** Called during authentication in order to retrieve user details */
225 public UserDetails mapUserFromContext(final DirContextOperations ctx,
226 final String username, GrantedAuthority[] authorities) {
227 log.debug("mapUserFromContext");
228 if (ctx == null)
229 throw new ArgeoException("No LDAP information for user " + username);
230 Node userProfile = SecurityJcrUtils.createUserProfileIfNeeded(securitySession,
231 username);
232 JcrUserDetails.checkAccountStatus(userProfile);
233
234 // password
235 SortedSet<?> passwordAttributes = ctx
236 .getAttributeSortedStringSet(passwordAttribute);
237 String password;
238 if (passwordAttributes == null || passwordAttributes.size() == 0) {
239 throw new ArgeoException("No password found for user " + username);
240 } else {
241 byte[] arr = (byte[]) passwordAttributes.first();
242 password = new String(arr);
243 // erase password
244 Arrays.fill(arr, (byte) 0);
245 }
246
247 try {
248 return new JcrUserDetails(userProfile, password, authorities);
249 } catch (RepositoryException e) {
250 throw new ArgeoException("Cannot retrieve user details for "
251 + username, e);
252 }
253 }
254
255 /**
256 * Writes an LDAP context to the JCR user profile.
257 *
258 * @return path to user profile
259 */
260 protected synchronized String mapLdapToJcr(DirContextAdapter ctx) {
261 Session session = securitySession;
262 try {
263 // process
264 String username = ctx.getStringAttribute(usernameAttribute);
265 Node userHome = SecurityJcrUtils.createUserHomeIfNeeded(session, username);
266 Node userProfile; // = userHome.getNode(ARGEO_PROFILE);
267 if (userHome.hasNode(ARGEO_PROFILE)) {
268 userProfile = userHome.getNode(ARGEO_PROFILE);
269
270 // compatibility with legacy, will be removed
271 if (!userProfile.hasProperty(ARGEO_ENABLED)) {
272 session.getWorkspace().getVersionManager()
273 .checkout(userProfile.getPath());
274 userProfile.setProperty(ARGEO_ENABLED, true);
275 userProfile.setProperty(ARGEO_ACCOUNT_NON_EXPIRED, true);
276 userProfile.setProperty(ARGEO_ACCOUNT_NON_LOCKED, true);
277 userProfile
278 .setProperty(ARGEO_CREDENTIALS_NON_EXPIRED, true);
279 session.save();
280 session.getWorkspace().getVersionManager()
281 .checkin(userProfile.getPath());
282 }
283 } else {
284 userProfile = SecurityJcrUtils.createUserProfile(securitySession,
285 username);
286 userProfile.getSession().save();
287 userProfile.getSession().getWorkspace().getVersionManager()
288 .checkin(userProfile.getPath());
289 }
290
291 Map<String, String> modifications = new HashMap<String, String>();
292 for (String jcrProperty : propertyToAttributes.keySet())
293 ldapToJcr(userProfile, jcrProperty, ctx, modifications);
294
295 // assign default values
296 // if (!userProfile.hasProperty(Property.JCR_DESCRIPTION)
297 // && !modifications.containsKey(Property.JCR_DESCRIPTION))
298 // modifications.put(Property.JCR_DESCRIPTION, "");
299 // if (!userProfile.hasProperty(Property.JCR_TITLE))
300 // modifications.put(Property.JCR_TITLE,
301 // userProfile.getProperty(ARGEO_FIRST_NAME).getString()
302 // + " "
303 // + userProfile.getProperty(ARGEO_LAST_NAME)
304 // .getString());
305 int modifCount = modifications.size();
306 if (modifCount > 0) {
307 session.getWorkspace().getVersionManager()
308 .checkout(userProfile.getPath());
309 for (String prop : modifications.keySet())
310 userProfile.setProperty(prop, modifications.get(prop));
311 JcrUtils.updateLastModified(userProfile);
312 session.save();
313 session.getWorkspace().getVersionManager()
314 .checkin(userProfile.getPath());
315 if (log.isDebugEnabled())
316 log.debug("Mapped " + modifCount + " LDAP modification"
317 + (modifCount == 1 ? "" : "s") + " from "
318 + ctx.getDn() + " to " + userProfile);
319 }
320 return userProfile.getPath();
321 } catch (Exception e) {
322 JcrUtils.discardQuietly(session);
323 throw new ArgeoException("Cannot synchronize JCR and LDAP", e);
324 }
325 }
326
327 /** Maps an LDAP property to a JCR property */
328 protected void ldapToJcr(Node userProfile, String jcrProperty,
329 DirContextOperations ctx, Map<String, String> modifications) {
330 // TODO do we really need DirContextOperations?
331 try {
332 String ldapAttribute;
333 if (propertyToAttributes.containsKey(jcrProperty))
334 ldapAttribute = propertyToAttributes.get(jcrProperty);
335 else
336 throw new ArgeoException(
337 "No LDAP attribute mapped for JCR proprty "
338 + jcrProperty);
339
340 String value = ctx.getStringAttribute(ldapAttribute);
341 // if (value == null && Property.JCR_TITLE.equals(jcrProperty))
342 // value = "";
343 // if (value == null &&
344 // Property.JCR_DESCRIPTION.equals(jcrProperty))
345 // value = "";
346 String jcrValue = userProfile.hasProperty(jcrProperty) ? userProfile
347 .getProperty(jcrProperty).getString() : null;
348 if (value != null && jcrValue != null) {
349 if (!value.equals(jcrValue))
350 modifications.put(jcrProperty, value);
351 } else if (value != null && jcrValue == null) {
352 modifications.put(jcrProperty, value);
353 } else if (value == null && jcrValue != null) {
354 modifications.put(jcrProperty, value);
355 }
356 } catch (Exception e) {
357 throw new ArgeoException("Cannot map JCR property " + jcrProperty
358 + " from LDAP", e);
359 }
360 }
361
362 /*
363 * JCR to LDAP
364 */
365
366 public void mapUserToContext(UserDetails user, final DirContextAdapter ctx) {
367 if (!(user instanceof JcrUserDetails))
368 throw new ArgeoException("Unsupported user details: "
369 + user.getClass());
370
371 ctx.setAttributeValues("objectClass", userClasses);
372 ctx.setAttributeValue(usernameAttribute, user.getUsername());
373 ctx.setAttributeValue(passwordAttribute,
374 encodePassword(user.getPassword()));
375
376 final JcrUserDetails jcrUserDetails = (JcrUserDetails) user;
377 try {
378 Node userProfile = securitySession.getNode(
379 jcrUserDetails.getHomePath()).getNode(ARGEO_PROFILE);
380 for (String jcrProperty : propertyToAttributes.keySet()) {
381 if (userProfile.hasProperty(jcrProperty)) {
382 ModificationItem mi = jcrToLdap(jcrProperty, userProfile
383 .getProperty(jcrProperty).getString());
384 if (mi != null)
385 ctx.setAttribute(mi.getAttribute());
386 }
387 }
388 if (log.isTraceEnabled())
389 log.trace("Mapped " + userProfile + " to " + ctx.getDn());
390 } catch (RepositoryException e) {
391 throw new ArgeoException("Cannot synchronize JCR and LDAP", e);
392 }
393
394 }
395
396 /** Maps a JCR property to an LDAP property */
397 protected ModificationItem jcrToLdap(String jcrProperty, String value) {
398 // TODO do we really need DirContextOperations?
399 try {
400 String ldapAttribute;
401 if (propertyToAttributes.containsKey(jcrProperty))
402 ldapAttribute = propertyToAttributes.get(jcrProperty);
403 else
404 return null;
405
406 // fix issue with empty 'sn' in LDAP
407 if (ldapAttribute.equals("sn") && (value.trim().equals("")))
408 return null;
409 // fix issue with empty 'description' in LDAP
410 if (ldapAttribute.equals("description") && value.trim().equals(""))
411 return null;
412 BasicAttribute attr = new BasicAttribute(
413 propertyToAttributes.get(jcrProperty), value);
414 ModificationItem mi = new ModificationItem(
415 DirContext.REPLACE_ATTRIBUTE, attr);
416 return mi;
417 } catch (Exception e) {
418 throw new ArgeoException("Cannot map JCR property " + jcrProperty
419 + " from LDAP", e);
420 }
421 }
422
423 /*
424 * UTILITIES
425 */
426 protected String encodePassword(String password) {
427 if (!password.startsWith("{")) {
428 byte[] salt = new byte[16];
429 random.nextBytes(salt);
430 return passwordEncoder.encodePassword(password, salt);
431 } else {
432 return password;
433 }
434 }
435
436 private static Random createRandom() {
437 try {
438 return SecureRandom.getInstance("SHA1PRNG");
439 } catch (NoSuchAlgorithmException e) {
440 return new Random(System.currentTimeMillis());
441 }
442 }
443
444 /*
445 * DEPENDENCY INJECTION
446 */
447
448 public void setLdapTemplate(LdapTemplate ldapTemplate) {
449 this.ldapTemplate = ldapTemplate;
450 }
451
452 public void setRawLdapTemplate(LdapTemplate rawLdapTemplate) {
453 this.rawLdapTemplate = rawLdapTemplate;
454 }
455
456 public void setRepository(Repository repository) {
457 this.repository = repository;
458 }
459
460 public void setSecurityWorkspace(String securityWorkspace) {
461 this.securityWorkspace = securityWorkspace;
462 }
463
464 public void setUserBase(String userBase) {
465 this.userBase = userBase;
466 }
467
468 public void setUsernameAttribute(String usernameAttribute) {
469 this.usernameAttribute = usernameAttribute;
470 }
471
472 public void setPropertyToAttributes(Map<String, String> propertyToAttributes) {
473 this.propertyToAttributes = propertyToAttributes;
474 }
475
476 public void setUsernameMapper(LdapUsernameToDnMapper usernameMapper) {
477 this.usernameMapper = usernameMapper;
478 }
479
480 public void setPasswordAttribute(String passwordAttribute) {
481 this.passwordAttribute = passwordAttribute;
482 }
483
484 public void setUserClasses(String[] userClasses) {
485 this.userClasses = userClasses;
486 }
487
488 public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
489 this.passwordEncoder = passwordEncoder;
490 }
491
492 /** Listen to LDAP */
493 class LdapUserListener implements ObjectChangeListener,
494 NamespaceChangeListener, UnsolicitedNotificationListener {
495
496 public void namingExceptionThrown(NamingExceptionEvent evt) {
497 evt.getException().printStackTrace();
498 }
499
500 public void objectChanged(NamingEvent evt) {
501 Binding user = evt.getNewBinding();
502 // TODO find a way not to be called when JCR is the source of the
503 // modification
504 DirContextAdapter ctx = (DirContextAdapter) ldapTemplate
505 .lookup(user.getName());
506 mapLdapToJcr(ctx);
507 }
508
509 public void objectAdded(NamingEvent evt) {
510 Binding user = evt.getNewBinding();
511 DirContextAdapter ctx = (DirContextAdapter) ldapTemplate
512 .lookup(user.getName());
513 mapLdapToJcr(ctx);
514 }
515
516 public void objectRemoved(NamingEvent evt) {
517 if (log.isDebugEnabled())
518 log.debug(evt);
519 }
520
521 public void objectRenamed(NamingEvent evt) {
522 if (log.isDebugEnabled())
523 log.debug(evt);
524 }
525
526 public void notificationReceived(UnsolicitedNotificationEvent evt) {
527 UnsolicitedNotification notification = evt.getNotification();
528 NamingException ne = notification.getException();
529 String msg = "LDAP notification " + "ID=" + notification.getID()
530 + ", referrals=" + notification.getReferrals();
531 if (ne != null) {
532 if (log.isTraceEnabled())
533 log.trace(msg + ", exception= " + ne, ne);
534 else
535 log.warn(msg + ", exception= " + ne);
536 } else if (log.isDebugEnabled()) {
537 log.debug("Unsollicited LDAP notification " + msg);
538 }
539 }
540
541 }
542
543 /** Listen to JCR */
544 class JcrProfileListener implements EventListener {
545
546 public void onEvent(EventIterator events) {
547 try {
548 final Map<Name, List<ModificationItem>> modifications = new HashMap<Name, List<ModificationItem>>();
549 while (events.hasNext()) {
550 Event event = events.nextEvent();
551 try {
552 if (Event.PROPERTY_CHANGED == event.getType()) {
553 Property property = (Property) securitySession
554 .getItem(event.getPath());
555 String propertyName = property.getName();
556 Node userProfile = property.getParent();
557 String username = userProfile.getProperty(
558 ARGEO_USER_ID).getString();
559 if (propertyToAttributes.containsKey(propertyName)) {
560 Name name = usernameMapper.buildDn(username);
561 if (!modifications.containsKey(name))
562 modifications.put(name,
563 new ArrayList<ModificationItem>());
564 String value = property.getString();
565 ModificationItem mi = jcrToLdap(propertyName,
566 value);
567 if (mi != null)
568 modifications.get(name).add(mi);
569 }
570 } else if (Event.NODE_ADDED == event.getType()) {
571 Node userProfile = securitySession.getNode(event
572 .getPath());
573 String username = userProfile.getProperty(
574 ARGEO_USER_ID).getString();
575 Name name = usernameMapper.buildDn(username);
576 for (String propertyName : propertyToAttributes
577 .keySet()) {
578 if (!modifications.containsKey(name))
579 modifications.put(name,
580 new ArrayList<ModificationItem>());
581 String value = userProfile.getProperty(
582 propertyName).getString();
583 ModificationItem mi = jcrToLdap(propertyName,
584 value);
585 if (mi != null)
586 modifications.get(name).add(mi);
587 }
588 }
589 } catch (RepositoryException e) {
590 throw new ArgeoException("Cannot process event "
591 + event, e);
592 }
593 }
594
595 for (Name name : modifications.keySet()) {
596 List<ModificationItem> userModifs = modifications.get(name);
597 int modifCount = userModifs.size();
598 ldapTemplate.modifyAttributes(name, userModifs
599 .toArray(new ModificationItem[modifCount]));
600 if (log.isDebugEnabled())
601 log.debug("Mapped " + modifCount + " JCR modification"
602 + (modifCount == 1 ? "" : "s") + " to " + name);
603 }
604 } catch (Exception e) {
605 // if (log.isDebugEnabled())
606 // e.printStackTrace();
607 throw new ArgeoException("Cannot process JCR events ("
608 + e.getMessage() + ")", e);
609 }
610 }
611
612 }
613 }