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
) {
189 return mapLdapToJcr((DirContextAdapter
) ctxObj
);
190 } catch (Exception e
) {
191 // do not break process because of error
193 "Could not LDAP->JCR synchronize user "
200 // disable accounts which are not in LDAP
201 Query query
= nodeSession
205 "select * from [" + ArgeoTypes
.ARGEO_USER_PROFILE
206 + "]", Query
.JCR_SQL2
);
207 NodeIterator it
= query
.execute().getNodes();
208 while (it
.hasNext()) {
209 Node userProfile
= it
.nextNode();
210 String path
= userProfile
.getPath();
211 if (!userPaths
.contains(path
)) {
214 + " not found in LDAP, disabling user "
215 + userProfile
.getProperty(ArgeoNames
.ARGEO_USER_ID
)
217 VersionManager versionManager
= nodeSession
.getWorkspace()
218 .getVersionManager();
219 versionManager
.checkout(userProfile
.getPath());
220 userProfile
.setProperty(ArgeoNames
.ARGEO_ENABLED
, false);
222 versionManager
.checkin(userProfile
.getPath());
225 } catch (Exception e
) {
226 JcrUtils
.discardQuietly(nodeSession
);
227 log
.error("Cannot synchronize LDAP and JCR", e
);
228 // throw new ArgeoException("Cannot synchronize LDAP and JCR", e);
232 /** Called during authentication in order to retrieve user details */
233 public UserDetails
mapUserFromContext(final DirContextOperations ctx
,
234 final String username
, GrantedAuthority
[] authorities
) {
236 throw new ArgeoException("No LDAP information for user " + username
);
238 // Node userProfile = SecurityJcrUtils.createUserProfileIfNeeded(
239 // securitySession, username);
240 Node userProfile
= jcrSecurityModel
.sync(nodeSession
, username
);
241 // JcrUserDetails.checkAccountStatus(userProfile);
244 SortedSet
<?
> passwordAttributes
= ctx
245 .getAttributeSortedStringSet(passwordAttribute
);
247 if (passwordAttributes
== null || passwordAttributes
.size() == 0) {
248 throw new ArgeoException("No password found for user " + username
);
250 byte[] arr
= (byte[]) passwordAttributes
.first();
251 password
= new String(arr
);
253 Arrays
.fill(arr
, (byte) 0);
257 return new JcrUserDetails(userProfile
, password
, authorities
);
258 } catch (RepositoryException e
) {
259 throw new ArgeoException("Cannot retrieve user details for "
265 * Writes an LDAP context to the JCR user profile.
267 * @return path to user profile
269 protected synchronized String
mapLdapToJcr(DirContextAdapter ctx
) {
270 Session session
= nodeSession
;
273 String username
= ctx
.getStringAttribute(usernameAttribute
);
275 Node userProfile
= jcrSecurityModel
.sync(session
, username
);
276 Map
<String
, String
> modifications
= new HashMap
<String
, String
>();
277 for (String jcrProperty
: propertyToAttributes
.keySet())
278 ldapToJcr(userProfile
, jcrProperty
, ctx
, modifications
);
280 int modifCount
= modifications
.size();
281 if (modifCount
> 0) {
282 session
.getWorkspace().getVersionManager()
283 .checkout(userProfile
.getPath());
284 for (String prop
: modifications
.keySet())
285 userProfile
.setProperty(prop
, modifications
.get(prop
));
286 JcrUtils
.updateLastModified(userProfile
);
288 session
.getWorkspace().getVersionManager()
289 .checkin(userProfile
.getPath());
290 if (log
.isDebugEnabled())
291 log
.debug("Mapped " + modifCount
+ " LDAP modification"
292 + (modifCount
== 1 ?
"" : "s") + " from "
293 + ctx
.getDn() + " to " + userProfile
);
295 return userProfile
.getPath();
296 } catch (Exception e
) {
297 JcrUtils
.discardQuietly(session
);
298 throw new ArgeoException("Cannot synchronize JCR and LDAP", e
);
302 /** Maps an LDAP property to a JCR property */
303 protected void ldapToJcr(Node userProfile
, String jcrProperty
,
304 DirContextOperations ctx
, Map
<String
, String
> modifications
) {
305 // TODO do we really need DirContextOperations?
307 String ldapAttribute
;
308 if (propertyToAttributes
.containsKey(jcrProperty
))
309 ldapAttribute
= propertyToAttributes
.get(jcrProperty
);
311 throw new ArgeoException(
312 "No LDAP attribute mapped for JCR proprty "
315 String value
= ctx
.getStringAttribute(ldapAttribute
);
316 // if (value == null && Property.JCR_TITLE.equals(jcrProperty))
318 // if (value == null &&
319 // Property.JCR_DESCRIPTION.equals(jcrProperty))
321 String jcrValue
= userProfile
.hasProperty(jcrProperty
) ? userProfile
322 .getProperty(jcrProperty
).getString() : null;
323 if (value
!= null && jcrValue
!= null) {
324 if (!value
.equals(jcrValue
))
325 modifications
.put(jcrProperty
, value
);
326 } else if (value
!= null && jcrValue
== null) {
327 modifications
.put(jcrProperty
, value
);
328 } else if (value
== null && jcrValue
!= null) {
329 modifications
.put(jcrProperty
, value
);
331 } catch (Exception e
) {
332 throw new ArgeoException("Cannot map JCR property " + jcrProperty
341 public void mapUserToContext(UserDetails user
, final DirContextAdapter ctx
) {
342 if (!(user
instanceof JcrUserDetails
))
343 throw new ArgeoException("Unsupported user details: "
346 ctx
.setAttributeValues("objectClass", userClasses
);
347 ctx
.setAttributeValue(usernameAttribute
, user
.getUsername());
348 ctx
.setAttributeValue(passwordAttribute
,
349 encodePassword(user
.getPassword()));
351 final JcrUserDetails jcrUserDetails
= (JcrUserDetails
) user
;
353 Node userProfile
= nodeSession
354 .getNode(jcrUserDetails
.getHomePath()).getNode(
356 for (String jcrProperty
: propertyToAttributes
.keySet()) {
357 if (userProfile
.hasProperty(jcrProperty
)) {
358 ModificationItem mi
= jcrToLdap(jcrProperty
, userProfile
359 .getProperty(jcrProperty
).getString());
361 ctx
.setAttribute(mi
.getAttribute());
364 if (log
.isTraceEnabled())
365 log
.trace("Mapped " + userProfile
+ " to " + ctx
.getDn());
366 } catch (RepositoryException e
) {
367 throw new ArgeoException("Cannot synchronize JCR and LDAP", e
);
372 /** Maps a JCR property to an LDAP property */
373 protected ModificationItem
jcrToLdap(String jcrProperty
, String value
) {
374 // TODO do we really need DirContextOperations?
376 String ldapAttribute
;
377 if (propertyToAttributes
.containsKey(jcrProperty
))
378 ldapAttribute
= propertyToAttributes
.get(jcrProperty
);
382 // fix issue with empty 'sn' in LDAP
383 if (ldapAttribute
.equals("sn") && (value
.trim().equals("")))
385 // fix issue with empty 'description' in LDAP
386 if (ldapAttribute
.equals("description") && value
.trim().equals(""))
388 BasicAttribute attr
= new BasicAttribute(
389 propertyToAttributes
.get(jcrProperty
), value
);
390 ModificationItem mi
= new ModificationItem(
391 DirContext
.REPLACE_ATTRIBUTE
, attr
);
393 } catch (Exception e
) {
394 throw new ArgeoException("Cannot map JCR property " + jcrProperty
402 protected String
encodePassword(String password
) {
403 if (!password
.startsWith("{")) {
404 byte[] salt
= new byte[16];
405 random
.nextBytes(salt
);
406 return passwordEncoder
.encodePassword(password
, salt
);
412 private static Random
createRandom() {
414 return SecureRandom
.getInstance("SHA1PRNG");
415 } catch (NoSuchAlgorithmException e
) {
416 return new Random(System
.currentTimeMillis());
421 * DEPENDENCY INJECTION
424 public void setLdapTemplate(LdapTemplate ldapTemplate
) {
425 this.ldapTemplate
= ldapTemplate
;
428 public void setRawLdapTemplate(LdapTemplate rawLdapTemplate
) {
429 this.rawLdapTemplate
= rawLdapTemplate
;
432 public void setRepository(Repository repository
) {
433 this.repository
= repository
;
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 public void setJcrSecurityModel(JcrSecurityModel jcrSecurityModel
) {
465 this.jcrSecurityModel
= jcrSecurityModel
;
468 /** Listen to LDAP */
469 class LdapUserListener
implements ObjectChangeListener
,
470 NamespaceChangeListener
, UnsolicitedNotificationListener
{
472 public void namingExceptionThrown(NamingExceptionEvent evt
) {
473 evt
.getException().printStackTrace();
476 public void objectChanged(NamingEvent evt
) {
477 Binding user
= evt
.getNewBinding();
478 // TODO find a way not to be called when JCR is the source of the
480 DirContextAdapter ctx
= (DirContextAdapter
) ldapTemplate
481 .lookup(user
.getName());
485 public void objectAdded(NamingEvent evt
) {
486 Binding user
= evt
.getNewBinding();
487 DirContextAdapter ctx
= (DirContextAdapter
) ldapTemplate
488 .lookup(user
.getName());
492 public void objectRemoved(NamingEvent evt
) {
493 if (log
.isDebugEnabled())
497 public void objectRenamed(NamingEvent evt
) {
498 if (log
.isDebugEnabled())
502 public void notificationReceived(UnsolicitedNotificationEvent evt
) {
503 UnsolicitedNotification notification
= evt
.getNotification();
504 NamingException ne
= notification
.getException();
505 String msg
= "LDAP notification " + "ID=" + notification
.getID()
506 + ", referrals=" + notification
.getReferrals();
508 if (log
.isTraceEnabled())
509 log
.trace(msg
+ ", exception= " + ne
, ne
);
511 log
.warn(msg
+ ", exception= " + ne
);
512 } else if (log
.isDebugEnabled()) {
513 log
.debug("Unsollicited LDAP notification " + msg
);
520 class JcrProfileListener
implements EventListener
{
522 public void onEvent(EventIterator events
) {
524 final Map
<Name
, List
<ModificationItem
>> modifications
= new HashMap
<Name
, List
<ModificationItem
>>();
525 while (events
.hasNext()) {
526 Event event
= events
.nextEvent();
528 if (Event
.PROPERTY_CHANGED
== event
.getType()) {
529 Property property
= (Property
) nodeSession
530 .getItem(event
.getPath());
531 String propertyName
= property
.getName();
532 Node userProfile
= property
.getParent();
533 String username
= userProfile
.getProperty(
534 ARGEO_USER_ID
).getString();
535 if (propertyToAttributes
.containsKey(propertyName
)) {
536 Name name
= usernameMapper
.buildDn(username
);
537 if (!modifications
.containsKey(name
))
538 modifications
.put(name
,
539 new ArrayList
<ModificationItem
>());
540 String value
= property
.getString();
541 ModificationItem mi
= jcrToLdap(propertyName
,
544 modifications
.get(name
).add(mi
);
546 } else if (Event
.NODE_ADDED
== event
.getType()) {
547 Node userProfile
= nodeSession
.getNode(event
549 String username
= userProfile
.getProperty(
550 ARGEO_USER_ID
).getString();
551 Name name
= usernameMapper
.buildDn(username
);
552 for (String propertyName
: propertyToAttributes
554 if (!modifications
.containsKey(name
))
555 modifications
.put(name
,
556 new ArrayList
<ModificationItem
>());
557 String value
= userProfile
.getProperty(
558 propertyName
).getString();
559 ModificationItem mi
= jcrToLdap(propertyName
,
562 modifications
.get(name
).add(mi
);
565 } catch (RepositoryException e
) {
566 throw new ArgeoException("Cannot process event "
571 for (Name name
: modifications
.keySet()) {
572 List
<ModificationItem
> userModifs
= modifications
.get(name
);
573 int modifCount
= userModifs
.size();
574 ldapTemplate
.modifyAttributes(name
, userModifs
575 .toArray(new ModificationItem
[modifCount
]));
576 if (log
.isDebugEnabled())
577 log
.debug("Mapped " + modifCount
+ " JCR modification"
578 + (modifCount
== 1 ?
"" : "s") + " to " + name
);
580 } catch (Exception e
) {
581 // if (log.isDebugEnabled())
582 // e.printStackTrace();
583 throw new ArgeoException("Cannot process JCR events ("
584 + e
.getMessage() + ")", e
);