James Moger
2012-04-24 0cb7a9c08cfaebeace058dc806099051f163f172
Merge pull request #12 from jcrygier/ldap_unboundid

Ldap unboundid
5 files added
22 files modified
1146 ■■■■■ changed files
.classpath 1 ●●●● patch | view | raw | blame | history
NOTICE 10 ●●●●● patch | view | raw | blame | history
distrib/gitblit.properties 85 ●●●●● patch | view | raw | blame | history
docs/01_setup.mkd 61 ●●●●● patch | view | raw | blame | history
docs/04_design.mkd 1 ●●●● patch | view | raw | blame | history
docs/ldapSample.png patch | view | raw | blame | history
src/com/gitblit/ConfigUserService.java 25 ●●●●● patch | view | raw | blame | history
src/com/gitblit/FileUserService.java 27 ●●●●● patch | view | raw | blame | history
src/com/gitblit/GitBlit.java 16 ●●●●● patch | view | raw | blame | history
src/com/gitblit/GitBlitServer.java 42 ●●●●● patch | view | raw | blame | history
src/com/gitblit/GitblitUserService.java 47 ●●●●● patch | view | raw | blame | history
src/com/gitblit/IStoredSettings.java 18 ●●●●● patch | view | raw | blame | history
src/com/gitblit/IUserService.java 16 ●●●●● patch | view | raw | blame | history
src/com/gitblit/LdapUserService.java 268 ●●●●● patch | view | raw | blame | history
src/com/gitblit/build/Build.java 6 ●●●●● patch | view | raw | blame | history
src/com/gitblit/utils/ConnectionUtils.java 89 ●●●●● patch | view | raw | blame | history
src/com/gitblit/utils/StringUtils.java 60 ●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/GitBlitWebApp.properties 4 ●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/BasePage.java 6 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/ChangePasswordPage.java 6 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/EditTeamPage.java 5 ●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/EditUserPage.java 82 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/panels/UsersPanel.java 3 ●●●● patch | view | raw | blame | history
tests/com/gitblit/tests/LdapUserServiceTest.java 105 ●●●●● patch | view | raw | blame | history
tests/com/gitblit/tests/StringUtilsTest.java 25 ●●●● patch | view | raw | blame | history
tests/com/gitblit/tests/mock/MemorySettings.java 50 ●●●●● patch | view | raw | blame | history
tests/com/gitblit/tests/resources/ldapUserServiceSampleData.ldif 88 ●●●●● patch | view | raw | blame | history
.classpath
@@ -30,5 +30,6 @@
    <classpathentry kind="lib" path="ext/org.eclipse.jgit.http.server-1.3.0.201202151440-r.jar" sourcepath="ext/org.eclipse.jgit.http.server-1.3.0.201202151440-r-sources.jar"/>
    <classpathentry kind="lib" path="ext/lucene-highlighter-3.5.0.jar" sourcepath="ext/lucene-highlighter-3.5.0-sources.jar"/>
    <classpathentry kind="lib" path="ext/lucene-memory-3.5.0.jar" sourcepath="ext/lucene-memory-3.5.0-sources.jar"/>
    <classpathentry kind="lib" path="ext/unboundid-ldapsdk-2.3.0.jar" sourcepath="ext/unboundid-ldapsdk-2.3.0-sources.jar"/>
    <classpathentry kind="output" path="bin"/>
</classpath>
NOTICE
@@ -214,4 +214,12 @@
   Creative Commons CC-BY License.
   http://glyphicons.com
---------------------------------------------------------------------------
UnboundID
---------------------------------------------------------------------------
   UnboundID, released under the
   GNU LESSER GENERAL PUBLIC LICENSE. (http://www.unboundid.com/products/ldap-sdk/docs/LICENSE-LGPLv2.1.txt)
   http://www.unboundid.com
distrib/gitblit.properties
@@ -136,6 +136,91 @@
# SINCE 0.5.0 
realm.minPasswordLength = 5
# URL of the LDAP server.
#
# SINCE 1.0.0
realm.ldap.server = ldap://localhost
# Login username for LDAP searches.
# The domain prefix may be omitted if it matches the domain specified in
# *realm.ldap.domain*. If this value is unspecified, anonymous LDAP login will
# be used.
#
# e.g. mydomain\\username
#
# SINCE 1.0.0
realm.ldap.username = cn=Directory Manager
# Login password for LDAP searches.
#
# SINCE 1.0.0
realm.ldap.password = password
# The LdapUserService must be backed by another user service for standard user
# and team management.
# default: users.conf
#
# SINCE 1.0.0
# RESTART REQUIRED
realm.ldap.backingUserService = users.conf
# Delegate team membership control to LDAP.
#
# If true, team user memberships will be specified by LDAP groups.  This will
# disable team selection in Edit User and user selection in Edit Team.
#
# If false, LDAP will only be used for authentication and Gitblit will maintain
# team memberships with the *realm.ldap.backingUserService*.
#
# SINCE 1.0.0
realm.ldap.maintainTeams = false
# Root node that all Users sit under in LDAP
#
# This is the root node that searches for user information will begin from in LDAP
# If blank, it will search ALL of ldap.
#
# SINCE 1.0.0
realm.ldap.accountBase = OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain
# Filter Criteria for Users in LDAP
#
# Query pattern to use when searching for a user account. This may be any valid
# LDAP query expression, including the standard (&) and (|) operators.  Variables may
# be injected via the ${variableName} syntax.  Recognized variables are:
#    ${username} - The text entered as the user name
#
# SINCE 1.0.0
realm.ldap.accountPattern = (&(objectClass=person)(sAMAccountName=${username}))
# Root node that all Teams sit under in LDAP
#
# This is the node that searches for team information will begin from in LDAP
# If blank, it will search ALL of ldap.
#
# SINCE 1.0.0
realm.ldap.groupBase = OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain
# Filter Criteria for Teams in LDAP
#
# Query pattern to use when searching for a team. This may be any valid
# LDAP query expression, including the standard (&) and (|) operators. Variables may
# be injected via the ${variableName} syntax.  Recognized variables are:
#    ${username} - The text entered as the user name
#    ${dn} - The Distinguished Name of the user logged in
# All attributes on the User's record are also passed in.  For example, if a user has an
# attribute "fullName" set to "John", "(fn=${fullName})" will be translated to "(fn=John)".
#
# SINCE 1.0.0
realm.ldap.groupMemberPattern = (&(objectClass=group)(member=${dn}))
# Users and or teams that are Admins, read from LDAP
#
# This is a space delimited list.  If it starts with @, it indicates a Team Name
#
# SINCE 1.0.0
realm.ldap.admins= @Git_Admins
#
# Gitblit Web Settings
#
docs/01_setup.mkd
@@ -447,4 +447,63 @@
<pre>https://yourserver/git/your/repository</pre>
- **Command-line Git**  
My testing indicates that your username must be embedded in the url.  YMMV.  
<pre>https://username@yourserver/git/your/repository</pre>
<pre>https://username@yourserver/git/your/repository</pre>
## LDAP Support
*SINCE 1.0.0*
LDAP can be used with Gitblit to read Users and the Teams that they belong to.  If configured, LDAP will be queried upon every login to the system, and synchronize that information with the traditional Gitblit backed file (.conf or .properties).  This "lazy" reading approach provides for fast reaction times, but will force a user to log in before you can maintain them (or their teams).
### Example Diagram (with attributes)
![block diagram](ldapSample.png "LDAP Sample")
Please see <gitblit>/tests/com/gitblit/tests/resources/ldapUserServiceSampleData.ldif to see the data in LDAP that reflects the above picture.
### GitBlit Properties (See gitblit.properties for full description)
The following is are descriptions of the properties that would follow the sample layout of an LDAP (or Active Directory) setup above.
<table border="1" cellpadding="1" cellspacing="1">
<tr>
  <td>realm.ldap.server</td><td>ldap://localhost:389</td>
  <td>Tells Gitblit to connect to the LDAP server on localhost, port 389.  URL Must be of form ldap(s)://<server>:<port> with port being optional (389 for ldap, 636 for ldaps).</td>
</tr>
<tr>
  <td>realm.ldap.username</td><td>cn=Directory Manager</td>
  <td>The credentials that will log into this gitblit server</td>
</tr>
<tr>
  <td>realm.ldap.password</td><td>password</td>
  <td>The credentials that will log into this gitblit server</td>
</tr>
<tr>
  <td>realm.ldap.backingUserService</td><td>users.conf</td>
  <td>Where to store all information that is used by Gitblit.  All information will be synced here upon user login.</td>
</tr>
<tr>
  <td>realm.ldap.maintainTeams</td><td>true</td>
  <td>Are users maintained in LDAP (true), or manually in Gitblit (false).</td>
</tr>
<tr>
  <td>realm.ldap.accountBase</td><td>OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain</td>
  <td>What is the root node for all users in this LDAP system.  Searches will be subtree searches starting from this node.</td>
</tr>
<tr>
  <td>realm.ldap.accountPattern</td><td>(&(objectClass=person)(sAMAccountName=${username}))</td><td>The LDAP Search filter that will match a particular user in LDAP.  ${username} will be replaced with whatever the user types in as their user name.</td>
</tr>
<tr>
  <td>realm.ldap.groupBase</td><td>OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain</td>
  <td>What is the root node for all teams in this LDAP system.  Searches will be subtree searches starting from this node.</td>
</tr>
<tr>
  <td>realm.ldap.groupMemberPattern</td><td>(&(objectClass=group)(member=${dn}))</td><td>The LDAP Search filter that will match all teams for the logging in user in LDAP.  ${username} will be replaced with whatever the user types in as their user name.  Anything else in ${} will be replaced by Attributes on the User node.</td>
</tr>
<tr>
  <td>realm.ldap.admins</td><td>@Git_Admins</td><td>A space delimited list of users and teams (if starting with @) that indicate admin status in Gitblit.</td>
</tr>
</table>
You may notice that there are no properties to find the password on the User record.  This is intentional, and the service utilizes the LDAP login process to verify that the user credentials are correct.
You can also start Gitblit GO with an in-memory (backed by an LDIF file) LDAP server by using the --ldapLdifFile property.  It will listen where ever gitblit.settings is pointed to.  However, it only supports ldap...not ldaps, so be sure to set that in gitblit.settings.  It reads the user / password in gitblit.settings to create the root user login.
Finally, writing back to LDAP is not implemented at this time, so do not worry about corrupting your corporate LDAP.  Many orgnizations are likely to go through a different flow to update their LDAP, so it's unlikely that this will become a feature.
docs/04_design.mkd
@@ -38,6 +38,7 @@
- [javamail](http://kenai.com/projects/javamail) (CDDL-1.0, BSD, GPL-2.0, GNU-Classpath)
- [Groovy](http://groovy.codehaus.org) (Apache 2.0)
- [Lucene](http://lucene.apache.org) (Apache 2.0)
- [UnboundID](http://www.unboundid.com) (LGPL 2.1)
### Other Build Dependencies
- [Fancybox image viewer](http://fancybox.net) (MIT and GPL dual-licensed)
docs/ldapSample.png
src/com/gitblit/ConfigUserService.java
@@ -100,6 +100,27 @@
    }
    /**
     * Does the user service support changes to credentials?
     *
     * @return true or false
     * @since 1.0.0
     */
    @Override
    public boolean supportsCredentialChanges() {
        return true;
    }
    /**
     * Does the user service support changes to team memberships?
     *
     * @return true or false
     * @since 1.0.0
     */
    public boolean supportsTeamMembershipChanges() {
        return true;
    }
    /**
     * Does the user service support cookie authentication?
     * 
     * @return true or false
@@ -656,7 +677,9 @@
        // write users
        for (UserModel model : users.values()) {
            config.setString(USER, model.username, PASSWORD, model.password);
            if (!StringUtils.isEmpty(model.password)) {
                config.setString(USER, model.username, PASSWORD, model.password);
            }
            // user roles
            List<String> roles = new ArrayList<String>();
src/com/gitblit/FileUserService.java
@@ -74,6 +74,27 @@
    }
    /**
     * Does the user service support changes to credentials?
     *
     * @return true or false
     * @since 1.0.0
     */
    @Override
    public boolean supportsCredentialChanges() {
        return true;
    }
    /**
     * Does the user service support changes to team memberships?
     *
     * @return true or false
     * @since 1.0.0
     */
    public boolean supportsTeamMembershipChanges() {
        return true;
    }
    /**
     * Does the user service support cookie authentication?
     * 
     * @return true or false
@@ -233,7 +254,9 @@
            }
            StringBuilder sb = new StringBuilder();
            sb.append(model.password);
            if (!StringUtils.isEmpty(model.password)) {
                sb.append(model.password);
            }
            sb.append(',');
            for (String role : roles) {
                sb.append(role);
@@ -658,6 +681,8 @@
                    team.addRepositories(repositories);
                    team.addUsers(users);
                    team.addMailingLists(mailingLists);
                    team.preReceiveScripts.addAll(preReceive);
                    team.postReceiveScripts.addAll(postReceive);
                    teams.put(team.name.toLowerCase(), team);
                } else {
                    // user definition
src/com/gitblit/GitBlit.java
@@ -377,6 +377,22 @@
        this.userService = userService;
        this.userService.setup(settings);
    }
    /**
     *
     * @return true if the user service supports credential changes
     */
    public boolean supportsCredentialChanges() {
        return userService.supportsCredentialChanges();
    }
    /**
     *
     * @return true if the user service supports team membership changes
     */
    public boolean supportsTeamMembershipChanges() {
        return userService.supportsTeamMembershipChanges();
    }
    /**
     * Authenticate a user based on a username and password.
src/com/gitblit/GitBlitServer.java
@@ -23,12 +23,14 @@
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
import java.net.URL;
import java.net.UnknownHostException;
import java.security.ProtectionDomain;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
import org.eclipse.jetty.ajp.Ajp13SocketConnector;
import org.eclipse.jetty.server.Connector;
@@ -50,6 +52,10 @@
import com.beust.jcommander.ParameterException;
import com.beust.jcommander.Parameters;
import com.gitblit.utils.StringUtils;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldif.LDIFReader;
/**
 * GitBlitServer is the embedded Jetty server for Gitblit GO. This class starts
@@ -268,6 +274,39 @@
        // Override settings from the command-line
        settings.overrideSetting(Keys.realm.userService, params.userService);
        settings.overrideSetting(Keys.git.repositoriesFolder, params.repositoriesFolder);
        // Start up an in-memory LDAP server, if configured
        try {
            if (StringUtils.isEmpty(params.ldapLdifFile) == false) {
                File ldifFile = new File(params.ldapLdifFile);
                if (ldifFile != null && ldifFile.exists()) {
                    URI ldapUrl = new URI(settings.getRequiredString(Keys.realm.ldap_server));
                    String firstLine = new Scanner(ldifFile).nextLine();
                    String rootDN = firstLine.substring(4);
                    String bindUserName = settings.getString(Keys.realm.ldap_username, "");
                    String bindPassword = settings.getString(Keys.realm.ldap_password, "");
                    // Get the port
                    int port = ldapUrl.getPort();
                    if (port == -1)
                        port = 389;
                    InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(rootDN);
                    config.addAdditionalBindCredentials(bindUserName, bindPassword);
                    config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("default", port));
                    config.setSchema(null);
                    InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
                    ds.importFromLDIF(true, new LDIFReader(ldifFile));
                    ds.startListening();
                    logger.info("LDAP Server started at ldap://localhost:" + port);
                }
            }
        } catch (Exception e) {
            // Completely optional, just show a warning
            logger.warn("Unable to start LDAP server", e);
        }
        // Set the server's contexts
        server.setHandler(rootContext);
@@ -506,6 +545,9 @@
         */
        @Parameter(names = { "--settings" }, description = "Path to alternative settings")
        public String settingsfile;
        @Parameter(names = { "--ldapLdifFile" }, description = "Path to LDIF file.  This will cause an in-memory LDAP server to be started according to gitblit settings")
        public String ldapLdifFile;
    }
}
src/com/gitblit/GitblitUserService.java
@@ -25,6 +25,7 @@
import com.gitblit.models.TeamModel;
import com.gitblit.models.UserModel;
import com.gitblit.utils.DeepCopier;
/**
 * This class wraps the default user service and is recommended as the starting
@@ -112,6 +113,16 @@
    }
    @Override
    public boolean supportsCredentialChanges() {
        return serviceImpl.supportsCredentialChanges();
    }
    @Override
    public boolean supportsTeamMembershipChanges() {
        return serviceImpl.supportsTeamMembershipChanges();
    }
    @Override
    public boolean supportsCookies() {
        return serviceImpl.supportsCookies();
    }
@@ -143,9 +154,33 @@
    @Override
    public boolean updateUserModel(String username, UserModel model) {
        return serviceImpl.updateUserModel(username, model);
        if (supportsCredentialChanges()) {
            if (!supportsTeamMembershipChanges()) {
                //  teams are externally controlled - copy from original model
                UserModel existingModel = getUserModel(username);
                model = DeepCopier.copy(model);
                model.teams.clear();
                model.teams.addAll(existingModel.teams);
            }
            return serviceImpl.updateUserModel(username, model);
        }
        if (model.username.equals(username)) {
            // passwords are not persisted by the backing user service
            model.password = null;
            if (!supportsTeamMembershipChanges()) {
                //  teams are externally controlled- copy from original model
                UserModel existingModel = getUserModel(username);
                model = DeepCopier.copy(model);
                model.teams.clear();
                model.teams.addAll(existingModel.teams);
            }
            return serviceImpl.updateUserModel(username, model);
        }
        logger.error("Users can not be renamed!");
        return false;
    }
    @Override
    public boolean deleteUserModel(UserModel model) {
        return serviceImpl.deleteUserModel(model);
@@ -198,6 +233,14 @@
    @Override
    public boolean updateTeamModel(String teamname, TeamModel model) {
        if (!supportsTeamMembershipChanges()) {
            // teams are externally controlled - copy from original model
            TeamModel existingModel = getTeamModel(teamname);
            model = DeepCopier.copy(model);
            model.users.clear();
            model.users.addAll(existingModel.users);
        }
        return serviceImpl.updateTeamModel(teamname, model);
    }
src/com/gitblit/IStoredSettings.java
@@ -157,6 +157,24 @@
        }
        return defaultValue;
    }
    /**
     * Returns the string value for the specified key.  If the key does not
     * exist an exception is thrown.
     *
     * @param key
     * @return key value
     */
    public String getRequiredString(String name) {
        Properties props = getSettings();
        if (props.containsKey(name)) {
            String value = props.getProperty(name);
            if (value != null) {
                return value.trim();
            }
        }
        throw new RuntimeException("Property (" + name + ") does not exist");
    }
    /**
     * Returns a list of space-separated strings from the specified key.
src/com/gitblit/IUserService.java
@@ -40,6 +40,22 @@
    void setup(IStoredSettings settings);
    /**
     * Does the user service support changes to credentials?
     *
     * @return true or false
     * @since 1.0.0
     */
    boolean supportsCredentialChanges();
    /**
     * Does the user service support changes to team memberships?
     *
     * @return true or false
     * @since 1.0.0
     */
    boolean supportsTeamMembershipChanges();
    /**
     * Does the user service support cookie authentication?
     * 
     * @return true or false
src/com/gitblit/LdapUserService.java
New file
@@ -0,0 +1,268 @@
/*
 * Copyright 2012 John Crygier
 * Copyright 2012 gitblit.com
 *
 * 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 com.gitblit;
import java.io.File;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.GeneralSecurityException;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.gitblit.models.TeamModel;
import com.gitblit.models.UserModel;
import com.gitblit.utils.StringUtils;
import com.unboundid.ldap.sdk.Attribute;
import com.unboundid.ldap.sdk.LDAPConnection;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPSearchException;
import com.unboundid.ldap.sdk.SearchResult;
import com.unboundid.ldap.sdk.SearchResultEntry;
import com.unboundid.ldap.sdk.SearchScope;
import com.unboundid.util.ssl.SSLUtil;
import com.unboundid.util.ssl.TrustAllTrustManager;
/**
 * Implementation of an LDAP user service.
 *
 * @author John Crygier
 */
public class LdapUserService extends GitblitUserService {
    public static final Logger logger = LoggerFactory.getLogger(LdapUserService.class);
    private IStoredSettings settings;
    public LdapUserService() {
        super();
    }
    @Override
    public void setup(IStoredSettings settings) {
        this.settings = settings;
        String file = settings.getString(Keys.realm.ldap_backingUserService, "users.conf");
        File realmFile = GitBlit.getFileOrFolder(file);
        serviceImpl = createUserService(realmFile);
        logger.info("LDAP User Service backed by " + serviceImpl.toString());
    }
    private LDAPConnection getLdapConnection() {
        try {
            URI ldapUrl = new URI(settings.getRequiredString(Keys.realm.ldap_server));
            String bindUserName = settings.getString(Keys.realm.ldap_username, "");
            String bindPassword = settings.getString(Keys.realm.ldap_password, "");
            int ldapPort = ldapUrl.getPort();
            if (ldapUrl.getScheme().equalsIgnoreCase("ldaps")) {    // SSL
                if (ldapPort == -1)    // Default Port
                    ldapPort = 636;
                SSLUtil sslUtil = new SSLUtil(new TrustAllTrustManager());
                return new LDAPConnection(sslUtil.createSSLSocketFactory(), ldapUrl.getHost(), ldapPort, bindUserName, bindPassword);
            } else {
                if (ldapPort == -1)    // Default Port
                    ldapPort = 389;
                return new LDAPConnection(ldapUrl.getHost(), ldapPort, bindUserName, bindPassword);
            }
        } catch (URISyntaxException e) {
            logger.error("Bad LDAP URL, should be in the form: ldap(s)://<server>:<port>", e);
        } catch (GeneralSecurityException e) {
            logger.error("Unable to create SSL Connection", e);
        } catch (LDAPException e) {
            logger.error("Error Connecting to LDAP", e);
        }
        return null;
    }
    /**
     * Credentials are defined in the LDAP server and can not be manipulated
     * from Gitblit.
     *
     * @return false
     * @since 1.0.0
     */
    @Override
    public boolean supportsCredentialChanges() {
        return false;
    }
    /**
     * If the LDAP server will maintain team memberships then LdapUserService
     * will not allow team membership changes.  In this scenario all team
     * changes must be made on the LDAP server by the LDAP administrator.
     *
     * @return true or false
     * @since 1.0.0
     */
    public boolean supportsTeamMembershipChanges() {
        return !settings.getBoolean(Keys.realm.ldap_maintainTeams, false);
    }
    /**
     * Does the user service support cookie authentication?
     *
     * @return true or false
     */
    @Override
    public boolean supportsCookies() {
        // TODO cookies need to be reviewed
        return false;
    }
    @Override
    public UserModel authenticate(String username, char[] password) {
        String simpleUsername = getSimpleUsername(username);
        LDAPConnection ldapConnection = getLdapConnection();
        if (ldapConnection != null) {
            // Find the logging in user's DN
            String accountBase = settings.getString(Keys.realm.ldap_accountBase, "");
            String accountPattern = settings.getString(Keys.realm.ldap_accountPattern, "(&(objectClass=person)(sAMAccountName=${username}))");
            accountPattern = StringUtils.replace(accountPattern, "${username}", simpleUsername);
            SearchResult result = doSearch(ldapConnection, accountBase, accountPattern);
            if (result != null && result.getEntryCount() == 1) {
                SearchResultEntry loggingInUser = result.getSearchEntries().get(0);
                String loggingInUserDN = loggingInUser.getDN();
                if (isAuthenticated(ldapConnection, loggingInUserDN, new String(password))) {
                    logger.debug("Authenitcated: " + username);
                    UserModel user = getUserModel(simpleUsername);
                    if (user == null)    // create user object for new authenticated user
                        user = createUserFromLdap(simpleUsername, loggingInUser);
                    user.password = "StoredInLDAP";
                    if (!supportsTeamMembershipChanges())
                        getTeamsFromLdap(ldapConnection, simpleUsername, loggingInUser, user);
                    // Get Admin Attributes
                    setAdminAttribute(user);
                    // Push the ldap looked up values to backing file
                    super.updateUserModel(user);
                    if (!supportsTeamMembershipChanges()) {
                        for (TeamModel userTeam : user.teams)
                            updateTeamModel(userTeam);
                    }
                    return user;
                }
            }
        }
        return null;
    }
    private void setAdminAttribute(UserModel user) {
        user.canAdmin = false;
        List<String>  admins = settings.getStrings(Keys.realm.ldap_admins);
        for (String admin : admins) {
            if (admin.startsWith("@")) { // Team
                if (user.getTeam(admin.substring(1)) != null)
                    user.canAdmin = true;
            } else
                if (user.getName().equalsIgnoreCase(admin))
                    user.canAdmin = true;
        }
    }
    private void getTeamsFromLdap(LDAPConnection ldapConnection, String simpleUsername, SearchResultEntry loggingInUser, UserModel user) {
        String loggingInUserDN = loggingInUser.getDN();
        user.teams.clear();        // Clear the users team memberships - we're going to get them from LDAP
        String groupBase = settings.getString(Keys.realm.ldap_groupBase, "");
        String groupMemberPattern = settings.getString(Keys.realm.ldap_groupMemberPattern, "(&(objectClass=group)(member=${dn}))");
        groupMemberPattern = StringUtils.replace(groupMemberPattern, "${dn}", loggingInUserDN);
        groupMemberPattern = StringUtils.replace(groupMemberPattern, "${username}", simpleUsername);
        // Fill in attributes into groupMemberPattern
        for (Attribute userAttribute : loggingInUser.getAttributes())
            groupMemberPattern = StringUtils.replace(groupMemberPattern, "${" + userAttribute.getName() + "}", userAttribute.getValue());
        SearchResult teamMembershipResult = doSearch(ldapConnection, groupBase, groupMemberPattern);
        if (teamMembershipResult != null && teamMembershipResult.getEntryCount() > 0) {
            for (int i = 0; i < teamMembershipResult.getEntryCount(); i++) {
                SearchResultEntry teamEntry = teamMembershipResult.getSearchEntries().get(i);
                String teamName = teamEntry.getAttribute("cn").getValue();
                TeamModel teamModel = getTeamModel(teamName);
                if (teamModel == null)
                    teamModel = createTeamFromLdap(teamEntry);
                user.teams.add(teamModel);
                teamModel.addUser(user.getName());
            }
        }
    }
    private TeamModel createTeamFromLdap(SearchResultEntry teamEntry) {
        TeamModel answer = new TeamModel(teamEntry.getAttributeValue("cn"));
        // If attributes other than team name ever from from LDAP, this is where to get them
        return answer;
    }
    private UserModel createUserFromLdap(String simpleUserName, SearchResultEntry userEntry) {
        UserModel answer = new UserModel(simpleUserName);
        //If attributes other than user name ever from from LDAP, this is where to get them
        return answer;
    }
    private SearchResult doSearch(LDAPConnection ldapConnection, String base, String filter) {
        try {
            return ldapConnection.search(base, SearchScope.SUB, filter);
        } catch (LDAPSearchException e) {
            logger.error("Problem Searching LDAP", e);
            return null;
        }
    }
    private boolean isAuthenticated(LDAPConnection ldapConnection, String userDn, String password) {
        try {
            ldapConnection.bind(userDn, password);
            return true;
        } catch (LDAPException e) {
            logger.error("Error authenitcating user", e);
            return false;
        }
    }
    /**
     * Returns a simple username without any domain prefixes.
     *
     * @param username
     * @return a simple username
     */
    protected String getSimpleUsername(String username) {
        int lastSlash = username.lastIndexOf('\\');
        if (lastSlash > -1) {
            username = username.substring(lastSlash + 1);
        }
        return username;
    }
}
src/com/gitblit/build/Build.java
@@ -94,6 +94,7 @@
        downloadFromApache(MavenObject.LUCENE, BuildType.RUNTIME);
        downloadFromApache(MavenObject.LUCENE_HIGHLIGHTER, BuildType.RUNTIME);
        downloadFromApache(MavenObject.LUCENE_MEMORY, BuildType.RUNTIME);
        downloadFromApache(MavenObject.UNBOUND_ID, BuildType.RUNTIME);
        downloadFromEclipse(MavenObject.JGIT, BuildType.RUNTIME);
        downloadFromEclipse(MavenObject.JGIT_HTTP, BuildType.RUNTIME);
@@ -124,6 +125,7 @@
        downloadFromApache(MavenObject.LUCENE, BuildType.COMPILETIME);
        downloadFromApache(MavenObject.LUCENE_HIGHLIGHTER, BuildType.COMPILETIME);
        downloadFromApache(MavenObject.LUCENE_MEMORY, BuildType.COMPILETIME);
        downloadFromApache(MavenObject.UNBOUND_ID, BuildType.COMPILETIME);
        
        downloadFromEclipse(MavenObject.JGIT, BuildType.COMPILETIME);
        downloadFromEclipse(MavenObject.JGIT_HTTP, BuildType.COMPILETIME);
@@ -524,6 +526,10 @@
        public static final MavenObject LUCENE_MEMORY = new MavenObject("lucene memory", "org/apache/lucene", "lucene-memory",
                "3.5.0", 30000, 23000, 0, "7908e954e8c1b4b2463aa712b34fa4a5612e241d",
                "69b19b38d78cc3b27ea5542a14f0ebbb1625ffdd", "");
        public static final MavenObject UNBOUND_ID = new MavenObject("unbound id", "com/unboundid", "unboundid-ldapsdk",
                "2.3.0", 1383417, 1439721, 0, "6fde8d9fb4ee3e7e3d7e764e3ea57195971e2eb2",
                "5276d3d29630693dba99ab9f7ea54f4c471d3af1", "");
        
        public final String name;
src/com/gitblit/utils/ConnectionUtils.java
@@ -16,16 +16,22 @@
package com.gitblit.utils;
import java.io.IOException;
import java.net.InetAddress;
import java.net.Socket;
import java.net.URL;
import java.net.URLConnection;
import java.net.UnknownHostException;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import javax.net.SocketFactory;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
@@ -87,6 +93,89 @@
        }
        return conn;
    }
    // Copyright (C) 2009 The Android Open Source Project
    //
    // 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.
    public static class BlindSSLSocketFactory extends SSLSocketFactory {
        private static final BlindSSLSocketFactory INSTANCE;
        static {
            try {
                final SSLContext context = SSLContext.getInstance("SSL");
                final TrustManager[] trustManagers = { new DummyTrustManager() };
                final SecureRandom rng = new SecureRandom();
                context.init(null, trustManagers, rng);
                INSTANCE = new BlindSSLSocketFactory(context.getSocketFactory());
            } catch (GeneralSecurityException e) {
                throw new RuntimeException("Cannot create BlindSslSocketFactory", e);
            }
        }
        public static SocketFactory getDefault() {
            return INSTANCE;
        }
        private final SSLSocketFactory sslFactory;
        private BlindSSLSocketFactory(final SSLSocketFactory sslFactory) {
            this.sslFactory = sslFactory;
        }
        @Override
        public Socket createSocket(Socket s, String host, int port, boolean autoClose)
                throws IOException {
            return sslFactory.createSocket(s, host, port, autoClose);
        }
        @Override
        public String[] getDefaultCipherSuites() {
            return sslFactory.getDefaultCipherSuites();
        }
        @Override
        public String[] getSupportedCipherSuites() {
            return sslFactory.getSupportedCipherSuites();
        }
        @Override
        public Socket createSocket() throws IOException {
            return sslFactory.createSocket();
        }
        @Override
        public Socket createSocket(String host, int port) throws IOException,
        UnknownHostException {
            return sslFactory.createSocket(host, port);
        }
        @Override
        public Socket createSocket(InetAddress host, int port) throws IOException {
            return sslFactory.createSocket(host, port);
        }
        @Override
        public Socket createSocket(String host, int port, InetAddress localHost,
                int localPort) throws IOException, UnknownHostException {
            return sslFactory.createSocket(host, port, localHost, localPort);
        }
        @Override
        public Socket createSocket(InetAddress address, int port,
                InetAddress localAddress, int localPort) throws IOException {
            return sslFactory.createSocket(address, port, localAddress, localPort);
        }
    }
    /**
     * DummyTrustManager trusts all certificates.
src/com/gitblit/utils/StringUtils.java
@@ -327,20 +327,24 @@
     * @return list of strings
     */
    public static List<String> getStringsFromValue(String value, String separator) {
        List<String> strings = new ArrayList<String>();
        try {
            String[] chunks = value.split(separator);
            for (String chunk : chunks) {
                chunk = chunk.trim();
                if (chunk.length() > 0) {
                    strings.add(chunk);
                }
            }
        } catch (PatternSyntaxException e) {
            throw new RuntimeException(e);
        }
        return strings;
    }
        List<String> strings = new ArrayList<String>();
        try {
            String[] chunks = value.split(separator + "(?=([^\"]*\"[^\"]*\")*[^\"]*$)");
            for (String chunk : chunks) {
                chunk = chunk.trim();
                if (chunk.length() > 0) {
                    if (chunk.charAt(0) == '"' && chunk.charAt(chunk.length() - 1) == '"') {
                        // strip double quotes
                        chunk = chunk.substring(1, chunk.length() - 1).trim();
                    }
                    strings.add(chunk);
                }
            }
        } catch (PatternSyntaxException e) {
            throw new RuntimeException(e);
        }
        return strings;
    }
    /**
     * Validates that a name is composed of letters, digits, or limited other
@@ -518,4 +522,32 @@
        }
        return "";
    }
    /**
     * Replace all occurences of a substring within a string with
     * another string.
     *
     * From Spring StringUtils.
     *
     * @param inString String to examine
     * @param oldPattern String to replace
     * @param newPattern String to insert
     * @return a String with the replacements
     */
    public static String replace(String inString, String oldPattern, String newPattern) {
        StringBuilder sb = new StringBuilder();
        int pos = 0; // our position in the old string
        int index = inString.indexOf(oldPattern);
        // the index of an occurrence we've found, or -1
        int patLen = oldPattern.length();
        while (index >= 0) {
            sb.append(inString.substring(pos, index));
            sb.append(newPattern);
            pos = index + patLen;
            index = inString.indexOf(oldPattern, pos);
        }
        sb.append(inString.substring(pos));
        // remember to append any characters to the right of a match
        return sb.toString();
    }
}
src/com/gitblit/wicket/GitBlitWebApp.properties
@@ -270,4 +270,6 @@
gb.noFederation = Sorry, {0} is not configured to federate with any Gitblit instances.
gb.proposalFailed = Sorry, {0} did not receive any proposal data!
gb.proposalError = Sorry, {0} reports that an unexpected error occurred!
gb.failedToSendProposal = Failed to send proposal!
gb.failedToSendProposal = Failed to send proposal!
gb.userServiceDoesNotPermitAddUser = {0} does not permit adding a user account!
gb.userServiceDoesNotPermitPasswordChanges = {0} does not permit password changes!
src/com/gitblit/wicket/pages/BasePage.java
@@ -254,9 +254,11 @@
                add(new Label("username", GitBlitWebSession.get().getUser().toString() + ":"));
                add(new LinkPanel("loginLink", null, markupProvider.getString("gb.logout"),
                        LogoutPage.class));
                boolean editCredentials = GitBlit.self().supportsCredentialChanges();
                // quick and dirty hack for showing a separator
                add(new Label("separator", "|"));
                add(new BookmarkablePageLink<Void>("changePasswordLink", ChangePasswordPage.class));
                add(new Label("separator", "|").setVisible(editCredentials));
                add(new BookmarkablePageLink<Void>("changePasswordLink",
                        ChangePasswordPage.class).setVisible(editCredentials));
            } else {
                // login
                add(new Label("username").setVisible(false));
src/com/gitblit/wicket/pages/ChangePasswordPage.java
@@ -50,6 +50,12 @@
            // no authentication enabled
            throw new RestartResponseException(getApplication().getHomePage());
        }
        if (!GitBlit.self().supportsCredentialChanges()) {
            error(MessageFormat.format(getString("gb.userServiceDoesNotPermitPasswordChanges"),
                    GitBlit.getString(Keys.realm.userService, "users.conf")), true);
        }
        setupPage(getString("gb.changePassword"), GitBlitWebSession.get().getUser().username);
        StatelessForm<Void> form = new StatelessForm<Void>("passwordForm") {
src/com/gitblit/wicket/pages/EditTeamPage.java
@@ -217,9 +217,12 @@
        // do not let the browser pre-populate these fields
        form.add(new SimpleAttributeModifier("autocomplete", "off"));
        // not all user services support manipulating team memberships
        boolean editMemberships = GitBlit.self().supportsTeamMembershipChanges();
        // field names reflective match TeamModel fields
        form.add(new TextField<String>("name"));
        form.add(users);
        form.add(users.setEnabled(editMemberships));
        mailingLists = new Model<String>(teamModel.mailingLists == null ? ""
                : StringUtils.flattenStrings(teamModel.mailingLists, " "));
        form.add(new TextField<String>("mailingLists", mailingLists));
src/com/gitblit/wicket/pages/EditUserPage.java
@@ -54,6 +54,10 @@
    public EditUserPage() {
        // create constructor
        super();
        if (!GitBlit.self().supportsCredentialChanges()) {
            error(MessageFormat.format(getString("gb.userServiceDoesNotPermitAddUser"),
                    GitBlit.getString(Keys.realm.userService, "users.conf")), true);
        }
        isCreate = true;
        setupPage(new UserModel(""));
    }
@@ -125,40 +129,42 @@
                }
                boolean rename = !StringUtils.isEmpty(oldName)
                        && !oldName.equalsIgnoreCase(username);
                if (!userModel.password.equals(confirmPassword.getObject())) {
                    error(getString("gb.passwordsDoNotMatch"));
                    return;
                }
                String password = userModel.password;
                if (!password.toUpperCase().startsWith(StringUtils.MD5_TYPE)
                        && !password.toUpperCase().startsWith(StringUtils.COMBINED_MD5_TYPE)) {
                    // This is a plain text password.
                    // Check length.
                    int minLength = GitBlit.getInteger(Keys.realm.minPasswordLength, 5);
                    if (minLength < 4) {
                        minLength = 4;
                    }
                    if (password.trim().length() < minLength) {
                        error(MessageFormat.format(getString("gb.passwordTooShort"),
                                minLength));
                if (GitBlit.self().supportsCredentialChanges()) {
                    if (!userModel.password.equals(confirmPassword.getObject())) {
                        error(getString("gb.passwordsDoNotMatch"));
                        return;
                    }
                    // Optionally store the password MD5 digest.
                    String type = GitBlit.getString(Keys.realm.passwordStorage, "md5");
                    if (type.equalsIgnoreCase("md5")) {
                        // store MD5 digest of password
                        userModel.password = StringUtils.MD5_TYPE
                                + StringUtils.getMD5(userModel.password);
                    } else if (type.equalsIgnoreCase("combined-md5")) {
                        // store MD5 digest of username+password
                        userModel.password = StringUtils.COMBINED_MD5_TYPE
                                + StringUtils.getMD5(username + userModel.password);
                    String password = userModel.password;
                    if (!password.toUpperCase().startsWith(StringUtils.MD5_TYPE)
                            && !password.toUpperCase().startsWith(StringUtils.COMBINED_MD5_TYPE)) {
                        // This is a plain text password.
                        // Check length.
                        int minLength = GitBlit.getInteger(Keys.realm.minPasswordLength, 5);
                        if (minLength < 4) {
                            minLength = 4;
                        }
                        if (password.trim().length() < minLength) {
                            error(MessageFormat.format(getString("gb.passwordTooShort"),
                                    minLength));
                            return;
                        }
                        // Optionally store the password MD5 digest.
                        String type = GitBlit.getString(Keys.realm.passwordStorage, "md5");
                        if (type.equalsIgnoreCase("md5")) {
                            // store MD5 digest of password
                            userModel.password = StringUtils.MD5_TYPE
                                    + StringUtils.getMD5(userModel.password);
                        } else if (type.equalsIgnoreCase("combined-md5")) {
                            // store MD5 digest of username+password
                            userModel.password = StringUtils.COMBINED_MD5_TYPE
                                    + StringUtils.getMD5(username + userModel.password);
                        }
                    } else if (rename
                            && password.toUpperCase().startsWith(StringUtils.COMBINED_MD5_TYPE)) {
                        error(getString("gb.combinedMd5Rename"));
                        return;
                    }
                } else if (rename
                        && password.toUpperCase().startsWith(StringUtils.COMBINED_MD5_TYPE)) {
                    error(getString("gb.combinedMd5Rename"));
                    return;
                }
                Iterator<String> selectedRepositories = repositories.getSelectedChoices();
@@ -200,20 +206,26 @@
        
        // do not let the browser pre-populate these fields
        form.add(new SimpleAttributeModifier("autocomplete", "off"));
        // not all user services support manipulating username and password
        boolean editCredentials = GitBlit.self().supportsCredentialChanges();
        // not all user services support manipulating team memberships
        boolean editTeams = GitBlit.self().supportsTeamMembershipChanges();
        // field names reflective match UserModel fields
        form.add(new TextField<String>("username"));
        form.add(new TextField<String>("username").setEnabled(editCredentials));
        PasswordTextField passwordField = new PasswordTextField("password");
        passwordField.setResetPassword(false);
        form.add(passwordField);
        form.add(passwordField.setEnabled(editCredentials));
        PasswordTextField confirmPasswordField = new PasswordTextField("confirmPassword",
                confirmPassword);
        confirmPasswordField.setResetPassword(false);
        form.add(confirmPasswordField);
        form.add(confirmPasswordField.setEnabled(editCredentials));
        form.add(new CheckBox("canAdmin"));
        form.add(new CheckBox("excludeFromFederation"));
        form.add(repositories);
        form.add(teams);
        form.add(teams.setEnabled(editTeams));
        form.add(new Button("save"));
        Button cancel = new Button("cancel") {
src/com/gitblit/wicket/panels/UsersPanel.java
@@ -39,7 +39,8 @@
        super(wicketId);
        Fragment adminLinks = new Fragment("adminPanel", "adminLinks", this);
        adminLinks.add(new BookmarkablePageLink<Void>("newUser", EditUserPage.class));
        adminLinks.add(new BookmarkablePageLink<Void>("newUser", EditUserPage.class)
                .setVisible(GitBlit.self().supportsCredentialChanges()));
        add(adminLinks.setVisible(showAdmin));
        final List<UserModel> users = GitBlit.self().getAllUsers();
tests/com/gitblit/tests/LdapUserServiceTest.java
New file
@@ -0,0 +1,105 @@
/*
 * Copyright 2012 John Crygier
 * Copyright 2012 gitblit.com
 *
 * 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 com.gitblit.tests;
import static org.junit.Assert.*;
import java.util.HashMap;
import java.util.Map;
import org.junit.Before;
import org.junit.Test;
import com.gitblit.LdapUserService;
import com.gitblit.models.UserModel;
import com.gitblit.tests.mock.MemorySettings;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.sdk.LDAPConnection;
import com.unboundid.ldif.LDIFReader;
/**
 * An Integration test for LDAP that tests going against an in-memory UnboundID
 * LDAP server.
 *
 * @author jcrygier
 *
 */
public class LdapUserServiceTest {
    private LdapUserService ldapUserService;
    @Before
    public void createInMemoryLdapServer() throws Exception {
        InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig("dc=MyDomain");
        config.addAdditionalBindCredentials("cn=Directory Manager", "password");
        config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("default", 389));
        config.setSchema(null);
        InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
        ds.importFromLDIF(true, new LDIFReader(this.getClass().getResourceAsStream("resources/ldapUserServiceSampleData.ldif")));
        ds.startListening();
    }
    @Before
    public void createLdapUserService() {
        Map<Object, Object> backingMap = new HashMap<Object, Object>();
        backingMap.put("realm.ldap.server", "ldap://localhost:389");
        backingMap.put("realm.ldap.domain", "");
        backingMap.put("realm.ldap.username", "cn=Directory Manager");
        backingMap.put("realm.ldap.password", "password");
        backingMap.put("realm.ldap.backingUserService", "users.conf");
        backingMap.put("realm.ldap.maintainTeams", "true");
        backingMap.put("realm.ldap.accountBase", "OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain");
        backingMap.put("realm.ldap.accountPattern", "(&(objectClass=person)(sAMAccountName=${username}))");
        backingMap.put("realm.ldap.groupBase", "OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain");
        backingMap.put("realm.ldap.groupPattern", "(&(objectClass=group)(member=${dn}))");
        backingMap.put("realm.ldap.admins", "UserThree @Git_Admins \"@Git Admins\"");
        MemorySettings ms = new MemorySettings(backingMap);
        ldapUserService = new LdapUserService();
        ldapUserService.setup(ms);
    }
    @Test
    public void testAuthenticate() {
        UserModel userOneModel = ldapUserService.authenticate("UserOne", "userOnePassword".toCharArray());
        assertNotNull(userOneModel);
        assertNotNull(userOneModel.getTeam("git_admins"));
        assertNotNull(userOneModel.getTeam("git_users"));
        assertTrue(userOneModel.canAdmin);
        UserModel userOneModelFailedAuth = ldapUserService.authenticate("UserOne", "userTwoPassword".toCharArray());
        assertNull(userOneModelFailedAuth);
        UserModel userTwoModel = ldapUserService.authenticate("UserTwo", "userTwoPassword".toCharArray());
        assertNotNull(userTwoModel);
        assertNotNull(userTwoModel.getTeam("git_users"));
        assertNull(userTwoModel.getTeam("git_admins"));
        assertNotNull(userTwoModel.getTeam("git admins"));
        assertTrue(userTwoModel.canAdmin);
        UserModel userThreeModel = ldapUserService.authenticate("UserThree", "userThreePassword".toCharArray());
        assertNotNull(userThreeModel);
        assertNotNull(userThreeModel.getTeam("git_users"));
        assertNull(userThreeModel.getTeam("git_admins"));
        assertTrue(userThreeModel.canAdmin);
    }
}
tests/com/gitblit/tests/StringUtilsTest.java
@@ -112,13 +112,24 @@
    @Test
    public void testStringsFromValue() throws Exception {
        List<String> strings = StringUtils.getStringsFromValue("A B C D");
        assertEquals(4, strings.size());
        assertEquals("A", strings.get(0));
        assertEquals("B", strings.get(1));
        assertEquals("C", strings.get(2));
        assertEquals("D", strings.get(3));
    }
        List<String> strings = StringUtils.getStringsFromValue("\"A A \" B \"C C\" D \"\" \"E\"");
        assertEquals(6, strings.size());
        assertEquals("A A", strings.get(0));
        assertEquals("B", strings.get(1));
        assertEquals("C C", strings.get(2));
        assertEquals("D", strings.get(3));
        assertEquals("", strings.get(4));
        assertEquals("E", strings.get(5));
        strings = StringUtils.getStringsFromValue("\"A A \", B, \"C C\", D, \"\", \"E\"", ",");
        assertEquals(6, strings.size());
        assertEquals("A A", strings.get(0));
        assertEquals("B", strings.get(1));
        assertEquals("C C", strings.get(2));
        assertEquals("D", strings.get(3));
        assertEquals("", strings.get(4));
        assertEquals("E", strings.get(5));
    }
    @Test
    public void testStringsFromValue2() throws Exception {
tests/com/gitblit/tests/mock/MemorySettings.java
New file
@@ -0,0 +1,50 @@
 /*
 * Copyright 2012 John Crygier
 * Copyright 2012 gitblit.com
 *
 * 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 com.gitblit.tests.mock;
import java.util.Map;
import java.util.Properties;
import com.gitblit.IStoredSettings;
public class MemorySettings extends IStoredSettings {
    private Map<Object, Object> backingMap;
    public MemorySettings(Map<Object, Object> backingMap) {
        super(MemorySettings.class);
        this.backingMap = backingMap;
    }
    @Override
    protected Properties read() {
        Properties props = new Properties();
        props.putAll(backingMap);
        return props;
    }
    public void put(Object key, Object value) {
        backingMap.put(key, value);
    }
    @Override
    public boolean saveSettings(Map<String, String> updatedSettings) {
        return false;
    }
}
tests/com/gitblit/tests/resources/ldapUserServiceSampleData.ldif
New file
@@ -0,0 +1,88 @@
dn: DC=MyDomain
dc: MyDomain
objectClass: top
objectClass: domain
dn: OU=MyOrganization,DC=MyDomain
objectClass: top
objectClass: organizationalUnit
ou: MyOrganization
dn: OU=UserControl,OU=MyOrganization,DC=MyDomain
objectClass: top
objectClass: organizationalUnit
ou: UserControl
dn: OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain
objectClass: top
objectClass: organizationalUnit
ou: Groups
dn: CN=Git_Admins,OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain
objectClass: top
objectClass: group
cn: Git_Admins
sAMAccountName: Git_Admins
member: CN=UserOne,OU=US,OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain
dn: CN=Git Admins,OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain
objectClass: top
objectClass: group
cn: Git Admins
sAMAccountName: Git_Admins_With_Space
member: CN=UserTwo,OU=US,OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain
dn: CN=Git_Users,OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain
objectClass: top
objectClass: group
cn: Git_Users
sAMAccountName: Git_Users
member: CN=UserOne,OU=US,OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain
member: CN=UserTwo,OU=US,OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain
member: CN=UserThree,OU=Canada,OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain
member: CN=UserFour,OU=Canada,OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain
dn: OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain
objectClass: top
objectClass: organizationalUnit
ou: Users
dn: OU=US,OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain
objectClass: top
objectClass: organizationalUnit
ou: US
dn: OU=Canada,OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain
objectClass: top
objectClass: organizationalUnit
ou: Canada
dn: CN=UserOne,OU=US,OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain
objectClass: user
objectClass: person
sAMAccountName: UserOne
userPassword: userOnePassword
memberOf: CN=Git_Admins,OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain
memberOf: CN=Git_Users,OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain
dn: CN=UserTwo,OU=US,OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain
objectClass: user
objectClass: person
sAMAccountName: UserTwo
userPassword: userTwoPassword
memberOf: CN=Git_Users,OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain
memberOf: CN=Git Admins,OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain
dn: CN=UserThree,OU=Canada,OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain
objectClass: user
objectClass: person
sAMAccountName: UserThree
userPassword: userThreePassword
memberOf: CN=Git_Users,OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain
dn: CN=UserFour,OU=Canada,OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain
objectClass: user
objectClass: person
sAMAccountName: UserFour
userPassword: userFourPassword
memberOf: CN=Git_Users,OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain