Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LDAP Group Provider Support #8335

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import io.trino.plugin.password.file.FileAuthenticatorFactory;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please improve commit message:

Introduce LDAP group provider

import io.trino.plugin.password.file.FileGroupProviderFactory;
import io.trino.plugin.password.ldap.LdapAuthenticatorFactory;
import io.trino.plugin.password.ldap.LdapGroupProviderFactory;
import io.trino.plugin.password.salesforce.SalesforceAuthenticatorFactory;
import io.trino.spi.Plugin;
import io.trino.spi.security.GroupProviderFactory;
Expand All @@ -40,6 +41,7 @@ public Iterable<GroupProviderFactory> getGroupProviderFactories()
{
return ImmutableList.<GroupProviderFactory>builder()
.add(new FileGroupProviderFactory())
.add(new LdapGroupProviderFactory())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,13 @@
import javax.naming.AuthenticationException;
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.SearchControls;
import javax.naming.directory.SearchResult;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.Rdn;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
Expand All @@ -36,6 +40,7 @@
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
Expand All @@ -49,16 +54,16 @@
import static javax.naming.Context.SECURITY_CREDENTIALS;
import static javax.naming.Context.SECURITY_PRINCIPAL;

public class JdkLdapAuthenticatorClient
implements LdapAuthenticatorClient
public class JdkLdapClient

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason for renaming the class? Could add to PR description.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please follow: https://github.com/trinodb/trino/blob/master/DEVELOPMENT.md#git-merge-strategy

Mechanical changes (like refactoring and renaming) should be separated from logical and functional changes.

Please extract class rename into a separate commit.

implements LdapClient
{
private static final Logger log = Logger.get(JdkLdapAuthenticatorClient.class);
private static final Logger log = Logger.get(JdkLdapClient.class);

private final Map<String, String> basicEnvironment;
private final Optional<SSLContext> sslContext;

@Inject
public JdkLdapAuthenticatorClient(LdapConfig ldapConfig)
public JdkLdapClient(LdapConfig ldapConfig)
{
String ldapUrl = requireNonNull(ldapConfig.getLdapUrl(), "ldapUrl is null");
if (ldapUrl.startsWith("ldap://")) {
Expand All @@ -72,7 +77,7 @@ public JdkLdapAuthenticatorClient(LdapConfig ldapConfig)
.build();

this.sslContext = Optional.ofNullable(ldapConfig.getTrustCertificate())
.map(JdkLdapAuthenticatorClient::createSslContext);
.map(JdkLdapClient::createSslContext);
}

@Override
Expand Down Expand Up @@ -106,11 +111,44 @@ public Set<String> lookupUserDistinguishedNames(String searchBase, String search
}
}

@Override
public Set<String> lookupUserGroups(String searchBase, String searchFilter, String contextUserDistinguishedName, String contextPassword)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Would it be possible to avoid two LDAP queries while logging in and getting user groups in a separate call? Logging in is simply checking if a context can be created, and a similar context is later used for getting groups.

throws NamingException
{
try (CloseableContext context = createUserDirContext(contextUserDistinguishedName, contextPassword); CloseableSearchResults search = searchContext(searchBase, searchFilter, context)) {
ImmutableSet.Builder<String> groupNames = ImmutableSet.builder();
if (search.hasMore()) {
Attributes attributes = search.next().getAttributes();
Attribute memberOfAttribute = attributes.get("memberof");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is memberof case sensitive? Elsewhere, on lines 124 and 151 you use memberOf.

Also, could make this string a constant.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For whatever reason, memberof has to have a lowercase 'O' on line 122, and uppercase on line 151. If you don't it returns null.

if (memberOfAttribute == null) {
log.error("No memberOf attribute found... The ldap group provider requires the memberOf overlay to be enabled.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should fail instead of silenlty ignore this problem. It will some time to users to understand why ldap group provider does not return groups. Throwing an exception here could make the realize the problem sooner.

}
else {
for (Enumeration groupDns = memberOfAttribute.getAll(); groupDns.hasMoreElements(); ) {
String dn = groupDns.nextElement().toString();
LdapName ln = new LdapName(dn);
for (Rdn rdn : ln.getRdns()) {
if (rdn.getType().equalsIgnoreCase("cn")) {
groupNames.add(rdn.getValue().toString());
break;
}
}
}
}
}
return groupNames.build();
}
}

private static CloseableSearchResults searchContext(String searchBase, String searchFilter, CloseableContext context)
throws NamingException
{
SearchControls searchControls = new SearchControls();
searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
searchControls.setReturningAttributes(new String[]
{
"memberOf"
});
return new CloseableSearchResults(context.search(searchBase, searchFilter, searchControls));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
package io.trino.plugin.password.ldap;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.CharMatcher;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
Expand Down Expand Up @@ -45,10 +44,8 @@ public class LdapAuthenticator
implements PasswordAuthenticator
{
private static final Logger log = Logger.get(LdapAuthenticator.class);
private static final CharMatcher SPECIAL_CHARACTERS = CharMatcher.anyOf(",=+<>#;*()\"\\\u0000");
private static final CharMatcher WHITESPACE = CharMatcher.anyOf(" \r");

private final LdapAuthenticatorClient client;
private final LdapClient client;

private final List<String> userBindSearchPatterns;
private final Optional<String> groupAuthorizationSearchPattern;
Expand All @@ -59,7 +56,7 @@ public class LdapAuthenticator
private final LoadingCache<Credential, Principal> authenticationCache;

@Inject
public LdapAuthenticator(LdapAuthenticatorClient client, LdapConfig ldapConfig)
public LdapAuthenticator(LdapClient client, LdapConfig ldapConfig)
{
this.client = requireNonNull(client, "client is null");

Expand Down Expand Up @@ -110,17 +107,17 @@ public Principal createAuthenticatedPrincipal(String user, String password)
private Principal authenticateWithUserBind(Credential credential)
{
String user = credential.getUser();
if (containsSpecialCharacters(user)) {
if (LdapUtil.containsSpecialCharacters(user)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use static imports from LdapUtil.

throw new AccessDeniedException("Username contains a special LDAP character");
}
Exception lastException = new RuntimeException();
for (String userBindSearchPattern : userBindSearchPatterns) {
try {
String userDistinguishedName = replaceUser(userBindSearchPattern, user);
String userDistinguishedName = LdapUtil.replaceUser(userBindSearchPattern, user);
if (groupAuthorizationSearchPattern.isPresent()) {
// user password is also validated as user DN and password is used for querying LDAP
String searchBase = userBaseDistinguishedName.orElseThrow();
String groupSearch = replaceUser(groupAuthorizationSearchPattern.get(), user);
String groupSearch = LdapUtil.replaceUser(groupAuthorizationSearchPattern.get(), user);
if (!client.isGroupMember(searchBase, groupSearch, userDistinguishedName, credential.getPassword())) {
String message = format("User [%s] not a member of an authorized group", user);
log.debug(message);
Expand All @@ -144,7 +141,7 @@ private Principal authenticateWithUserBind(Credential credential)
private Principal authenticateWithBindDistinguishedName(Credential credential)
{
String user = credential.getUser();
if (containsSpecialCharacters(user)) {
if (LdapUtil.containsSpecialCharacters(user)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extraction of LdapUtil can go as separate commit too.

throw new AccessDeniedException("Username contains a special LDAP character");
}
try {
Expand All @@ -159,27 +156,11 @@ private Principal authenticateWithBindDistinguishedName(Credential credential)
return new BasicPrincipal(credential.getUser());
}

/**
* Returns {@code true} when parameter contains a character that has a special meaning in
* LDAP search or bind name (DN).
* <p>
* Based on <a href="https://www.owasp.org/index.php/Preventing_LDAP_Injection_in_Java">Preventing_LDAP_Injection_in_Java</a> and
* {@link javax.naming.ldap.Rdn#escapeValue(Object) escapeValue} method.
*/
@VisibleForTesting
static boolean containsSpecialCharacters(String user)
{
if (WHITESPACE.indexIn(user) == 0 || WHITESPACE.lastIndexIn(user) == user.length() - 1) {
return true;
}
return SPECIAL_CHARACTERS.matchesAnyOf(user);
}

private String lookupUserDistinguishedName(String user)
throws NamingException
{
String searchBase = userBaseDistinguishedName.orElseThrow();
String searchFilter = replaceUser(groupAuthorizationSearchPattern.orElseThrow(), user);
String searchFilter = LdapUtil.replaceUser(groupAuthorizationSearchPattern.orElseThrow(), user);
Set<String> userDistinguishedNames = client.lookupUserDistinguishedNames(searchBase, searchFilter, bindDistinguishedName.orElseThrow(), bindPassword.orElseThrow());
if (userDistinguishedNames.isEmpty()) {
String message = format("User [%s] not a member of an authorized group", user);
Expand All @@ -193,9 +174,4 @@ private String lookupUserDistinguishedName(String user)
}
return getOnlyElement(userDistinguishedNames);
}

private static String replaceUser(String pattern, String user)
{
return pattern.replace("${USER}", user);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public PasswordAuthenticator create(Map<String, String> config)
binder -> {
configBinder(binder).bindConfig(LdapConfig.class);
binder.bind(LdapAuthenticator.class).in(Scopes.SINGLETON);
binder.bind(LdapAuthenticatorClient.class).to(JdkLdapAuthenticatorClient.class).in(Scopes.SINGLETON);
binder.bind(LdapClient.class).to(JdkLdapClient.class).in(Scopes.SINGLETON);
});

Injector injector = app
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import java.util.Set;

public interface LdapAuthenticatorClient
public interface LdapClient

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason for renaming the interface? Could add to PR description.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added an explanation in the PR description. Essentially, Authenticator is a type of Trino plugin, but we're re-purposing the client for Group Provider plugin as well.

{
void validatePassword(String userDistinguishedName, String password)
throws NamingException;
Expand All @@ -27,4 +27,7 @@ boolean isGroupMember(String searchBase, String groupSearch, String contextUserD

Set<String> lookupUserDistinguishedNames(String searchBase, String searchFilter, String contextUserDistinguishedName, String contextPassword)
throws NamingException;

Set<String> lookupUserGroups(String searchBase, String searchFilter, String contextUserDistinguishedName, String contextPassword)
throws NamingException;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* 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 io.trino.plugin.password.ldap;

import io.airlift.log.Logger;
import io.trino.spi.security.AccessDeniedException;
import io.trino.spi.security.GroupProvider;

import javax.inject.Inject;
import javax.naming.NamingException;

import java.util.List;
import java.util.Optional;
import java.util.Set;

import static com.google.common.base.Preconditions.checkArgument;
import static java.util.Objects.requireNonNull;

public class LdapGroupProvider
implements GroupProvider
{
private static final Logger log = Logger.get(LdapGroupProvider.class);

private final LdapClient client;

private final List<String> userBindSearchPatterns;
private final Optional<String> userBaseDistinguishedName;
private final Optional<String> bindDistinguishedName;
private final Optional<String> bindPassword;

@Inject
public LdapGroupProvider(LdapClient client, LdapConfig ldapConfig)
{
this.client = requireNonNull(client, "client is null");

this.userBindSearchPatterns = ldapConfig.getUserBindSearchPatterns();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we perform a requireNonNull() check on ldapConfig before reaching this point? Or are we just letting the NPE happen in that case?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it's unnecessary because unlike the client, we're not keeping a reference, and we check the values afterwards.

this.userBaseDistinguishedName = Optional.ofNullable(ldapConfig.getUserBaseDistinguishedName());
this.bindDistinguishedName = Optional.ofNullable(ldapConfig.getBindDistingushedName());
this.bindPassword = Optional.ofNullable(ldapConfig.getBindPassword());

checkArgument(
userBaseDistinguishedName.isPresent(),
"Base distinguished name (DN) for user must be provided");
checkArgument(
bindDistinguishedName.isPresent() == bindPassword.isPresent(),
"Both bind distinguished name and bind password must be provided");
checkArgument(
!userBindSearchPatterns.isEmpty(),
"User bind search pattern must be provided");
}

@Override
public Set<String> getGroups(String user)
{
if (LdapUtil.containsSpecialCharacters(user)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

static import

throw new AccessDeniedException("Username contains a special LDAP character");
}
for (String userBindSearchPattern : userBindSearchPatterns) {
String userDistinguishedName = LdapUtil.replaceUser(userBindSearchPattern, user);
String searchBase = userBaseDistinguishedName.orElseThrow();
try {
return client.lookupUserGroups(searchBase, userDistinguishedName, bindDistinguishedName.orElseThrow(), bindPassword.orElseThrow());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are breaking a loop in first iteration and so we are ignoring rest of userBindSearchPatterns. Is this intentional? If so, why? Can you please add a comemnt?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder to keep #8134 in mind. All patterns must be tried and only the last AccessDeniedException must be bubbled up.

}
catch (NamingException e) {
log.debug(e, "Authentication failed for user [%s], %s", user, e.getMessage());
throw new RuntimeException("Authentication error");
}
}
return null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should return an empty set

Suggested change
return null;
return ImmutableSet.of();

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* 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 io.trino.plugin.password.ldap;

import com.google.inject.Injector;
import com.google.inject.Scopes;
import io.airlift.bootstrap.Bootstrap;
import io.trino.spi.security.GroupProvider;
import io.trino.spi.security.GroupProviderFactory;

import java.util.Map;

import static io.airlift.configuration.ConfigBinder.configBinder;

public class LdapGroupProviderFactory
implements GroupProviderFactory
{
@Override
public String getName()
{
return "ldap";
Copy link
Member

@MiguelWeezardo MiguelWeezardo Aug 27, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Starburst already has a group provider called "ldap", and this will cause conflicts while parsing configuration, since their config properties are different. Maybe use "ldap-group-provider", "ldap-query" or "ldap-trino"?

}

@Override
public GroupProvider create(Map<String, String> config)
{
Bootstrap app = new Bootstrap(
binder -> {
configBinder(binder).bindConfig(LdapConfig.class);
binder.bind(LdapGroupProvider.class).in(Scopes.SINGLETON);
binder.bind(LdapClient.class).to(JdkLdapClient.class).in(Scopes.SINGLETON);
Copy link
Member

@MiguelWeezardo MiguelWeezardo Aug 27, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this fail at runtime if LdapClient interface was already bound in LdapAuthenticatorFactory? Maybe we need some @ForAuthentication and @ForGroupProvider annotations?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is in separate guice context, so I think it is not an issue.

});

Injector injector = app
.strictConfig()
.doNotInitializeLogging()
.setRequiredConfigurationProperties(config)
.initialize();

return injector.getInstance(LdapGroupProvider.class);
}
}
Loading