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
.security
.jcr
.JcrUserDetails
;
63 import org
.springframework
.ldap
.core
.ContextExecutor
;
64 import org
.springframework
.ldap
.core
.ContextMapper
;
65 import org
.springframework
.ldap
.core
.DirContextAdapter
;
66 import org
.springframework
.ldap
.core
.DirContextOperations
;
67 import org
.springframework
.ldap
.core
.DistinguishedName
;
68 import org
.springframework
.ldap
.core
.LdapTemplate
;
69 import org
.springframework
.security
.GrantedAuthority
;
70 import org
.springframework
.security
.ldap
.LdapUsernameToDnMapper
;
71 import org
.springframework
.security
.providers
.encoding
.PasswordEncoder
;
72 import org
.springframework
.security
.userdetails
.UserDetails
;
73 import org
.springframework
.security
.userdetails
.ldap
.UserDetailsContextMapper
;
75 /** Guarantees that LDAP and JCR are in line. */
76 public class JcrLdapSynchronizer
implements UserDetailsContextMapper
,
78 private final static Log log
= LogFactory
.getLog(JcrLdapSynchronizer
.class);
81 private LdapTemplate ldapTemplate
;
83 * LDAP template whose context source has an object factory set to null. see
85 * "http://forum.springsource.org/showthread.php?55955-Persistent-search-with-spring-ldap"
88 private LdapTemplate rawLdapTemplate
;
90 private String userBase
;
91 private String usernameAttribute
;
92 private String passwordAttribute
;
93 private String
[] userClasses
;
95 private NamingListener ldapUserListener
;
96 private SearchControls subTreeSearchControls
;
97 private LdapUsernameToDnMapper usernameMapper
;
99 private PasswordEncoder passwordEncoder
;
100 private final Random random
;
103 /** Admin session on the security workspace */
104 private Session securitySession
;
105 private Repository repository
;
107 private String securityWorkspace
= "security";
109 private JcrProfileListener jcrProfileListener
;
112 private Map
<String
, String
> propertyToAttributes
= new HashMap
<String
, String
>();
114 public JcrLdapSynchronizer() {
115 random
= createRandom();
120 securitySession
= repository
.login(securityWorkspace
);
125 subTreeSearchControls
= new SearchControls();
126 subTreeSearchControls
.setSearchScope(SearchControls
.SUBTREE_SCOPE
);
128 ldapUserListener
= new LdapUserListener();
129 rawLdapTemplate
.executeReadOnly(new ContextExecutor() {
130 public Object
executeWithContext(DirContext ctx
)
131 throws NamingException
{
132 EventDirContext ectx
= (EventDirContext
) ctx
.lookup("");
133 ectx
.addNamingListener(userBase
, "(" + usernameAttribute
134 + "=*)", subTreeSearchControls
, ldapUserListener
);
140 String
[] nodeTypes
= { ArgeoTypes
.ARGEO_USER_PROFILE
};
141 jcrProfileListener
= new JcrProfileListener();
142 // noLocal is used so that we are not notified when we modify JCR
146 .getObservationManager()
147 .addEventListener(jcrProfileListener
,
148 Event
.PROPERTY_CHANGED
| Event
.NODE_ADDED
, "/",
149 true, null, nodeTypes
, true);
150 } catch (Exception e
) {
151 JcrUtils
.logoutQuietly(securitySession
);
152 throw new ArgeoException("Cannot initialize LDAP/JCR synchronizer",
157 public void destroy() {
158 JcrUtils
.removeListenerQuietly(securitySession
, jcrProfileListener
);
159 JcrUtils
.logoutQuietly(securitySession
);
161 rawLdapTemplate
.executeReadOnly(new ContextExecutor() {
162 public Object
executeWithContext(DirContext ctx
)
163 throws NamingException
{
164 EventDirContext ectx
= (EventDirContext
) ctx
.lookup("");
165 ectx
.removeNamingListener(ldapUserListener
);
169 } catch (Exception e
) {
170 // silent (LDAP server may have been shutdown already)
171 if (log
.isTraceEnabled())
172 log
.trace("Cannot remove LDAP listener", e
);
179 /** Full synchronization between LDAP and JCR. LDAP has priority. */
180 protected void synchronize() {
182 Name userBaseName
= new DistinguishedName(userBase
);
183 // TODO subtree search?
184 @SuppressWarnings("unchecked")
185 List
<String
> userPaths
= (List
<String
>) ldapTemplate
.listBindings(
186 userBaseName
, new ContextMapper() {
187 public Object
mapFromContext(Object ctxObj
) {
188 return mapLdapToJcr((DirContextAdapter
) ctxObj
);
192 // disable accounts which are not in LDAP
193 Query query
= securitySession
197 "select * from [" + ArgeoTypes
.ARGEO_USER_PROFILE
198 + "]", Query
.JCR_SQL2
);
199 NodeIterator it
= query
.execute().getNodes();
200 while (it
.hasNext()) {
201 Node userProfile
= it
.nextNode();
202 String path
= userProfile
.getPath();
203 if (!userPaths
.contains(path
)) {
206 + " not found in LDAP, disabling user "
207 + userProfile
.getProperty(ArgeoNames
.ARGEO_USER_ID
)
209 VersionManager versionManager
= securitySession
210 .getWorkspace().getVersionManager();
211 versionManager
.checkout(userProfile
.getPath());
212 userProfile
.setProperty(ArgeoNames
.ARGEO_ENABLED
, false);
213 securitySession
.save();
214 versionManager
.checkin(userProfile
.getPath());
217 } catch (Exception e
) {
218 JcrUtils
.discardQuietly(securitySession
);
219 throw new ArgeoException("Cannot synchronized LDAP and JCR", e
);
223 /** Called during authentication in order to retrieve user details */
224 public UserDetails
mapUserFromContext(final DirContextOperations ctx
,
225 final String username
, GrantedAuthority
[] authorities
) {
227 throw new ArgeoException("No LDAP information for user " + username
);
228 Node userProfile
= JcrUtils
.createUserProfileIfNeeded(securitySession
,
230 JcrUserDetails
.checkAccountStatus(userProfile
);
233 SortedSet
<?
> passwordAttributes
= ctx
234 .getAttributeSortedStringSet(passwordAttribute
);
236 if (passwordAttributes
== null || passwordAttributes
.size() == 0) {
237 throw new ArgeoException("No password found for user " + username
);
239 byte[] arr
= (byte[]) passwordAttributes
.first();
240 password
= new String(arr
);
242 Arrays
.fill(arr
, (byte) 0);
246 return new JcrUserDetails(userProfile
, password
, authorities
);
247 } catch (RepositoryException e
) {
248 throw new ArgeoException("Cannot retrieve user details for "
254 * Writes an LDAP context to the JCR user profile.
256 * @return path to user profile
258 protected synchronized String
mapLdapToJcr(DirContextAdapter ctx
) {
259 Session session
= securitySession
;
262 String username
= ctx
.getStringAttribute(usernameAttribute
);
263 Node userHome
= JcrUtils
.createUserHomeIfNeeded(session
, username
);
264 Node userProfile
; // = userHome.getNode(ARGEO_PROFILE);
265 if (userHome
.hasNode(ARGEO_PROFILE
)) {
266 userProfile
= userHome
.getNode(ARGEO_PROFILE
);
268 // compatibility with legacy, will be removed
269 if (!userProfile
.hasProperty(ARGEO_ENABLED
)) {
270 session
.getWorkspace().getVersionManager()
271 .checkout(userProfile
.getPath());
272 userProfile
.setProperty(ARGEO_ENABLED
, true);
273 userProfile
.setProperty(ARGEO_ACCOUNT_NON_EXPIRED
, true);
274 userProfile
.setProperty(ARGEO_ACCOUNT_NON_LOCKED
, true);
276 .setProperty(ARGEO_CREDENTIALS_NON_EXPIRED
, true);
278 session
.getWorkspace().getVersionManager()
279 .checkin(userProfile
.getPath());
282 userProfile
= JcrUtils
.createUserProfile(securitySession
,
284 userProfile
.getSession().save();
285 userProfile
.getSession().getWorkspace().getVersionManager()
286 .checkin(userProfile
.getPath());
289 Map
<String
, String
> modifications
= new HashMap
<String
, String
>();
290 for (String jcrProperty
: propertyToAttributes
.keySet())
291 ldapToJcr(userProfile
, jcrProperty
, ctx
, modifications
);
293 // assign default values
294 // if (!userProfile.hasProperty(Property.JCR_DESCRIPTION)
295 // && !modifications.containsKey(Property.JCR_DESCRIPTION))
296 // modifications.put(Property.JCR_DESCRIPTION, "");
297 // if (!userProfile.hasProperty(Property.JCR_TITLE))
298 // modifications.put(Property.JCR_TITLE,
299 // userProfile.getProperty(ARGEO_FIRST_NAME).getString()
301 // + userProfile.getProperty(ARGEO_LAST_NAME)
303 int modifCount
= modifications
.size();
304 if (modifCount
> 0) {
305 session
.getWorkspace().getVersionManager()
306 .checkout(userProfile
.getPath());
307 for (String prop
: modifications
.keySet())
308 userProfile
.setProperty(prop
, modifications
.get(prop
));
309 JcrUtils
.updateLastModified(userProfile
);
311 session
.getWorkspace().getVersionManager()
312 .checkin(userProfile
.getPath());
313 if (log
.isDebugEnabled())
314 log
.debug("Mapped " + modifCount
+ " LDAP modification"
315 + (modifCount
== 1 ?
"" : "s") + " from "
316 + ctx
.getDn() + " to " + userProfile
);
318 return userProfile
.getPath();
319 } catch (Exception e
) {
320 JcrUtils
.discardQuietly(session
);
321 throw new ArgeoException("Cannot synchronize JCR and LDAP", e
);
325 /** Maps an LDAP property to a JCR property */
326 protected void ldapToJcr(Node userProfile
, String jcrProperty
,
327 DirContextOperations ctx
, Map
<String
, String
> modifications
) {
328 // TODO do we really need DirContextOperations?
330 String ldapAttribute
;
331 if (propertyToAttributes
.containsKey(jcrProperty
))
332 ldapAttribute
= propertyToAttributes
.get(jcrProperty
);
334 throw new ArgeoException(
335 "No LDAP attribute mapped for JCR proprty "
338 String value
= ctx
.getStringAttribute(ldapAttribute
);
339 // if (value == null && Property.JCR_TITLE.equals(jcrProperty))
341 // if (value == null &&
342 // Property.JCR_DESCRIPTION.equals(jcrProperty))
344 String jcrValue
= userProfile
.hasProperty(jcrProperty
) ? userProfile
345 .getProperty(jcrProperty
).getString() : null;
346 if (value
!= null && jcrValue
!= null) {
347 if (!value
.equals(jcrValue
))
348 modifications
.put(jcrProperty
, value
);
349 } else if (value
!= null && jcrValue
== null) {
350 modifications
.put(jcrProperty
, value
);
351 } else if (value
== null && jcrValue
!= null) {
352 modifications
.put(jcrProperty
, value
);
354 } catch (Exception e
) {
355 throw new ArgeoException("Cannot map JCR property " + jcrProperty
364 public void mapUserToContext(UserDetails user
, final DirContextAdapter ctx
) {
365 if (!(user
instanceof JcrUserDetails
))
366 throw new ArgeoException("Unsupported user details: "
369 ctx
.setAttributeValues("objectClass", userClasses
);
370 ctx
.setAttributeValue(usernameAttribute
, user
.getUsername());
371 ctx
.setAttributeValue(passwordAttribute
,
372 encodePassword(user
.getPassword()));
374 final JcrUserDetails jcrUserDetails
= (JcrUserDetails
) user
;
376 Node userProfile
= securitySession
.getNode(
377 jcrUserDetails
.getHomePath()).getNode(ARGEO_PROFILE
);
378 for (String jcrProperty
: propertyToAttributes
.keySet()) {
379 if (userProfile
.hasProperty(jcrProperty
)) {
380 ModificationItem mi
= jcrToLdap(jcrProperty
, userProfile
381 .getProperty(jcrProperty
).getString());
383 ctx
.setAttribute(mi
.getAttribute());
386 if (log
.isTraceEnabled())
387 log
.trace("Mapped " + userProfile
+ " to " + ctx
.getDn());
388 } catch (RepositoryException e
) {
389 throw new ArgeoException("Cannot synchronize JCR and LDAP", e
);
394 /** Maps a JCR property to an LDAP property */
395 protected ModificationItem
jcrToLdap(String jcrProperty
, String value
) {
396 // TODO do we really need DirContextOperations?
398 String ldapAttribute
;
399 if (propertyToAttributes
.containsKey(jcrProperty
))
400 ldapAttribute
= propertyToAttributes
.get(jcrProperty
);
404 // fix issue with empty 'sn' in LDAP
405 if (ldapAttribute
.equals("sn") && (value
.trim().equals("")))
407 // fix issue with empty 'description' in LDAP
408 if (ldapAttribute
.equals("description") && value
.trim().equals(""))
410 BasicAttribute attr
= new BasicAttribute(
411 propertyToAttributes
.get(jcrProperty
), value
);
412 ModificationItem mi
= new ModificationItem(
413 DirContext
.REPLACE_ATTRIBUTE
, attr
);
415 } catch (Exception e
) {
416 throw new ArgeoException("Cannot map JCR property " + jcrProperty
424 protected String
encodePassword(String password
) {
425 if (!password
.startsWith("{")) {
426 byte[] salt
= new byte[16];
427 random
.nextBytes(salt
);
428 return passwordEncoder
.encodePassword(password
, salt
);
434 private static Random
createRandom() {
436 return SecureRandom
.getInstance("SHA1PRNG");
437 } catch (NoSuchAlgorithmException e
) {
438 return new Random(System
.currentTimeMillis());
443 * DEPENDENCY INJECTION
446 public void setLdapTemplate(LdapTemplate ldapTemplate
) {
447 this.ldapTemplate
= ldapTemplate
;
450 public void setRawLdapTemplate(LdapTemplate rawLdapTemplate
) {
451 this.rawLdapTemplate
= rawLdapTemplate
;
454 public void setRepository(Repository repository
) {
455 this.repository
= repository
;
458 public void setSecurityWorkspace(String securityWorkspace
) {
459 this.securityWorkspace
= securityWorkspace
;
462 public void setUserBase(String userBase
) {
463 this.userBase
= userBase
;
466 public void setUsernameAttribute(String usernameAttribute
) {
467 this.usernameAttribute
= usernameAttribute
;
470 public void setPropertyToAttributes(Map
<String
, String
> propertyToAttributes
) {
471 this.propertyToAttributes
= propertyToAttributes
;
474 public void setUsernameMapper(LdapUsernameToDnMapper usernameMapper
) {
475 this.usernameMapper
= usernameMapper
;
478 public void setPasswordAttribute(String passwordAttribute
) {
479 this.passwordAttribute
= passwordAttribute
;
482 public void setUserClasses(String
[] userClasses
) {
483 this.userClasses
= userClasses
;
486 public void setPasswordEncoder(PasswordEncoder passwordEncoder
) {
487 this.passwordEncoder
= passwordEncoder
;
490 /** Listen to LDAP */
491 class LdapUserListener
implements ObjectChangeListener
,
492 NamespaceChangeListener
, UnsolicitedNotificationListener
{
494 public void namingExceptionThrown(NamingExceptionEvent evt
) {
495 evt
.getException().printStackTrace();
498 public void objectChanged(NamingEvent evt
) {
499 Binding user
= evt
.getNewBinding();
500 // TODO find a way not to be called when JCR is the source of the
502 DirContextAdapter ctx
= (DirContextAdapter
) ldapTemplate
503 .lookup(user
.getName());
507 public void objectAdded(NamingEvent evt
) {
508 Binding user
= evt
.getNewBinding();
509 DirContextAdapter ctx
= (DirContextAdapter
) ldapTemplate
510 .lookup(user
.getName());
514 public void objectRemoved(NamingEvent evt
) {
515 if (log
.isDebugEnabled())
519 public void objectRenamed(NamingEvent evt
) {
520 if (log
.isDebugEnabled())
524 public void notificationReceived(UnsolicitedNotificationEvent evt
) {
525 UnsolicitedNotification notification
= evt
.getNotification();
526 NamingException ne
= notification
.getException();
527 String msg
= "LDAP notification " + "ID=" + notification
.getID()
528 + ", referrals=" + notification
.getReferrals();
530 if (log
.isTraceEnabled())
531 log
.trace(msg
+ ", exception= " + ne
, ne
);
533 log
.warn(msg
+ ", exception= " + ne
);
534 } else if (log
.isDebugEnabled()) {
535 log
.debug("Unsollicited LDAP notification " + msg
);
542 class JcrProfileListener
implements EventListener
{
544 public void onEvent(EventIterator events
) {
546 final Map
<Name
, List
<ModificationItem
>> modifications
= new HashMap
<Name
, List
<ModificationItem
>>();
547 while (events
.hasNext()) {
548 Event event
= events
.nextEvent();
550 if (Event
.PROPERTY_CHANGED
== event
.getType()) {
551 Property property
= (Property
) securitySession
552 .getItem(event
.getPath());
553 String propertyName
= property
.getName();
554 Node userProfile
= property
.getParent();
555 String username
= userProfile
.getProperty(
556 ARGEO_USER_ID
).getString();
557 if (propertyToAttributes
.containsKey(propertyName
)) {
558 Name name
= usernameMapper
.buildDn(username
);
559 if (!modifications
.containsKey(name
))
560 modifications
.put(name
,
561 new ArrayList
<ModificationItem
>());
562 String value
= property
.getString();
563 ModificationItem mi
= jcrToLdap(propertyName
,
566 modifications
.get(name
).add(mi
);
568 } else if (Event
.NODE_ADDED
== event
.getType()) {
569 Node userProfile
= securitySession
.getNode(event
571 String username
= userProfile
.getProperty(
572 ARGEO_USER_ID
).getString();
573 Name name
= usernameMapper
.buildDn(username
);
574 for (String propertyName
: propertyToAttributes
576 if (!modifications
.containsKey(name
))
577 modifications
.put(name
,
578 new ArrayList
<ModificationItem
>());
579 String value
= userProfile
.getProperty(
580 propertyName
).getString();
581 ModificationItem mi
= jcrToLdap(propertyName
,
584 modifications
.get(name
).add(mi
);
587 } catch (RepositoryException e
) {
588 throw new ArgeoException("Cannot process event "
593 for (Name name
: modifications
.keySet()) {
594 List
<ModificationItem
> userModifs
= modifications
.get(name
);
595 int modifCount
= userModifs
.size();
596 ldapTemplate
.modifyAttributes(name
, userModifs
597 .toArray(new ModificationItem
[modifCount
]));
598 if (log
.isDebugEnabled())
599 log
.debug("Mapped " + modifCount
+ " JCR modification"
600 + (modifCount
== 1 ?
"" : "s") + " to " + name
);
602 } catch (Exception e
) {
603 // if (log.isDebugEnabled())
604 // e.printStackTrace();
605 throw new ArgeoException("Cannot process JCR events ("
606 + e
.getMessage() + ")", e
);