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