]> 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
Improve user admin
[lgpl/argeo-commons.git] / security / runtime / org.argeo.security.ldap / src / main / java / org / argeo / security / ldap / jcr / JcrLdapSynchronizer.java
1 package org.argeo.security.ldap.jcr;
2
3 import java.security.NoSuchAlgorithmException;
4 import java.security.SecureRandom;
5 import java.util.ArrayList;
6 import java.util.Arrays;
7 import java.util.HashMap;
8 import java.util.List;
9 import java.util.Map;
10 import java.util.Random;
11 import java.util.SortedSet;
12
13 import javax.jcr.Node;
14 import javax.jcr.NodeIterator;
15 import javax.jcr.Property;
16 import javax.jcr.Repository;
17 import javax.jcr.RepositoryException;
18 import javax.jcr.Session;
19 import javax.jcr.observation.Event;
20 import javax.jcr.observation.EventIterator;
21 import javax.jcr.observation.EventListener;
22 import javax.jcr.query.Query;
23 import javax.jcr.version.VersionManager;
24 import javax.naming.Binding;
25 import javax.naming.Name;
26 import javax.naming.NamingException;
27 import javax.naming.directory.BasicAttribute;
28 import javax.naming.directory.DirContext;
29 import javax.naming.directory.ModificationItem;
30 import javax.naming.directory.SearchControls;
31 import javax.naming.event.EventDirContext;
32 import javax.naming.event.NamespaceChangeListener;
33 import javax.naming.event.NamingEvent;
34 import javax.naming.event.NamingExceptionEvent;
35 import javax.naming.event.NamingListener;
36 import javax.naming.event.ObjectChangeListener;
37 import javax.naming.ldap.UnsolicitedNotification;
38 import javax.naming.ldap.UnsolicitedNotificationEvent;
39 import javax.naming.ldap.UnsolicitedNotificationListener;
40
41 import org.apache.commons.logging.Log;
42 import org.apache.commons.logging.LogFactory;
43 import org.argeo.ArgeoException;
44 import org.argeo.jcr.ArgeoNames;
45 import org.argeo.jcr.ArgeoTypes;
46 import org.argeo.jcr.JcrUtils;
47 import org.argeo.security.jcr.JcrUserDetails;
48 import org.springframework.ldap.core.ContextExecutor;
49 import org.springframework.ldap.core.ContextMapper;
50 import org.springframework.ldap.core.DirContextAdapter;
51 import org.springframework.ldap.core.DirContextOperations;
52 import org.springframework.ldap.core.DistinguishedName;
53 import org.springframework.ldap.core.LdapTemplate;
54 import org.springframework.security.GrantedAuthority;
55 import org.springframework.security.ldap.LdapUsernameToDnMapper;
56 import org.springframework.security.providers.encoding.PasswordEncoder;
57 import org.springframework.security.userdetails.UserDetails;
58 import org.springframework.security.userdetails.ldap.UserDetailsContextMapper;
59
60 /** Guarantees that LDAP and JCR are in line. */
61 public class JcrLdapSynchronizer implements UserDetailsContextMapper,
62 ArgeoNames {
63 private final static Log log = LogFactory.getLog(JcrLdapSynchronizer.class);
64
65 // LDAP
66 private LdapTemplate ldapTemplate;
67 /**
68 * LDAP template whose context source has an object factory set to null. see
69 * <a href=
70 * "http://forum.springsource.org/showthread.php?55955-Persistent-search-with-spring-ldap"
71 * >this</a>
72 */
73 private LdapTemplate rawLdapTemplate;
74
75 private String userBase;
76 private String usernameAttribute;
77 private String passwordAttribute;
78 private String[] userClasses;
79
80 private NamingListener ldapUserListener;
81 private SearchControls subTreeSearchControls;
82 private LdapUsernameToDnMapper usernameMapper;
83
84 private PasswordEncoder passwordEncoder;
85 private final Random random;
86
87 // JCR
88 /** Admin session on the security workspace */
89 private Session securitySession;
90 private Repository repository;
91
92 private String securityWorkspace = "security";
93
94 private JcrProfileListener jcrProfileListener;
95
96 // Mapping
97 private Map<String, String> propertyToAttributes = new HashMap<String, String>();
98
99 public JcrLdapSynchronizer() {
100 random = createRandom();
101 }
102
103 public void init() {
104 try {
105 securitySession = repository.login(securityWorkspace);
106
107 synchronize();
108
109 // LDAP
110 subTreeSearchControls = new SearchControls();
111 subTreeSearchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
112 // LDAP listener
113 ldapUserListener = new LdapUserListener();
114 rawLdapTemplate.executeReadOnly(new ContextExecutor() {
115 public Object executeWithContext(DirContext ctx)
116 throws NamingException {
117 EventDirContext ectx = (EventDirContext) ctx.lookup("");
118 ectx.addNamingListener(userBase, "(" + usernameAttribute
119 + "=*)", subTreeSearchControls, ldapUserListener);
120 return null;
121 }
122 });
123
124 // JCR
125 String[] nodeTypes = { ArgeoTypes.ARGEO_USER_PROFILE };
126 jcrProfileListener = new JcrProfileListener();
127 // noLocal is used so that we are not notified when we modify JCR
128 // from LDAP
129 securitySession
130 .getWorkspace()
131 .getObservationManager()
132 .addEventListener(jcrProfileListener,
133 Event.PROPERTY_CHANGED | Event.NODE_ADDED, "/",
134 true, null, nodeTypes, true);
135 } catch (Exception e) {
136 JcrUtils.logoutQuietly(securitySession);
137 throw new ArgeoException("Cannot initialize LDAP/JCR synchronizer",
138 e);
139 }
140 }
141
142 public void destroy() {
143 JcrUtils.removeListenerQuietly(securitySession, jcrProfileListener);
144 JcrUtils.logoutQuietly(securitySession);
145 try {
146 rawLdapTemplate.executeReadOnly(new ContextExecutor() {
147 public Object executeWithContext(DirContext ctx)
148 throws NamingException {
149 EventDirContext ectx = (EventDirContext) ctx.lookup("");
150 ectx.removeNamingListener(ldapUserListener);
151 return null;
152 }
153 });
154 } catch (Exception e) {
155 // silent (LDAP server may have been shutdown already)
156 if (log.isTraceEnabled())
157 log.trace("Cannot remove LDAP listener", e);
158 }
159 }
160
161 /*
162 * LDAP TO JCR
163 */
164 /** Full synchronization between LDAP and JCR. LDAP has priority. */
165 protected void synchronize() {
166 try {
167 Name userBaseName = new DistinguishedName(userBase);
168 // TODO subtree search?
169 @SuppressWarnings("unchecked")
170 List<String> userPaths = (List<String>) ldapTemplate.listBindings(
171 userBaseName, new ContextMapper() {
172 public Object mapFromContext(Object ctxObj) {
173 return mapLdapToJcr((DirContextAdapter) ctxObj);
174 }
175 });
176
177 // disable accounts which are not in LDAP
178 Query query = securitySession
179 .getWorkspace()
180 .getQueryManager()
181 .createQuery(
182 "select * from [" + ArgeoTypes.ARGEO_USER_PROFILE
183 + "]", Query.JCR_SQL2);
184 NodeIterator it = query.execute().getNodes();
185 while (it.hasNext()) {
186 Node userProfile = it.nextNode();
187 String path = userProfile.getPath();
188 if (!userPaths.contains(path)) {
189 log.warn("Path "
190 + path
191 + " not found in LDAP, disabling user "
192 + userProfile.getProperty(ArgeoNames.ARGEO_USER_ID)
193 .getString());
194 VersionManager versionManager = securitySession
195 .getWorkspace().getVersionManager();
196 versionManager.checkout(userProfile.getPath());
197 userProfile.setProperty(ArgeoNames.ARGEO_ENABLED, false);
198 securitySession.save();
199 versionManager.checkin(userProfile.getPath());
200 }
201 }
202 } catch (Exception e) {
203 JcrUtils.discardQuietly(securitySession);
204 throw new ArgeoException("Cannot synchronized LDAP and JCR", e);
205 }
206 }
207
208 /** Called during authentication in order to retrieve user details */
209 public UserDetails mapUserFromContext(final DirContextOperations ctx,
210 final String username, GrantedAuthority[] authorities) {
211 if (ctx == null)
212 throw new ArgeoException("No LDAP information for user " + username);
213 Node userProfile = JcrUtils.createUserProfileIfNeeded(securitySession,
214 username);
215 JcrUserDetails.checkAccountStatus(userProfile);
216
217 // password
218 SortedSet<?> passwordAttributes = ctx
219 .getAttributeSortedStringSet(passwordAttribute);
220 String password;
221 if (passwordAttributes == null || passwordAttributes.size() == 0) {
222 throw new ArgeoException("No password found for user " + username);
223 } else {
224 byte[] arr = (byte[]) passwordAttributes.first();
225 password = new String(arr);
226 // erase password
227 Arrays.fill(arr, (byte) 0);
228 }
229
230 try {
231 return new JcrUserDetails(userProfile, password, authorities);
232 } catch (RepositoryException e) {
233 throw new ArgeoException("Cannot retrieve user details for "
234 + username, e);
235 }
236 }
237
238 /**
239 * Writes an LDAP context to the JCR user profile.
240 *
241 * @return path to user profile
242 */
243 protected synchronized String mapLdapToJcr(DirContextAdapter ctx) {
244 Session session = securitySession;
245 try {
246 // process
247 String username = ctx.getStringAttribute(usernameAttribute);
248 Node userHome = JcrUtils.createUserHomeIfNeeded(session, username);
249 Node userProfile; // = userHome.getNode(ARGEO_PROFILE);
250 if (userHome.hasNode(ARGEO_PROFILE)) {
251 userProfile = userHome.getNode(ARGEO_PROFILE);
252
253 // compatibility with legacy, will be removed
254 if (!userProfile.hasProperty(ARGEO_ENABLED)) {
255 session.getWorkspace().getVersionManager()
256 .checkout(userProfile.getPath());
257 userProfile.setProperty(ARGEO_ENABLED, true);
258 userProfile.setProperty(ARGEO_ACCOUNT_NON_EXPIRED, true);
259 userProfile.setProperty(ARGEO_ACCOUNT_NON_LOCKED, true);
260 userProfile
261 .setProperty(ARGEO_CREDENTIALS_NON_EXPIRED, true);
262 session.save();
263 session.getWorkspace().getVersionManager()
264 .checkin(userProfile.getPath());
265 }
266 } else {
267 userProfile = JcrUtils.createUserProfile(securitySession,
268 username);
269 userProfile.getSession().save();
270 userProfile.getSession().getWorkspace().getVersionManager()
271 .checkin(userProfile.getPath());
272 }
273
274 Map<String, String> modifications = new HashMap<String, String>();
275 for (String jcrProperty : propertyToAttributes.keySet())
276 ldapToJcr(userProfile, jcrProperty, ctx, modifications);
277
278 // assign default values
279 // if (!userProfile.hasProperty(Property.JCR_DESCRIPTION)
280 // && !modifications.containsKey(Property.JCR_DESCRIPTION))
281 // modifications.put(Property.JCR_DESCRIPTION, "");
282 // if (!userProfile.hasProperty(Property.JCR_TITLE))
283 // modifications.put(Property.JCR_TITLE,
284 // userProfile.getProperty(ARGEO_FIRST_NAME).getString()
285 // + " "
286 // + userProfile.getProperty(ARGEO_LAST_NAME)
287 // .getString());
288 int modifCount = modifications.size();
289 if (modifCount > 0) {
290 session.getWorkspace().getVersionManager()
291 .checkout(userProfile.getPath());
292 for (String prop : modifications.keySet())
293 userProfile.setProperty(prop, modifications.get(prop));
294 JcrUtils.updateLastModified(userProfile);
295 session.save();
296 session.getWorkspace().getVersionManager()
297 .checkin(userProfile.getPath());
298 if (log.isDebugEnabled())
299 log.debug("Mapped " + modifCount + " LDAP modification"
300 + (modifCount == 1 ? "" : "s") + " from "
301 + ctx.getDn() + " to " + userProfile);
302 }
303 return userProfile.getPath();
304 } catch (Exception e) {
305 JcrUtils.discardQuietly(session);
306 throw new ArgeoException("Cannot synchronize JCR and LDAP", e);
307 }
308 }
309
310 /** Maps an LDAP property to a JCR property */
311 protected void ldapToJcr(Node userProfile, String jcrProperty,
312 DirContextOperations ctx, Map<String, String> modifications) {
313 // TODO do we really need DirContextOperations?
314 try {
315 String ldapAttribute;
316 if (propertyToAttributes.containsKey(jcrProperty))
317 ldapAttribute = propertyToAttributes.get(jcrProperty);
318 else
319 throw new ArgeoException(
320 "No LDAP attribute mapped for JCR proprty "
321 + jcrProperty);
322
323 String value = ctx.getStringAttribute(ldapAttribute);
324 // if (value == null && Property.JCR_TITLE.equals(jcrProperty))
325 // value = "";
326 // if (value == null &&
327 // Property.JCR_DESCRIPTION.equals(jcrProperty))
328 // value = "";
329 String jcrValue = userProfile.hasProperty(jcrProperty) ? userProfile
330 .getProperty(jcrProperty).getString() : null;
331 if (value != null && jcrValue != null) {
332 if (!value.equals(jcrValue))
333 modifications.put(jcrProperty, value);
334 } else if (value != null && jcrValue == null) {
335 modifications.put(jcrProperty, value);
336 } else if (value == null && jcrValue != null) {
337 modifications.put(jcrProperty, value);
338 }
339 } catch (Exception e) {
340 throw new ArgeoException("Cannot map JCR property " + jcrProperty
341 + " from LDAP", e);
342 }
343 }
344
345 /*
346 * JCR to LDAP
347 */
348
349 public void mapUserToContext(UserDetails user, final DirContextAdapter ctx) {
350 if (!(user instanceof JcrUserDetails))
351 throw new ArgeoException("Unsupported user details: "
352 + user.getClass());
353
354 ctx.setAttributeValues("objectClass", userClasses);
355 ctx.setAttributeValue(usernameAttribute, user.getUsername());
356 ctx.setAttributeValue(passwordAttribute,
357 encodePassword(user.getPassword()));
358
359 final JcrUserDetails jcrUserDetails = (JcrUserDetails) user;
360 try {
361 Node userProfile = securitySession.getNode(
362 jcrUserDetails.getHomePath()).getNode(ARGEO_PROFILE);
363 for (String jcrProperty : propertyToAttributes.keySet()) {
364 if (userProfile.hasProperty(jcrProperty)) {
365 ModificationItem mi = jcrToLdap(jcrProperty, userProfile
366 .getProperty(jcrProperty).getString());
367 if (mi != null)
368 ctx.setAttribute(mi.getAttribute());
369 }
370 }
371 if (log.isTraceEnabled())
372 log.trace("Mapped " + userProfile + " to " + ctx.getDn());
373 } catch (RepositoryException e) {
374 throw new ArgeoException("Cannot synchronize JCR and LDAP", e);
375 }
376
377 }
378
379 /** Maps a JCR property to an LDAP property */
380 protected ModificationItem jcrToLdap(String jcrProperty, String value) {
381 // TODO do we really need DirContextOperations?
382 try {
383 String ldapAttribute;
384 if (propertyToAttributes.containsKey(jcrProperty))
385 ldapAttribute = propertyToAttributes.get(jcrProperty);
386 else
387 return null;
388
389 // fix issue with empty 'sn' in LDAP
390 if (ldapAttribute.equals("sn") && (value.trim().equals("")))
391 return null;
392 // fix issue with empty 'description' in LDAP
393 if (ldapAttribute.equals("description") && value.trim().equals(""))
394 return null;
395 BasicAttribute attr = new BasicAttribute(
396 propertyToAttributes.get(jcrProperty), value);
397 ModificationItem mi = new ModificationItem(
398 DirContext.REPLACE_ATTRIBUTE, attr);
399 return mi;
400 } catch (Exception e) {
401 throw new ArgeoException("Cannot map JCR property " + jcrProperty
402 + " from LDAP", e);
403 }
404 }
405
406 /*
407 * UTILITIES
408 */
409 protected String encodePassword(String password) {
410 if (!password.startsWith("{")) {
411 byte[] salt = new byte[16];
412 random.nextBytes(salt);
413 return passwordEncoder.encodePassword(password, salt);
414 } else {
415 return password;
416 }
417 }
418
419 private static Random createRandom() {
420 try {
421 return SecureRandom.getInstance("SHA1PRNG");
422 } catch (NoSuchAlgorithmException e) {
423 return new Random(System.currentTimeMillis());
424 }
425 }
426
427 /*
428 * DEPENDENCY INJECTION
429 */
430
431 public void setLdapTemplate(LdapTemplate ldapTemplate) {
432 this.ldapTemplate = ldapTemplate;
433 }
434
435 public void setRawLdapTemplate(LdapTemplate rawLdapTemplate) {
436 this.rawLdapTemplate = rawLdapTemplate;
437 }
438
439 public void setRepository(Repository repository) {
440 this.repository = repository;
441 }
442
443 public void setSecurityWorkspace(String securityWorkspace) {
444 this.securityWorkspace = securityWorkspace;
445 }
446
447 public void setUserBase(String userBase) {
448 this.userBase = userBase;
449 }
450
451 public void setUsernameAttribute(String usernameAttribute) {
452 this.usernameAttribute = usernameAttribute;
453 }
454
455 public void setPropertyToAttributes(Map<String, String> propertyToAttributes) {
456 this.propertyToAttributes = propertyToAttributes;
457 }
458
459 public void setUsernameMapper(LdapUsernameToDnMapper usernameMapper) {
460 this.usernameMapper = usernameMapper;
461 }
462
463 public void setPasswordAttribute(String passwordAttribute) {
464 this.passwordAttribute = passwordAttribute;
465 }
466
467 public void setUserClasses(String[] userClasses) {
468 this.userClasses = userClasses;
469 }
470
471 public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
472 this.passwordEncoder = passwordEncoder;
473 }
474
475 /** Listen to LDAP */
476 class LdapUserListener implements ObjectChangeListener,
477 NamespaceChangeListener, UnsolicitedNotificationListener {
478
479 public void namingExceptionThrown(NamingExceptionEvent evt) {
480 evt.getException().printStackTrace();
481 }
482
483 public void objectChanged(NamingEvent evt) {
484 Binding user = evt.getNewBinding();
485 // TODO find a way not to be called when JCR is the source of the
486 // modification
487 DirContextAdapter ctx = (DirContextAdapter) ldapTemplate
488 .lookup(user.getName());
489 mapLdapToJcr(ctx);
490 }
491
492 public void objectAdded(NamingEvent evt) {
493 Binding user = evt.getNewBinding();
494 DirContextAdapter ctx = (DirContextAdapter) ldapTemplate
495 .lookup(user.getName());
496 mapLdapToJcr(ctx);
497 }
498
499 public void objectRemoved(NamingEvent evt) {
500 if (log.isDebugEnabled())
501 log.debug(evt);
502 }
503
504 public void objectRenamed(NamingEvent evt) {
505 if (log.isDebugEnabled())
506 log.debug(evt);
507 }
508
509 public void notificationReceived(UnsolicitedNotificationEvent evt) {
510 UnsolicitedNotification notification = evt.getNotification();
511 NamingException ne = notification.getException();
512 String msg = "LDAP notification " + "ID=" + notification.getID()
513 + ", referrals=" + notification.getReferrals();
514 if (ne != null) {
515 if (log.isTraceEnabled())
516 log.trace(msg + ", exception= " + ne, ne);
517 else
518 log.warn(msg + ", exception= " + ne);
519 } else if (log.isDebugEnabled()) {
520 log.debug("Unsollicited LDAP notification " + msg);
521 }
522 }
523
524 }
525
526 /** Listen to JCR */
527 class JcrProfileListener implements EventListener {
528
529 public void onEvent(EventIterator events) {
530 try {
531 final Map<Name, List<ModificationItem>> modifications = new HashMap<Name, List<ModificationItem>>();
532 while (events.hasNext()) {
533 Event event = events.nextEvent();
534 try {
535 if (Event.PROPERTY_CHANGED == event.getType()) {
536 Property property = (Property) securitySession
537 .getItem(event.getPath());
538 String propertyName = property.getName();
539 Node userProfile = property.getParent();
540 String username = userProfile.getProperty(
541 ARGEO_USER_ID).getString();
542 if (propertyToAttributes.containsKey(propertyName)) {
543 Name name = usernameMapper.buildDn(username);
544 if (!modifications.containsKey(name))
545 modifications.put(name,
546 new ArrayList<ModificationItem>());
547 String value = property.getString();
548 ModificationItem mi = jcrToLdap(propertyName,
549 value);
550 if (mi != null)
551 modifications.get(name).add(mi);
552 }
553 } else if (Event.NODE_ADDED == event.getType()) {
554 Node userProfile = securitySession.getNode(event
555 .getPath());
556 String username = userProfile.getProperty(
557 ARGEO_USER_ID).getString();
558 Name name = usernameMapper.buildDn(username);
559 for (String propertyName : propertyToAttributes
560 .keySet()) {
561 if (!modifications.containsKey(name))
562 modifications.put(name,
563 new ArrayList<ModificationItem>());
564 String value = userProfile.getProperty(
565 propertyName).getString();
566 ModificationItem mi = jcrToLdap(propertyName,
567 value);
568 if (mi != null)
569 modifications.get(name).add(mi);
570 }
571 }
572 } catch (RepositoryException e) {
573 throw new ArgeoException("Cannot process event "
574 + event, e);
575 }
576 }
577
578 for (Name name : modifications.keySet()) {
579 List<ModificationItem> userModifs = modifications.get(name);
580 int modifCount = userModifs.size();
581 ldapTemplate.modifyAttributes(name, userModifs
582 .toArray(new ModificationItem[modifCount]));
583 if (log.isDebugEnabled())
584 log.debug("Mapped " + modifCount + " JCR modification"
585 + (modifCount == 1 ? "" : "s") + " to " + name);
586 }
587 } catch (Exception e) {
588 // if (log.isDebugEnabled())
589 // e.printStackTrace();
590 throw new ArgeoException("Cannot process JCR events ("
591 + e.getMessage() + ")", e);
592 }
593 }
594
595 }
596 }