2 * Copyright (C) 2007-2012 Mathieu Baudier
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
8 * http://www.apache.org/licenses/LICENSE-2.0
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.
16 package org
.argeo
.security
.ldap
.jcr
;
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
;
25 import java
.util
.Random
;
26 import java
.util
.SortedSet
;
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
;
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
;
76 /** Guarantees that LDAP and JCR are in line. */
77 public class JcrLdapSynchronizer
implements UserDetailsContextMapper
,
79 private final static Log log
= LogFactory
.getLog(JcrLdapSynchronizer
.class);
82 private LdapTemplate ldapTemplate
;
84 * LDAP template whose context source has an object factory set to null. see
86 * "http://forum.springsource.org/showthread.php?55955-Persistent-search-with-spring-ldap"
89 private LdapTemplate rawLdapTemplate
;
91 private String userBase
;
92 private String usernameAttribute
;
93 private String passwordAttribute
;
94 private String
[] userClasses
;
96 private NamingListener ldapUserListener
;
97 private SearchControls subTreeSearchControls
;
98 private LdapUsernameToDnMapper usernameMapper
;
100 private PasswordEncoder passwordEncoder
;
101 private final Random random
;
104 /** Admin session on the security workspace */
105 private Session securitySession
;
106 private Repository repository
;
108 private String securityWorkspace
= "security";
110 private JcrProfileListener jcrProfileListener
;
113 private Map
<String
, String
> propertyToAttributes
= new HashMap
<String
, String
>();
115 public JcrLdapSynchronizer() {
116 random
= createRandom();
121 securitySession
= repository
.login(securityWorkspace
);
126 subTreeSearchControls
= new SearchControls();
127 subTreeSearchControls
.setSearchScope(SearchControls
.SUBTREE_SCOPE
);
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
);
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
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",
158 public void destroy() {
159 JcrUtils
.removeListenerQuietly(securitySession
, jcrProfileListener
);
160 JcrUtils
.logoutQuietly(securitySession
);
162 rawLdapTemplate
.executeReadOnly(new ContextExecutor() {
163 public Object
executeWithContext(DirContext ctx
)
164 throws NamingException
{
165 EventDirContext ectx
= (EventDirContext
) ctx
.lookup("");
166 ectx
.removeNamingListener(ldapUserListener
);
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
);
180 /** Full synchronization between LDAP and JCR. LDAP has priority. */
181 protected void synchronize() {
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
);
193 // disable accounts which are not in LDAP
194 Query query
= securitySession
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
)) {
207 + " not found in LDAP, disabling user "
208 + userProfile
.getProperty(ArgeoNames
.ARGEO_USER_ID
)
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());
218 } catch (Exception e
) {
219 JcrUtils
.discardQuietly(securitySession
);
220 throw new ArgeoException("Cannot synchronized LDAP and JCR", e
);
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");
229 throw new ArgeoException("No LDAP information for user " + username
);
230 Node userProfile
= SecurityJcrUtils
.createUserProfileIfNeeded(securitySession
,
232 JcrUserDetails
.checkAccountStatus(userProfile
);
235 SortedSet
<?
> passwordAttributes
= ctx
236 .getAttributeSortedStringSet(passwordAttribute
);
238 if (passwordAttributes
== null || passwordAttributes
.size() == 0) {
239 throw new ArgeoException("No password found for user " + username
);
241 byte[] arr
= (byte[]) passwordAttributes
.first();
242 password
= new String(arr
);
244 Arrays
.fill(arr
, (byte) 0);
248 return new JcrUserDetails(userProfile
, password
, authorities
);
249 } catch (RepositoryException e
) {
250 throw new ArgeoException("Cannot retrieve user details for "
256 * Writes an LDAP context to the JCR user profile.
258 * @return path to user profile
260 protected synchronized String
mapLdapToJcr(DirContextAdapter ctx
) {
261 Session session
= securitySession
;
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
);
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);
278 .setProperty(ARGEO_CREDENTIALS_NON_EXPIRED
, true);
280 session
.getWorkspace().getVersionManager()
281 .checkin(userProfile
.getPath());
284 userProfile
= SecurityJcrUtils
.createUserProfile(securitySession
,
286 userProfile
.getSession().save();
287 userProfile
.getSession().getWorkspace().getVersionManager()
288 .checkin(userProfile
.getPath());
291 Map
<String
, String
> modifications
= new HashMap
<String
, String
>();
292 for (String jcrProperty
: propertyToAttributes
.keySet())
293 ldapToJcr(userProfile
, jcrProperty
, ctx
, modifications
);
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()
303 // + userProfile.getProperty(ARGEO_LAST_NAME)
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
);
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
);
320 return userProfile
.getPath();
321 } catch (Exception e
) {
322 JcrUtils
.discardQuietly(session
);
323 throw new ArgeoException("Cannot synchronize JCR and LDAP", e
);
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?
332 String ldapAttribute
;
333 if (propertyToAttributes
.containsKey(jcrProperty
))
334 ldapAttribute
= propertyToAttributes
.get(jcrProperty
);
336 throw new ArgeoException(
337 "No LDAP attribute mapped for JCR proprty "
340 String value
= ctx
.getStringAttribute(ldapAttribute
);
341 // if (value == null && Property.JCR_TITLE.equals(jcrProperty))
343 // if (value == null &&
344 // Property.JCR_DESCRIPTION.equals(jcrProperty))
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
);
356 } catch (Exception e
) {
357 throw new ArgeoException("Cannot map JCR property " + jcrProperty
366 public void mapUserToContext(UserDetails user
, final DirContextAdapter ctx
) {
367 if (!(user
instanceof JcrUserDetails
))
368 throw new ArgeoException("Unsupported user details: "
371 ctx
.setAttributeValues("objectClass", userClasses
);
372 ctx
.setAttributeValue(usernameAttribute
, user
.getUsername());
373 ctx
.setAttributeValue(passwordAttribute
,
374 encodePassword(user
.getPassword()));
376 final JcrUserDetails jcrUserDetails
= (JcrUserDetails
) user
;
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());
385 ctx
.setAttribute(mi
.getAttribute());
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
);
396 /** Maps a JCR property to an LDAP property */
397 protected ModificationItem
jcrToLdap(String jcrProperty
, String value
) {
398 // TODO do we really need DirContextOperations?
400 String ldapAttribute
;
401 if (propertyToAttributes
.containsKey(jcrProperty
))
402 ldapAttribute
= propertyToAttributes
.get(jcrProperty
);
406 // fix issue with empty 'sn' in LDAP
407 if (ldapAttribute
.equals("sn") && (value
.trim().equals("")))
409 // fix issue with empty 'description' in LDAP
410 if (ldapAttribute
.equals("description") && value
.trim().equals(""))
412 BasicAttribute attr
= new BasicAttribute(
413 propertyToAttributes
.get(jcrProperty
), value
);
414 ModificationItem mi
= new ModificationItem(
415 DirContext
.REPLACE_ATTRIBUTE
, attr
);
417 } catch (Exception e
) {
418 throw new ArgeoException("Cannot map JCR property " + jcrProperty
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
);
436 private static Random
createRandom() {
438 return SecureRandom
.getInstance("SHA1PRNG");
439 } catch (NoSuchAlgorithmException e
) {
440 return new Random(System
.currentTimeMillis());
445 * DEPENDENCY INJECTION
448 public void setLdapTemplate(LdapTemplate ldapTemplate
) {
449 this.ldapTemplate
= ldapTemplate
;
452 public void setRawLdapTemplate(LdapTemplate rawLdapTemplate
) {
453 this.rawLdapTemplate
= rawLdapTemplate
;
456 public void setRepository(Repository repository
) {
457 this.repository
= repository
;
460 public void setSecurityWorkspace(String securityWorkspace
) {
461 this.securityWorkspace
= securityWorkspace
;
464 public void setUserBase(String userBase
) {
465 this.userBase
= userBase
;
468 public void setUsernameAttribute(String usernameAttribute
) {
469 this.usernameAttribute
= usernameAttribute
;
472 public void setPropertyToAttributes(Map
<String
, String
> propertyToAttributes
) {
473 this.propertyToAttributes
= propertyToAttributes
;
476 public void setUsernameMapper(LdapUsernameToDnMapper usernameMapper
) {
477 this.usernameMapper
= usernameMapper
;
480 public void setPasswordAttribute(String passwordAttribute
) {
481 this.passwordAttribute
= passwordAttribute
;
484 public void setUserClasses(String
[] userClasses
) {
485 this.userClasses
= userClasses
;
488 public void setPasswordEncoder(PasswordEncoder passwordEncoder
) {
489 this.passwordEncoder
= passwordEncoder
;
492 /** Listen to LDAP */
493 class LdapUserListener
implements ObjectChangeListener
,
494 NamespaceChangeListener
, UnsolicitedNotificationListener
{
496 public void namingExceptionThrown(NamingExceptionEvent evt
) {
497 evt
.getException().printStackTrace();
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
504 DirContextAdapter ctx
= (DirContextAdapter
) ldapTemplate
505 .lookup(user
.getName());
509 public void objectAdded(NamingEvent evt
) {
510 Binding user
= evt
.getNewBinding();
511 DirContextAdapter ctx
= (DirContextAdapter
) ldapTemplate
512 .lookup(user
.getName());
516 public void objectRemoved(NamingEvent evt
) {
517 if (log
.isDebugEnabled())
521 public void objectRenamed(NamingEvent evt
) {
522 if (log
.isDebugEnabled())
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();
532 if (log
.isTraceEnabled())
533 log
.trace(msg
+ ", exception= " + ne
, ne
);
535 log
.warn(msg
+ ", exception= " + ne
);
536 } else if (log
.isDebugEnabled()) {
537 log
.debug("Unsollicited LDAP notification " + msg
);
544 class JcrProfileListener
implements EventListener
{
546 public void onEvent(EventIterator events
) {
548 final Map
<Name
, List
<ModificationItem
>> modifications
= new HashMap
<Name
, List
<ModificationItem
>>();
549 while (events
.hasNext()) {
550 Event event
= events
.nextEvent();
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
,
568 modifications
.get(name
).add(mi
);
570 } else if (Event
.NODE_ADDED
== event
.getType()) {
571 Node userProfile
= securitySession
.getNode(event
573 String username
= userProfile
.getProperty(
574 ARGEO_USER_ID
).getString();
575 Name name
= usernameMapper
.buildDn(username
);
576 for (String propertyName
: propertyToAttributes
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
,
586 modifications
.get(name
).add(mi
);
589 } catch (RepositoryException e
) {
590 throw new ArgeoException("Cannot process event "
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
);
604 } catch (Exception e
) {
605 // if (log.isDebugEnabled())
606 // e.printStackTrace();
607 throw new ArgeoException("Cannot process JCR events ("
608 + e
.getMessage() + ")", e
);