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
.JcrSecurityModel
;
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 /** Makes sure 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 nodeSession
;
106 private Repository repository
;
108 private JcrProfileListener jcrProfileListener
;
109 private JcrSecurityModel jcrSecurityModel
= new JcrSecurityModel();
112 private Map
<String
, String
> propertyToAttributes
= new HashMap
<String
, String
>();
114 public JcrLdapSynchronizer() {
115 random
= createRandom();
120 nodeSession
= repository
.login();
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(nodeSession
);
152 throw new ArgeoException("Cannot initialize LDAP/JCR synchronizer",
157 public void destroy() {
158 JcrUtils
.removeListenerQuietly(nodeSession
, jcrProfileListener
);
159 JcrUtils
.logoutQuietly(nodeSession
);
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
= nodeSession
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
= nodeSession
.getWorkspace()
210 .getVersionManager();
211 versionManager
.checkout(userProfile
.getPath());
212 userProfile
.setProperty(ArgeoNames
.ARGEO_ENABLED
, false);
214 versionManager
.checkin(userProfile
.getPath());
217 } catch (Exception e
) {
218 JcrUtils
.discardQuietly(nodeSession
);
219 log
.error("Cannot synchronize LDAP and JCR", e
);
220 // throw new ArgeoException("Cannot synchronize 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
) {
228 throw new ArgeoException("No LDAP information for user " + username
);
230 // Node userProfile = SecurityJcrUtils.createUserProfileIfNeeded(
231 // securitySession, username);
232 Node userProfile
= jcrSecurityModel
.sync(nodeSession
, username
);
233 JcrUserDetails
.checkAccountStatus(userProfile
);
236 SortedSet
<?
> passwordAttributes
= ctx
237 .getAttributeSortedStringSet(passwordAttribute
);
239 if (passwordAttributes
== null || passwordAttributes
.size() == 0) {
240 throw new ArgeoException("No password found for user " + username
);
242 byte[] arr
= (byte[]) passwordAttributes
.first();
243 password
= new String(arr
);
245 Arrays
.fill(arr
, (byte) 0);
249 return new JcrUserDetails(userProfile
, password
, authorities
);
250 } catch (RepositoryException e
) {
251 throw new ArgeoException("Cannot retrieve user details for "
257 * Writes an LDAP context to the JCR user profile.
259 * @return path to user profile
261 protected synchronized String
mapLdapToJcr(DirContextAdapter ctx
) {
262 Session session
= nodeSession
;
265 String username
= ctx
.getStringAttribute(usernameAttribute
);
266 // Node userHome = SecurityJcrUtils.createUserHomeIfNeeded(session,
268 // Node userProfile; // = userHome.getNode(ARGEO_PROFILE);
269 // if (userHome.hasNode(ARGEO_PROFILE)) {
270 // userProfile = userHome.getNode(ARGEO_PROFILE);
272 // // compatibility with legacy, will be removed
273 // if (!userProfile.hasProperty(ARGEO_ENABLED)) {
274 // session.getWorkspace().getVersionManager()
275 // .checkout(userProfile.getPath());
276 // userProfile.setProperty(ARGEO_ENABLED, true);
277 // userProfile.setProperty(ARGEO_ACCOUNT_NON_EXPIRED, true);
278 // userProfile.setProperty(ARGEO_ACCOUNT_NON_LOCKED, true);
280 // .setProperty(ARGEO_CREDENTIALS_NON_EXPIRED, true);
282 // session.getWorkspace().getVersionManager()
283 // .checkin(userProfile.getPath());
286 // userProfile = SecurityJcrUtils.createUserProfile(
287 // securitySession, username);
288 // userProfile.getSession().save();
289 // userProfile.getSession().getWorkspace().getVersionManager()
290 // .checkin(userProfile.getPath());
293 Node userProfile
= jcrSecurityModel
.sync(session
, username
);
294 Map
<String
, String
> modifications
= new HashMap
<String
, String
>();
295 for (String jcrProperty
: propertyToAttributes
.keySet())
296 ldapToJcr(userProfile
, jcrProperty
, ctx
, modifications
);
298 // assign default values
299 // if (!userProfile.hasProperty(Property.JCR_DESCRIPTION)
300 // && !modifications.containsKey(Property.JCR_DESCRIPTION))
301 // modifications.put(Property.JCR_DESCRIPTION, "");
302 // if (!userProfile.hasProperty(Property.JCR_TITLE))
303 // modifications.put(Property.JCR_TITLE,
304 // userProfile.getProperty(ARGEO_FIRST_NAME).getString()
306 // + userProfile.getProperty(ARGEO_LAST_NAME)
308 int modifCount
= modifications
.size();
309 if (modifCount
> 0) {
310 session
.getWorkspace().getVersionManager()
311 .checkout(userProfile
.getPath());
312 for (String prop
: modifications
.keySet())
313 userProfile
.setProperty(prop
, modifications
.get(prop
));
314 JcrUtils
.updateLastModified(userProfile
);
316 session
.getWorkspace().getVersionManager()
317 .checkin(userProfile
.getPath());
318 if (log
.isDebugEnabled())
319 log
.debug("Mapped " + modifCount
+ " LDAP modification"
320 + (modifCount
== 1 ?
"" : "s") + " from "
321 + ctx
.getDn() + " to " + userProfile
);
323 return userProfile
.getPath();
324 } catch (Exception e
) {
325 JcrUtils
.discardQuietly(session
);
326 throw new ArgeoException("Cannot synchronize JCR and LDAP", e
);
330 /** Maps an LDAP property to a JCR property */
331 protected void ldapToJcr(Node userProfile
, String jcrProperty
,
332 DirContextOperations ctx
, Map
<String
, String
> modifications
) {
333 // TODO do we really need DirContextOperations?
335 String ldapAttribute
;
336 if (propertyToAttributes
.containsKey(jcrProperty
))
337 ldapAttribute
= propertyToAttributes
.get(jcrProperty
);
339 throw new ArgeoException(
340 "No LDAP attribute mapped for JCR proprty "
343 String value
= ctx
.getStringAttribute(ldapAttribute
);
344 // if (value == null && Property.JCR_TITLE.equals(jcrProperty))
346 // if (value == null &&
347 // Property.JCR_DESCRIPTION.equals(jcrProperty))
349 String jcrValue
= userProfile
.hasProperty(jcrProperty
) ? userProfile
350 .getProperty(jcrProperty
).getString() : null;
351 if (value
!= null && jcrValue
!= null) {
352 if (!value
.equals(jcrValue
))
353 modifications
.put(jcrProperty
, value
);
354 } else if (value
!= null && jcrValue
== null) {
355 modifications
.put(jcrProperty
, value
);
356 } else if (value
== null && jcrValue
!= null) {
357 modifications
.put(jcrProperty
, value
);
359 } catch (Exception e
) {
360 throw new ArgeoException("Cannot map JCR property " + jcrProperty
369 public void mapUserToContext(UserDetails user
, final DirContextAdapter ctx
) {
370 if (!(user
instanceof JcrUserDetails
))
371 throw new ArgeoException("Unsupported user details: "
374 ctx
.setAttributeValues("objectClass", userClasses
);
375 ctx
.setAttributeValue(usernameAttribute
, user
.getUsername());
376 ctx
.setAttributeValue(passwordAttribute
,
377 encodePassword(user
.getPassword()));
379 final JcrUserDetails jcrUserDetails
= (JcrUserDetails
) user
;
381 Node userProfile
= nodeSession
382 .getNode(jcrUserDetails
.getHomePath()).getNode(
384 for (String jcrProperty
: propertyToAttributes
.keySet()) {
385 if (userProfile
.hasProperty(jcrProperty
)) {
386 ModificationItem mi
= jcrToLdap(jcrProperty
, userProfile
387 .getProperty(jcrProperty
).getString());
389 ctx
.setAttribute(mi
.getAttribute());
392 if (log
.isTraceEnabled())
393 log
.trace("Mapped " + userProfile
+ " to " + ctx
.getDn());
394 } catch (RepositoryException e
) {
395 throw new ArgeoException("Cannot synchronize JCR and LDAP", e
);
400 /** Maps a JCR property to an LDAP property */
401 protected ModificationItem
jcrToLdap(String jcrProperty
, String value
) {
402 // TODO do we really need DirContextOperations?
404 String ldapAttribute
;
405 if (propertyToAttributes
.containsKey(jcrProperty
))
406 ldapAttribute
= propertyToAttributes
.get(jcrProperty
);
410 // fix issue with empty 'sn' in LDAP
411 if (ldapAttribute
.equals("sn") && (value
.trim().equals("")))
413 // fix issue with empty 'description' in LDAP
414 if (ldapAttribute
.equals("description") && value
.trim().equals(""))
416 BasicAttribute attr
= new BasicAttribute(
417 propertyToAttributes
.get(jcrProperty
), value
);
418 ModificationItem mi
= new ModificationItem(
419 DirContext
.REPLACE_ATTRIBUTE
, attr
);
421 } catch (Exception e
) {
422 throw new ArgeoException("Cannot map JCR property " + jcrProperty
430 protected String
encodePassword(String password
) {
431 if (!password
.startsWith("{")) {
432 byte[] salt
= new byte[16];
433 random
.nextBytes(salt
);
434 return passwordEncoder
.encodePassword(password
, salt
);
440 private static Random
createRandom() {
442 return SecureRandom
.getInstance("SHA1PRNG");
443 } catch (NoSuchAlgorithmException e
) {
444 return new Random(System
.currentTimeMillis());
449 * DEPENDENCY INJECTION
452 public void setLdapTemplate(LdapTemplate ldapTemplate
) {
453 this.ldapTemplate
= ldapTemplate
;
456 public void setRawLdapTemplate(LdapTemplate rawLdapTemplate
) {
457 this.rawLdapTemplate
= rawLdapTemplate
;
460 public void setRepository(Repository repository
) {
461 this.repository
= repository
;
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 public void setJcrSecurityModel(JcrSecurityModel jcrSecurityModel
) {
493 this.jcrSecurityModel
= jcrSecurityModel
;
496 /** Listen to LDAP */
497 class LdapUserListener
implements ObjectChangeListener
,
498 NamespaceChangeListener
, UnsolicitedNotificationListener
{
500 public void namingExceptionThrown(NamingExceptionEvent evt
) {
501 evt
.getException().printStackTrace();
504 public void objectChanged(NamingEvent evt
) {
505 Binding user
= evt
.getNewBinding();
506 // TODO find a way not to be called when JCR is the source of the
508 DirContextAdapter ctx
= (DirContextAdapter
) ldapTemplate
509 .lookup(user
.getName());
513 public void objectAdded(NamingEvent evt
) {
514 Binding user
= evt
.getNewBinding();
515 DirContextAdapter ctx
= (DirContextAdapter
) ldapTemplate
516 .lookup(user
.getName());
520 public void objectRemoved(NamingEvent evt
) {
521 if (log
.isDebugEnabled())
525 public void objectRenamed(NamingEvent evt
) {
526 if (log
.isDebugEnabled())
530 public void notificationReceived(UnsolicitedNotificationEvent evt
) {
531 UnsolicitedNotification notification
= evt
.getNotification();
532 NamingException ne
= notification
.getException();
533 String msg
= "LDAP notification " + "ID=" + notification
.getID()
534 + ", referrals=" + notification
.getReferrals();
536 if (log
.isTraceEnabled())
537 log
.trace(msg
+ ", exception= " + ne
, ne
);
539 log
.warn(msg
+ ", exception= " + ne
);
540 } else if (log
.isDebugEnabled()) {
541 log
.debug("Unsollicited LDAP notification " + msg
);
548 class JcrProfileListener
implements EventListener
{
550 public void onEvent(EventIterator events
) {
552 final Map
<Name
, List
<ModificationItem
>> modifications
= new HashMap
<Name
, List
<ModificationItem
>>();
553 while (events
.hasNext()) {
554 Event event
= events
.nextEvent();
556 if (Event
.PROPERTY_CHANGED
== event
.getType()) {
557 Property property
= (Property
) nodeSession
558 .getItem(event
.getPath());
559 String propertyName
= property
.getName();
560 Node userProfile
= property
.getParent();
561 String username
= userProfile
.getProperty(
562 ARGEO_USER_ID
).getString();
563 if (propertyToAttributes
.containsKey(propertyName
)) {
564 Name name
= usernameMapper
.buildDn(username
);
565 if (!modifications
.containsKey(name
))
566 modifications
.put(name
,
567 new ArrayList
<ModificationItem
>());
568 String value
= property
.getString();
569 ModificationItem mi
= jcrToLdap(propertyName
,
572 modifications
.get(name
).add(mi
);
574 } else if (Event
.NODE_ADDED
== event
.getType()) {
575 Node userProfile
= nodeSession
.getNode(event
577 String username
= userProfile
.getProperty(
578 ARGEO_USER_ID
).getString();
579 Name name
= usernameMapper
.buildDn(username
);
580 for (String propertyName
: propertyToAttributes
582 if (!modifications
.containsKey(name
))
583 modifications
.put(name
,
584 new ArrayList
<ModificationItem
>());
585 String value
= userProfile
.getProperty(
586 propertyName
).getString();
587 ModificationItem mi
= jcrToLdap(propertyName
,
590 modifications
.get(name
).add(mi
);
593 } catch (RepositoryException e
) {
594 throw new ArgeoException("Cannot process event "
599 for (Name name
: modifications
.keySet()) {
600 List
<ModificationItem
> userModifs
= modifications
.get(name
);
601 int modifCount
= userModifs
.size();
602 ldapTemplate
.modifyAttributes(name
, userModifs
603 .toArray(new ModificationItem
[modifCount
]));
604 if (log
.isDebugEnabled())
605 log
.debug("Mapped " + modifCount
+ " JCR modification"
606 + (modifCount
== 1 ?
"" : "s") + " to " + name
);
608 } catch (Exception e
) {
609 // if (log.isDebugEnabled())
610 // e.printStackTrace();
611 throw new ArgeoException("Cannot process JCR events ("
612 + e
.getMessage() + ")", e
);