]> git.argeo.org Git - lgpl/argeo-commons.git/blobdiff - security/runtime/org.argeo.security.ldap/src/main/java/org/argeo/security/ldap/jcr/JcrLdapSynchronizer.java
Big cleanup of the security layers
[lgpl/argeo-commons.git] / security / runtime / org.argeo.security.ldap / src / main / java / org / argeo / security / ldap / jcr / JcrLdapSynchronizer.java
diff --git a/security/runtime/org.argeo.security.ldap/src/main/java/org/argeo/security/ldap/jcr/JcrLdapSynchronizer.java b/security/runtime/org.argeo.security.ldap/src/main/java/org/argeo/security/ldap/jcr/JcrLdapSynchronizer.java
new file mode 100644 (file)
index 0000000..3a644a6
--- /dev/null
@@ -0,0 +1,568 @@
+package org.argeo.security.ldap.jcr;
+
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.SortedSet;
+
+import javax.jcr.Node;
+import javax.jcr.NodeIterator;
+import javax.jcr.Property;
+import javax.jcr.Repository;
+import javax.jcr.RepositoryException;
+import javax.jcr.Session;
+import javax.jcr.observation.Event;
+import javax.jcr.observation.EventIterator;
+import javax.jcr.observation.EventListener;
+import javax.jcr.query.Query;
+import javax.naming.Binding;
+import javax.naming.Name;
+import javax.naming.NamingException;
+import javax.naming.directory.BasicAttribute;
+import javax.naming.directory.DirContext;
+import javax.naming.directory.ModificationItem;
+import javax.naming.directory.SearchControls;
+import javax.naming.event.EventDirContext;
+import javax.naming.event.NamespaceChangeListener;
+import javax.naming.event.NamingEvent;
+import javax.naming.event.NamingExceptionEvent;
+import javax.naming.event.NamingListener;
+import javax.naming.event.ObjectChangeListener;
+import javax.naming.ldap.UnsolicitedNotification;
+import javax.naming.ldap.UnsolicitedNotificationEvent;
+import javax.naming.ldap.UnsolicitedNotificationListener;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.argeo.ArgeoException;
+import org.argeo.jcr.ArgeoNames;
+import org.argeo.jcr.ArgeoTypes;
+import org.argeo.jcr.JcrUtils;
+import org.argeo.security.jcr.JcrUserDetails;
+import org.springframework.ldap.core.ContextExecutor;
+import org.springframework.ldap.core.ContextMapper;
+import org.springframework.ldap.core.DirContextAdapter;
+import org.springframework.ldap.core.DirContextOperations;
+import org.springframework.ldap.core.DistinguishedName;
+import org.springframework.ldap.core.LdapTemplate;
+import org.springframework.security.GrantedAuthority;
+import org.springframework.security.ldap.LdapUsernameToDnMapper;
+import org.springframework.security.providers.encoding.PasswordEncoder;
+import org.springframework.security.userdetails.UserDetails;
+import org.springframework.security.userdetails.ldap.UserDetailsContextMapper;
+
+/** Guarantees that LDAP and JCR are in line. */
+public class JcrLdapSynchronizer implements UserDetailsContextMapper,
+               ArgeoNames {
+       private final static Log log = LogFactory.getLog(JcrLdapSynchronizer.class);
+
+       // LDAP
+       private LdapTemplate ldapTemplate;
+       /**
+        * LDAP template whose context source has an object factory set to null. see
+        * <a href=
+        * "http://forum.springsource.org/showthread.php?55955-Persistent-search-with-spring-ldap"
+        * >this</a>
+        */
+       private LdapTemplate rawLdapTemplate;
+
+       private String userBase;
+       private String usernameAttribute;
+       private String passwordAttribute;
+       private String[] userClasses;
+
+       private NamingListener ldapUserListener;
+       private SearchControls subTreeSearchControls;
+       private LdapUsernameToDnMapper usernameMapper;
+
+       private PasswordEncoder passwordEncoder;
+       private final Random random;
+
+       // JCR
+       /** Admin session on the security workspace */
+       private Session securitySession;
+       private Repository repository;
+
+       private String securityWorkspace = "security";
+
+       private JcrProfileListener jcrProfileListener;
+
+       // Mapping
+       private Map<String, String> propertyToAttributes = new HashMap<String, String>();
+
+       public JcrLdapSynchronizer() {
+               random = createRandom();
+       }
+
+       public void init() {
+               try {
+                       securitySession = repository.login(securityWorkspace);
+
+                       synchronize();
+
+                       // LDAP
+                       subTreeSearchControls = new SearchControls();
+                       subTreeSearchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
+                       // LDAP listener
+                       ldapUserListener = new LdapUserListener();
+                       rawLdapTemplate.executeReadOnly(new ContextExecutor() {
+                               public Object executeWithContext(DirContext ctx)
+                                               throws NamingException {
+                                       EventDirContext ectx = (EventDirContext) ctx.lookup("");
+                                       ectx.addNamingListener(userBase, "(" + usernameAttribute
+                                                       + "=*)", subTreeSearchControls, ldapUserListener);
+                                       return null;
+                               }
+                       });
+
+                       // JCR
+                       String[] nodeTypes = { ArgeoTypes.ARGEO_USER_PROFILE };
+                       jcrProfileListener = new JcrProfileListener();
+                       // noLocal is used so that we are not notified when we modify JCR
+                       // from LDAP
+                       securitySession
+                                       .getWorkspace()
+                                       .getObservationManager()
+                                       .addEventListener(jcrProfileListener,
+                                                       Event.PROPERTY_CHANGED | Event.NODE_ADDED, "/",
+                                                       true, null, nodeTypes, true);
+               } catch (Exception e) {
+                       JcrUtils.logoutQuietly(securitySession);
+                       throw new ArgeoException("Cannot initialize LDAP/JCR synchronizer",
+                                       e);
+               }
+       }
+
+       public void destroy() {
+               JcrUtils.removeListenerQuietly(securitySession, jcrProfileListener);
+               JcrUtils.logoutQuietly(securitySession);
+               try {
+                       rawLdapTemplate.executeReadOnly(new ContextExecutor() {
+                               public Object executeWithContext(DirContext ctx)
+                                               throws NamingException {
+                                       EventDirContext ectx = (EventDirContext) ctx.lookup("");
+                                       ectx.removeNamingListener(ldapUserListener);
+                                       return null;
+                               }
+                       });
+               } catch (Exception e) {
+                       // silent (LDAP server may have been shutdown already)
+                       if (log.isTraceEnabled())
+                               log.trace("Cannot remove LDAP listener", e);
+               }
+       }
+
+       /*
+        * LDAP TO JCR
+        */
+       /** Full synchronization between LDAP and JCR. LDAP has priority. */
+       protected void synchronize() {
+               try {
+                       Name userBaseName = new DistinguishedName(userBase);
+                       // TODO subtree search?
+                       @SuppressWarnings("unchecked")
+                       List<String> userPaths = (List<String>) ldapTemplate.listBindings(
+                                       userBaseName, new ContextMapper() {
+                                               public Object mapFromContext(Object ctxObj) {
+                                                       return mapLdapToJcr((DirContextAdapter) ctxObj);
+                                               }
+                                       });
+
+                       // disable accounts which are not in LDAP
+                       Query query = securitySession
+                                       .getWorkspace()
+                                       .getQueryManager()
+                                       .createQuery(
+                                                       "select * from [" + ArgeoTypes.ARGEO_USER_PROFILE
+                                                                       + "]", Query.JCR_SQL2);
+                       NodeIterator it = query.execute().getNodes();
+                       while (it.hasNext()) {
+                               Node userProfile = it.nextNode();
+                               String path = userProfile.getPath();
+                               if (!userPaths.contains(path)) {
+                                       userProfile.setProperty(ArgeoNames.ARGEO_ENABLED, false);
+                               }
+                       }
+               } catch (Exception e) {
+                       throw new ArgeoException("Cannot synchronized LDAP and JCR", e);
+               }
+       }
+
+       /** Called during authentication in order to retrieve user details */
+       public UserDetails mapUserFromContext(final DirContextOperations ctx,
+                       final String username, GrantedAuthority[] authorities) {
+               if (ctx == null)
+                       throw new ArgeoException("No LDAP information for user " + username);
+               Node userHome = JcrUtils.getUserHome(securitySession, username);
+               if (userHome == null)
+                       throw new ArgeoException("No JCR information for user " + username);
+
+               // password
+               SortedSet<?> passwordAttributes = ctx
+                               .getAttributeSortedStringSet(passwordAttribute);
+               String password;
+               if (passwordAttributes == null || passwordAttributes.size() == 0) {
+                       throw new ArgeoException("No password found for user " + username);
+               } else {
+                       byte[] arr = (byte[]) passwordAttributes.first();
+                       password = new String(arr);
+                       // erase password
+                       Arrays.fill(arr, (byte) 0);
+               }
+
+               try {
+                       return new JcrUserDetails(userHome.getNode(ARGEO_PROFILE),
+                                       password, authorities);
+               } catch (RepositoryException e) {
+                       throw new ArgeoException("Cannot retrieve user details for "
+                                       + username, e);
+               }
+       }
+
+       /**
+        * Writes an LDAP context to the JCR user profile.
+        * 
+        * @return path to user profile
+        */
+       protected String mapLdapToJcr(DirContextAdapter ctx) {
+               Session session = securitySession;
+               try {
+                       // process
+                       String username = ctx.getStringAttribute(usernameAttribute);
+                       Node userHome = JcrUtils.createUserHomeIfNeeded(session, username);
+                       Node userProfile; // = userHome.getNode(ARGEO_PROFILE);
+                       if (userHome.hasNode(ARGEO_PROFILE)) {
+                               userProfile = userHome.getNode(ARGEO_PROFILE);
+                       } else {
+                               userProfile = JcrUtils.createUserProfile(securitySession,
+                                               username);
+                               userProfile.getSession().save();
+                               userProfile.getSession().getWorkspace().getVersionManager()
+                                               .checkin(userProfile.getPath());
+                       }
+
+                       Map<String, String> modifications = new HashMap<String, String>();
+                       for (String jcrProperty : propertyToAttributes.keySet())
+                               ldapToJcr(userProfile, jcrProperty, ctx, modifications);
+
+                       // assign default values
+                       // if (!userProfile.hasProperty(Property.JCR_DESCRIPTION)
+                       // && !modifications.containsKey(Property.JCR_DESCRIPTION))
+                       // modifications.put(Property.JCR_DESCRIPTION, "");
+                       // if (!userProfile.hasProperty(Property.JCR_TITLE))
+                       // modifications.put(Property.JCR_TITLE,
+                       // userProfile.getProperty(ARGEO_FIRST_NAME).getString()
+                       // + " "
+                       // + userProfile.getProperty(ARGEO_LAST_NAME)
+                       // .getString());
+                       int modifCount = modifications.size();
+                       if (modifCount > 0) {
+                               session.getWorkspace().getVersionManager()
+                                               .checkout(userProfile.getPath());
+                               for (String prop : modifications.keySet())
+                                       userProfile.setProperty(prop, modifications.get(prop));
+                               JcrUtils.updateLastModified(userProfile);
+                               session.save();
+                               session.getWorkspace().getVersionManager()
+                                               .checkin(userProfile.getPath());
+                               if (log.isDebugEnabled())
+                                       log.debug("Mapped " + modifCount + " LDAP modification"
+                                                       + (modifCount == 1 ? "" : "s") + " from "
+                                                       + ctx.getDn() + " to " + userProfile);
+                       }
+                       return userProfile.getPath();
+               } catch (Exception e) {
+                       JcrUtils.discardQuietly(session);
+                       throw new ArgeoException("Cannot synchronize JCR and LDAP", e);
+               }
+       }
+
+       /** Maps an LDAP property to a JCR property */
+       protected void ldapToJcr(Node userProfile, String jcrProperty,
+                       DirContextOperations ctx, Map<String, String> modifications) {
+               // TODO do we really need DirContextOperations?
+               try {
+                       String ldapAttribute;
+                       if (propertyToAttributes.containsKey(jcrProperty))
+                               ldapAttribute = propertyToAttributes.get(jcrProperty);
+                       else
+                               throw new ArgeoException(
+                                               "No LDAP attribute mapped for JCR proprty "
+                                                               + jcrProperty);
+
+                       String value = ctx.getStringAttribute(ldapAttribute);
+                       // if (value == null && Property.JCR_TITLE.equals(jcrProperty))
+                       // value = "";
+                       // if (value == null &&
+                       // Property.JCR_DESCRIPTION.equals(jcrProperty))
+                       // value = "";
+                       String jcrValue = userProfile.hasProperty(jcrProperty) ? userProfile
+                                       .getProperty(jcrProperty).getString() : null;
+                       if (value != null && jcrValue != null) {
+                               if (!value.equals(jcrValue))
+                                       modifications.put(jcrProperty, value);
+                       } else if (value != null && jcrValue == null) {
+                               modifications.put(jcrProperty, value);
+                       } else if (value == null && jcrValue != null) {
+                               modifications.put(jcrProperty, value);
+                       }
+               } catch (Exception e) {
+                       throw new ArgeoException("Cannot map JCR property " + jcrProperty
+                                       + " from LDAP", e);
+               }
+       }
+
+       /*
+        * JCR to LDAP
+        */
+
+       public void mapUserToContext(UserDetails user, final DirContextAdapter ctx) {
+               if (!(user instanceof JcrUserDetails))
+                       throw new ArgeoException("Unsupported user details: "
+                                       + user.getClass());
+
+               ctx.setAttributeValues("objectClass", userClasses);
+               ctx.setAttributeValue(usernameAttribute, user.getUsername());
+               ctx.setAttributeValue(passwordAttribute,
+                               encodePassword(user.getPassword()));
+
+               final JcrUserDetails jcrUserDetails = (JcrUserDetails) user;
+               try {
+                       Node userProfile = securitySession.getNode(
+                                       jcrUserDetails.getHomePath()).getNode(ARGEO_PROFILE);
+                       for (String jcrProperty : propertyToAttributes.keySet()) {
+                               ModificationItem mi = jcrToLdap(jcrProperty, userProfile
+                                               .getProperty(jcrProperty).getString());
+                               ctx.setAttribute(mi.getAttribute());
+                       }
+                       if (log.isTraceEnabled())
+                               log.trace("Mapped " + userProfile + " to " + ctx.getDn());
+               } catch (RepositoryException e) {
+                       throw new ArgeoException("Cannot synchronize JCR and LDAP", e);
+               }
+
+       }
+
+       /** Maps a JCR property to an LDAP property */
+       protected ModificationItem jcrToLdap(String jcrProperty, String value) {
+               // TODO do we really need DirContextOperations?
+               try {
+                       String ldapAttribute;
+                       if (propertyToAttributes.containsKey(jcrProperty))
+                               ldapAttribute = propertyToAttributes.get(jcrProperty);
+                       else
+                               return null;
+
+                       // fix issue with empty 'sn' in LDAP
+                       if (ldapAttribute.equals("sn") && (value.trim().equals("")))
+                               return null;
+                       // fix issue with empty 'description' in LDAP
+                       if (ldapAttribute.equals("description") && value.trim().equals(""))
+                               return null;
+                       BasicAttribute attr = new BasicAttribute(
+                                       propertyToAttributes.get(jcrProperty), value);
+                       ModificationItem mi = new ModificationItem(
+                                       DirContext.REPLACE_ATTRIBUTE, attr);
+                       return mi;
+               } catch (Exception e) {
+                       throw new ArgeoException("Cannot map JCR property " + jcrProperty
+                                       + " from LDAP", e);
+               }
+       }
+
+       /*
+        * UTILITIES
+        */
+       protected String encodePassword(String password) {
+               if (!password.startsWith("{")) {
+                       byte[] salt = new byte[16];
+                       random.nextBytes(salt);
+                       return passwordEncoder.encodePassword(password, salt);
+               } else {
+                       return password;
+               }
+       }
+
+       private static Random createRandom() {
+               try {
+                       return SecureRandom.getInstance("SHA1PRNG");
+               } catch (NoSuchAlgorithmException e) {
+                       return new Random(System.currentTimeMillis());
+               }
+       }
+
+       /*
+        * DEPENDENCY INJECTION
+        */
+
+       public void setLdapTemplate(LdapTemplate ldapTemplate) {
+               this.ldapTemplate = ldapTemplate;
+       }
+
+       public void setRawLdapTemplate(LdapTemplate rawLdapTemplate) {
+               this.rawLdapTemplate = rawLdapTemplate;
+       }
+
+       public void setRepository(Repository repository) {
+               this.repository = repository;
+       }
+
+       public void setSecurityWorkspace(String securityWorkspace) {
+               this.securityWorkspace = securityWorkspace;
+       }
+
+       public void setUserBase(String userBase) {
+               this.userBase = userBase;
+       }
+
+       public void setUsernameAttribute(String usernameAttribute) {
+               this.usernameAttribute = usernameAttribute;
+       }
+
+       public void setPropertyToAttributes(Map<String, String> propertyToAttributes) {
+               this.propertyToAttributes = propertyToAttributes;
+       }
+
+       public void setUsernameMapper(LdapUsernameToDnMapper usernameMapper) {
+               this.usernameMapper = usernameMapper;
+       }
+
+       public void setPasswordAttribute(String passwordAttribute) {
+               this.passwordAttribute = passwordAttribute;
+       }
+
+       public void setUserClasses(String[] userClasses) {
+               this.userClasses = userClasses;
+       }
+
+       public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
+               this.passwordEncoder = passwordEncoder;
+       }
+
+       /** Listen to LDAP */
+       class LdapUserListener implements ObjectChangeListener,
+                       NamespaceChangeListener, UnsolicitedNotificationListener {
+
+               public void namingExceptionThrown(NamingExceptionEvent evt) {
+                       evt.getException().printStackTrace();
+               }
+
+               public void objectChanged(NamingEvent evt) {
+                       Binding user = evt.getNewBinding();
+                       // TODO find a way not to be called when JCR is the source of the
+                       // modification
+                       DirContextAdapter ctx = (DirContextAdapter) ldapTemplate
+                                       .lookup(user.getName());
+                       mapLdapToJcr(ctx);
+               }
+
+               public void objectAdded(NamingEvent evt) {
+                       Binding user = evt.getNewBinding();
+                       DirContextAdapter ctx = (DirContextAdapter) ldapTemplate
+                                       .lookup(user.getName());
+                       mapLdapToJcr(ctx);
+               }
+
+               public void objectRemoved(NamingEvent evt) {
+                       if (log.isDebugEnabled())
+                               log.debug(evt);
+               }
+
+               public void objectRenamed(NamingEvent evt) {
+                       if (log.isDebugEnabled())
+                               log.debug(evt);
+               }
+
+               public void notificationReceived(UnsolicitedNotificationEvent evt) {
+                       UnsolicitedNotification notification = evt.getNotification();
+                       NamingException ne = notification.getException();
+                       String msg = "LDAP notification " + "ID=" + notification.getID()
+                                       + ", referrals=" + notification.getReferrals();
+                       if (ne != null) {
+                               if (log.isTraceEnabled())
+                                       log.trace(msg + ", exception= " + ne, ne);
+                               else
+                                       log.warn(msg + ", exception= " + ne);
+                       } else if (log.isDebugEnabled()) {
+                               log.debug("Unsollicited LDAP notification " + msg);
+                       }
+               }
+
+       }
+
+       /** Listen to JCR */
+       class JcrProfileListener implements EventListener {
+
+               public void onEvent(EventIterator events) {
+                       try {
+                               final Map<Name, List<ModificationItem>> modifications = new HashMap<Name, List<ModificationItem>>();
+                               while (events.hasNext()) {
+                                       Event event = events.nextEvent();
+                                       try {
+                                               if (Event.PROPERTY_CHANGED == event.getType()) {
+                                                       Property property = (Property) securitySession
+                                                                       .getItem(event.getPath());
+                                                       String propertyName = property.getName();
+                                                       Node userProfile = property.getParent();
+                                                       String username = userProfile.getProperty(
+                                                                       ARGEO_USER_ID).getString();
+                                                       if (propertyToAttributes.containsKey(propertyName)) {
+                                                               Name name = usernameMapper.buildDn(username);
+                                                               if (!modifications.containsKey(name))
+                                                                       modifications.put(name,
+                                                                                       new ArrayList<ModificationItem>());
+                                                               String value = property.getString();
+                                                               ModificationItem mi = jcrToLdap(propertyName,
+                                                                               value);
+                                                               if (mi != null)
+                                                                       modifications.get(name).add(mi);
+                                                       }
+                                               } else if (Event.NODE_ADDED == event.getType()) {
+                                                       Node userProfile = securitySession.getNode(event
+                                                                       .getPath());
+                                                       String username = userProfile.getProperty(
+                                                                       ARGEO_USER_ID).getString();
+                                                       Name name = usernameMapper.buildDn(username);
+                                                       for (String propertyName : propertyToAttributes
+                                                                       .keySet()) {
+                                                               if (!modifications.containsKey(name))
+                                                                       modifications.put(name,
+                                                                                       new ArrayList<ModificationItem>());
+                                                               String value = userProfile.getProperty(
+                                                                               propertyName).getString();
+                                                               ModificationItem mi = jcrToLdap(propertyName,
+                                                                               value);
+                                                               if (mi != null)
+                                                                       modifications.get(name).add(mi);
+                                                       }
+                                               }
+                                       } catch (RepositoryException e) {
+                                               throw new ArgeoException("Cannot process event "
+                                                               + event, e);
+                                       }
+                               }
+
+                               for (Name name : modifications.keySet()) {
+                                       List<ModificationItem> userModifs = modifications.get(name);
+                                       int modifCount = userModifs.size();
+                                       ldapTemplate.modifyAttributes(name, userModifs
+                                                       .toArray(new ModificationItem[modifCount]));
+                                       if (log.isDebugEnabled())
+                                               log.debug("Mapped " + modifCount + " JCR modification"
+                                                               + (modifCount == 1 ? "" : "s") + " to " + name);
+                               }
+                       } catch (Exception e) {
+                               // if (log.isDebugEnabled())
+                               // e.printStackTrace();
+                               throw new ArgeoException("Cannot process JCR events ("
+                                               + e.getMessage() + ")", e);
+                       }
+               }
+
+       }
+}