blob: 62198f4a143cc0d4ef200e0646b119e879c0f6f9 [file]
/*
* 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();
}
}