/**
* Copyright (c) Codice Foundation
* <p/>
* This is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or any later version.
* <p/>
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
* even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details. A copy of the GNU Lesser General Public License
* is distributed along with this program and can be found at
* <http://www.gnu.org/licenses/lgpl.html>.
*/
package ddf.ldap.ldaplogin;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.NoSuchAlgorithmException;
import java.security.Principal;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.net.ssl.SSLContext;
import javax.security.auth.Subject;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.NameCallback;
import javax.security.auth.callback.PasswordCallback;
import javax.security.auth.callback.UnsupportedCallbackException;
import javax.security.auth.login.LoginException;
import org.apache.commons.lang3.StringUtils;
import org.apache.karaf.jaas.boot.principal.RolePrincipal;
import org.apache.karaf.jaas.boot.principal.UserPrincipal;
import org.apache.karaf.jaas.modules.AbstractKarafLoginModule;
import org.forgerock.opendj.ldap.Attribute;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.Connection;
import org.forgerock.opendj.ldap.LDAPConnectionFactory;
import org.forgerock.opendj.ldap.LdapException;
import org.forgerock.opendj.ldap.SearchResultReferenceIOException;
import org.forgerock.opendj.ldap.SearchScope;
import org.forgerock.opendj.ldap.requests.BindRequest;
import org.forgerock.opendj.ldap.requests.DigestMD5SASLBindRequest;
import org.forgerock.opendj.ldap.requests.GSSAPISASLBindRequest;
import org.forgerock.opendj.ldap.requests.Requests;
import org.forgerock.opendj.ldap.responses.BindResult;
import org.forgerock.opendj.ldap.responses.SearchResultEntry;
import org.forgerock.opendj.ldif.ConnectionEntryReader;
import org.forgerock.util.Options;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import org.osgi.framework.FrameworkUtil;
import org.osgi.framework.ServiceReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.collect.ImmutableSet;
import ddf.security.common.audit.SecurityLogger;
import ddf.security.encryption.EncryptionService;
public class SslLdapLoginModule extends AbstractKarafLoginModule {
public static final String CONNECTION_URL = "connection.url";
public static final String CONNECTION_USERNAME = "connection.username";
public static final String CONNECTION_PASSWORD = "connection.password";
public static final String USER_BASE_DN = "user.base.dn";
public static final String USER_FILTER = "user.filter";
public static final String USER_SEARCH_SUBTREE = "user.search.subtree";
public static final String ROLE_BASE_DN = "role.base.dn";
public static final String ROLE_FILTER = "role.filter";
public static final String ROLE_NAME_ATTRIBUTE = "role.name.attribute";
public static final String ROLE_SEARCH_SUBTREE = "role.search.subtree";
public static final String SSL_STARTTLS = "ssl.starttls";
public static final String BIND_METHOD = "bindMethod";
public static final String REALM = "realm";
public static final String KDC_ADDRESS = "kdcAddress";
private static final Logger LOGGER = LoggerFactory.getLogger(SslLdapLoginModule.class);
private static final String DEFAULT_AUTHENTICATION = "simple";
private String realm;
private String kdcAddress;
private String bindMethod = DEFAULT_AUTHENTICATION;
private String connectionURL;
private String connectionUsername;
private char[] connectionPassword;
private String userBaseDN;
private String userFilter;
private boolean userSearchSubtree = true;
private String roleBaseDN;
private EncryptionService encryptionService;
private String roleFilter;
private String roleNameAttribute;
private boolean roleSearchSubtree = true;
private boolean startTls = false;
private LDAPConnectionFactory ldapConnectionFactory;
private ServiceReference serviceReference;
private SSLContext sslContext;
protected boolean doLogin() throws LoginException {
//--------- EXTRACT USERNAME AND PASSWORD FOR LDAP LOOKUP -------------
Callback[] callbacks = new Callback[2];
callbacks[0] = new NameCallback("Username: ");
callbacks[1] = new PasswordCallback("Password: ", false);
try {
callbackHandler.handle(callbacks);
} catch (IOException ioException) {
throw new LoginException(ioException.getMessage());
} catch (UnsupportedCallbackException unsupportedCallbackException) {
boolean result;
throw new LoginException(unsupportedCallbackException.getMessage()
+ " not available to obtain information from user.");
}
user = ((NameCallback) callbacks[0]).getName();
if (user == null) {
return false;
}
user = user.trim();
validateUsername(user);
char[] tmpPassword = ((PasswordCallback) callbacks[1]).getPassword();
// If either a username or password is specified don't allow authentication = "none".
// This is to prevent someone from logging into Karaf as any user without providing a
// valid password (because if authentication = none, the password could be any
// value - it is ignored).
// Username is not checked in this conditional because a null username immediately exits
// this method.
if ("none".equalsIgnoreCase(getBindMethod()) && (tmpPassword != null)) {
LOGGER.debug(
"Changing from authentication = none to simple since user or password was specified.");
// default to simple so that the provided user/password will get checked
setBindMethod(DEFAULT_AUTHENTICATION);
}
if (tmpPassword == null) {
tmpPassword = new char[0];
}
//---------------------------------------------------------------------
// RESET OBJECT STATE AND DECLARE LOCAL VARS
principals = new HashSet<>();
Connection connection;
String userDn;
//---------------------------------------------------------------------
//------------- CREATE CONNECTION #1 ----------------------------------
try {
connection = ldapConnectionFactory.getConnection();
} catch (LdapException e) {
LOGGER.info("Unable to get LDAP Connection from factory.", e);
return false;
}
if (connection != null) {
try {
//------------- BIND #1 (CONNECTION USERNAME & PASSWORD) --------------
try {
BindRequest request;
switch (getBindMethod()) {
case "Simple":
request = Requests.newSimpleBindRequest(connectionUsername,
connectionPassword);
break;
case "SASL":
request = Requests.newPlainSASLBindRequest(connectionUsername,
connectionPassword);
break;
case "GSSAPI SASL":
request = Requests.newGSSAPISASLBindRequest(connectionUsername,
connectionPassword);
((GSSAPISASLBindRequest) request).setRealm(realm);
((GSSAPISASLBindRequest) request).setKDCAddress(kdcAddress);
break;
case "Digest MD5 SASL":
request = Requests.newDigestMD5SASLBindRequest(connectionUsername,
connectionPassword);
((DigestMD5SASLBindRequest) request).setCipher(DigestMD5SASLBindRequest.CIPHER_HIGH);
((DigestMD5SASLBindRequest) request).getQOPs()
.clear();
((DigestMD5SASLBindRequest) request).getQOPs()
.add(DigestMD5SASLBindRequest.QOP_AUTH_CONF);
((DigestMD5SASLBindRequest) request).getQOPs()
.add(DigestMD5SASLBindRequest.QOP_AUTH_INT);
((DigestMD5SASLBindRequest) request).getQOPs()
.add(DigestMD5SASLBindRequest.QOP_AUTH);
if (StringUtils.isNotEmpty(realm)) {
((DigestMD5SASLBindRequest) request).setRealm(realm);
}
break;
default:
request = Requests.newSimpleBindRequest(connectionUsername,
connectionPassword);
break;
}
BindResult bindResult = connection.bind(request);
if (!bindResult.isSuccess()) {
LOGGER.debug("Bind failed");
return false;
}
} catch (LdapException e) {
LOGGER.debug("Unable to bind to LDAP server.", e);
return false;
}
//--------- SEARCH #1, FIND USER DISTINGUISHED NAME -----------
SearchScope scope;
if (userSearchSubtree) {
scope = SearchScope.WHOLE_SUBTREE;
} else {
scope = SearchScope.SINGLE_LEVEL;
}
userFilter = userFilter.replaceAll(Pattern.quote("%u"),
Matcher.quoteReplacement(user));
userFilter = userFilter.replace("\\", "\\\\");
ConnectionEntryReader entryReader = connection.search(userBaseDN, scope,
userFilter);
try {
if (!entryReader.hasNext()) {
LOGGER.info("User {} not found in LDAP.", user);
return false;
}
SearchResultEntry searchResultEntry = entryReader.readEntry();
userDn = searchResultEntry.getName()
.toString();
} catch (LdapException | SearchResultReferenceIOException e) {
LOGGER.info("Unable to read contents of LDAP user search.", e);
return false;
}
} finally {
//------------ CLOSE CONNECTION -------------------------------
connection.close();
}
} else {
return false;
}
//------------- CREATE CONNECTION #2 ----------------------------------
try {
connection = ldapConnectionFactory.getConnection();
} catch (LdapException e) {
LOGGER.info("Unable to get LDAP Connection from factory.", e);
return false;
}
if (connection != null) {
//----- BIND #2 (USER DISTINGUISHED NAME AND PASSWORD) ------------
// Validate user's credentials.
try {
BindResult bindResult = connection.bind(userDn, tmpPassword);
if (!bindResult.isSuccess()) {
LOGGER.info("Bind failed");
return false;
}
} catch (Exception e) {
LOGGER.info("Unable to bind user to LDAP server.", e);
return false;
} finally {
//------------ CLOSE CONNECTION -------------------------------
connection.close();
}
//---------- ADD USER AS PRINCIPAL --------------------------------
principals.add(new UserPrincipal(user));
} else {
return false;
}
//-------------- CREATE CONNECTION #3 ---------------------------------
try {
connection = ldapConnectionFactory.getConnection();
} catch (LdapException e) {
LOGGER.info("Unable to get LDAP Connection from factory.", e);
return false;
}
if (connection != null) {
try {
//----- BIND #3 (CONNECTION USERNAME & PASSWORD) --------------
try {
BindResult bindResult = connection.bind(connectionUsername, connectionPassword);
if (!bindResult.isSuccess()) {
LOGGER.info("Bind failed");
return false;
}
} catch (LdapException e) {
LOGGER.info("Unable to bind to LDAP server.", e);
return false;
}
//--------- SEARCH #3, GET ROLES ------------------------------
SearchScope scope;
if (roleSearchSubtree) {
scope = SearchScope.WHOLE_SUBTREE;
} else {
scope = SearchScope.SINGLE_LEVEL;
}
roleFilter = roleFilter.replaceAll(Pattern.quote("%u"),
Matcher.quoteReplacement(user));
roleFilter = roleFilter.replaceAll(Pattern.quote("%dn"),
Matcher.quoteReplacement(userBaseDN));
roleFilter = roleFilter.replaceAll(Pattern.quote("%fqdn"),
Matcher.quoteReplacement(userDn));
roleFilter = roleFilter.replace("\\", "\\\\");
ConnectionEntryReader entryReader = connection.search(roleBaseDN, scope, roleFilter,
roleNameAttribute);
SearchResultEntry entry;
//------------- ADD ROLES AS NEW PRINCIPALS -------------------
try {
while (entryReader.hasNext()) {
entry = entryReader.readEntry();
Attribute attr = entry.getAttribute(roleNameAttribute);
for (ByteString role : attr) {
principals.add(new RolePrincipal(role.toString()));
}
}
} catch (Exception e) {
boolean result;
throw new LoginException(
"Can't get user " + user + " roles: " + e.getMessage());
}
} finally {
//------------ CLOSE CONNECTION -------------------------------
connection.close();
}
} else {
return false;
}
return true;
}
@Override
public boolean abort() throws LoginException {
return true;
}
@Override
public boolean logout() throws LoginException {
subject.getPrincipals()
.removeAll(principals);
principals.clear();
ldapConnectionFactory.close();
ldapConnectionFactory = null;
return true;
}
protected BundleContext getContext() {
Bundle cxfBundle = FrameworkUtil.getBundle(SslLdapLoginModule.class);
if (cxfBundle != null) {
return cxfBundle.getBundleContext();
}
return null;
}
@Override
public void initialize(Subject subject, CallbackHandler callbackHandler,
Map<String, ?> sharedState, Map<String, ?> options) {
super.initialize(subject, callbackHandler, options);
installEncryptionService();
connectionURL = (String) options.get(CONNECTION_URL);
connectionUsername = (String) options.get(CONNECTION_USERNAME);
connectionPassword = getDecryptedPassword((String) options.get(CONNECTION_PASSWORD));
userBaseDN = (String) options.get(USER_BASE_DN);
userFilter = (String) options.get(USER_FILTER);
userSearchSubtree = Boolean.parseBoolean((String) options.get(USER_SEARCH_SUBTREE));
roleBaseDN = (String) options.get(ROLE_BASE_DN);
roleFilter = (String) options.get(ROLE_FILTER);
roleNameAttribute = (String) options.get(ROLE_NAME_ATTRIBUTE);
roleSearchSubtree = Boolean.parseBoolean((String) options.get(ROLE_SEARCH_SUBTREE));
startTls = Boolean.parseBoolean(String.valueOf(options.get(SSL_STARTTLS)));
setBindMethod((String) options.get(BIND_METHOD));
realm = (String) options.get(REALM);
kdcAddress = (String) options.get(KDC_ADDRESS);
if (ldapConnectionFactory != null) {
ldapConnectionFactory.close();
}
try {
ldapConnectionFactory = createLdapConnectionFactory(connectionURL, startTls);
} catch (LdapException e) {
LOGGER.info(
"Unable to create LDAP Connection Factory. LDAP log in will not be possible.",
e);
}
}
@Override
public boolean login() throws LoginException {
boolean isLoggedIn;
String message = "";
try {
isLoggedIn = doLogin();
message = "Username [" + user
+ "] could not log in successfuly using LDAP authentication due to an exception";
if (!isLoggedIn) {
SecurityLogger.audit("Username [" + user + "] failed LDAP authentication.");
}
return isLoggedIn;
} catch (InvalidCharactersException e) {
SecurityLogger.audit(e.getMessage());
throw new LoginException(message);
} catch (LoginException e) {
throw new LoginException(message);
}
}
protected LDAPConnectionFactory createLdapConnectionFactory(String url, Boolean startTls)
throws LdapException {
boolean useSsl = url.startsWith("ldaps");
boolean useTls = !url.startsWith("ldaps") && startTls;
Options lo = Options.defaultOptions();
try {
if (useSsl || useTls) {
initializeSslContext();
lo.set(LDAPConnectionFactory.SSL_CONTEXT, getSslContext());
}
} catch (GeneralSecurityException e) {
LOGGER.info("Error encountered while configuring SSL. Secure connection will fail.", e);
}
lo.set(LDAPConnectionFactory.SSL_USE_STARTTLS, useTls);
lo.set(LDAPConnectionFactory.SSL_ENABLED_CIPHER_SUITES,
Arrays.asList(System.getProperty("https.cipherSuites")
.split(",")));
lo.set(LDAPConnectionFactory.SSL_ENABLED_PROTOCOLS,
Arrays.asList(System.getProperty("https.protocols")
.split(",")));
lo.set(LDAPConnectionFactory.TRANSPORT_PROVIDER_CLASS_LOADER,
SslLdapLoginModule.class.getClassLoader());
String host = url.substring(url.indexOf("://") + 3, url.lastIndexOf(":"));
Integer port = useSsl ? 636 : 389;
try {
port = Integer.valueOf(url.substring(url.lastIndexOf(":") + 1));
} catch (NumberFormatException ignore) {
}
return new LDAPConnectionFactory(host, port, lo);
}
private void initializeSslContext() throws NoSuchAlgorithmException {
// Only set if null so tests can inject a context.
if (getSslContext() == null) {
setSslContext(SSLContext.getDefault());
}
}
void validateUsername(String username) throws InvalidCharactersException {
boolean hasBadCharacters = false;
for (int i = 0; i < username.length(); i++) {
char curChar = username.charAt(i);
switch (curChar) {
case '\\':
case ',':
case '+':
case '"':
case '<':
case '>':
case ';':
case '#':
hasBadCharacters = true;
break;
}
if (hasBadCharacters) {
throw new InvalidCharactersException(String.format(
"Username [%s] contains invalid LDAP characters",
username));
}
}
}
Set<Principal> getPrincipals() {
return ImmutableSet.copyOf(principals);
}
private void installEncryptionService() {
BundleContext bundleContext = getContext();
if (null != bundleContext) {
serviceReference = bundleContext.getServiceReference(EncryptionService.class.getName());
setEncryptionService((EncryptionService) bundleContext.getService(serviceReference));
bundleContext.ungetService(serviceReference);
}
}
protected char[] getDecryptedPassword(String encryptedPassword) {
char[] decryptedPassword = null;
if (getEncryptionService() != null) {
try {
decryptedPassword = getEncryptionService().decryptValue(encryptedPassword)
.toCharArray();
} catch (SecurityException | IllegalStateException e) {
LOGGER.info("Error decrypting connection password passed into LDAP configuration: ",
e);
}
} else {
LOGGER.info("Encryption service is not available.");
}
return decryptedPassword;
}
public EncryptionService getEncryptionService() {
return encryptionService;
}
public void setEncryptionService(EncryptionService encryptionService) {
this.encryptionService = encryptionService;
}
public SSLContext getSslContext() {
return sslContext;
}
public void setSslContext(SSLContext sslContext) {
this.sslContext = sslContext;
}
String getBindMethod() {
return bindMethod;
}
void setBindMethod(String bindMethod) {
this.bindMethod = bindMethod;
}
private static class InvalidCharactersException extends LoginException {
public InvalidCharactersException(String message) {
super(message);
}
}
}