-
Notifications
You must be signed in to change notification settings - Fork 3k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
|
@@ -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; | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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://")) { | ||
|
@@ -72,7 +77,7 @@ public JdkLdapAuthenticatorClient(LdapConfig ldapConfig) | |
.build(); | ||
|
||
this.sslContext = Optional.ofNullable(ldapConfig.getTrustCertificate()) | ||
.map(JdkLdapAuthenticatorClient::createSslContext); | ||
.map(JdkLdapClient::createSslContext); | ||
} | ||
|
||
@Override | ||
|
@@ -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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is Also, could make this string a constant. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For whatever reason, |
||
if (memberOfAttribute == null) { | ||
log.error("No memberOf attribute found... The ldap group provider requires the memberOf overlay to be enabled."); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)); | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
|
@@ -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; | ||
|
@@ -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"); | ||
|
||
|
@@ -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)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
@@ -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)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. extraction of |
||
throw new AccessDeniedException("Username contains a special LDAP character"); | ||
} | ||
try { | ||
|
@@ -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); | ||
|
@@ -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 |
---|---|---|
|
@@ -17,7 +17,7 @@ | |
|
||
import java.util.Set; | ||
|
||
public interface LdapAuthenticatorClient | ||
public interface LdapClient | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
@@ -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(); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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()); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should return an empty set
Suggested change
|
||||||
} | ||||||
} |
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"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Won't this fail at runtime if There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please improve commit message: