]> git.argeo.org Git - lgpl/argeo-commons.git/blob - security/runtime/org.argeo.security.ldap/src/main/java/org/argeo/security/ldap/jcr/JcrLdapSynchronizer.java
Update license headers
[lgpl/argeo-commons.git] / security / runtime / org.argeo.security.ldap / src / main / java / org / argeo / security / ldap / jcr / 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.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;
74
75 /** Guarantees that LDAP and JCR are in line. */
76 public class JcrLdapSynchronizer implements UserDetailsContextMapper,
77 ArgeoNames {
78 private final static Log log = LogFactory.getLog(JcrLdapSynchronizer.class);
79
80 // LDAP
81 private LdapTemplate ldapTemplate;
82 /**
83 * LDAP template whose context source has an object factory set to null. see
84 * <a href=
85 * "http://forum.springsource.org/showthread.php?55955-Persistent-search-with-spring-ldap"
86 * >this</a>
87 */
88 private LdapTemplate rawLdapTemplate;
89
90 private String userBase;
91 private String usernameAttribute;
92 private String passwordAttribute;
93 private String[] userClasses;
94
95 private NamingListener ldapUserListener;
96 private SearchControls subTreeSearchControls;
97 private LdapUsernameToDnMapper usernameMapper;
98
99 private PasswordEncoder passwordEncoder;
100 private final Random random;
101
102 // JCR
103 /** Admin session on the security workspace */
104 private Session securitySession;
105 private Repository repository;
106
107 private String securityWorkspace = "security";
108
109 private JcrProfileListener jcrProfileListener;
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 securitySession = repository.login(securityWorkspace);
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 securitySession
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(securitySession);
152 throw new ArgeoException("Cannot initialize LDAP/JCR synchronizer",
153 e);
154 }
155 }
156
157 public void destroy() {
158 JcrUtils.removeListenerQuietly(securitySession, jcrProfileListener);
159 JcrUtils.logoutQuietly(securitySession);
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 return mapLdapToJcr((DirContextAdapter) ctxObj);
189 }
190 });
191
192 // disable accounts which are not in LDAP
193 Query query = securitySession
194 .getWorkspace()
195 .getQueryManager()
196 .createQuery(
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)) {
204 log.warn("Path "
205 + path
206 + " not found in LDAP, disabling user "
207 + userProfile.getProperty(ArgeoNames.ARGEO_USER_ID)
208 .getString());
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());
215 }
216 }
217 } catch (Exception e) {
218 JcrUtils.discardQuietly(securitySession);
219 throw new ArgeoException("Cannot synchronized LDAP and JCR", e);
220 }
221 }
222
223 /** Called during authentication in order to retrieve user details */
224 public UserDetails mapUserFromContext(final DirContextOperations ctx,
225 final String username, GrantedAuthority[] authorities) {
226 if (ctx == null)
227 throw new ArgeoException("No LDAP information for user " + username);
228 Node userProfile = JcrUtils.createUserProfileIfNeeded(securitySession,
229 username);
230 JcrUserDetails.checkAccountStatus(userProfile);
231
232 // password
233 SortedSet<?> passwordAttributes = ctx
234 .getAttributeSortedStringSet(passwordAttribute);
235 String password;
236 if (passwordAttributes == null || passwordAttributes.size() == 0) {
237 throw new ArgeoException("No password found for user " + username);
238 } else {
239 byte[] arr = (byte[]) passwordAttributes.first();
240 password = new String(arr);
241 // erase password
242 Arrays.fill(arr, (byte) 0);
243 }
244
245 try {
246 return new JcrUserDetails(userProfile, password, authorities);
247 } catch (RepositoryException e) {
248 throw new ArgeoException("Cannot retrieve user details for "
249 + username, e);
250 }
251 }
252
253 /**
254 * Writes an LDAP context to the JCR user profile.
255 *
256 * @return path to user profile
257 */
258 protected synchronized String mapLdapToJcr(DirContextAdapter ctx) {
259 Session session = securitySession;
260 try {
261 // process
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);
267
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);
275 userProfile
276 .setProperty(ARGEO_CREDENTIALS_NON_EXPIRED, true);
277 session.save();
278 session.getWorkspace().getVersionManager()
279 .checkin(userProfile.getPath());
280 }
281 } else {
282 userProfile = JcrUtils.createUserProfile(securitySession,
283 username);
284 userProfile.getSession().save();
285 userProfile.getSession().getWorkspace().getVersionManager()
286 .checkin(userProfile.getPath());
287 }
288
289 Map<String, String> modifications = new HashMap<String, String>();
290 for (String jcrProperty : propertyToAttributes.keySet())
291 ldapToJcr(userProfile, jcrProperty, ctx, modifications);
292
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()
300 // + " "
301 // + userProfile.getProperty(ARGEO_LAST_NAME)
302 // .getString());
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);
310 session.save();
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);
317 }
318 return userProfile.getPath();
319 } catch (Exception e) {
320 JcrUtils.discardQuietly(session);
321 throw new ArgeoException("Cannot synchronize JCR and LDAP", e);
322 }
323 }
324
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?
329 try {
330 String ldapAttribute;
331 if (propertyToAttributes.containsKey(jcrProperty))
332 ldapAttribute = propertyToAttributes.get(jcrProperty);
333 else
334 throw new ArgeoException(
335 "No LDAP attribute mapped for JCR proprty "
336 + jcrProperty);
337
338 String value = ctx.getStringAttribute(ldapAttribute);
339 // if (value == null && Property.JCR_TITLE.equals(jcrProperty))
340 // value = "";
341 // if (value == null &&
342 // Property.JCR_DESCRIPTION.equals(jcrProperty))
343 // value = "";
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);
353 }
354 } catch (Exception e) {
355 throw new ArgeoException("Cannot map JCR property " + jcrProperty
356 + " from LDAP", e);
357 }
358 }
359
360 /*
361 * JCR to LDAP
362 */
363
364 public void mapUserToContext(UserDetails user, final DirContextAdapter ctx) {
365 if (!(user instanceof JcrUserDetails))
366 throw new ArgeoException("Unsupported user details: "
367 + user.getClass());
368
369 ctx.setAttributeValues("objectClass", userClasses);
370 ctx.setAttributeValue(usernameAttribute, user.getUsername());
371 ctx.setAttributeValue(passwordAttribute,
372 encodePassword(user.getPassword()));
373
374 final JcrUserDetails jcrUserDetails = (JcrUserDetails) user;
375 try {
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());
382 if (mi != null)
383 ctx.setAttribute(mi.getAttribute());
384 }
385 }
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);
390 }
391
392 }
393
394 /** Maps a JCR property to an LDAP property */
395 protected ModificationItem jcrToLdap(String jcrProperty, String value) {
396 // TODO do we really need DirContextOperations?
397 try {
398 String ldapAttribute;
399 if (propertyToAttributes.containsKey(jcrProperty))
400 ldapAttribute = propertyToAttributes.get(jcrProperty);
401 else
402 return null;
403
404 // fix issue with empty 'sn' in LDAP
405 if (ldapAttribute.equals("sn") && (value.trim().equals("")))
406 return null;
407 // fix issue with empty 'description' in LDAP
408 if (ldapAttribute.equals("description") && value.trim().equals(""))
409 return null;
410 BasicAttribute attr = new BasicAttribute(
411 propertyToAttributes.get(jcrProperty), value);
412 ModificationItem mi = new ModificationItem(
413 DirContext.REPLACE_ATTRIBUTE, attr);
414 return mi;
415 } catch (Exception e) {
416 throw new ArgeoException("Cannot map JCR property " + jcrProperty
417 + " from LDAP", e);
418 }
419 }
420
421 /*
422 * UTILITIES
423 */
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);
429 } else {
430 return password;
431 }
432 }
433
434 private static Random createRandom() {
435 try {
436 return SecureRandom.getInstance("SHA1PRNG");
437 } catch (NoSuchAlgorithmException e) {
438 return new Random(System.currentTimeMillis());
439 }
440 }
441
442 /*
443 * DEPENDENCY INJECTION
444 */
445
446 public void setLdapTemplate(LdapTemplate ldapTemplate) {
447 this.ldapTemplate = ldapTemplate;
448 }
449
450 public void setRawLdapTemplate(LdapTemplate rawLdapTemplate) {
451 this.rawLdapTemplate = rawLdapTemplate;
452 }
453
454 public void setRepository(Repository repository) {
455 this.repository = repository;
456 }
457
458 public void setSecurityWorkspace(String securityWorkspace) {
459 this.securityWorkspace = securityWorkspace;
460 }
461
462 public void setUserBase(String userBase) {
463 this.userBase = userBase;
464 }
465
466 public void setUsernameAttribute(String usernameAttribute) {
467 this.usernameAttribute = usernameAttribute;
468 }
469
470 public void setPropertyToAttributes(Map<String, String> propertyToAttributes) {
471 this.propertyToAttributes = propertyToAttributes;
472 }
473
474 public void setUsernameMapper(LdapUsernameToDnMapper usernameMapper) {
475 this.usernameMapper = usernameMapper;
476 }
477
478 public void setPasswordAttribute(String passwordAttribute) {
479 this.passwordAttribute = passwordAttribute;
480 }
481
482 public void setUserClasses(String[] userClasses) {
483 this.userClasses = userClasses;
484 }
485
486 public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
487 this.passwordEncoder = passwordEncoder;
488 }
489
490 /** Listen to LDAP */
491 class LdapUserListener implements ObjectChangeListener,
492 NamespaceChangeListener, UnsolicitedNotificationListener {
493
494 public void namingExceptionThrown(NamingExceptionEvent evt) {
495 evt.getException().printStackTrace();
496 }
497
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
501 // modification
502 DirContextAdapter ctx = (DirContextAdapter) ldapTemplate
503 .lookup(user.getName());
504 mapLdapToJcr(ctx);
505 }
506
507 public void objectAdded(NamingEvent evt) {
508 Binding user = evt.getNewBinding();
509 DirContextAdapter ctx = (DirContextAdapter) ldapTemplate
510 .lookup(user.getName());
511 mapLdapToJcr(ctx);
512 }
513
514 public void objectRemoved(NamingEvent evt) {
515 if (log.isDebugEnabled())
516 log.debug(evt);
517 }
518
519 public void objectRenamed(NamingEvent evt) {
520 if (log.isDebugEnabled())
521 log.debug(evt);
522 }
523
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();
529 if (ne != null) {
530 if (log.isTraceEnabled())
531 log.trace(msg + ", exception= " + ne, ne);
532 else
533 log.warn(msg + ", exception= " + ne);
534 } else if (log.isDebugEnabled()) {
535 log.debug("Unsollicited LDAP notification " + msg);
536 }
537 }
538
539 }
540
541 /** Listen to JCR */
542 class JcrProfileListener implements EventListener {
543
544 public void onEvent(EventIterator events) {
545 try {
546 final Map<Name, List<ModificationItem>> modifications = new HashMap<Name, List<ModificationItem>>();
547 while (events.hasNext()) {
548 Event event = events.nextEvent();
549 try {
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,
564 value);
565 if (mi != null)
566 modifications.get(name).add(mi);
567 }
568 } else if (Event.NODE_ADDED == event.getType()) {
569 Node userProfile = securitySession.getNode(event
570 .getPath());
571 String username = userProfile.getProperty(
572 ARGEO_USER_ID).getString();
573 Name name = usernameMapper.buildDn(username);
574 for (String propertyName : propertyToAttributes
575 .keySet()) {
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,
582 value);
583 if (mi != null)
584 modifications.get(name).add(mi);
585 }
586 }
587 } catch (RepositoryException e) {
588 throw new ArgeoException("Cannot process event "
589 + event, e);
590 }
591 }
592
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);
601 }
602 } catch (Exception e) {
603 // if (log.isDebugEnabled())
604 // e.printStackTrace();
605 throw new ArgeoException("Cannot process JCR events ("
606 + e.getMessage() + ")", e);
607 }
608 }
609
610 }
611 }