1 package org
.argeo
.security
.ldap
.jcr
;
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
;
10 import java
.util
.Random
;
11 import java
.util
.SortedSet
;
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
.jcr
.version
.VersionManager
;
24 import javax
.naming
.Binding
;
25 import javax
.naming
.Name
;
26 import javax
.naming
.NamingException
;
27 import javax
.naming
.directory
.BasicAttribute
;
28 import javax
.naming
.directory
.DirContext
;
29 import javax
.naming
.directory
.ModificationItem
;
30 import javax
.naming
.directory
.SearchControls
;
31 import javax
.naming
.event
.EventDirContext
;
32 import javax
.naming
.event
.NamespaceChangeListener
;
33 import javax
.naming
.event
.NamingEvent
;
34 import javax
.naming
.event
.NamingExceptionEvent
;
35 import javax
.naming
.event
.NamingListener
;
36 import javax
.naming
.event
.ObjectChangeListener
;
37 import javax
.naming
.ldap
.UnsolicitedNotification
;
38 import javax
.naming
.ldap
.UnsolicitedNotificationEvent
;
39 import javax
.naming
.ldap
.UnsolicitedNotificationListener
;
41 import org
.apache
.commons
.logging
.Log
;
42 import org
.apache
.commons
.logging
.LogFactory
;
43 import org
.argeo
.ArgeoException
;
44 import org
.argeo
.jcr
.ArgeoNames
;
45 import org
.argeo
.jcr
.ArgeoTypes
;
46 import org
.argeo
.jcr
.JcrUtils
;
47 import org
.argeo
.security
.jcr
.JcrUserDetails
;
48 import org
.springframework
.ldap
.core
.ContextExecutor
;
49 import org
.springframework
.ldap
.core
.ContextMapper
;
50 import org
.springframework
.ldap
.core
.DirContextAdapter
;
51 import org
.springframework
.ldap
.core
.DirContextOperations
;
52 import org
.springframework
.ldap
.core
.DistinguishedName
;
53 import org
.springframework
.ldap
.core
.LdapTemplate
;
54 import org
.springframework
.security
.GrantedAuthority
;
55 import org
.springframework
.security
.ldap
.LdapUsernameToDnMapper
;
56 import org
.springframework
.security
.providers
.encoding
.PasswordEncoder
;
57 import org
.springframework
.security
.userdetails
.UserDetails
;
58 import org
.springframework
.security
.userdetails
.ldap
.UserDetailsContextMapper
;
60 /** Guarantees that LDAP and JCR are in line. */
61 public class JcrLdapSynchronizer
implements UserDetailsContextMapper
,
63 private final static Log log
= LogFactory
.getLog(JcrLdapSynchronizer
.class);
66 private LdapTemplate ldapTemplate
;
68 * LDAP template whose context source has an object factory set to null. see
70 * "http://forum.springsource.org/showthread.php?55955-Persistent-search-with-spring-ldap"
73 private LdapTemplate rawLdapTemplate
;
75 private String userBase
;
76 private String usernameAttribute
;
77 private String passwordAttribute
;
78 private String
[] userClasses
;
80 private NamingListener ldapUserListener
;
81 private SearchControls subTreeSearchControls
;
82 private LdapUsernameToDnMapper usernameMapper
;
84 private PasswordEncoder passwordEncoder
;
85 private final Random random
;
88 /** Admin session on the security workspace */
89 private Session securitySession
;
90 private Repository repository
;
92 private String securityWorkspace
= "security";
94 private JcrProfileListener jcrProfileListener
;
97 private Map
<String
, String
> propertyToAttributes
= new HashMap
<String
, String
>();
99 public JcrLdapSynchronizer() {
100 random
= createRandom();
105 securitySession
= repository
.login(securityWorkspace
);
110 subTreeSearchControls
= new SearchControls();
111 subTreeSearchControls
.setSearchScope(SearchControls
.SUBTREE_SCOPE
);
113 ldapUserListener
= new LdapUserListener();
114 rawLdapTemplate
.executeReadOnly(new ContextExecutor() {
115 public Object
executeWithContext(DirContext ctx
)
116 throws NamingException
{
117 EventDirContext ectx
= (EventDirContext
) ctx
.lookup("");
118 ectx
.addNamingListener(userBase
, "(" + usernameAttribute
119 + "=*)", subTreeSearchControls
, ldapUserListener
);
125 String
[] nodeTypes
= { ArgeoTypes
.ARGEO_USER_PROFILE
};
126 jcrProfileListener
= new JcrProfileListener();
127 // noLocal is used so that we are not notified when we modify JCR
131 .getObservationManager()
132 .addEventListener(jcrProfileListener
,
133 Event
.PROPERTY_CHANGED
| Event
.NODE_ADDED
, "/",
134 true, null, nodeTypes
, true);
135 } catch (Exception e
) {
136 JcrUtils
.logoutQuietly(securitySession
);
137 throw new ArgeoException("Cannot initialize LDAP/JCR synchronizer",
142 public void destroy() {
143 JcrUtils
.removeListenerQuietly(securitySession
, jcrProfileListener
);
144 JcrUtils
.logoutQuietly(securitySession
);
146 rawLdapTemplate
.executeReadOnly(new ContextExecutor() {
147 public Object
executeWithContext(DirContext ctx
)
148 throws NamingException
{
149 EventDirContext ectx
= (EventDirContext
) ctx
.lookup("");
150 ectx
.removeNamingListener(ldapUserListener
);
154 } catch (Exception e
) {
155 // silent (LDAP server may have been shutdown already)
156 if (log
.isTraceEnabled())
157 log
.trace("Cannot remove LDAP listener", e
);
164 /** Full synchronization between LDAP and JCR. LDAP has priority. */
165 protected void synchronize() {
167 Name userBaseName
= new DistinguishedName(userBase
);
168 // TODO subtree search?
169 @SuppressWarnings("unchecked")
170 List
<String
> userPaths
= (List
<String
>) ldapTemplate
.listBindings(
171 userBaseName
, new ContextMapper() {
172 public Object
mapFromContext(Object ctxObj
) {
173 return mapLdapToJcr((DirContextAdapter
) ctxObj
);
177 // disable accounts which are not in LDAP
178 Query query
= securitySession
182 "select * from [" + ArgeoTypes
.ARGEO_USER_PROFILE
183 + "]", Query
.JCR_SQL2
);
184 NodeIterator it
= query
.execute().getNodes();
185 while (it
.hasNext()) {
186 Node userProfile
= it
.nextNode();
187 String path
= userProfile
.getPath();
188 if (!userPaths
.contains(path
)) {
191 + " not found in LDAP, disabling user "
192 + userProfile
.getProperty(ArgeoNames
.ARGEO_USER_ID
)
194 VersionManager versionManager
= securitySession
195 .getWorkspace().getVersionManager();
196 versionManager
.checkout(userProfile
.getPath());
197 userProfile
.setProperty(ArgeoNames
.ARGEO_ENABLED
, false);
198 securitySession
.save();
199 versionManager
.checkin(userProfile
.getPath());
202 } catch (Exception e
) {
203 JcrUtils
.discardQuietly(securitySession
);
204 throw new ArgeoException("Cannot synchronized LDAP and JCR", e
);
208 /** Called during authentication in order to retrieve user details */
209 public UserDetails
mapUserFromContext(final DirContextOperations ctx
,
210 final String username
, GrantedAuthority
[] authorities
) {
212 throw new ArgeoException("No LDAP information for user " + username
);
213 Node userProfile
= JcrUtils
.createUserProfileIfNeeded(securitySession
,
215 JcrUserDetails
.checkAccountStatus(userProfile
);
218 SortedSet
<?
> passwordAttributes
= ctx
219 .getAttributeSortedStringSet(passwordAttribute
);
221 if (passwordAttributes
== null || passwordAttributes
.size() == 0) {
222 throw new ArgeoException("No password found for user " + username
);
224 byte[] arr
= (byte[]) passwordAttributes
.first();
225 password
= new String(arr
);
227 Arrays
.fill(arr
, (byte) 0);
231 return new JcrUserDetails(userProfile
, password
, authorities
);
232 } catch (RepositoryException e
) {
233 throw new ArgeoException("Cannot retrieve user details for "
239 * Writes an LDAP context to the JCR user profile.
241 * @return path to user profile
243 protected synchronized String
mapLdapToJcr(DirContextAdapter ctx
) {
244 Session session
= securitySession
;
247 String username
= ctx
.getStringAttribute(usernameAttribute
);
248 Node userHome
= JcrUtils
.createUserHomeIfNeeded(session
, username
);
249 Node userProfile
; // = userHome.getNode(ARGEO_PROFILE);
250 if (userHome
.hasNode(ARGEO_PROFILE
)) {
251 userProfile
= userHome
.getNode(ARGEO_PROFILE
);
253 // compatibility with legacy, will be removed
254 if (!userProfile
.hasProperty(ARGEO_ENABLED
)) {
255 session
.getWorkspace().getVersionManager()
256 .checkout(userProfile
.getPath());
257 userProfile
.setProperty(ARGEO_ENABLED
, true);
258 userProfile
.setProperty(ARGEO_ACCOUNT_NON_EXPIRED
, true);
259 userProfile
.setProperty(ARGEO_ACCOUNT_NON_LOCKED
, true);
261 .setProperty(ARGEO_CREDENTIALS_NON_EXPIRED
, true);
263 session
.getWorkspace().getVersionManager()
264 .checkin(userProfile
.getPath());
267 userProfile
= JcrUtils
.createUserProfile(securitySession
,
269 userProfile
.getSession().save();
270 userProfile
.getSession().getWorkspace().getVersionManager()
271 .checkin(userProfile
.getPath());
274 Map
<String
, String
> modifications
= new HashMap
<String
, String
>();
275 for (String jcrProperty
: propertyToAttributes
.keySet())
276 ldapToJcr(userProfile
, jcrProperty
, ctx
, modifications
);
278 // assign default values
279 // if (!userProfile.hasProperty(Property.JCR_DESCRIPTION)
280 // && !modifications.containsKey(Property.JCR_DESCRIPTION))
281 // modifications.put(Property.JCR_DESCRIPTION, "");
282 // if (!userProfile.hasProperty(Property.JCR_TITLE))
283 // modifications.put(Property.JCR_TITLE,
284 // userProfile.getProperty(ARGEO_FIRST_NAME).getString()
286 // + userProfile.getProperty(ARGEO_LAST_NAME)
288 int modifCount
= modifications
.size();
289 if (modifCount
> 0) {
290 session
.getWorkspace().getVersionManager()
291 .checkout(userProfile
.getPath());
292 for (String prop
: modifications
.keySet())
293 userProfile
.setProperty(prop
, modifications
.get(prop
));
294 JcrUtils
.updateLastModified(userProfile
);
296 session
.getWorkspace().getVersionManager()
297 .checkin(userProfile
.getPath());
298 if (log
.isDebugEnabled())
299 log
.debug("Mapped " + modifCount
+ " LDAP modification"
300 + (modifCount
== 1 ?
"" : "s") + " from "
301 + ctx
.getDn() + " to " + userProfile
);
303 return userProfile
.getPath();
304 } catch (Exception e
) {
305 JcrUtils
.discardQuietly(session
);
306 throw new ArgeoException("Cannot synchronize JCR and LDAP", e
);
310 /** Maps an LDAP property to a JCR property */
311 protected void ldapToJcr(Node userProfile
, String jcrProperty
,
312 DirContextOperations ctx
, Map
<String
, String
> modifications
) {
313 // TODO do we really need DirContextOperations?
315 String ldapAttribute
;
316 if (propertyToAttributes
.containsKey(jcrProperty
))
317 ldapAttribute
= propertyToAttributes
.get(jcrProperty
);
319 throw new ArgeoException(
320 "No LDAP attribute mapped for JCR proprty "
323 String value
= ctx
.getStringAttribute(ldapAttribute
);
324 // if (value == null && Property.JCR_TITLE.equals(jcrProperty))
326 // if (value == null &&
327 // Property.JCR_DESCRIPTION.equals(jcrProperty))
329 String jcrValue
= userProfile
.hasProperty(jcrProperty
) ? userProfile
330 .getProperty(jcrProperty
).getString() : null;
331 if (value
!= null && jcrValue
!= null) {
332 if (!value
.equals(jcrValue
))
333 modifications
.put(jcrProperty
, value
);
334 } else if (value
!= null && jcrValue
== null) {
335 modifications
.put(jcrProperty
, value
);
336 } else if (value
== null && jcrValue
!= null) {
337 modifications
.put(jcrProperty
, value
);
339 } catch (Exception e
) {
340 throw new ArgeoException("Cannot map JCR property " + jcrProperty
349 public void mapUserToContext(UserDetails user
, final DirContextAdapter ctx
) {
350 if (!(user
instanceof JcrUserDetails
))
351 throw new ArgeoException("Unsupported user details: "
354 ctx
.setAttributeValues("objectClass", userClasses
);
355 ctx
.setAttributeValue(usernameAttribute
, user
.getUsername());
356 ctx
.setAttributeValue(passwordAttribute
,
357 encodePassword(user
.getPassword()));
359 final JcrUserDetails jcrUserDetails
= (JcrUserDetails
) user
;
361 Node userProfile
= securitySession
.getNode(
362 jcrUserDetails
.getHomePath()).getNode(ARGEO_PROFILE
);
363 for (String jcrProperty
: propertyToAttributes
.keySet()) {
364 if (userProfile
.hasProperty(jcrProperty
)) {
365 ModificationItem mi
= jcrToLdap(jcrProperty
, userProfile
366 .getProperty(jcrProperty
).getString());
368 ctx
.setAttribute(mi
.getAttribute());
371 if (log
.isTraceEnabled())
372 log
.trace("Mapped " + userProfile
+ " to " + ctx
.getDn());
373 } catch (RepositoryException e
) {
374 throw new ArgeoException("Cannot synchronize JCR and LDAP", e
);
379 /** Maps a JCR property to an LDAP property */
380 protected ModificationItem
jcrToLdap(String jcrProperty
, String value
) {
381 // TODO do we really need DirContextOperations?
383 String ldapAttribute
;
384 if (propertyToAttributes
.containsKey(jcrProperty
))
385 ldapAttribute
= propertyToAttributes
.get(jcrProperty
);
389 // fix issue with empty 'sn' in LDAP
390 if (ldapAttribute
.equals("sn") && (value
.trim().equals("")))
392 // fix issue with empty 'description' in LDAP
393 if (ldapAttribute
.equals("description") && value
.trim().equals(""))
395 BasicAttribute attr
= new BasicAttribute(
396 propertyToAttributes
.get(jcrProperty
), value
);
397 ModificationItem mi
= new ModificationItem(
398 DirContext
.REPLACE_ATTRIBUTE
, attr
);
400 } catch (Exception e
) {
401 throw new ArgeoException("Cannot map JCR property " + jcrProperty
409 protected String
encodePassword(String password
) {
410 if (!password
.startsWith("{")) {
411 byte[] salt
= new byte[16];
412 random
.nextBytes(salt
);
413 return passwordEncoder
.encodePassword(password
, salt
);
419 private static Random
createRandom() {
421 return SecureRandom
.getInstance("SHA1PRNG");
422 } catch (NoSuchAlgorithmException e
) {
423 return new Random(System
.currentTimeMillis());
428 * DEPENDENCY INJECTION
431 public void setLdapTemplate(LdapTemplate ldapTemplate
) {
432 this.ldapTemplate
= ldapTemplate
;
435 public void setRawLdapTemplate(LdapTemplate rawLdapTemplate
) {
436 this.rawLdapTemplate
= rawLdapTemplate
;
439 public void setRepository(Repository repository
) {
440 this.repository
= repository
;
443 public void setSecurityWorkspace(String securityWorkspace
) {
444 this.securityWorkspace
= securityWorkspace
;
447 public void setUserBase(String userBase
) {
448 this.userBase
= userBase
;
451 public void setUsernameAttribute(String usernameAttribute
) {
452 this.usernameAttribute
= usernameAttribute
;
455 public void setPropertyToAttributes(Map
<String
, String
> propertyToAttributes
) {
456 this.propertyToAttributes
= propertyToAttributes
;
459 public void setUsernameMapper(LdapUsernameToDnMapper usernameMapper
) {
460 this.usernameMapper
= usernameMapper
;
463 public void setPasswordAttribute(String passwordAttribute
) {
464 this.passwordAttribute
= passwordAttribute
;
467 public void setUserClasses(String
[] userClasses
) {
468 this.userClasses
= userClasses
;
471 public void setPasswordEncoder(PasswordEncoder passwordEncoder
) {
472 this.passwordEncoder
= passwordEncoder
;
475 /** Listen to LDAP */
476 class LdapUserListener
implements ObjectChangeListener
,
477 NamespaceChangeListener
, UnsolicitedNotificationListener
{
479 public void namingExceptionThrown(NamingExceptionEvent evt
) {
480 evt
.getException().printStackTrace();
483 public void objectChanged(NamingEvent evt
) {
484 Binding user
= evt
.getNewBinding();
485 // TODO find a way not to be called when JCR is the source of the
487 DirContextAdapter ctx
= (DirContextAdapter
) ldapTemplate
488 .lookup(user
.getName());
492 public void objectAdded(NamingEvent evt
) {
493 Binding user
= evt
.getNewBinding();
494 DirContextAdapter ctx
= (DirContextAdapter
) ldapTemplate
495 .lookup(user
.getName());
499 public void objectRemoved(NamingEvent evt
) {
500 if (log
.isDebugEnabled())
504 public void objectRenamed(NamingEvent evt
) {
505 if (log
.isDebugEnabled())
509 public void notificationReceived(UnsolicitedNotificationEvent evt
) {
510 UnsolicitedNotification notification
= evt
.getNotification();
511 NamingException ne
= notification
.getException();
512 String msg
= "LDAP notification " + "ID=" + notification
.getID()
513 + ", referrals=" + notification
.getReferrals();
515 if (log
.isTraceEnabled())
516 log
.trace(msg
+ ", exception= " + ne
, ne
);
518 log
.warn(msg
+ ", exception= " + ne
);
519 } else if (log
.isDebugEnabled()) {
520 log
.debug("Unsollicited LDAP notification " + msg
);
527 class JcrProfileListener
implements EventListener
{
529 public void onEvent(EventIterator events
) {
531 final Map
<Name
, List
<ModificationItem
>> modifications
= new HashMap
<Name
, List
<ModificationItem
>>();
532 while (events
.hasNext()) {
533 Event event
= events
.nextEvent();
535 if (Event
.PROPERTY_CHANGED
== event
.getType()) {
536 Property property
= (Property
) securitySession
537 .getItem(event
.getPath());
538 String propertyName
= property
.getName();
539 Node userProfile
= property
.getParent();
540 String username
= userProfile
.getProperty(
541 ARGEO_USER_ID
).getString();
542 if (propertyToAttributes
.containsKey(propertyName
)) {
543 Name name
= usernameMapper
.buildDn(username
);
544 if (!modifications
.containsKey(name
))
545 modifications
.put(name
,
546 new ArrayList
<ModificationItem
>());
547 String value
= property
.getString();
548 ModificationItem mi
= jcrToLdap(propertyName
,
551 modifications
.get(name
).add(mi
);
553 } else if (Event
.NODE_ADDED
== event
.getType()) {
554 Node userProfile
= securitySession
.getNode(event
556 String username
= userProfile
.getProperty(
557 ARGEO_USER_ID
).getString();
558 Name name
= usernameMapper
.buildDn(username
);
559 for (String propertyName
: propertyToAttributes
561 if (!modifications
.containsKey(name
))
562 modifications
.put(name
,
563 new ArrayList
<ModificationItem
>());
564 String value
= userProfile
.getProperty(
565 propertyName
).getString();
566 ModificationItem mi
= jcrToLdap(propertyName
,
569 modifications
.get(name
).add(mi
);
572 } catch (RepositoryException e
) {
573 throw new ArgeoException("Cannot process event "
578 for (Name name
: modifications
.keySet()) {
579 List
<ModificationItem
> userModifs
= modifications
.get(name
);
580 int modifCount
= userModifs
.size();
581 ldapTemplate
.modifyAttributes(name
, userModifs
582 .toArray(new ModificationItem
[modifCount
]));
583 if (log
.isDebugEnabled())
584 log
.debug("Mapped " + modifCount
+ " JCR modification"
585 + (modifCount
== 1 ?
"" : "s") + " to " + name
);
587 } catch (Exception e
) {
588 // if (log.isDebugEnabled())
589 // e.printStackTrace();
590 throw new ArgeoException("Cannot process JCR events ("
591 + e
.getMessage() + ")", e
);