/*
* Copyright 2016 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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 org.keycloak.storage.ldap.idm.store.ldap;
import org.jboss.logging.Logger;
import org.keycloak.models.LDAPConstants;
import org.keycloak.models.ModelException;
import org.keycloak.storage.ldap.LDAPConfig;
import org.keycloak.storage.ldap.idm.model.LDAPDn;
import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery;
import org.keycloak.storage.ldap.mappers.LDAPOperationDecorator;
import javax.naming.AuthenticationException;
import javax.naming.Binding;
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NameAlreadyBoundException;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;
import javax.naming.directory.ModificationItem;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import javax.naming.ldap.Control;
import javax.naming.ldap.InitialLdapContext;
import javax.naming.ldap.LdapContext;
import javax.naming.ldap.PagedResultsControl;
import javax.naming.ldap.PagedResultsResponseControl;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
/**
* <p>This class provides a set of operations to manage LDAP trees.</p>
*
* @author Anil Saldhana
* @author <a href="mailto:psilva@redhat.com">Pedro Silva</a>
*/
public class LDAPOperationManager {
private static final Logger logger = Logger.getLogger(LDAPOperationManager.class);
private final LDAPConfig config;
private final Map<String, Object> connectionProperties;
public LDAPOperationManager(LDAPConfig config) throws NamingException {
this.config = config;
this.connectionProperties = Collections.unmodifiableMap(createConnectionProperties());
}
/**
* <p>
* Modifies the given {@link javax.naming.directory.Attribute} instance using the given DN. This method performs a REPLACE_ATTRIBUTE
* operation.
* </p>
*
* @param dn
* @param attribute
*/
public void modifyAttribute(String dn, Attribute attribute) {
ModificationItem[] mods = new ModificationItem[]{new ModificationItem(DirContext.REPLACE_ATTRIBUTE, attribute)};
modifyAttributes(dn, mods, null);
}
/**
* <p>
* Modifies the given {@link Attribute} instances using the given DN. This method performs a REPLACE_ATTRIBUTE
* operation.
* </p>
*
* @param dn
* @param attributes
*/
public void modifyAttributes(String dn, NamingEnumeration<Attribute> attributes) {
try {
List<ModificationItem> modItems = new ArrayList<ModificationItem>();
while (attributes.hasMore()) {
ModificationItem modItem = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, attributes.next());
modItems.add(modItem);
}
modifyAttributes(dn, modItems.toArray(new ModificationItem[] {}), null);
} catch (NamingException ne) {
throw new ModelException("Could not modify attributes on entry from DN [" + dn + "]", ne);
}
}
/**
* <p>
* Removes the given {@link Attribute} instance using the given DN. This method performs a REMOVE_ATTRIBUTE
* operation.
* </p>
*
* @param dn
* @param attribute
*/
public void removeAttribute(String dn, Attribute attribute) {
ModificationItem[] mods = new ModificationItem[]{new ModificationItem(DirContext.REMOVE_ATTRIBUTE, attribute)};
modifyAttributes(dn, mods, null);
}
/**
* <p>
* Adds the given {@link Attribute} instance using the given DN. This method performs a ADD_ATTRIBUTE operation.
* </p>
*
* @param dn
* @param attribute
*/
public void addAttribute(String dn, Attribute attribute) {
ModificationItem[] mods = new ModificationItem[]{new ModificationItem(DirContext.ADD_ATTRIBUTE, attribute)};
modifyAttributes(dn, mods, null);
}
/**
* <p>
* Removes the object from the LDAP tree
* </p>
*/
public void removeEntry(final String entryDn) {
try {
execute(new LdapOperation<SearchResult>() {
@Override
public SearchResult execute(LdapContext context) throws NamingException {
if (logger.isTraceEnabled()) {
logger.tracef("Removing entry with DN [%s]", entryDn);
}
destroySubcontext(context, entryDn);
return null;
}
});
} catch (NamingException e) {
throw new ModelException("Could not remove entry from DN [" + entryDn + "]", e);
}
}
/**
* Rename LDAPObject name (DN)
*
* @param oldDn
* @param newDn
* @param fallback With fallback=true, we will try to find the another DN in case of conflict. For example if there is an
* attempt to rename to "CN=John Doe", but there is already existing "CN=John Doe", we will try "CN=John Doe0"
* @return the non-conflicting DN, which was used in the end
*/
public String renameEntry(String oldDn, String newDn, boolean fallback) {
try {
String newNonConflictingDn = execute(new LdapOperation<String>() {
@Override
public String execute(LdapContext context) throws NamingException {
String dn = newDn;
// Max 5 attempts for now
int max = 5;
for (int i=0 ; i<max ; i++) {
try {
context.rename(oldDn, dn);
return dn;
} catch (NameAlreadyBoundException ex) {
if (!fallback) {
throw ex;
} else {
String failedDn = dn;
if (i<max) {
dn = findNextDNForFallback(newDn, i);
logger.warnf("Failed to rename DN [%s] to [%s]. Will try to fallback to DN [%s]", oldDn, failedDn, dn);
} else {
logger.warnf("Failed all fallbacks for renaming [%s]", oldDn);
throw ex;
}
}
}
}
throw new ModelException("Could not rename entry from DN [" + oldDn + "] to new DN [" + newDn + "]. All fallbacks failed");
}
});
return newNonConflictingDn;
} catch (NamingException e) {
throw new ModelException("Could not rename entry from DN [" + oldDn + "] to new DN [" + newDn + "]", e);
}
}
private String findNextDNForFallback(String newDn, int counter) {
LDAPDn dn = LDAPDn.fromString(newDn);
String rdnAttrName = dn.getFirstRdnAttrName();
String rdnAttrVal = dn.getFirstRdnAttrValue();
LDAPDn parentDn = dn.getParentDn();
parentDn.addFirst(rdnAttrName, rdnAttrVal + counter);
return parentDn.toString();
}
public List<SearchResult> search(final String baseDN, final String filter, Collection<String> returningAttributes, int searchScope) throws NamingException {
final List<SearchResult> result = new ArrayList<SearchResult>();
final SearchControls cons = getSearchControls(returningAttributes, searchScope);
try {
return execute(new LdapOperation<List<SearchResult>>() {
@Override
public List<SearchResult> execute(LdapContext context) throws NamingException {
NamingEnumeration<SearchResult> search = context.search(baseDN, filter, cons);
while (search.hasMoreElements()) {
result.add(search.nextElement());
}
search.close();
return result;
}
});
} catch (NamingException e) {
logger.errorf(e, "Could not query server using DN [%s] and filter [%s]", baseDN, filter);
throw e;
}
}
public List<SearchResult> searchPaginated(final String baseDN, final String filter, final LDAPQuery identityQuery) throws NamingException {
final List<SearchResult> result = new ArrayList<SearchResult>();
final SearchControls cons = getSearchControls(identityQuery.getReturningLdapAttributes(), identityQuery.getSearchScope());
try {
return execute(new LdapOperation<List<SearchResult>>() {
@Override
public List<SearchResult> execute(LdapContext context) throws NamingException {
try {
byte[] cookie = identityQuery.getPaginationContext();
PagedResultsControl pagedControls = new PagedResultsControl(identityQuery.getLimit(), cookie, Control.CRITICAL);
context.setRequestControls(new Control[] { pagedControls });
NamingEnumeration<SearchResult> search = context.search(baseDN, filter, cons);
while (search.hasMoreElements()) {
result.add(search.nextElement());
}
search.close();
Control[] responseControls = context.getResponseControls();
if (responseControls != null) {
for (Control respControl : responseControls) {
if (respControl instanceof PagedResultsResponseControl) {
PagedResultsResponseControl prrc = (PagedResultsResponseControl)respControl;
cookie = prrc.getCookie();
identityQuery.setPaginationContext(cookie);
}
}
}
return result;
} catch (IOException ioe) {
logger.errorf(ioe, "Could not query server with paginated query using DN [%s], filter [%s]", baseDN, filter);
throw new NamingException(ioe.getMessage());
}
}
});
} catch (NamingException e) {
logger.errorf(e, "Could not query server using DN [%s] and filter [%s]", baseDN, filter);
throw e;
}
}
private SearchControls getSearchControls(Collection<String> returningAttributes, int searchScope) {
final SearchControls cons = new SearchControls();
cons.setSearchScope(searchScope);
cons.setReturningObjFlag(false);
returningAttributes = getReturningAttributes(returningAttributes);
cons.setReturningAttributes(returningAttributes.toArray(new String[returningAttributes.size()]));
return cons;
}
public String getFilterById(String id) {
String filter = null;
if (this.config.isObjectGUID()) {
final String strObjectGUID = "<GUID=" + id + ">";
try {
Attributes attributes = execute(new LdapOperation<Attributes>() {
@Override
public Attributes execute(LdapContext context) throws NamingException {
return context.getAttributes(strObjectGUID);
}
});
byte[] objectGUID = (byte[]) attributes.get(LDAPConstants.OBJECT_GUID).get();
filter = "(&(objectClass=*)(" + getUuidAttributeName() + LDAPConstants.EQUAL + LDAPUtil.convertObjectGUIToByteString(objectGUID) + "))";
} catch (NamingException ne) {
filter = null;
}
}
if (filter == null) {
filter = "(&(objectClass=*)(" + getUuidAttributeName() + LDAPConstants.EQUAL + id + "))";
}
return filter;
}
public SearchResult lookupById(final String baseDN, final String id, final Collection<String> returningAttributes) {
final String filter = getFilterById(id);
try {
final SearchControls cons = getSearchControls(returningAttributes, this.config.getSearchScope());
return execute(new LdapOperation<SearchResult>() {
@Override
public SearchResult execute(LdapContext context) throws NamingException {
NamingEnumeration<SearchResult> search = context.search(baseDN, filter, cons);
try {
if (search.hasMoreElements()) {
return search.next();
}
} finally {
if (search != null) {
search.close();
}
}
return null;
}
});
} catch (NamingException e) {
throw new ModelException("Could not query server using DN [" + baseDN + "] and filter [" + filter + "]", e);
}
}
/**
* <p>
* Destroys a subcontext with the given DN from the LDAP tree.
* </p>
*
* @param dn
*/
private void destroySubcontext(LdapContext context, final String dn) {
try {
NamingEnumeration<Binding> enumeration = null;
try {
enumeration = context.listBindings(dn);
while (enumeration.hasMore()) {
Binding binding = enumeration.next();
String name = binding.getNameInNamespace();
destroySubcontext(context, name);
}
context.unbind(dn);
} finally {
try {
enumeration.close();
} catch (Exception e) {
}
}
} catch (Exception e) {
throw new ModelException("Could not unbind DN [" + dn + "]", e);
}
}
/**
* <p>
* Performs a simple authentication using the given DN and password to bind to the authentication context.
* </p>
*
* @param dn
* @param password
* @throws AuthenticationException if authentication is not successful
*
*/
public void authenticate(String dn, String password) throws AuthenticationException {
InitialContext authCtx = null;
try {
if (password == null || password.isEmpty()) {
throw new AuthenticationException("Empty password used");
}
Hashtable<String, Object> env = new Hashtable<String, Object>(this.connectionProperties);
env.put(Context.SECURITY_AUTHENTICATION, LDAPConstants.AUTH_TYPE_SIMPLE);
env.put(Context.SECURITY_PRINCIPAL, dn);
env.put(Context.SECURITY_CREDENTIALS, password);
// Never use connection pool to prevent password caching
env.put("com.sun.jndi.ldap.connect.pool", "false");
authCtx = new InitialLdapContext(env, null);
} catch (AuthenticationException ae) {
if (logger.isDebugEnabled()) {
logger.debugf(ae, "Authentication failed for DN [%s]", dn);
}
throw ae;
} catch (Exception e) {
logger.errorf(e, "Unexpected exception when validating password of DN [%s]", dn);
throw new AuthenticationException("Unexpected exception when validating password of user");
} finally {
if (authCtx != null) {
try {
authCtx.close();
} catch (NamingException e) {
}
}
}
}
public void modifyAttributes(final String dn, final ModificationItem[] mods, LDAPOperationDecorator decorator) {
try {
if (logger.isTraceEnabled()) {
logger.tracef("Modifying attributes for entry [%s]: [", dn);
for (ModificationItem item : mods) {
Object values;
if (item.getAttribute().size() > 0) {
values = item.getAttribute().get();
} else {
values = "No values";
}
String attrName = item.getAttribute().getID().toUpperCase();
if (attrName.contains("PASSWORD") || attrName.contains("UNICODEPWD")) {
values = "********************";
}
logger.tracef(" Op [%s]: %s = %s", item.getModificationOp(), item.getAttribute().getID(), values);
}
logger.tracef("]");
}
execute(new LdapOperation<Void>() {
@Override
public Void execute(LdapContext context) throws NamingException {
context.modifyAttributes(dn, mods);
return null;
}
}, decorator);
} catch (NamingException e) {
throw new ModelException("Could not modify attribute for DN [" + dn + "]", e);
}
}
public void createSubContext(final String name, final Attributes attributes) {
try {
if (logger.isTraceEnabled()) {
logger.tracef("Creating entry [%s] with attributes: [", name);
NamingEnumeration<? extends Attribute> all = attributes.getAll();
while (all.hasMore()) {
Attribute attribute = all.next();
String attrName = attribute.getID().toUpperCase();
Object attrVal = attribute.get();
if (attrName.contains("PASSWORD") || attrName.contains("UNICODEPWD")) {
attrVal = "********************";
}
logger.tracef(" %s = %s", attribute.getID(), attrVal);
}
logger.tracef("]");
}
execute(new LdapOperation<Void>() {
@Override
public Void execute(LdapContext context) throws NamingException {
DirContext subcontext = context.createSubcontext(name, attributes);
subcontext.close();
return null;
}
});
} catch (NamingException e) {
throw new ModelException("Error creating subcontext [" + name + "]", e);
}
}
private String getUuidAttributeName() {
return this.config.getUuidLDAPAttributeName();
}
public Attributes getAttributes(final String entryUUID, final String baseDN, Set<String> returningAttributes) {
SearchResult search = lookupById(baseDN, entryUUID, returningAttributes);
if (search == null) {
throw new ModelException("Couldn't find item with ID [" + entryUUID + " under base DN [" + baseDN + "]");
}
return search.getAttributes();
}
public String decodeEntryUUID(final Object entryUUID) {
String id;
if (this.config.isObjectGUID() && entryUUID instanceof byte[]) {
id = LDAPUtil.decodeObjectGUID((byte[]) entryUUID);
} else {
id = entryUUID.toString();
}
return id;
}
private LdapContext createLdapContext() throws NamingException {
return new InitialLdapContext(new Hashtable<Object, Object>(this.connectionProperties), null);
}
private Map<String, Object> createConnectionProperties() {
HashMap<String, Object> env = new HashMap<String, Object>();
String authType = this.config.getAuthType();
env.put(Context.INITIAL_CONTEXT_FACTORY, this.config.getFactoryName());
env.put(Context.SECURITY_AUTHENTICATION, authType);
String bindDN = this.config.getBindDN();
char[] bindCredential = null;
if (this.config.getBindCredential() != null) {
bindCredential = this.config.getBindCredential().toCharArray();
}
if (!LDAPConstants.AUTH_TYPE_NONE.equals(authType)) {
env.put(Context.SECURITY_PRINCIPAL, bindDN);
env.put(Context.SECURITY_CREDENTIALS, bindCredential);
}
String url = this.config.getConnectionUrl();
if (url != null) {
env.put(Context.PROVIDER_URL, url);
} else {
logger.warn("LDAP URL is null. LDAPOperationManager won't work correctly");
}
String useTruststoreSpi = this.config.getUseTruststoreSpi();
LDAPConstants.setTruststoreSpiIfNeeded(useTruststoreSpi, url, env);
String connectionPooling = this.config.getConnectionPooling();
if (connectionPooling != null) {
env.put("com.sun.jndi.ldap.connect.pool", connectionPooling);
}
String connectionTimeout = config.getConnectionTimeout();
if (connectionTimeout != null && !connectionTimeout.isEmpty()) {
env.put("com.sun.jndi.ldap.connect.timeout", connectionTimeout);
}
String readTimeout = config.getReadTimeout();
if (readTimeout != null && !readTimeout.isEmpty()) {
env.put("com.sun.jndi.ldap.read.timeout", readTimeout);
}
// Just dump the additional properties
Properties additionalProperties = this.config.getAdditionalConnectionProperties();
if (additionalProperties != null) {
for (Object key : additionalProperties.keySet()) {
env.put(key.toString(), additionalProperties.getProperty(key.toString()));
}
}
StringBuilder binaryAttrsBuilder = new StringBuilder();
if (this.config.isObjectGUID()) {
binaryAttrsBuilder.append(LDAPConstants.OBJECT_GUID).append(" ");
}
for (String attrName : config.getBinaryAttributeNames()) {
binaryAttrsBuilder.append(attrName).append(" ");
}
String binaryAttrs = binaryAttrsBuilder.toString().trim();
if (!binaryAttrs.isEmpty()) {
env.put("java.naming.ldap.attributes.binary", binaryAttrs);
}
if (logger.isDebugEnabled()) {
Map<String, Object> copyEnv = new HashMap<>(env);
if (copyEnv.containsKey(Context.SECURITY_CREDENTIALS)) {
copyEnv.put(Context.SECURITY_CREDENTIALS, "**************************************");
}
logger.debugf("Creating LdapContext using properties: [%s]", copyEnv);
}
return env;
}
private <R> R execute(LdapOperation<R> operation) throws NamingException {
return execute(operation, null);
}
private <R> R execute(LdapOperation<R> operation, LDAPOperationDecorator decorator) throws NamingException {
LdapContext context = null;
try {
context = createLdapContext();
if (decorator != null) {
decorator.beforeLDAPOperation(context, operation);
}
return operation.execute(context);
} finally {
if (context != null) {
try {
context.close();
} catch (NamingException ne) {
logger.error("Could not close Ldap context.", ne);
}
}
}
}
public interface LdapOperation<R> {
R execute(LdapContext context) throws NamingException;
}
private Set<String> getReturningAttributes(final Collection<String> returningAttributes) {
Set<String> result = new HashSet<String>();
result.addAll(returningAttributes);
result.add(getUuidAttributeName());
result.add(LDAPConstants.OBJECT_CLASS);
return result;
}
}