--- /dev/null
+/*
+ * Copyright (C) 2007-2012 Argeo GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.argeo.security.ldap.jcr;
+
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+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 java.util.UUID;
+
+import javax.jcr.Node;
+import javax.jcr.NodeIterator;
+import javax.jcr.Repository;
+import javax.jcr.RepositoryException;
+import javax.jcr.Session;
+import javax.jcr.query.Query;
+import javax.jcr.version.VersionManager;
+import javax.naming.Name;
+import javax.naming.directory.BasicAttribute;
+import javax.naming.directory.DirContext;
+import javax.naming.directory.ModificationItem;
+
+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.SecurityUtils;
+import org.argeo.security.jcr.JcrSecurityModel;
+import org.argeo.security.jcr.JcrUserDetails;
+import org.argeo.security.jcr.SimpleJcrSecurityModel;
+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;
+
+/** Makes sure 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 String defaultUserRole ="ROLE_USER";
+
+ // private NamingListener ldapUserListener;
+ // private SearchControls subTreeSearchControls;
+ private LdapUsernameToDnMapper usernameMapper;
+
+ private PasswordEncoder passwordEncoder;
+ private final Random random;
+
+ // JCR
+ /** Admin session on the main workspace */
+ private Session nodeSession;
+ private Repository repository;
+
+ // private JcrProfileListener jcrProfileListener;
+ private JcrSecurityModel jcrSecurityModel = new SimpleJcrSecurityModel();
+
+ // Mapping
+ private Map<String, String> propertyToAttributes = new HashMap<String, String>();
+
+ public JcrLdapSynchronizer() {
+ random = createRandom();
+ }
+
+ public void init() {
+ try {
+ nodeSession = repository.login();
+
+ // TODO put this in a different thread, and poll the LDAP server
+ // until it is up
+ try {
+ 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;
+ // }
+ // });
+ } catch (Exception e) {
+ log.error("Could not synchronize and listen to LDAP,"
+ + " probably because the LDAP server is not available."
+ + " Restart the system as soon as possible.", e);
+ }
+
+ // 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
+ // nodeSession
+ // .getWorkspace()
+ // .getObservationManager()
+ // .addEventListener(jcrProfileListener,
+ // Event.PROPERTY_CHANGED | Event.NODE_ADDED, "/",
+ // true, null, nodeTypes, true);
+ } catch (Exception e) {
+ JcrUtils.logoutQuietly(nodeSession);
+ throw new ArgeoException("Cannot initialize LDAP/JCR synchronizer",
+ e);
+ }
+ }
+
+ public void destroy() {
+ // JcrUtils.removeListenerQuietly(nodeSession, jcrProfileListener);
+ JcrUtils.logoutQuietly(nodeSession);
+ // 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) {
+ try {
+ return mapLdapToJcr((DirContextAdapter) ctxObj);
+ } catch (Exception e) {
+ // do not break process because of error
+ log.error(
+ "Could not LDAP->JCR synchronize user "
+ + ctxObj, e);
+ return null;
+ }
+ }
+ });
+
+ // create accounts which are not in LDAP
+ Query query = nodeSession
+ .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();
+ try {
+ if (!userPaths.contains(path)) {
+ String username = userProfile
+ .getProperty(ARGEO_USER_ID).getString();
+ // GrantedAuthority[] authorities = {new
+ // GrantedAuthorityImpl(defaultUserRole)};
+ GrantedAuthority[] authorities = {};
+ JcrUserDetails userDetails = new JcrUserDetails(
+ userProfile, username, authorities);
+ String dn = createLdapUser(userDetails);
+ log.warn("Created ldap entry '" + dn + "' for user '"
+ + username + "'");
+
+ // if(!userProfile.getProperty(ARGEO_ENABLED).getBoolean()){
+ // continue profiles;
+ // }
+ //
+ // log.warn("Path "
+ // + path
+ // + " not found in LDAP, disabling user "
+ // + userProfile.getProperty(ArgeoNames.ARGEO_USER_ID)
+ // .getString());
+
+ // Temporary hack to repair previous behaviour
+ if (!userProfile.getProperty(ARGEO_ENABLED)
+ .getBoolean()) {
+ VersionManager versionManager = nodeSession
+ .getWorkspace().getVersionManager();
+ versionManager.checkout(userProfile.getPath());
+ userProfile.setProperty(ArgeoNames.ARGEO_ENABLED,
+ true);
+ nodeSession.save();
+ versionManager.checkin(userProfile.getPath());
+ }
+ }
+ } catch (Exception e) {
+ log.error("Cannot process " + path, e);
+ }
+ }
+ } catch (Exception e) {
+ JcrUtils.discardQuietly(nodeSession);
+ log.error("Cannot synchronize LDAP and JCR", e);
+ // throw new ArgeoException("Cannot synchronize LDAP and JCR", e);
+ }
+ }
+
+ private String createLdapUser(UserDetails user) {
+ DirContextAdapter ctx = new DirContextAdapter();
+ mapUserToContext(user, ctx);
+ DistinguishedName dn = usernameMapper.buildDn(user.getUsername());
+ ldapTemplate.bind(dn, ctx, null);
+ return dn.toString();
+ }
+
+ /** 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);
+
+ String ldapUsername = ctx.getStringAttribute(usernameAttribute);
+ if (!ldapUsername.equals(username))
+ throw new ArgeoException("Logged in with username " + username
+ + " but LDAP user is " + ldapUsername);
+
+ Node userProfile = jcrSecurityModel.sync(nodeSession, username,
+ SecurityUtils.authoritiesToStringList(authorities));
+ // JcrUserDetails.checkAccountStatus(userProfile);
+
+ // password
+ SortedSet<?> passwordAttributes = ctx
+ .getAttributeSortedStringSet(passwordAttribute);
+ String password;
+ if (passwordAttributes == null || passwordAttributes.size() == 0) {
+ //throw new ArgeoException("No password found for user " + username);
+ password = "NULL";
+ } else {
+ byte[] arr = (byte[]) passwordAttributes.first();
+ password = new String(arr);
+ // erase password
+ Arrays.fill(arr, (byte) 0);
+ }
+
+ try {
+ return new JcrUserDetails(userProfile, 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 synchronized String mapLdapToJcr(DirContextAdapter ctx) {
+ Session session = nodeSession;
+ try {
+ // process
+ String username = ctx.getStringAttribute(usernameAttribute);
+
+ Node userProfile = jcrSecurityModel.sync(session, username, null);
+ Map<String, String> modifications = new HashMap<String, String>();
+ for (String jcrProperty : propertyToAttributes.keySet())
+ ldapToJcr(userProfile, jcrProperty, ctx, modifications);
+
+ 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);
+ 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 = nodeSession
+ .getNode(jcrUserDetails.getHomePath()).getNode(
+ ARGEO_PROFILE);
+ for (String jcrProperty : propertyToAttributes.keySet()) {
+ if (userProfile.hasProperty(jcrProperty)) {
+ ModificationItem mi = jcrToLdap(jcrProperty, userProfile
+ .getProperty(jcrProperty).getString());
+ if (mi != null)
+ 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 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;
+ }
+
+ public void setJcrSecurityModel(JcrSecurityModel jcrSecurityModel) {
+ this.jcrSecurityModel = jcrSecurityModel;
+ }
+
+ /** 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) nodeSession
+ // .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 = nodeSession.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);
+ // }
+ // }
+ //
+ // }
+}