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 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 userProfile
= JcrUtils
.createUserProfile(securitySession
,
244 userProfile
.getSession().save();
245 userProfile
.getSession().getWorkspace().getVersionManager()
246 .checkin(userProfile
.getPath());
249 Map
<String
, String
> modifications
= new HashMap
<String
, String
>();
250 for (String jcrProperty
: propertyToAttributes
.keySet())
251 ldapToJcr(userProfile
, jcrProperty
, ctx
, modifications
);
253 // assign default values
254 // if (!userProfile.hasProperty(Property.JCR_DESCRIPTION)
255 // && !modifications.containsKey(Property.JCR_DESCRIPTION))
256 // modifications.put(Property.JCR_DESCRIPTION, "");
257 // if (!userProfile.hasProperty(Property.JCR_TITLE))
258 // modifications.put(Property.JCR_TITLE,
259 // userProfile.getProperty(ARGEO_FIRST_NAME).getString()
261 // + userProfile.getProperty(ARGEO_LAST_NAME)
263 int modifCount
= modifications
.size();
264 if (modifCount
> 0) {
265 session
.getWorkspace().getVersionManager()
266 .checkout(userProfile
.getPath());
267 for (String prop
: modifications
.keySet())
268 userProfile
.setProperty(prop
, modifications
.get(prop
));
269 JcrUtils
.updateLastModified(userProfile
);
271 session
.getWorkspace().getVersionManager()
272 .checkin(userProfile
.getPath());
273 if (log
.isDebugEnabled())
274 log
.debug("Mapped " + modifCount
+ " LDAP modification"
275 + (modifCount
== 1 ?
"" : "s") + " from "
276 + ctx
.getDn() + " to " + userProfile
);
278 return userProfile
.getPath();
279 } catch (Exception e
) {
280 JcrUtils
.discardQuietly(session
);
281 throw new ArgeoException("Cannot synchronize JCR and LDAP", e
);
285 /** Maps an LDAP property to a JCR property */
286 protected void ldapToJcr(Node userProfile
, String jcrProperty
,
287 DirContextOperations ctx
, Map
<String
, String
> modifications
) {
288 // TODO do we really need DirContextOperations?
290 String ldapAttribute
;
291 if (propertyToAttributes
.containsKey(jcrProperty
))
292 ldapAttribute
= propertyToAttributes
.get(jcrProperty
);
294 throw new ArgeoException(
295 "No LDAP attribute mapped for JCR proprty "
298 String value
= ctx
.getStringAttribute(ldapAttribute
);
299 // if (value == null && Property.JCR_TITLE.equals(jcrProperty))
301 // if (value == null &&
302 // Property.JCR_DESCRIPTION.equals(jcrProperty))
304 String jcrValue
= userProfile
.hasProperty(jcrProperty
) ? userProfile
305 .getProperty(jcrProperty
).getString() : null;
306 if (value
!= null && jcrValue
!= null) {
307 if (!value
.equals(jcrValue
))
308 modifications
.put(jcrProperty
, value
);
309 } else if (value
!= null && jcrValue
== null) {
310 modifications
.put(jcrProperty
, value
);
311 } else if (value
== null && jcrValue
!= null) {
312 modifications
.put(jcrProperty
, value
);
314 } catch (Exception e
) {
315 throw new ArgeoException("Cannot map JCR property " + jcrProperty
324 public void mapUserToContext(UserDetails user
, final DirContextAdapter ctx
) {
325 if (!(user
instanceof JcrUserDetails
))
326 throw new ArgeoException("Unsupported user details: "
329 ctx
.setAttributeValues("objectClass", userClasses
);
330 ctx
.setAttributeValue(usernameAttribute
, user
.getUsername());
331 ctx
.setAttributeValue(passwordAttribute
,
332 encodePassword(user
.getPassword()));
334 final JcrUserDetails jcrUserDetails
= (JcrUserDetails
) user
;
336 Node userProfile
= securitySession
.getNode(
337 jcrUserDetails
.getHomePath()).getNode(ARGEO_PROFILE
);
338 for (String jcrProperty
: propertyToAttributes
.keySet()) {
339 if (userProfile
.hasProperty(jcrProperty
)) {
340 ModificationItem mi
= jcrToLdap(jcrProperty
, userProfile
341 .getProperty(jcrProperty
).getString());
343 ctx
.setAttribute(mi
.getAttribute());
346 if (log
.isTraceEnabled())
347 log
.trace("Mapped " + userProfile
+ " to " + ctx
.getDn());
348 } catch (RepositoryException e
) {
349 throw new ArgeoException("Cannot synchronize JCR and LDAP", e
);
354 /** Maps a JCR property to an LDAP property */
355 protected ModificationItem
jcrToLdap(String jcrProperty
, String value
) {
356 // TODO do we really need DirContextOperations?
358 String ldapAttribute
;
359 if (propertyToAttributes
.containsKey(jcrProperty
))
360 ldapAttribute
= propertyToAttributes
.get(jcrProperty
);
364 // fix issue with empty 'sn' in LDAP
365 if (ldapAttribute
.equals("sn") && (value
.trim().equals("")))
367 // fix issue with empty 'description' in LDAP
368 if (ldapAttribute
.equals("description") && value
.trim().equals(""))
370 BasicAttribute attr
= new BasicAttribute(
371 propertyToAttributes
.get(jcrProperty
), value
);
372 ModificationItem mi
= new ModificationItem(
373 DirContext
.REPLACE_ATTRIBUTE
, attr
);
375 } catch (Exception e
) {
376 throw new ArgeoException("Cannot map JCR property " + jcrProperty
384 protected String
encodePassword(String password
) {
385 if (!password
.startsWith("{")) {
386 byte[] salt
= new byte[16];
387 random
.nextBytes(salt
);
388 return passwordEncoder
.encodePassword(password
, salt
);
394 private static Random
createRandom() {
396 return SecureRandom
.getInstance("SHA1PRNG");
397 } catch (NoSuchAlgorithmException e
) {
398 return new Random(System
.currentTimeMillis());
403 * DEPENDENCY INJECTION
406 public void setLdapTemplate(LdapTemplate ldapTemplate
) {
407 this.ldapTemplate
= ldapTemplate
;
410 public void setRawLdapTemplate(LdapTemplate rawLdapTemplate
) {
411 this.rawLdapTemplate
= rawLdapTemplate
;
414 public void setRepository(Repository repository
) {
415 this.repository
= repository
;
418 public void setSecurityWorkspace(String securityWorkspace
) {
419 this.securityWorkspace
= securityWorkspace
;
422 public void setUserBase(String userBase
) {
423 this.userBase
= userBase
;
426 public void setUsernameAttribute(String usernameAttribute
) {
427 this.usernameAttribute
= usernameAttribute
;
430 public void setPropertyToAttributes(Map
<String
, String
> propertyToAttributes
) {
431 this.propertyToAttributes
= propertyToAttributes
;
434 public void setUsernameMapper(LdapUsernameToDnMapper usernameMapper
) {
435 this.usernameMapper
= usernameMapper
;
438 public void setPasswordAttribute(String passwordAttribute
) {
439 this.passwordAttribute
= passwordAttribute
;
442 public void setUserClasses(String
[] userClasses
) {
443 this.userClasses
= userClasses
;
446 public void setPasswordEncoder(PasswordEncoder passwordEncoder
) {
447 this.passwordEncoder
= passwordEncoder
;
450 /** Listen to LDAP */
451 class LdapUserListener
implements ObjectChangeListener
,
452 NamespaceChangeListener
, UnsolicitedNotificationListener
{
454 public void namingExceptionThrown(NamingExceptionEvent evt
) {
455 evt
.getException().printStackTrace();
458 public void objectChanged(NamingEvent evt
) {
459 Binding user
= evt
.getNewBinding();
460 // TODO find a way not to be called when JCR is the source of the
462 DirContextAdapter ctx
= (DirContextAdapter
) ldapTemplate
463 .lookup(user
.getName());
467 public void objectAdded(NamingEvent evt
) {
468 Binding user
= evt
.getNewBinding();
469 DirContextAdapter ctx
= (DirContextAdapter
) ldapTemplate
470 .lookup(user
.getName());
474 public void objectRemoved(NamingEvent evt
) {
475 if (log
.isDebugEnabled())
479 public void objectRenamed(NamingEvent evt
) {
480 if (log
.isDebugEnabled())
484 public void notificationReceived(UnsolicitedNotificationEvent evt
) {
485 UnsolicitedNotification notification
= evt
.getNotification();
486 NamingException ne
= notification
.getException();
487 String msg
= "LDAP notification " + "ID=" + notification
.getID()
488 + ", referrals=" + notification
.getReferrals();
490 if (log
.isTraceEnabled())
491 log
.trace(msg
+ ", exception= " + ne
, ne
);
493 log
.warn(msg
+ ", exception= " + ne
);
494 } else if (log
.isDebugEnabled()) {
495 log
.debug("Unsollicited LDAP notification " + msg
);
502 class JcrProfileListener
implements EventListener
{
504 public void onEvent(EventIterator events
) {
506 final Map
<Name
, List
<ModificationItem
>> modifications
= new HashMap
<Name
, List
<ModificationItem
>>();
507 while (events
.hasNext()) {
508 Event event
= events
.nextEvent();
510 if (Event
.PROPERTY_CHANGED
== event
.getType()) {
511 Property property
= (Property
) securitySession
512 .getItem(event
.getPath());
513 String propertyName
= property
.getName();
514 Node userProfile
= property
.getParent();
515 String username
= userProfile
.getProperty(
516 ARGEO_USER_ID
).getString();
517 if (propertyToAttributes
.containsKey(propertyName
)) {
518 Name name
= usernameMapper
.buildDn(username
);
519 if (!modifications
.containsKey(name
))
520 modifications
.put(name
,
521 new ArrayList
<ModificationItem
>());
522 String value
= property
.getString();
523 ModificationItem mi
= jcrToLdap(propertyName
,
526 modifications
.get(name
).add(mi
);
528 } else if (Event
.NODE_ADDED
== event
.getType()) {
529 Node userProfile
= securitySession
.getNode(event
531 String username
= userProfile
.getProperty(
532 ARGEO_USER_ID
).getString();
533 Name name
= usernameMapper
.buildDn(username
);
534 for (String propertyName
: propertyToAttributes
536 if (!modifications
.containsKey(name
))
537 modifications
.put(name
,
538 new ArrayList
<ModificationItem
>());
539 String value
= userProfile
.getProperty(
540 propertyName
).getString();
541 ModificationItem mi
= jcrToLdap(propertyName
,
544 modifications
.get(name
).add(mi
);
547 } catch (RepositoryException e
) {
548 throw new ArgeoException("Cannot process event "
553 for (Name name
: modifications
.keySet()) {
554 List
<ModificationItem
> userModifs
= modifications
.get(name
);
555 int modifCount
= userModifs
.size();
556 ldapTemplate
.modifyAttributes(name
, userModifs
557 .toArray(new ModificationItem
[modifCount
]));
558 if (log
.isDebugEnabled())
559 log
.debug("Mapped " + modifCount
+ " JCR modification"
560 + (modifCount
== 1 ?
"" : "s") + " to " + name
);
562 } catch (Exception e
) {
563 // if (log.isDebugEnabled())
564 // e.printStackTrace();
565 throw new ArgeoException("Cannot process JCR events ("
566 + e
.getMessage() + ")", e
);