]> git.argeo.org Git - lgpl/argeo-commons.git/blob - JcrLdapSynchronizer.java
669231bc91e483944e357ef1c18cc2003d7cf6f8
[lgpl/argeo-commons.git] / JcrLdapSynchronizer.java
1 /*
2 * Copyright (C) 2007-2012 Mathieu Baudier
3 *
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
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
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.
15 */
16 package org.argeo.security.ldap.jcr;
17
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;
24 import java.util.Map;
25 import java.util.Random;
26 import java.util.SortedSet;
27
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;
55
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;
75
76 /** Makes sure that LDAP and JCR are in line. */
77 public class JcrLdapSynchronizer implements UserDetailsContextMapper,
78 ArgeoNames {
79 private final static Log log = LogFactory.getLog(JcrLdapSynchronizer.class);
80
81 // LDAP
82 private LdapTemplate ldapTemplate;
83 /**
84 * LDAP template whose context source has an object factory set to null. see
85 * <a href=
86 * "http://forum.springsource.org/showthread.php?55955-Persistent-search-with-spring-ldap"
87 * >this</a>
88 */
89 private LdapTemplate rawLdapTemplate;
90
91 private String userBase;
92 private String usernameAttribute;
93 private String passwordAttribute;
94 private String[] userClasses;
95
96 private NamingListener ldapUserListener;
97 private SearchControls subTreeSearchControls;
98 private LdapUsernameToDnMapper usernameMapper;
99
100 private PasswordEncoder passwordEncoder;
101 private final Random random;
102
103 // JCR
104 /** Admin session on the security workspace */
105 private Session nodeSession;
106 private Repository repository;
107
108 private JcrProfileListener jcrProfileListener;
109 private JcrSecurityModel jcrSecurityModel = new JcrSecurityModel();
110
111 // Mapping
112 private Map<String, String> propertyToAttributes = new HashMap<String, String>();
113
114 public JcrLdapSynchronizer() {
115 random = createRandom();
116 }
117
118 public void init() {
119 try {
120 nodeSession = repository.login();
121
122 synchronize();
123
124 // LDAP
125 subTreeSearchControls = new SearchControls();
126 subTreeSearchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
127 // LDAP listener
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);
135 return null;
136 }
137 });
138
139 // JCR
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
143 // from LDAP
144 nodeSession
145 .getWorkspace()
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",
153 e);
154 }
155 }
156
157 public void destroy() {
158 JcrUtils.removeListenerQuietly(nodeSession, jcrProfileListener);
159 JcrUtils.logoutQuietly(nodeSession);
160 try {
161 rawLdapTemplate.executeReadOnly(new ContextExecutor() {
162 public Object executeWithContext(DirContext ctx)
163 throws NamingException {
164 EventDirContext ectx = (EventDirContext) ctx.lookup("");
165 ectx.removeNamingListener(ldapUserListener);
166 return null;
167 }
168 });
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);
173 }
174 }
175
176 /*
177 * LDAP TO JCR
178 */
179 /** Full synchronization between LDAP and JCR. LDAP has priority. */
180 protected void synchronize() {
181 try {
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 try {
189 return mapLdapToJcr((DirContextAdapter) ctxObj);
190 } catch (Exception e) {
191 // do not break process because of error
192 log.error(
193 "Could not LDAP->JCR synchronize user "
194 + ctxObj, e);
195 return null;
196 }
197 }
198 });
199
200 // disable accounts which are not in LDAP
201 Query query = nodeSession
202 .getWorkspace()
203 .getQueryManager()
204 .createQuery(
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)) {
212 log.warn("Path "
213 + path
214 + " not found in LDAP, disabling user "
215 + userProfile.getProperty(ArgeoNames.ARGEO_USER_ID)
216 .getString());
217 VersionManager versionManager = nodeSession.getWorkspace()
218 .getVersionManager();
219 versionManager.checkout(userProfile.getPath());
220 userProfile.setProperty(ArgeoNames.ARGEO_ENABLED, false);
221 nodeSession.save();
222 versionManager.checkin(userProfile.getPath());
223 }
224 }
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);
229 }
230 }
231
232 /** Called during authentication in order to retrieve user details */
233 public UserDetails mapUserFromContext(final DirContextOperations ctx,
234 final String username, GrantedAuthority[] authorities) {
235 if (ctx == null)
236 throw new ArgeoException("No LDAP information for user " + username);
237
238 // Node userProfile = SecurityJcrUtils.createUserProfileIfNeeded(
239 // securitySession, username);
240 Node userProfile = jcrSecurityModel.sync(nodeSession, username);
241 // JcrUserDetails.checkAccountStatus(userProfile);
242
243 // password
244 SortedSet<?> passwordAttributes = ctx
245 .getAttributeSortedStringSet(passwordAttribute);
246 String password;
247 if (passwordAttributes == null || passwordAttributes.size() == 0) {
248 throw new ArgeoException("No password found for user " + username);
249 } else {
250 byte[] arr = (byte[]) passwordAttributes.first();
251 password = new String(arr);
252 // erase password
253 Arrays.fill(arr, (byte) 0);
254 }
255
256 try {
257 return new JcrUserDetails(userProfile, password, authorities);
258 } catch (RepositoryException e) {
259 throw new ArgeoException("Cannot retrieve user details for "
260 + username, e);
261 }
262 }
263
264 /**
265 * Writes an LDAP context to the JCR user profile.
266 *
267 * @return path to user profile
268 */
269 protected synchronized String mapLdapToJcr(DirContextAdapter ctx) {
270 Session session = nodeSession;
271 try {
272 // process
273 String username = ctx.getStringAttribute(usernameAttribute);
274
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);
279
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);
287 session.save();
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);
294 }
295 return userProfile.getPath();
296 } catch (Exception e) {
297 JcrUtils.discardQuietly(session);
298 throw new ArgeoException("Cannot synchronize JCR and LDAP", e);
299 }
300 }
301
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?
306 try {
307 String ldapAttribute;
308 if (propertyToAttributes.containsKey(jcrProperty))
309 ldapAttribute = propertyToAttributes.get(jcrProperty);
310 else
311 throw new ArgeoException(
312 "No LDAP attribute mapped for JCR proprty "
313 + jcrProperty);
314
315 String value = ctx.getStringAttribute(ldapAttribute);
316 // if (value == null && Property.JCR_TITLE.equals(jcrProperty))
317 // value = "";
318 // if (value == null &&
319 // Property.JCR_DESCRIPTION.equals(jcrProperty))
320 // value = "";
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);
330 }
331 } catch (Exception e) {
332 throw new ArgeoException("Cannot map JCR property " + jcrProperty
333 + " from LDAP", e);
334 }
335 }
336
337 /*
338 * JCR to LDAP
339 */
340
341 public void mapUserToContext(UserDetails user, final DirContextAdapter ctx) {
342 if (!(user instanceof JcrUserDetails))
343 throw new ArgeoException("Unsupported user details: "
344 + user.getClass());
345
346 ctx.setAttributeValues("objectClass", userClasses);
347 ctx.setAttributeValue(usernameAttribute, user.getUsername());
348 ctx.setAttributeValue(passwordAttribute,
349 encodePassword(user.getPassword()));
350
351 final JcrUserDetails jcrUserDetails = (JcrUserDetails) user;
352 try {
353 Node userProfile = nodeSession
354 .getNode(jcrUserDetails.getHomePath()).getNode(
355 ARGEO_PROFILE);
356 for (String jcrProperty : propertyToAttributes.keySet()) {
357 if (userProfile.hasProperty(jcrProperty)) {
358 ModificationItem mi = jcrToLdap(jcrProperty, userProfile
359 .getProperty(jcrProperty).getString());
360 if (mi != null)
361 ctx.setAttribute(mi.getAttribute());
362 }
363 }
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);
368 }
369
370 }
371
372 /** Maps a JCR property to an LDAP property */
373 protected ModificationItem jcrToLdap(String jcrProperty, String value) {
374 // TODO do we really need DirContextOperations?
375 try {
376 String ldapAttribute;
377 if (propertyToAttributes.containsKey(jcrProperty))
378 ldapAttribute = propertyToAttributes.get(jcrProperty);
379 else
380 return null;
381
382 // fix issue with empty 'sn' in LDAP
383 if (ldapAttribute.equals("sn") && (value.trim().equals("")))
384 return null;
385 // fix issue with empty 'description' in LDAP
386 if (ldapAttribute.equals("description") && value.trim().equals(""))
387 return null;
388 BasicAttribute attr = new BasicAttribute(
389 propertyToAttributes.get(jcrProperty), value);
390 ModificationItem mi = new ModificationItem(
391 DirContext.REPLACE_ATTRIBUTE, attr);
392 return mi;
393 } catch (Exception e) {
394 throw new ArgeoException("Cannot map JCR property " + jcrProperty
395 + " from LDAP", e);
396 }
397 }
398
399 /*
400 * UTILITIES
401 */
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);
407 } else {
408 return password;
409 }
410 }
411
412 private static Random createRandom() {
413 try {
414 return SecureRandom.getInstance("SHA1PRNG");
415 } catch (NoSuchAlgorithmException e) {
416 return new Random(System.currentTimeMillis());
417 }
418 }
419
420 /*
421 * DEPENDENCY INJECTION
422 */
423
424 public void setLdapTemplate(LdapTemplate ldapTemplate) {
425 this.ldapTemplate = ldapTemplate;
426 }
427
428 public void setRawLdapTemplate(LdapTemplate rawLdapTemplate) {
429 this.rawLdapTemplate = rawLdapTemplate;
430 }
431
432 public void setRepository(Repository repository) {
433 this.repository = repository;
434 }
435
436 public void setUserBase(String userBase) {
437 this.userBase = userBase;
438 }
439
440 public void setUsernameAttribute(String usernameAttribute) {
441 this.usernameAttribute = usernameAttribute;
442 }
443
444 public void setPropertyToAttributes(Map<String, String> propertyToAttributes) {
445 this.propertyToAttributes = propertyToAttributes;
446 }
447
448 public void setUsernameMapper(LdapUsernameToDnMapper usernameMapper) {
449 this.usernameMapper = usernameMapper;
450 }
451
452 public void setPasswordAttribute(String passwordAttribute) {
453 this.passwordAttribute = passwordAttribute;
454 }
455
456 public void setUserClasses(String[] userClasses) {
457 this.userClasses = userClasses;
458 }
459
460 public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
461 this.passwordEncoder = passwordEncoder;
462 }
463
464 public void setJcrSecurityModel(JcrSecurityModel jcrSecurityModel) {
465 this.jcrSecurityModel = jcrSecurityModel;
466 }
467
468 /** Listen to LDAP */
469 class LdapUserListener implements ObjectChangeListener,
470 NamespaceChangeListener, UnsolicitedNotificationListener {
471
472 public void namingExceptionThrown(NamingExceptionEvent evt) {
473 evt.getException().printStackTrace();
474 }
475
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
479 // modification
480 DirContextAdapter ctx = (DirContextAdapter) ldapTemplate
481 .lookup(user.getName());
482 mapLdapToJcr(ctx);
483 }
484
485 public void objectAdded(NamingEvent evt) {
486 Binding user = evt.getNewBinding();
487 DirContextAdapter ctx = (DirContextAdapter) ldapTemplate
488 .lookup(user.getName());
489 mapLdapToJcr(ctx);
490 }
491
492 public void objectRemoved(NamingEvent evt) {
493 if (log.isDebugEnabled())
494 log.debug(evt);
495 }
496
497 public void objectRenamed(NamingEvent evt) {
498 if (log.isDebugEnabled())
499 log.debug(evt);
500 }
501
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();
507 if (ne != null) {
508 if (log.isTraceEnabled())
509 log.trace(msg + ", exception= " + ne, ne);
510 else
511 log.warn(msg + ", exception= " + ne);
512 } else if (log.isDebugEnabled()) {
513 log.debug("Unsollicited LDAP notification " + msg);
514 }
515 }
516
517 }
518
519 /** Listen to JCR */
520 class JcrProfileListener implements EventListener {
521
522 public void onEvent(EventIterator events) {
523 try {
524 final Map<Name, List<ModificationItem>> modifications = new HashMap<Name, List<ModificationItem>>();
525 while (events.hasNext()) {
526 Event event = events.nextEvent();
527 try {
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,
542 value);
543 if (mi != null)
544 modifications.get(name).add(mi);
545 }
546 } else if (Event.NODE_ADDED == event.getType()) {
547 Node userProfile = nodeSession.getNode(event
548 .getPath());
549 String username = userProfile.getProperty(
550 ARGEO_USER_ID).getString();
551 Name name = usernameMapper.buildDn(username);
552 for (String propertyName : propertyToAttributes
553 .keySet()) {
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,
560 value);
561 if (mi != null)
562 modifications.get(name).add(mi);
563 }
564 }
565 } catch (RepositoryException e) {
566 throw new ArgeoException("Cannot process event "
567 + event, e);
568 }
569 }
570
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);
579 }
580 } catch (Exception e) {
581 // if (log.isDebugEnabled())
582 // e.printStackTrace();
583 throw new ArgeoException("Cannot process JCR events ("
584 + e.getMessage() + ")", e);
585 }
586 }
587
588 }
589 }