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