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
.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
;
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
;
59 /** Guarantees that LDAP and JCR are in line. */
60 public class JcrLdapSynchronizer
implements UserDetailsContextMapper
,
62 private final static Log log
= LogFactory
.getLog(JcrLdapSynchronizer
.class);
65 private LdapTemplate ldapTemplate
;
67 * LDAP template whose context source has an object factory set to null. see
69 * "http://forum.springsource.org/showthread.php?55955-Persistent-search-with-spring-ldap"
72 private LdapTemplate rawLdapTemplate
;
74 private String userBase
;
75 private String usernameAttribute
;
76 private String passwordAttribute
;
77 private String
[] userClasses
;
79 private NamingListener ldapUserListener
;
80 private SearchControls subTreeSearchControls
;
81 private LdapUsernameToDnMapper usernameMapper
;
83 private PasswordEncoder passwordEncoder
;
84 private final Random random
;
87 /** Admin session on the security workspace */
88 private Session securitySession
;
89 private Repository repository
;
91 private String securityWorkspace
= "security";
93 private JcrProfileListener jcrProfileListener
;
96 private Map
<String
, String
> propertyToAttributes
= new HashMap
<String
, String
>();
98 public JcrLdapSynchronizer() {
99 random
= createRandom();
104 securitySession
= repository
.login(securityWorkspace
);
109 subTreeSearchControls
= new SearchControls();
110 subTreeSearchControls
.setSearchScope(SearchControls
.SUBTREE_SCOPE
);
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
);
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
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",
141 public void destroy() {
142 JcrUtils
.removeListenerQuietly(securitySession
, jcrProfileListener
);
143 JcrUtils
.logoutQuietly(securitySession
);
145 rawLdapTemplate
.executeReadOnly(new ContextExecutor() {
146 public Object
executeWithContext(DirContext ctx
)
147 throws NamingException
{
148 EventDirContext ectx
= (EventDirContext
) ctx
.lookup("");
149 ectx
.removeNamingListener(ldapUserListener
);
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
);
163 /** Full synchronization between LDAP and JCR. LDAP has priority. */
164 protected void synchronize() {
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
);
176 // disable accounts which are not in LDAP
177 Query query
= securitySession
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);
191 } catch (Exception e
) {
192 throw new ArgeoException("Cannot synchronized LDAP and JCR", e
);
196 /** Called during authentication in order to retrieve user details */
197 public UserDetails
mapUserFromContext(final DirContextOperations ctx
,
198 final String username
, GrantedAuthority
[] authorities
) {
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
);
206 SortedSet
<?
> passwordAttributes
= ctx
207 .getAttributeSortedStringSet(passwordAttribute
);
209 if (passwordAttributes
== null || passwordAttributes
.size() == 0) {
210 throw new ArgeoException("No password found for user " + username
);
212 byte[] arr
= (byte[]) passwordAttributes
.first();
213 password
= new String(arr
);
215 Arrays
.fill(arr
, (byte) 0);
219 return new JcrUserDetails(userHome
.getNode(ARGEO_PROFILE
),
220 password
, authorities
);
221 } catch (RepositoryException e
) {
222 throw new ArgeoException("Cannot retrieve user details for "
228 * Writes an LDAP context to the JCR user profile.
230 * @return path to user profile
232 protected synchronized String
mapLdapToJcr(DirContextAdapter ctx
) {
233 Session session
= securitySession
;
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
);
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);
250 .setProperty(ARGEO_CREDENTIALS_NON_EXPIRED
, true);
252 session
.getWorkspace().getVersionManager()
253 .checkin(userProfile
.getPath());
256 userProfile
= JcrUtils
.createUserProfile(securitySession
,
258 userProfile
.getSession().save();
259 userProfile
.getSession().getWorkspace().getVersionManager()
260 .checkin(userProfile
.getPath());
263 Map
<String
, String
> modifications
= new HashMap
<String
, String
>();
264 for (String jcrProperty
: propertyToAttributes
.keySet())
265 ldapToJcr(userProfile
, jcrProperty
, ctx
, modifications
);
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()
275 // + userProfile.getProperty(ARGEO_LAST_NAME)
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
);
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
);
292 return userProfile
.getPath();
293 } catch (Exception e
) {
294 JcrUtils
.discardQuietly(session
);
295 throw new ArgeoException("Cannot synchronize JCR and LDAP", e
);
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?
304 String ldapAttribute
;
305 if (propertyToAttributes
.containsKey(jcrProperty
))
306 ldapAttribute
= propertyToAttributes
.get(jcrProperty
);
308 throw new ArgeoException(
309 "No LDAP attribute mapped for JCR proprty "
312 String value
= ctx
.getStringAttribute(ldapAttribute
);
313 // if (value == null && Property.JCR_TITLE.equals(jcrProperty))
315 // if (value == null &&
316 // Property.JCR_DESCRIPTION.equals(jcrProperty))
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
);
328 } catch (Exception e
) {
329 throw new ArgeoException("Cannot map JCR property " + jcrProperty
338 public void mapUserToContext(UserDetails user
, final DirContextAdapter ctx
) {
339 if (!(user
instanceof JcrUserDetails
))
340 throw new ArgeoException("Unsupported user details: "
343 ctx
.setAttributeValues("objectClass", userClasses
);
344 ctx
.setAttributeValue(usernameAttribute
, user
.getUsername());
345 ctx
.setAttributeValue(passwordAttribute
,
346 encodePassword(user
.getPassword()));
348 final JcrUserDetails jcrUserDetails
= (JcrUserDetails
) user
;
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());
357 ctx
.setAttribute(mi
.getAttribute());
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
);
368 /** Maps a JCR property to an LDAP property */
369 protected ModificationItem
jcrToLdap(String jcrProperty
, String value
) {
370 // TODO do we really need DirContextOperations?
372 String ldapAttribute
;
373 if (propertyToAttributes
.containsKey(jcrProperty
))
374 ldapAttribute
= propertyToAttributes
.get(jcrProperty
);
378 // fix issue with empty 'sn' in LDAP
379 if (ldapAttribute
.equals("sn") && (value
.trim().equals("")))
381 // fix issue with empty 'description' in LDAP
382 if (ldapAttribute
.equals("description") && value
.trim().equals(""))
384 BasicAttribute attr
= new BasicAttribute(
385 propertyToAttributes
.get(jcrProperty
), value
);
386 ModificationItem mi
= new ModificationItem(
387 DirContext
.REPLACE_ATTRIBUTE
, attr
);
389 } catch (Exception e
) {
390 throw new ArgeoException("Cannot map JCR property " + jcrProperty
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
);
408 private static Random
createRandom() {
410 return SecureRandom
.getInstance("SHA1PRNG");
411 } catch (NoSuchAlgorithmException e
) {
412 return new Random(System
.currentTimeMillis());
417 * DEPENDENCY INJECTION
420 public void setLdapTemplate(LdapTemplate ldapTemplate
) {
421 this.ldapTemplate
= ldapTemplate
;
424 public void setRawLdapTemplate(LdapTemplate rawLdapTemplate
) {
425 this.rawLdapTemplate
= rawLdapTemplate
;
428 public void setRepository(Repository repository
) {
429 this.repository
= repository
;
432 public void setSecurityWorkspace(String securityWorkspace
) {
433 this.securityWorkspace
= securityWorkspace
;
436 public void setUserBase(String userBase
) {
437 this.userBase
= userBase
;
440 public void setUsernameAttribute(String usernameAttribute
) {
441 this.usernameAttribute
= usernameAttribute
;
444 public void setPropertyToAttributes(Map
<String
, String
> propertyToAttributes
) {
445 this.propertyToAttributes
= propertyToAttributes
;
448 public void setUsernameMapper(LdapUsernameToDnMapper usernameMapper
) {
449 this.usernameMapper
= usernameMapper
;
452 public void setPasswordAttribute(String passwordAttribute
) {
453 this.passwordAttribute
= passwordAttribute
;
456 public void setUserClasses(String
[] userClasses
) {
457 this.userClasses
= userClasses
;
460 public void setPasswordEncoder(PasswordEncoder passwordEncoder
) {
461 this.passwordEncoder
= passwordEncoder
;
464 /** Listen to LDAP */
465 class LdapUserListener
implements ObjectChangeListener
,
466 NamespaceChangeListener
, UnsolicitedNotificationListener
{
468 public void namingExceptionThrown(NamingExceptionEvent evt
) {
469 evt
.getException().printStackTrace();
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
476 DirContextAdapter ctx
= (DirContextAdapter
) ldapTemplate
477 .lookup(user
.getName());
481 public void objectAdded(NamingEvent evt
) {
482 Binding user
= evt
.getNewBinding();
483 DirContextAdapter ctx
= (DirContextAdapter
) ldapTemplate
484 .lookup(user
.getName());
488 public void objectRemoved(NamingEvent evt
) {
489 if (log
.isDebugEnabled())
493 public void objectRenamed(NamingEvent evt
) {
494 if (log
.isDebugEnabled())
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();
504 if (log
.isTraceEnabled())
505 log
.trace(msg
+ ", exception= " + ne
, ne
);
507 log
.warn(msg
+ ", exception= " + ne
);
508 } else if (log
.isDebugEnabled()) {
509 log
.debug("Unsollicited LDAP notification " + msg
);
516 class JcrProfileListener
implements EventListener
{
518 public void onEvent(EventIterator events
) {
520 final Map
<Name
, List
<ModificationItem
>> modifications
= new HashMap
<Name
, List
<ModificationItem
>>();
521 while (events
.hasNext()) {
522 Event event
= events
.nextEvent();
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
,
540 modifications
.get(name
).add(mi
);
542 } else if (Event
.NODE_ADDED
== event
.getType()) {
543 Node userProfile
= securitySession
.getNode(event
545 String username
= userProfile
.getProperty(
546 ARGEO_USER_ID
).getString();
547 Name name
= usernameMapper
.buildDn(username
);
548 for (String propertyName
: propertyToAttributes
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
,
558 modifications
.get(name
).add(mi
);
561 } catch (RepositoryException e
) {
562 throw new ArgeoException("Cannot process event "
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
);
576 } catch (Exception e
) {
577 // if (log.isDebugEnabled())
578 // e.printStackTrace();
579 throw new ArgeoException("Cannot process JCR events ("
580 + e
.getMessage() + ")", e
);