2 * Copyright (C) 2007-2012 Argeo GmbH
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
.cms
.internal
.useradmin
.ldap
;
18 import java
.security
.NoSuchAlgorithmException
;
19 import java
.security
.SecureRandom
;
20 import java
.util
.ArrayList
;
21 import java
.util
.Arrays
;
22 import java
.util
.Collection
;
23 import java
.util
.HashMap
;
24 import java
.util
.List
;
26 import java
.util
.Random
;
27 import java
.util
.SortedSet
;
29 import javax
.jcr
.Node
;
30 import javax
.jcr
.NodeIterator
;
31 import javax
.jcr
.Repository
;
32 import javax
.jcr
.RepositoryException
;
33 import javax
.jcr
.Session
;
34 import javax
.jcr
.query
.Query
;
35 import javax
.jcr
.version
.VersionManager
;
36 import javax
.naming
.Name
;
37 import javax
.naming
.directory
.BasicAttribute
;
38 import javax
.naming
.directory
.DirContext
;
39 import javax
.naming
.directory
.ModificationItem
;
41 import org
.apache
.commons
.logging
.Log
;
42 import org
.apache
.commons
.logging
.LogFactory
;
43 import org
.argeo
.ArgeoException
;
44 import org
.argeo
.cms
.internal
.auth
.JcrSecurityModel
;
45 import org
.argeo
.cms
.internal
.useradmin
.SimpleJcrSecurityModel
;
46 import org
.argeo
.jcr
.ArgeoNames
;
47 import org
.argeo
.jcr
.ArgeoTypes
;
48 import org
.argeo
.jcr
.JcrUtils
;
49 import org
.argeo
.security
.SecurityUtils
;
50 import org
.argeo
.security
.jcr
.JcrUserDetails
;
51 import org
.springframework
.ldap
.core
.ContextMapper
;
52 import org
.springframework
.ldap
.core
.DirContextAdapter
;
53 import org
.springframework
.ldap
.core
.DirContextOperations
;
54 import org
.springframework
.ldap
.core
.DistinguishedName
;
55 import org
.springframework
.ldap
.core
.LdapTemplate
;
56 import org
.springframework
.security
.authentication
.encoding
.PasswordEncoder
;
57 import org
.springframework
.security
.core
.GrantedAuthority
;
58 import org
.springframework
.security
.core
.userdetails
.UserDetails
;
59 import org
.springframework
.security
.ldap
.LdapUsernameToDnMapper
;
60 import org
.springframework
.security
.ldap
.userdetails
.UserDetailsContextMapper
;
62 /** Makes sure that LDAP and JCR are in line. */
63 @SuppressWarnings("deprecation")
64 public class JcrLdapSynchronizer
implements UserDetailsContextMapper
,
66 private final static Log log
= LogFactory
.getLog(JcrLdapSynchronizer
.class);
69 private LdapTemplate ldapTemplate
;
71 * LDAP template whose context source has an object factory set to null. see
73 * "http://forum.springsource.org/showthread.php?55955-Persistent-search-with-spring-ldap"
76 // private LdapTemplate rawLdapTemplate;
78 private String userBase
;
79 private String usernameAttribute
;
80 private String passwordAttribute
;
81 private String
[] userClasses
;
82 // private String defaultUserRole ="ROLE_USER";
84 // private NamingListener ldapUserListener;
85 // private SearchControls subTreeSearchControls;
86 private LdapUsernameToDnMapper usernameMapper
;
88 private PasswordEncoder passwordEncoder
;
89 private final Random random
;
92 /** Admin session on the main workspace */
93 private Session nodeSession
;
94 private Repository repository
;
96 // private JcrProfileListener jcrProfileListener;
97 private JcrSecurityModel jcrSecurityModel
= new SimpleJcrSecurityModel();
100 private Map
<String
, String
> propertyToAttributes
= new HashMap
<String
, String
>();
102 public JcrLdapSynchronizer() {
103 random
= createRandom();
108 nodeSession
= repository
.login();
110 // TODO put this in a different thread, and poll the LDAP server
116 // subTreeSearchControls = new SearchControls();
117 // subTreeSearchControls
118 // .setSearchScope(SearchControls.SUBTREE_SCOPE);
120 // ldapUserListener = new LdapUserListener();
121 // rawLdapTemplate.executeReadOnly(new ContextExecutor() {
122 // public Object executeWithContext(DirContext ctx)
123 // throws NamingException {
124 // EventDirContext ectx = (EventDirContext) ctx.lookup("");
125 // ectx.addNamingListener(userBase, "("
126 // + usernameAttribute + "=*)",
127 // subTreeSearchControls, ldapUserListener);
131 } catch (Exception e
) {
132 log
.error("Could not synchronize and listen to LDAP,"
133 + " probably because the LDAP server is not available."
134 + " Restart the system as soon as possible.", e
);
138 // String[] nodeTypes = { ArgeoTypes.ARGEO_USER_PROFILE };
139 // jcrProfileListener = new JcrProfileListener();
140 // noLocal is used so that we are not notified when we modify JCR
144 // .getObservationManager()
145 // .addEventListener(jcrProfileListener,
146 // Event.PROPERTY_CHANGED | Event.NODE_ADDED, "/",
147 // true, null, nodeTypes, true);
148 } catch (Exception e
) {
149 JcrUtils
.logoutQuietly(nodeSession
);
150 throw new ArgeoException("Cannot initialize LDAP/JCR synchronizer",
155 public void destroy() {
156 // JcrUtils.removeListenerQuietly(nodeSession, jcrProfileListener);
157 JcrUtils
.logoutQuietly(nodeSession
);
159 // rawLdapTemplate.executeReadOnly(new ContextExecutor() {
160 // public Object executeWithContext(DirContext ctx)
161 // throws NamingException {
162 // EventDirContext ectx = (EventDirContext) ctx.lookup("");
163 // ectx.removeNamingListener(ldapUserListener);
167 // } catch (Exception e) {
168 // // silent (LDAP server may have been shutdown already)
169 // if (log.isTraceEnabled())
170 // log.trace("Cannot remove LDAP listener", e);
177 /** Full synchronization between LDAP and JCR. LDAP has priority. */
178 protected void synchronize() {
180 Name userBaseName
= new DistinguishedName(userBase
);
181 // TODO subtree search?
182 @SuppressWarnings("unchecked")
183 List
<String
> userPaths
= (List
<String
>) ldapTemplate
.listBindings(
184 userBaseName
, new ContextMapper() {
185 public Object
mapFromContext(Object ctxObj
) {
187 return mapLdapToJcr((DirContextAdapter
) ctxObj
);
188 } catch (Exception e
) {
189 // do not break process because of error
191 "Could not LDAP->JCR synchronize user "
198 // create accounts which are not in LDAP
199 Query query
= nodeSession
203 "select * from [" + ArgeoTypes
.ARGEO_USER_PROFILE
204 + "]", Query
.JCR_SQL2
);
205 NodeIterator it
= query
.execute().getNodes();
206 while (it
.hasNext()) {
207 Node userProfile
= it
.nextNode();
208 String path
= userProfile
.getPath();
210 if (!userPaths
.contains(path
)) {
211 String username
= userProfile
212 .getProperty(ARGEO_USER_ID
).getString();
213 // GrantedAuthority[] authorities = {new
214 // GrantedAuthorityImpl(defaultUserRole)};
215 List
<GrantedAuthority
> authorities
= new ArrayList
<GrantedAuthority
>();
216 JcrUserDetails userDetails
= new JcrUserDetails(
217 userProfile
, username
, authorities
);
218 String dn
= createLdapUser(userDetails
);
219 log
.warn("Created ldap entry '" + dn
+ "' for user '"
222 // if(!userProfile.getProperty(ARGEO_ENABLED).getBoolean()){
223 // continue profiles;
228 // + " not found in LDAP, disabling user "
229 // + userProfile.getProperty(ArgeoNames.ARGEO_USER_ID)
232 // Temporary hack to repair previous behaviour
233 if (!userProfile
.getProperty(ARGEO_ENABLED
)
235 VersionManager versionManager
= nodeSession
236 .getWorkspace().getVersionManager();
237 versionManager
.checkout(userProfile
.getPath());
238 userProfile
.setProperty(ArgeoNames
.ARGEO_ENABLED
,
241 versionManager
.checkin(userProfile
.getPath());
244 } catch (Exception e
) {
245 log
.error("Cannot process " + path
, e
);
248 } catch (Exception e
) {
249 JcrUtils
.discardQuietly(nodeSession
);
250 log
.error("Cannot synchronize LDAP and JCR", e
);
251 // throw new ArgeoException("Cannot synchronize LDAP and JCR", e);
255 private String
createLdapUser(UserDetails user
) {
256 DirContextAdapter ctx
= new DirContextAdapter();
257 mapUserToContext(user
, ctx
);
258 DistinguishedName dn
= usernameMapper
.buildDn(user
.getUsername());
259 ldapTemplate
.bind(dn
, ctx
, null);
260 return dn
.toString();
263 /** Called during authentication in order to retrieve user details */
264 public UserDetails
mapUserFromContext(final DirContextOperations ctx
,
265 final String username
,
266 Collection
<?
extends GrantedAuthority
> authorities
) {
268 throw new ArgeoException("No LDAP information for user " + username
);
270 String ldapUsername
= ctx
.getStringAttribute(usernameAttribute
);
271 if (!ldapUsername
.equals(username
))
272 throw new ArgeoException("Logged in with username " + username
273 + " but LDAP user is " + ldapUsername
);
275 Node userProfile
= jcrSecurityModel
.sync(nodeSession
, username
,
276 SecurityUtils
.authoritiesToStringList(authorities
));
277 // JcrUserDetails.checkAccountStatus(userProfile);
280 SortedSet
<?
> passwordAttributes
= ctx
281 .getAttributeSortedStringSet(passwordAttribute
);
283 if (passwordAttributes
== null || passwordAttributes
.size() == 0) {
284 // throw new ArgeoException("No password found for user " +
288 byte[] arr
= (byte[]) passwordAttributes
.first();
289 password
= new String(arr
);
291 Arrays
.fill(arr
, (byte) 0);
295 return new JcrUserDetails(userProfile
, password
, authorities
);
296 } catch (RepositoryException e
) {
297 throw new ArgeoException("Cannot retrieve user details for "
303 * Writes an LDAP context to the JCR user profile.
305 * @return path to user profile
307 protected synchronized String
mapLdapToJcr(DirContextAdapter ctx
) {
308 Session session
= nodeSession
;
311 String username
= ctx
.getStringAttribute(usernameAttribute
);
313 Node userProfile
= jcrSecurityModel
.sync(session
, username
, null);
314 Map
<String
, String
> modifications
= new HashMap
<String
, String
>();
315 for (String jcrProperty
: propertyToAttributes
.keySet())
316 ldapToJcr(userProfile
, jcrProperty
, ctx
, modifications
);
318 int modifCount
= modifications
.size();
319 if (modifCount
> 0) {
320 session
.getWorkspace().getVersionManager()
321 .checkout(userProfile
.getPath());
322 for (String prop
: modifications
.keySet())
323 userProfile
.setProperty(prop
, modifications
.get(prop
));
324 JcrUtils
.updateLastModified(userProfile
);
326 session
.getWorkspace().getVersionManager()
327 .checkin(userProfile
.getPath());
328 if (log
.isDebugEnabled())
329 log
.debug("Mapped " + modifCount
+ " LDAP modification"
330 + (modifCount
== 1 ?
"" : "s") + " from "
331 + ctx
.getDn() + " to " + userProfile
);
333 return userProfile
.getPath();
334 } catch (Exception e
) {
335 JcrUtils
.discardQuietly(session
);
336 throw new ArgeoException("Cannot synchronize JCR and LDAP", e
);
340 /** Maps an LDAP property to a JCR property */
341 protected void ldapToJcr(Node userProfile
, String jcrProperty
,
342 DirContextOperations ctx
, Map
<String
, String
> modifications
) {
343 // TODO do we really need DirContextOperations?
345 String ldapAttribute
;
346 if (propertyToAttributes
.containsKey(jcrProperty
))
347 ldapAttribute
= propertyToAttributes
.get(jcrProperty
);
349 throw new ArgeoException(
350 "No LDAP attribute mapped for JCR proprty "
353 String value
= ctx
.getStringAttribute(ldapAttribute
);
354 String jcrValue
= userProfile
.hasProperty(jcrProperty
) ? userProfile
355 .getProperty(jcrProperty
).getString() : null;
356 if (value
!= null && jcrValue
!= null) {
357 if (!value
.equals(jcrValue
))
358 modifications
.put(jcrProperty
, value
);
359 } else if (value
!= null && jcrValue
== null) {
360 modifications
.put(jcrProperty
, value
);
361 } else if (value
== null && jcrValue
!= null) {
362 modifications
.put(jcrProperty
, value
);
364 } catch (Exception e
) {
365 throw new ArgeoException("Cannot map JCR property " + jcrProperty
374 public void mapUserToContext(UserDetails user
, final DirContextAdapter ctx
) {
375 if (!(user
instanceof JcrUserDetails
))
376 throw new ArgeoException("Unsupported user details: "
379 ctx
.setAttributeValues("objectClass", userClasses
);
380 ctx
.setAttributeValue(usernameAttribute
, user
.getUsername());
381 ctx
.setAttributeValue(passwordAttribute
,
382 encodePassword(user
.getPassword()));
384 final JcrUserDetails jcrUserDetails
= (JcrUserDetails
) user
;
386 Node userProfile
= nodeSession
387 .getNode(jcrUserDetails
.getHomePath()).getNode(
389 for (String jcrProperty
: propertyToAttributes
.keySet()) {
390 if (userProfile
.hasProperty(jcrProperty
)) {
391 ModificationItem mi
= jcrToLdap(jcrProperty
, userProfile
392 .getProperty(jcrProperty
).getString());
394 ctx
.setAttribute(mi
.getAttribute());
397 if (log
.isTraceEnabled())
398 log
.trace("Mapped " + userProfile
+ " to " + ctx
.getDn());
399 } catch (RepositoryException e
) {
400 throw new ArgeoException("Cannot synchronize JCR and LDAP", e
);
405 /** Maps a JCR property to an LDAP property */
406 protected ModificationItem
jcrToLdap(String jcrProperty
, String value
) {
407 // TODO do we really need DirContextOperations?
409 String ldapAttribute
;
410 if (propertyToAttributes
.containsKey(jcrProperty
))
411 ldapAttribute
= propertyToAttributes
.get(jcrProperty
);
415 // fix issue with empty 'sn' in LDAP
416 if (ldapAttribute
.equals("sn") && (value
.trim().equals("")))
418 // fix issue with empty 'description' in LDAP
419 if (ldapAttribute
.equals("description") && value
.trim().equals(""))
421 BasicAttribute attr
= new BasicAttribute(
422 propertyToAttributes
.get(jcrProperty
), value
);
423 ModificationItem mi
= new ModificationItem(
424 DirContext
.REPLACE_ATTRIBUTE
, attr
);
426 } catch (Exception e
) {
427 throw new ArgeoException("Cannot map JCR property " + jcrProperty
435 protected String
encodePassword(String password
) {
436 if (!password
.startsWith("{")) {
437 byte[] salt
= new byte[16];
438 random
.nextBytes(salt
);
439 return passwordEncoder
.encodePassword(password
, salt
);
445 private static Random
createRandom() {
447 return SecureRandom
.getInstance("SHA1PRNG");
448 } catch (NoSuchAlgorithmException e
) {
449 return new Random(System
.currentTimeMillis());
454 * DEPENDENCY INJECTION
457 public void setLdapTemplate(LdapTemplate ldapTemplate
) {
458 this.ldapTemplate
= ldapTemplate
;
461 public void setRawLdapTemplate(LdapTemplate rawLdapTemplate
) {
462 // this.rawLdapTemplate = rawLdapTemplate;
465 public void setRepository(Repository repository
) {
466 this.repository
= repository
;
469 public void setUserBase(String userBase
) {
470 this.userBase
= userBase
;
473 public void setUsernameAttribute(String usernameAttribute
) {
474 this.usernameAttribute
= usernameAttribute
;
477 public void setPropertyToAttributes(Map
<String
, String
> propertyToAttributes
) {
478 this.propertyToAttributes
= propertyToAttributes
;
481 public void setUsernameMapper(LdapUsernameToDnMapper usernameMapper
) {
482 this.usernameMapper
= usernameMapper
;
485 public void setPasswordAttribute(String passwordAttribute
) {
486 this.passwordAttribute
= passwordAttribute
;
489 public void setUserClasses(String
[] userClasses
) {
490 this.userClasses
= userClasses
;
493 public void setPasswordEncoder(PasswordEncoder passwordEncoder
) {
494 this.passwordEncoder
= passwordEncoder
;
497 public void setJcrSecurityModel(JcrSecurityModel jcrSecurityModel
) {
498 this.jcrSecurityModel
= jcrSecurityModel
;
501 /** Listen to LDAP */
502 // class LdapUserListener implements ObjectChangeListener,
503 // NamespaceChangeListener, UnsolicitedNotificationListener {
505 // public void namingExceptionThrown(NamingExceptionEvent evt) {
506 // evt.getException().printStackTrace();
509 // public void objectChanged(NamingEvent evt) {
510 // Binding user = evt.getNewBinding();
511 // // TODO find a way not to be called when JCR is the source of the
513 // DirContextAdapter ctx = (DirContextAdapter) ldapTemplate
514 // .lookup(user.getName());
515 // mapLdapToJcr(ctx);
518 // public void objectAdded(NamingEvent evt) {
519 // Binding user = evt.getNewBinding();
520 // DirContextAdapter ctx = (DirContextAdapter) ldapTemplate
521 // .lookup(user.getName());
522 // mapLdapToJcr(ctx);
525 // public void objectRemoved(NamingEvent evt) {
526 // if (log.isDebugEnabled())
530 // public void objectRenamed(NamingEvent evt) {
531 // if (log.isDebugEnabled())
535 // public void notificationReceived(UnsolicitedNotificationEvent evt) {
536 // UnsolicitedNotification notification = evt.getNotification();
537 // NamingException ne = notification.getException();
538 // String msg = "LDAP notification " + "ID=" + notification.getID()
539 // + ", referrals=" + notification.getReferrals();
541 // if (log.isTraceEnabled())
542 // log.trace(msg + ", exception= " + ne, ne);
544 // log.warn(msg + ", exception= " + ne);
545 // } else if (log.isDebugEnabled()) {
546 // log.debug("Unsollicited LDAP notification " + msg);
553 // class JcrProfileListener implements EventListener {
555 // public void onEvent(EventIterator events) {
557 // final Map<Name, List<ModificationItem>> modifications = new HashMap<Name,
558 // List<ModificationItem>>();
559 // while (events.hasNext()) {
560 // Event event = events.nextEvent();
562 // if (Event.PROPERTY_CHANGED == event.getType()) {
563 // Property property = (Property) nodeSession
564 // .getItem(event.getPath());
565 // String propertyName = property.getName();
566 // Node userProfile = property.getParent();
567 // String username = userProfile.getProperty(
568 // ARGEO_USER_ID).getString();
569 // if (propertyToAttributes.containsKey(propertyName)) {
570 // Name name = usernameMapper.buildDn(username);
571 // if (!modifications.containsKey(name))
572 // modifications.put(name,
573 // new ArrayList<ModificationItem>());
574 // String value = property.getString();
575 // ModificationItem mi = jcrToLdap(propertyName,
578 // modifications.get(name).add(mi);
580 // } else if (Event.NODE_ADDED == event.getType()) {
581 // Node userProfile = nodeSession.getNode(event
583 // String username = userProfile.getProperty(
584 // ARGEO_USER_ID).getString();
585 // Name name = usernameMapper.buildDn(username);
586 // for (String propertyName : propertyToAttributes
588 // if (!modifications.containsKey(name))
589 // modifications.put(name,
590 // new ArrayList<ModificationItem>());
591 // String value = userProfile.getProperty(
592 // propertyName).getString();
593 // ModificationItem mi = jcrToLdap(propertyName,
596 // modifications.get(name).add(mi);
599 // } catch (RepositoryException e) {
600 // throw new ArgeoException("Cannot process event "
605 // for (Name name : modifications.keySet()) {
606 // List<ModificationItem> userModifs = modifications.get(name);
607 // int modifCount = userModifs.size();
608 // ldapTemplate.modifyAttributes(name, userModifs
609 // .toArray(new ModificationItem[modifCount]));
610 // if (log.isDebugEnabled())
611 // log.debug("Mapped " + modifCount + " JCR modification"
612 // + (modifCount == 1 ? "" : "s") + " to " + name);
614 // } catch (Exception e) {
615 // // if (log.isDebugEnabled())
616 // // e.printStackTrace();
617 // throw new ArgeoException("Cannot process JCR events ("
618 // + e.getMessage() + ")", e);