com.gitblit.HtpasswdUserService.java Source code

Java tutorial

Introduction

Here is the source code for com.gitblit.HtpasswdUserService.java

Source

/*
 * 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();
    }
}