Add an Apache htpasswd user service

Add a new class, HtpasswdUserService, which performs authentication
against a text file created with the Apache 'htpasswd' program.

Added dependency on commons-codec:1.7
diff --git a/.classpath b/.classpath
index bfdaad2..04b4bba 100644
--- a/.classpath
+++ b/.classpath
@@ -46,6 +46,7 @@
 	<classpathentry kind="lib" path="ext/jna-3.5.0.jar" sourcepath="ext/src/jna-3.5.0.jar" />
 	<classpathentry kind="lib" path="ext/guava-13.0.1.jar" sourcepath="ext/src/guava-13.0.1.jar" />
 	<classpathentry kind="lib" path="ext/libpam4j-1.7.jar" sourcepath="ext/src/libpam4j-1.7.jar" />
+	<classpathentry kind="lib" path="ext/commons-codec-1.7.jar" sourcepath="ext/src/commons-codec-1.7.jar" />
 	<classpathentry kind="lib" path="ext/junit-4.11.jar" sourcepath="ext/src/junit-4.11.jar" />
 	<classpathentry kind="lib" path="ext/hamcrest-core-1.3.jar" sourcepath="ext/src/hamcrest-core-1.3.jar" />
 	<classpathentry kind="lib" path="ext/selenium-java-2.28.0.jar" sourcepath="ext/src/selenium-java-2.28.0.jar" />
@@ -58,7 +59,6 @@
 	<classpathentry kind="lib" path="ext/httpclient-4.2.1.jar" sourcepath="ext/src/httpclient-4.2.1.jar" />
 	<classpathentry kind="lib" path="ext/httpcore-4.2.1.jar" sourcepath="ext/src/httpcore-4.2.1.jar" />
 	<classpathentry kind="lib" path="ext/commons-logging-1.1.1.jar" sourcepath="ext/src/commons-logging-1.1.1.jar" />
-	<classpathentry kind="lib" path="ext/commons-codec-1.6.jar" sourcepath="ext/src/commons-codec-1.6.jar" />
 	<classpathentry kind="lib" path="ext/commons-exec-1.1.jar" sourcepath="ext/src/commons-exec-1.1.jar" />
 	<classpathentry kind="lib" path="ext/commons-io-2.2.jar" sourcepath="ext/src/commons-io-2.2.jar" />
 	<classpathentry kind="output" path="bin/classes" />
diff --git a/NOTICE b/NOTICE
index ca7979b..6468c49 100644
--- a/NOTICE
+++ b/NOTICE
@@ -310,4 +310,12 @@
    MIT license.

    

    https://github.com/kohsuke/libpam4j

-  
\ No newline at end of file
+

+---------------------------------------------------------------------------

+commons-codec

+---------------------------------------------------------------------------

+   commons-codec, release under the

+   Apache License 2.0.

+   

+   http://commons.apache.org/proper/commons-codec

+    
\ No newline at end of file
diff --git a/build.moxie b/build.moxie
index 9862f95..79a9030 100644
--- a/build.moxie
+++ b/build.moxie
@@ -152,6 +152,7 @@
 - compile 'org.freemarker:freemarker:2.3.19' :war
 - compile 'com.github.dblock.waffle:waffle-jna:1.5' :war
 - compile 'org.kohsuke:libpam4j:1.7' :war
+- compile 'commons-codec:commons-codec:1.7' :war
 - test 'junit'
 # Dependencies for Selenium web page testing
 - test 'org.seleniumhq.selenium:selenium-java:${selenium.version}' @jar
diff --git a/gitblit.iml b/gitblit.iml
index c4ba270..d29b31a 100644
--- a/gitblit.iml
+++ b/gitblit.iml
@@ -479,6 +479,17 @@
         </SOURCES>
       </library>
     </orderEntry>
+    <orderEntry type="module-library">
+      <library name="commons-codec-1.7.jar">
+        <CLASSES>
+          <root url="jar://$MODULE_DIR$/ext/commons-codec-1.7.jar!/" />
+        </CLASSES>
+        <JAVADOC />
+        <SOURCES>
+          <root url="jar://$MODULE_DIR$/ext/src/commons-codec-1.7.jar!/" />
+        </SOURCES>
+      </library>
+    </orderEntry>
     <orderEntry type="module-library" scope="TEST">
       <library name="junit-4.11.jar">
         <CLASSES>
@@ -612,17 +623,6 @@
       </library>
     </orderEntry>
     <orderEntry type="module-library" scope="TEST">
-      <library name="commons-codec-1.6.jar">
-        <CLASSES>
-          <root url="jar://$MODULE_DIR$/ext/commons-codec-1.6.jar!/" />
-        </CLASSES>
-        <JAVADOC />
-        <SOURCES>
-          <root url="jar://$MODULE_DIR$/ext/src/commons-codec-1.6.jar!/" />
-        </SOURCES>
-      </library>
-    </orderEntry>
-    <orderEntry type="module-library" scope="TEST">
       <library name="commons-exec-1.1.jar">
         <CLASSES>
           <root url="jar://$MODULE_DIR$/ext/commons-exec-1.1.jar!/" />
diff --git a/releases.moxie b/releases.moxie
index fea4499..5400234 100644
--- a/releases.moxie
+++ b/releases.moxie
@@ -20,13 +20,18 @@
     changes: ~
     additions:
     - Add setting for maximum number of days of activity to that may be requested
-    dependencyChanges: ~
+    - Added HtpasswdUserService to authenticate users against an htpasswd file
+    dependencyChanges:
+    - Added commons-codec 1.7
     contributors:
     - github/guriguri
     - Doug Ayers
     - Ori Livneh
+    - Florian Zschocke
     settings:
     - { name: 'web.activityDurationMaximum', defaultValue: 30 }
+    - { name: 'realm.htpasswd.userFile', defaultValue: '${baseFolder}/htpasswd' }
+    - { name: 'realm.htpasswd.overrideLocalAuthentication', defaultValue: 'false' }
 }
 
 #
diff --git a/src/main/distrib/data/gitblit.properties b/src/main/distrib/data/gitblit.properties
index 770bd39..9be7f64 100644
--- a/src/main/distrib/data/gitblit.properties
+++ b/src/main/distrib/data/gitblit.properties
@@ -502,6 +502,7 @@
 #    com.gitblit.SalesforceUserService

 #    com.gitblit.WindowsUserService

 #    com.gitblit.PAMUserService

+#    com.gitblit.HtpasswdUserService

 #

 # Any custom user service implementation must have a public default constructor.

 #

@@ -1233,6 +1234,38 @@
 # SINCE 1.3.1

 realm.pam.serviceName = system-auth

 

+# The HtpasswdUserService must be backed by another user service for standard user

+# and team management and attributes. This can be one of the local Gitblit user services.

+# default: users.conf

+#

+# RESTART REQUIRED

+# BASEFOLDER

+# SINCE 1.3.2

+realm.htpasswd.backingUserService = ${baseFolder}/users.conf

+

+# The Apache htpasswd file that contains the users and passwords.

+# default: ${baseFolder}/htpasswd

+#

+# RESTART REQUIRED

+# BASEFOLDER

+# SINCE 1.3.2

+realm.htpasswd.userfile = ${baseFolder}/htpasswd

+

+#  Determines how accounts are looked up upon login.

+#

+# If set to false, then authentication for local accounts is done against

+# the backing user service.

+# If set to true, then authentication will first be checked against the

+# htpasswd store, even if the account appears as a local account in the

+# backing user service. If the user is found in the htpasswd store, then

+# an already existing local account will be turned into an external account.

+# In this case an initial local password is never used and gets overwritten

+# by the externally stored password upon login.

+# default: false

+#

+# SINCE 1.3.2

+realm.htpasswd.overrideLocalAuthentication = false

+

 # The SalesforceUserService must be backed by another user service for standard user

 # and team management.

 # default: users.conf

diff --git a/src/main/java/com/gitblit/Constants.java b/src/main/java/com/gitblit/Constants.java
index c180baf..2c67bff 100644
--- a/src/main/java/com/gitblit/Constants.java
+++ b/src/main/java/com/gitblit/Constants.java
@@ -480,7 +480,7 @@
 	}

 	

 	public static enum AccountType {

-		LOCAL, EXTERNAL, LDAP, REDMINE, SALESFORCE, WINDOWS, PAM;

+		LOCAL, EXTERNAL, LDAP, REDMINE, SALESFORCE, WINDOWS, PAM, HTPASSWD;

 		

 		public boolean isLocal() {

 			return this == LOCAL;

diff --git a/src/main/java/com/gitblit/HtpasswdUserService.java b/src/main/java/com/gitblit/HtpasswdUserService.java
new file mode 100644
index 0000000..62198f4
--- /dev/null
+++ b/src/main/java/com/gitblit/HtpasswdUserService.java
@@ -0,0 +1,356 @@
+/*
+ * Copyright 2013 Florian Zschocke
+ * Copyright 2013 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.io.FileInputStream;
+import java.text.MessageFormat;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.commons.codec.binary.Base64;
+import org.apache.commons.codec.digest.Crypt;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.commons.codec.digest.Md5Crypt;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.gitblit.Constants.AccountType;
+import com.gitblit.models.UserModel;
+import com.gitblit.utils.ArrayUtils;
+import com.gitblit.utils.StringUtils;
+
+
+/**
+ * Implementation of a user service using an Apache htpasswd file for authentication.
+ * 
+ * This user service implement custom authentication using entries in a file created
+ * by the 'htpasswd' program of an Apache web server. All possible output
+ * options of the 'htpasswd' program version 2.2 are supported:
+ * plain text (only on Windows and Netware),
+ * glibc crypt() (not on Windows and NetWare),
+ * Apache MD5 (apr1),
+ * unsalted SHA-1.
+ * 
+ * Configuration options:
+ * realm.htpasswd.backingUserService - Specify the backing user service that is used
+ *                                     to keep the user data other than the password.
+ *                                     The default is '${baseFolder}/users.conf'.
+ * realm.htpasswd.userfile - The text file with the htpasswd entries to be used for
+ *                           authentication.
+ *                           The default is '${baseFolder}/htpasswd'.
+ * realm.htpasswd.overrideLocalAuthentication - Specify if local accounts are overwritten
+ *                                              when authentication matches for an
+ *                                              external account.
+ * 
+ * @author Florian Zschocke
+ *
+ */
+public class HtpasswdUserService extends GitblitUserService
+{
+
+    private static final String KEY_BACKING_US = Keys.realm.htpasswd.backingUserService;
+    private static final String DEFAULT_BACKING_US = "${baseFolder}/users.conf";
+
+    private static final String KEY_HTPASSWD_FILE = Keys.realm.htpasswd.userfile;
+    private static final String DEFAULT_HTPASSWD_FILE = "${baseFolder}/htpasswd";
+
+    private static final String KEY_OVERRIDE_LOCALAUTH = Keys.realm.htpasswd.overrideLocalAuthentication;
+    private static final boolean DEFAULT_OVERRIDE_LOCALAUTH = true;
+
+    private static final String KEY_SUPPORT_PLAINTEXT_PWD = "realm.htpasswd.supportPlaintextPasswords";
+
+    private final boolean SUPPORT_PLAINTEXT_PWD;
+
+    private IStoredSettings settings;
+    private File htpasswdFile;
+
+
+    private final Logger logger = LoggerFactory.getLogger(HtpasswdUserService.class);
+
+    private final Map<String, String> htUsers = new ConcurrentHashMap<String, String>();
+
+    private volatile long lastModified;
+
+    private volatile boolean forceReload;
+
+
+
+    public HtpasswdUserService()
+    {
+        super();
+
+        String os = System.getProperty("os.name").toLowerCase();
+        if (os.startsWith("windows") || os.startsWith("netware")) {
+            SUPPORT_PLAINTEXT_PWD = true;
+        }
+        else {
+            SUPPORT_PLAINTEXT_PWD = false;
+        }
+    }
+
+
+
+    /**
+     * Setup the user service.
+     * 
+     * The HtpasswdUserService extends the GitblitUserService and is thus
+     * backed by the available user services provided by the GitblitUserService.
+     * In addition the setup tries to read and parse the htpasswd file to be used
+     * for authentication.
+     * 
+     * @param settings
+     * @since 0.7.0
+     */
+    @Override
+    public void setup(IStoredSettings settings)
+    {
+        this.settings = settings;
+
+        // This is done in two steps in order to avoid calling GitBlit.getFileOrFolder(String, String) which will segfault for unit tests.
+        String file = settings.getString(KEY_BACKING_US, DEFAULT_BACKING_US);
+        File realmFile = GitBlit.getFileOrFolder(file);
+        serviceImpl = createUserService(realmFile);
+        logger.info("Htpasswd User Service backed by " + serviceImpl.toString());
+
+        read();
+
+        logger.debug("Read " + htUsers.size() + " users from htpasswd file: " + this.htpasswdFile);
+    }
+
+
+
+    /**
+     * For now, credentials are defined in the htpasswd file and can not be manipulated
+     * from Gitblit.
+     *
+     * @return false
+     * @since 1.0.0
+     */
+    @Override
+    public boolean supportsCredentialChanges()
+    {
+        return false;
+    }
+
+
+
+    /**
+     * Authenticate a user based on a username and password.
+     *
+     * If the account is determined to be a local account, authentication
+     * will be done against the locally stored password.
+     * Otherwise, the configured htpasswd file is read. All current output options
+     * of htpasswd are supported: clear text, crypt(), Apache MD5 and unsalted SHA-1.
+     *
+     * @param username
+     * @param password
+     * @return a user object or null
+     */
+    @Override
+    public UserModel authenticate(String username, char[] password)
+    {
+        if (isLocalAccount(username)) {
+            // local account, bypass htpasswd authentication
+            return super.authenticate(username, password);
+        }
+
+
+        read();
+        String storedPwd = htUsers.get(username);
+        if (storedPwd != null) {
+            boolean authenticated = false;
+            final String passwd = new String(password);
+
+            // test Apache MD5 variant encrypted password
+            if ( storedPwd.startsWith("$apr1$") ) {
+                if ( storedPwd.equals(Md5Crypt.apr1Crypt(passwd, storedPwd)) ) {
+                    logger.debug("Apache MD5 encoded password matched for user '" + username + "'");
+                    authenticated = true;
+                }
+            }
+            // test unsalted SHA password
+            else if ( storedPwd.startsWith("{SHA}") ) {
+                String passwd64 = Base64.encodeBase64String(DigestUtils.sha1(passwd));
+                if ( storedPwd.substring("{SHA}".length()).equals(passwd64) ) {
+                    logger.debug("Unsalted SHA-1 encoded password matched for user '" + username + "'");
+                    authenticated = true;
+                }
+            }
+            // test libc crypt() encoded password
+            else if ( supportCryptPwd() && storedPwd.equals(Crypt.crypt(passwd, storedPwd)) ) {
+                logger.debug("Libc crypt encoded password matched for user '" + username + "'");
+                authenticated = true;
+            }
+            // test clear text
+            else if ( supportPlaintextPwd() && storedPwd.equals(passwd) ){
+                logger.debug("Clear text password matched for user '" + username + "'");
+                authenticated = true;
+            }
+
+
+            if (authenticated) {
+                logger.debug("Htpasswd authenticated: " + username);
+
+                UserModel user = getUserModel(username);
+                if (user == null) {
+                    // create user object for new authenticated user
+                    user = new UserModel(username);
+                }
+
+                // create a user cookie
+                if (StringUtils.isEmpty(user.cookie) && !ArrayUtils.isEmpty(password)) {
+                    user.cookie = StringUtils.getSHA1(user.username + passwd);
+                }
+
+                // Set user attributes, hide password from backing user service.
+                user.password = Constants.EXTERNAL_ACCOUNT;
+                user.accountType = getAccountType();
+
+                // Push the looked up values to backing file
+                super.updateUserModel(user);
+
+                return user;
+            }
+        }
+
+        return null;
+    }
+
+
+
+    /**
+     * Determine if the account is to be treated as a local account.
+     * 
+     * This influences authentication. A local account will be authenticated
+     * by the backing user service while an external account will be handled 
+     * by this user service.
+     * <br/>
+     * The decision also depends on the setting of the key
+     * realm.htpasswd.overrideLocalAuthentication.
+     * If it is set to true, then passwords will first be checked against the
+     * htpasswd store. If an account exists and is marked as local in the backing
+     * user service, that setting will be overwritten by the result. This
+     * means that an account that looks local to the backing user service will
+     * be turned into an external account upon valid login of a user that has
+     * an entry in the htpasswd file.
+     * If the key is set to false, then it is determined if the account is local
+     * according to the logic of the GitblitUserService.
+     */
+    protected boolean isLocalAccount(String username)
+    {
+        if ( settings.getBoolean(KEY_OVERRIDE_LOCALAUTH, DEFAULT_OVERRIDE_LOCALAUTH) ) {
+            read();
+            if ( htUsers.containsKey(username) ) return false;
+        }
+        return super.isLocalAccount(username);
+    }
+
+
+
+    /**
+     * Get the account type used for this user service.
+     *
+     * @return AccountType.HTPASSWD
+     */
+    protected AccountType getAccountType()
+    {
+        return AccountType.HTPASSWD;
+    }
+
+
+
+    private String htpasswdFilePath = null;
+    /**
+     * Reads the realm file and rebuilds the in-memory lookup tables.
+     */
+    protected synchronized void read()
+    {
+
+        // This is done in two steps in order to avoid calling GitBlit.getFileOrFolder(String, String) which will segfault for unit tests.
+        String file = settings.getString(KEY_HTPASSWD_FILE, DEFAULT_HTPASSWD_FILE);
+        if ( !file.equals(htpasswdFilePath) ) {
+            // The htpasswd file setting changed. Rediscover the file.
+            this.htpasswdFilePath = file;
+            this.htpasswdFile = GitBlit.getFileOrFolder(file);
+            this.htUsers.clear();
+            this.forceReload = true;
+        }
+
+        if (htpasswdFile.exists() && (forceReload || (htpasswdFile.lastModified() != lastModified))) {
+            forceReload = false;
+            lastModified = htpasswdFile.lastModified();
+            htUsers.clear();
+
+            Pattern entry = Pattern.compile("^([^:]+):(.+)");
+
+            Scanner scanner = null;
+            try {
+                scanner = new Scanner(new FileInputStream(htpasswdFile));
+                while( scanner.hasNextLine()) {
+                    String line = scanner.nextLine().trim();
+                    if ( !line.isEmpty() &&  !line.startsWith("#") ) {
+                        Matcher m = entry.matcher(line);
+                        if ( m.matches() ) {
+                            htUsers.put(m.group(1), m.group(2));
+                        }
+                    }
+                }
+            } catch (Exception e) {
+                logger.error(MessageFormat.format("Failed to read {0}", htpasswdFile), e);
+            }
+            finally {
+                if (scanner != null) scanner.close();
+            }
+        }
+    }
+
+
+
+    private boolean supportPlaintextPwd()
+    {
+        return this.settings.getBoolean(KEY_SUPPORT_PLAINTEXT_PWD, SUPPORT_PLAINTEXT_PWD);
+    }
+
+
+    private boolean supportCryptPwd()
+    {
+        return !supportPlaintextPwd();
+    }
+
+
+
+    @Override
+    public String toString()
+    {
+        return getClass().getSimpleName() + "(" + ((htpasswdFile != null) ? htpasswdFile.getAbsolutePath() : "null") + ")";
+    }
+
+
+
+
+    /*
+     * Method only used for unit tests. Return number of users read from htpasswd file.
+     */
+    public int getNumberHtpasswdUsers()
+    {
+        return this.htUsers.size();
+    }
+}
diff --git a/src/site/design.mkd b/src/site/design.mkd
index 601d68a..ce67620 100644
--- a/src/site/design.mkd
+++ b/src/site/design.mkd
@@ -52,6 +52,7 @@
 - [JNA](https://github.com/twall/jna) (LGPL 2.1)

 - [Guava](https://code.google.com/p/guava-libraries) (Apache 2.0)

 - [libpam4j](https://github.com/kohsuke/libpam4j) (MIT)

+- [commons-codec](http://commons.apache.org/proper/commons-codec) (Apache 2.0)

 

 ### Other Build Dependencies

 - [Fancybox image viewer](http://fancybox.net) (MIT and GPL dual-licensed)

diff --git a/src/site/setup_authentication.mkd b/src/site/setup_authentication.mkd
index 0ec07fa..3fb4a6c 100644
--- a/src/site/setup_authentication.mkd
+++ b/src/site/setup_authentication.mkd
@@ -7,6 +7,7 @@
 * LDAP authentication

 * Windows authentication

 * PAM authentication

+* Htpasswd authentication

 * Redmine auhentication

 * Salesforce.com authentication

 * Servlet container authentication

@@ -91,6 +92,13 @@
     realm.userService = com.gitblit.PAMUserService

     realm.pam.serviceName = system-auth

 

+### Htpasswd Authentication

+

+Htpasswd authentication allows you to maintain your user credentials in an Apache htpasswd file thay may be shared with other htpasswd-capable servers.

+

+    realm.userService = com.gitblit.HtpasswdUserService

+    realm.htpasswd.userFile = /path/to/htpasswd

+

 ### Redmine Authentication

 

 You may authenticate your users against a Redmine installation as long as your Redmine install has properly enabled [API authentication](http://www.redmine.org/projects/redmine/wiki/Rest_Api#Authentication).  This user service only supports user authentication; it does not support team creation based on Redmine groups.  Redmine administrators will also be Gitblit administrators.

diff --git a/src/test/java/com/gitblit/tests/GitBlitSuite.java b/src/test/java/com/gitblit/tests/GitBlitSuite.java
index 6fff241..64398b2 100644
--- a/src/test/java/com/gitblit/tests/GitBlitSuite.java
+++ b/src/test/java/com/gitblit/tests/GitBlitSuite.java
@@ -60,7 +60,7 @@
 		DiffUtilsTest.class, MetricUtilsTest.class, TicgitUtilsTest.class, X509UtilsTest.class,

 		GitBlitTest.class, FederationTests.class, RpcTests.class, GitServletTest.class, GitDaemonTest.class,

 		GroovyScriptTest.class, LuceneExecutorTest.class, IssuesTest.class, RepositoryModelTest.class,

-		FanoutServiceTest.class, Issue0259Test.class, Issue0271Test.class })

+		FanoutServiceTest.class, Issue0259Test.class, Issue0271Test.class, HtpasswdUserServiceTest.class })

 public class GitBlitSuite {

 

 	public static final File REPOSITORIES = new File("data/git");

diff --git a/src/test/java/com/gitblit/tests/HtpasswdUserServiceTest.java b/src/test/java/com/gitblit/tests/HtpasswdUserServiceTest.java
new file mode 100644
index 0000000..ef9d35f
--- /dev/null
+++ b/src/test/java/com/gitblit/tests/HtpasswdUserServiceTest.java
@@ -0,0 +1,556 @@
+package com.gitblit.tests;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.File;
+import java.io.FilenameFilter;
+import java.io.IOException;
+import java.util.HashMap;
+import org.apache.commons.io.FileUtils;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import com.gitblit.HtpasswdUserService;
+import com.gitblit.models.UserModel;
+import com.gitblit.tests.mock.MemorySettings;
+import com.gitblit.utils.StringUtils;
+
+/**
+ * Test the Htpasswd user service.
+ *
+ */
+public class HtpasswdUserServiceTest {
+
+    private static final String RESOURCE_DIR = "src/test/resources/htpasswdUSTest/";
+    private static final String KEY_SUPPORT_PLAINTEXT_PWD = "realm.htpasswd.supportPlaintextPasswords";
+
+    private static final int NUM_USERS_HTPASSWD = 10;
+
+    private static final MemorySettings MS = new MemorySettings(new HashMap<String, Object>());
+
+    private HtpasswdUserService htpwdUserService;
+
+
+    private MemorySettings getSettings( String userfile, String groupfile, Boolean overrideLA)
+    {
+        MS.put("realm.htpasswd.backingUserService", RESOURCE_DIR + "users.conf");
+        MS.put("realm.htpasswd.userfile", (userfile == null) ? (RESOURCE_DIR+"htpasswd") : userfile);
+        MS.put("realm.htpasswd.groupfile", (groupfile == null) ? (RESOURCE_DIR+"htgroup") : groupfile);
+        MS.put("realm.htpasswd.overrideLocalAuthentication", (overrideLA == null) ? "false" : overrideLA.toString());
+        // Default to keep test the same on all platforms.
+        MS.put(KEY_SUPPORT_PLAINTEXT_PWD, "false");
+
+        return MS;
+    }
+
+    private MemorySettings getSettings()
+    {
+        return getSettings(null, null, null);
+    }
+
+    private MemorySettings getSettings(boolean overrideLA)
+    {
+        return getSettings(null, null, new Boolean(overrideLA));
+    }
+
+
+    private void setupUS()
+    {
+        htpwdUserService = new HtpasswdUserService();
+        htpwdUserService.setup(getSettings());
+    }
+
+    private void setupUS(boolean overrideLA)
+    {
+        htpwdUserService = new HtpasswdUserService();
+        htpwdUserService.setup(getSettings(overrideLA));
+    }
+
+
+    private void copyInFiles() throws IOException
+    {
+        File dir = new File(RESOURCE_DIR);
+        FilenameFilter filter = new FilenameFilter() {
+            public boolean accept(File dir, String file) {
+                return file.endsWith(".in");
+                }
+            };
+        for (File inf : dir.listFiles(filter)) {
+            File dest = new File(inf.getParent(), inf.getName().substring(0, inf.getName().length()-3));
+            FileUtils.copyFile(inf, dest);
+        }
+    }
+
+
+    private void deleteGeneratedFiles()
+    {
+        File dir = new File(RESOURCE_DIR);
+        FilenameFilter filter = new FilenameFilter() {
+            public boolean accept(File dir, String file) {
+                return !(file.endsWith(".in"));
+                }
+            };
+        for (File file : dir.listFiles(filter)) {
+            file.delete();
+        }
+    }
+
+
+    @Before
+    public void setup() throws IOException
+    {
+        copyInFiles();
+        setupUS();
+    }
+
+
+    @After
+    public void tearDown()
+    {
+        deleteGeneratedFiles();
+    }
+
+
+
+    @Test
+    public void testSetup() throws IOException
+    {
+        assertEquals(NUM_USERS_HTPASSWD, htpwdUserService.getNumberHtpasswdUsers());
+    }
+
+
+    @Test
+    public void testAuthenticate()
+    {
+        MS.put(KEY_SUPPORT_PLAINTEXT_PWD, "true");
+        UserModel user = htpwdUserService.authenticate("user1", "pass1".toCharArray());
+        assertNotNull(user);
+        assertEquals("user1", user.username);
+
+        user = htpwdUserService.authenticate("user2", "pass2".toCharArray());
+        assertNotNull(user);
+        assertEquals("user2", user.username);
+
+        // Test different encryptions
+        user = htpwdUserService.authenticate("plain", "passWord".toCharArray());
+        assertNotNull(user);
+        assertEquals("plain", user.username);
+
+        MS.put(KEY_SUPPORT_PLAINTEXT_PWD, "false");
+        user = htpwdUserService.authenticate("crypt", "password".toCharArray());
+        assertNotNull(user);
+        assertEquals("crypt", user.username);
+
+        user = htpwdUserService.authenticate("md5", "password".toCharArray());
+        assertNotNull(user);
+        assertEquals("md5", user.username);
+
+        user = htpwdUserService.authenticate("sha", "password".toCharArray());
+        assertNotNull(user);
+        assertEquals("sha", user.username);
+
+
+        // Test leading and trailing whitespace
+        user = htpwdUserService.authenticate("trailing", "whitespace".toCharArray());
+        assertNotNull(user);
+        assertEquals("trailing", user.username);
+
+        user = htpwdUserService.authenticate("tabbed", "frontAndBack".toCharArray());
+        assertNotNull(user);
+        assertEquals("tabbed", user.username);
+
+        user = htpwdUserService.authenticate("leading", "whitespace".toCharArray());
+        assertNotNull(user);
+        assertEquals("leading", user.username);
+
+
+        // Test local account
+        user = htpwdUserService.authenticate("admin", "admin".toCharArray());
+        assertNotNull(user);
+        assertEquals("admin", user.username);
+    }
+
+
+    @Test
+    public void testAttributes()
+    {
+        MS.put(KEY_SUPPORT_PLAINTEXT_PWD, "true");
+        UserModel user = htpwdUserService.authenticate("user1", "pass1".toCharArray());
+        assertNotNull(user);
+        assertEquals("El Capitan", user.displayName);
+        assertEquals("cheffe@example.com", user.emailAddress);
+        assertTrue(user.canAdmin);
+
+        user = htpwdUserService.authenticate("user2", "pass2".toCharArray());
+        assertNotNull(user);
+        assertEquals("User Two", user.displayName);
+        assertTrue(user.canCreate);
+        assertTrue(user.canFork);
+
+
+        user = htpwdUserService.authenticate("admin", "admin".toCharArray());
+        assertNotNull(user);
+        assertTrue(user.canAdmin);
+
+        user = htpwdUserService.authenticate("staylocal", "localUser".toCharArray());
+        assertNotNull(user);
+        assertEquals("Local User", user.displayName);
+        assertFalse(user.canCreate);
+        assertFalse(user.canFork);
+        assertFalse(user.canAdmin);
+    }
+
+
+    @Test
+    public void testAuthenticateDenied()
+    {
+        UserModel user = null;
+        MS.put(KEY_SUPPORT_PLAINTEXT_PWD, "true");
+        user = htpwdUserService.authenticate("user1", "".toCharArray());
+        assertNull("User 'user1' falsely authenticated.", user);
+
+        user = htpwdUserService.authenticate("user1", "pass2".toCharArray());
+        assertNull("User 'user1' falsely authenticated.", user);
+
+        user = htpwdUserService.authenticate("user2", "lalala".toCharArray());
+        assertNull("User 'user2' falsely authenticated.", user);
+
+
+        user = htpwdUserService.authenticate("user3", "disabled".toCharArray());
+        assertNull("User 'user3' falsely authenticated.", user);
+
+        user = htpwdUserService.authenticate("user4", "disabled".toCharArray());
+        assertNull("User 'user4' falsely authenticated.", user);
+
+
+        user = htpwdUserService.authenticate("plain", "text".toCharArray());
+        assertNull("User 'plain' falsely authenticated.", user);
+
+        user = htpwdUserService.authenticate("plain", "password".toCharArray());
+        assertNull("User 'plain' falsely authenticated.", user);
+
+
+        MS.put(KEY_SUPPORT_PLAINTEXT_PWD, "false");
+
+        user = htpwdUserService.authenticate("crypt", "".toCharArray());
+        assertNull("User 'cyrpt' falsely authenticated.", user);
+
+        user = htpwdUserService.authenticate("crypt", "passwd".toCharArray());
+        assertNull("User 'crypt' falsely authenticated.", user);
+
+        user = htpwdUserService.authenticate("md5", "".toCharArray());
+        assertNull("User 'md5' falsely authenticated.", user);
+
+        user = htpwdUserService.authenticate("md5", "pwd".toCharArray());
+        assertNull("User 'md5' falsely authenticated.", user);
+
+        user = htpwdUserService.authenticate("sha", "".toCharArray());
+        assertNull("User 'sha' falsely authenticated.", user);
+
+        user = htpwdUserService.authenticate("sha", "letmein".toCharArray());
+        assertNull("User 'sha' falsely authenticated.", user);
+
+
+        user = htpwdUserService.authenticate("  tabbed", "frontAndBack".toCharArray());
+        assertNull("User 'tabbed' falsely authenticated.", user);
+
+        user = htpwdUserService.authenticate("    leading", "whitespace".toCharArray());
+        assertNull("User 'leading' falsely authenticated.", user);
+    }
+
+
+    @Test
+    public void testNewLocalAccount()
+    {
+        UserModel newUser = new UserModel("newlocal");
+        newUser.displayName = "Local User 2";
+        newUser.password = StringUtils.MD5_TYPE + StringUtils.getMD5("localPwd2");
+        assertTrue("Failed to add local account.", htpwdUserService.updateUserModel(newUser));
+
+        UserModel localAccount = htpwdUserService.authenticate(newUser.username, "localPwd2".toCharArray());
+        assertNotNull(localAccount);
+        assertEquals(newUser, localAccount);
+
+        localAccount = htpwdUserService.authenticate(newUser.username, "localPwd2".toCharArray());
+        assertNotNull(localAccount);
+        assertEquals(newUser, localAccount);
+
+        assertTrue("Failed to delete local account.", htpwdUserService.deleteUser(localAccount.username));
+        assertNull(htpwdUserService.authenticate(newUser.username, "localPwd2".toCharArray()));
+    }
+
+
+    @Test
+    public void testCleartextIntrusion()
+    {
+        MS.put(KEY_SUPPORT_PLAINTEXT_PWD, "true");
+        assertNull(htpwdUserService.authenticate("md5", "$apr1$qAGGNfli$sAn14mn.WKId/3EQS7KSX0".toCharArray()));
+        assertNull(htpwdUserService.authenticate("sha", "{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g=".toCharArray()));
+
+        assertNull(htpwdUserService.authenticate("user1", "#externalAccount".toCharArray()));
+
+        MS.put(KEY_SUPPORT_PLAINTEXT_PWD, "false");
+        assertNull(htpwdUserService.authenticate("md5", "$apr1$qAGGNfli$sAn14mn.WKId/3EQS7KSX0".toCharArray()));
+        assertNull(htpwdUserService.authenticate("sha", "{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g=".toCharArray()));
+
+        assertNull(htpwdUserService.authenticate("user1", "#externalAccount".toCharArray()));
+    }
+
+
+    @Test
+    public void testCryptVsPlaintext()
+    {
+        MS.put(KEY_SUPPORT_PLAINTEXT_PWD, "false");
+        assertNull(htpwdUserService.authenticate("crypt", "6TmlbxqZ2kBIA".toCharArray()));
+        assertNotNull(htpwdUserService.authenticate("crypt", "password".toCharArray()));
+
+        MS.put(KEY_SUPPORT_PLAINTEXT_PWD, "true");
+        assertNotNull(htpwdUserService.authenticate("crypt", "6TmlbxqZ2kBIA".toCharArray()));
+        assertNull(htpwdUserService.authenticate("crypt", "password".toCharArray()));
+    }
+
+
+    /*
+     * Test case: User exists in user.conf with a local password and in htpasswd with an external password.
+     * If overrideLocalAuthentication is false, the local account takes precedence and is never updated.
+     */
+    @Test
+    public void testPreparedAccountPreferLocal() throws IOException
+    {
+        setupUS(false);
+
+        UserModel user = htpwdUserService.authenticate("leaderred", "localPassword".toCharArray());
+        assertNotNull(user);
+        assertEquals("leaderred", user.getName());
+
+        user = htpwdUserService.authenticate("leaderred", "localPassword".toCharArray());
+        assertNotNull(user);
+        assertEquals("leaderred", user.getName());
+
+        user = htpwdUserService.authenticate("leaderred", "externalPassword".toCharArray());
+        assertNull(user);
+
+        user = htpwdUserService.authenticate("staylocal", "localUser".toCharArray());
+        assertNotNull(user);
+        assertEquals("staylocal", user.getName());
+
+
+        deleteGeneratedFiles();
+        copyInFiles();
+        setupUS(false);
+
+        user = htpwdUserService.authenticate("leaderred", "externalPassword".toCharArray());
+        assertNull(user);
+
+        user = htpwdUserService.authenticate("leaderred", "localPassword".toCharArray());
+        assertNotNull(user);
+        assertEquals("leaderred", user.getName());
+
+        user = htpwdUserService.authenticate("leaderred", "localPassword".toCharArray());
+        assertNotNull(user);
+        assertEquals("leaderred", user.getName());
+
+        user = htpwdUserService.authenticate("staylocal", "localUser".toCharArray());
+        assertNotNull(user);
+        assertEquals("staylocal", user.getName());
+    }
+
+
+    /*
+     * Test case: User exists in user.conf with a local password and in htpasswd with an external password.
+     * If overrideLocalAuthentication is true, the external account takes precedence,
+     * the initial local password is never used and discarded.
+     */
+    @Test
+    public void testPreparedAccountPreferExternal() throws IOException
+    {
+        setupUS(true);
+
+        UserModel user = htpwdUserService.authenticate("leaderred", "externalPassword".toCharArray());
+        assertNotNull(user);
+        assertEquals("leaderred", user.getName());
+
+        user = htpwdUserService.authenticate("leaderred", "externalPassword".toCharArray());
+        assertNotNull(user);
+        assertEquals("leaderred", user.getName());
+
+        user = htpwdUserService.authenticate("leaderred", "localPassword".toCharArray());
+        assertNull(user);
+
+        user = htpwdUserService.authenticate("staylocal", "localUser".toCharArray());
+        assertNotNull(user);
+        assertEquals("staylocal", user.getName());
+
+
+        deleteGeneratedFiles();
+        copyInFiles();
+        setupUS(true);
+
+
+        user = htpwdUserService.authenticate("leaderred", "localPassword".toCharArray());
+        assertNull(user);
+
+        user = htpwdUserService.authenticate("leaderred", "externalPassword".toCharArray());
+        assertNotNull(user);
+        assertEquals("leaderred", user.getName());
+
+        user = htpwdUserService.authenticate("leaderred", "externalPassword".toCharArray());
+        assertNotNull(user);
+        assertEquals("leaderred", user.getName());
+
+        user = htpwdUserService.authenticate("staylocal", "localUser".toCharArray());
+        assertNotNull(user);
+        assertEquals("staylocal", user.getName());
+
+        // Make sure no authentication by using the string constant for external accounts is possible.
+        user = htpwdUserService.authenticate("leaderred", "#externalAccount".toCharArray());
+        assertNull(user);
+    }
+
+
+    /*
+     * Test case: User exists in user.conf with a local password and in htpasswd with an external password.
+     * If overrideLocalAuthentication is true, the external account takes precedence,
+     * the initial local password is never used and discarded.
+     */
+    @Test
+    public void testPreparedAccountChangeSetting() throws IOException
+    {
+        getSettings(false);
+
+        UserModel user = htpwdUserService.authenticate("leaderred", "localPassword".toCharArray());
+        assertNotNull(user);
+        assertEquals("leaderred", user.getName());
+
+        user = htpwdUserService.authenticate("leaderred", "externalPassword".toCharArray());
+        assertNull(user);
+
+        user = htpwdUserService.authenticate("staylocal", "localUser".toCharArray());
+        assertNotNull(user);
+        assertEquals("staylocal", user.getName());
+
+
+        getSettings(true);
+
+
+        user = htpwdUserService.authenticate("leaderred", "localPassword".toCharArray());
+        assertNull(user);
+
+        user = htpwdUserService.authenticate("leaderred", "externalPassword".toCharArray());
+        assertNotNull(user);
+        assertEquals("leaderred", user.getName());
+
+        user = htpwdUserService.authenticate("staylocal", "localUser".toCharArray());
+        assertNotNull(user);
+        assertEquals("staylocal", user.getName());
+
+        // Make sure no authentication by using the string constant for external accounts is possible.
+        user = htpwdUserService.authenticate("leaderred", "#externalAccount".toCharArray());
+        assertNull(user);
+
+
+        getSettings(false);
+        // The preference is now back to local accounts but since the prepared account got switched
+        // to an external account, it will stay this way.
+
+        user = htpwdUserService.authenticate("leaderred", "localPassword".toCharArray());
+        assertNull(user);
+
+        user = htpwdUserService.authenticate("leaderred", "externalPassword".toCharArray());
+        assertNotNull(user);
+        assertEquals("leaderred", user.getName());
+
+        user = htpwdUserService.authenticate("staylocal", "localUser".toCharArray());
+        assertNotNull(user);
+        assertEquals("staylocal", user.getName());
+
+        // Make sure no authentication by using the string constant for external accounts is possible.
+        user = htpwdUserService.authenticate("leaderred", "#externalAccount".toCharArray());
+        assertNull(user);
+    }
+
+
+    @Test
+    public void testChangeHtpasswdFile()
+    {
+        UserModel user;
+
+        // User default set up.
+        user = htpwdUserService.authenticate("md5", "password".toCharArray());
+        assertNotNull(user);
+        assertEquals("md5", user.username);
+
+        user = htpwdUserService.authenticate("sha", "password".toCharArray());
+        assertNotNull(user);
+        assertEquals("sha", user.username);
+
+        user = htpwdUserService.authenticate("blueone", "GoBlue!".toCharArray());
+        assertNull(user);
+
+        user = htpwdUserService.authenticate("bluetwo", "YayBlue!".toCharArray());
+        assertNull(user);
+
+
+        // Switch to different htpasswd file.
+        getSettings(RESOURCE_DIR + "htpasswd-user", null, null);
+
+        user = htpwdUserService.authenticate("md5", "password".toCharArray());
+        assertNull(user);
+
+        user = htpwdUserService.authenticate("sha", "password".toCharArray());
+        assertNull(user);
+
+        user = htpwdUserService.authenticate("blueone", "GoBlue!".toCharArray());
+        assertNotNull(user);
+        assertEquals("blueone", user.username);
+
+        user = htpwdUserService.authenticate("bluetwo", "YayBlue!".toCharArray());
+        assertNotNull(user);
+        assertEquals("bluetwo", user.username);
+    }
+
+
+    @Test
+    public void testChangeHtpasswdFileNotExisting()
+    {
+        UserModel user;
+
+        // User default set up.
+        user = htpwdUserService.authenticate("md5", "password".toCharArray());
+        assertNotNull(user);
+        assertEquals("md5", user.username);
+
+        user = htpwdUserService.authenticate("sha", "password".toCharArray());
+        assertNotNull(user);
+        assertEquals("sha", user.username);
+
+        user = htpwdUserService.authenticate("blueone", "GoBlue!".toCharArray());
+        assertNull(user);
+
+        user = htpwdUserService.authenticate("bluetwo", "YayBlue!".toCharArray());
+        assertNull(user);
+
+
+        // Switch to different htpasswd file that doesn't exist.
+        // Currently we stop working with old users upon this change.
+        getSettings(RESOURCE_DIR + "no-such-file", null, null);
+
+        user = htpwdUserService.authenticate("md5", "password".toCharArray());
+        assertNull(user);
+
+        user = htpwdUserService.authenticate("sha", "password".toCharArray());
+        assertNull(user);
+
+        user = htpwdUserService.authenticate("blueone", "GoBlue!".toCharArray());
+        assertNull(user);
+
+        user = htpwdUserService.authenticate("bluetwo", "YayBlue!".toCharArray());
+        assertNull(user);
+    }
+
+}
diff --git a/src/test/resources/htpasswdUSTest/htpasswd-user.in b/src/test/resources/htpasswdUSTest/htpasswd-user.in
new file mode 100644
index 0000000..3ea87ed
--- /dev/null
+++ b/src/test/resources/htpasswdUSTest/htpasswd-user.in
@@ -0,0 +1,15 @@
+# User database
+
+# htpasswd generated entries
+
+# Plaintext
+redone:Yonder
+
+# Unix crypt() "GoRed!"
+redtwo:RMghf6oG.QwAs
+     
+ # Apache MD5  "GoBlue!"
+blueone:$apr1$phRTn/7N$237Owfhw5wZTdTyP9NPvC1
+
+# SHA1  "YayBlue!"
+bluetwo:{SHA}ITMvZI9OU5+Rx324C4jpf+MHAL8=
diff --git a/src/test/resources/htpasswdUSTest/htpasswd.in b/src/test/resources/htpasswdUSTest/htpasswd.in
new file mode 100644
index 0000000..f2900e7
--- /dev/null
+++ b/src/test/resources/htpasswdUSTest/htpasswd.in
@@ -0,0 +1,31 @@
+# User database
+
+user1:pass1
+user2:pass2
+
+# "externalPassword"
+leaderred:{SHA}2VZsTsVQYmWAMfQUjNAScpaAlJI=
+
+#user3:disabled
+	# user4:disabled
+
+# htpasswd generated entries
+
+# Plaintext
+plain:passWord
+
+# Unix crypt()  "password"
+crypt:6TmlbxqZ2kBIA
+     
+ # Apache MD5   "password"
+md5:$apr1$qAGGNfli$sAn14mn.WKId/3EQS7KSX0
+	
+
+# SHA1  "password"
+sha:{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g=
+
+
+trailing:.dAxRAQiOOlN.    
+
+	tabbed:$apr1$Is7zctsH$CMAXrGkgACQKgRYuQ5vHq.	
+    leading:$apr1$O1nQtxjE$8gN15gMeuF3W1Nr8Yz/6J.
diff --git a/src/test/resources/htpasswdUSTest/users.conf.in b/src/test/resources/htpasswdUSTest/users.conf.in
new file mode 100644
index 0000000..142265a
--- /dev/null
+++ b/src/test/resources/htpasswdUSTest/users.conf.in
@@ -0,0 +1,26 @@
+[user "admin"]
+	password = admin
+	cookie = dd94709528bb1c83d08f3088d4043f4742891f4f
+	role = "#admin"
+	role = "#notfederated"
+[user "user1"]
+	password = "#externalAccount"
+	cookie = 6c7d13cf0aa43054d0fb620546e3a4d79e3d3e89
+	displayName = El Capitan
+	emailAddress = cheffe@example.com
+	role = "#admin"
+[user "user2"]
+	password = "#externalAccount"
+	cookie = d15eabb3a83c44a05ccbdaf3bf5fd1402d971e99
+	displayName = User Two
+	role = "#create"
+	role = "#fork"
+[user "staylocal"]
+	password = localUser
+	cookie = 0a99767e0259dc06ccae5ee6349177be289968f3
+	displayName = Local User
+	role = "#none"
+[user "leaderRed"]
+	password = localPassword
+	displayName = Red Leader
+	role = "#create"