| // Copyright (C) 2009 The Android Open Source Project | |
| // | |
| // 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.google.gerrit.auth.ldap; | |
| import com.google.common.base.Throwables; | |
| import com.google.common.cache.Cache; | |
| import com.google.common.collect.ImmutableSet; | |
| import com.google.common.flogger.FluentLogger; | |
| import com.google.gerrit.common.Nullable; | |
| import com.google.gerrit.common.data.ParameterizedString; | |
| import com.google.gerrit.entities.AccountGroup; | |
| import com.google.gerrit.metrics.Description; | |
| import com.google.gerrit.metrics.Description.Units; | |
| import com.google.gerrit.metrics.MetricMaker; | |
| import com.google.gerrit.metrics.Timer0; | |
| import com.google.gerrit.server.account.AccountException; | |
| import com.google.gerrit.server.account.AuthenticationFailedException; | |
| import com.google.gerrit.server.auth.NoSuchUserException; | |
| import com.google.gerrit.server.config.ConfigUtil; | |
| import com.google.gerrit.server.config.GerritServerConfig; | |
| import com.google.gerrit.util.ssl.BlindHostnameVerifier; | |
| import com.google.gerrit.util.ssl.BlindSSLSocketFactory; | |
| import com.google.inject.Inject; | |
| import com.google.inject.Singleton; | |
| import com.google.inject.name.Named; | |
| import java.io.IOException; | |
| import java.security.PrivilegedActionException; | |
| import java.security.PrivilegedExceptionAction; | |
| import java.util.ArrayList; | |
| import java.util.Collections; | |
| import java.util.HashMap; | |
| import java.util.HashSet; | |
| import java.util.List; | |
| import java.util.Properties; | |
| import java.util.Set; | |
| import java.util.concurrent.TimeUnit; | |
| import javax.naming.CompositeName; | |
| import javax.naming.Context; | |
| import javax.naming.Name; | |
| import javax.naming.NamingEnumeration; | |
| import javax.naming.NamingException; | |
| import javax.naming.PartialResultException; | |
| import javax.naming.directory.Attribute; | |
| import javax.naming.directory.DirContext; | |
| import javax.naming.ldap.InitialLdapContext; | |
| import javax.naming.ldap.LdapContext; | |
| import javax.naming.ldap.StartTlsRequest; | |
| import javax.naming.ldap.StartTlsResponse; | |
| import javax.net.ssl.SSLSocketFactory; | |
| import javax.security.auth.Subject; | |
| import javax.security.auth.login.LoginContext; | |
| import javax.security.auth.login.LoginException; | |
| import org.eclipse.jgit.lib.Config; | |
| @Singleton | |
| class Helper { | |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); | |
| static final String LDAP_UUID = "ldap:"; | |
| static final String STARTTLS_PROPERTY = Helper.class.getName() + ".startTls"; | |
| private final Cache<String, ImmutableSet<String>> parentGroups; | |
| private final Config config; | |
| private final String server; | |
| private final String username; | |
| private final String password; | |
| private final String referral; | |
| private final boolean startTls; | |
| private final boolean supportAnonymous; | |
| private final boolean sslVerify; | |
| private final String authentication; | |
| private volatile LdapSchema ldapSchema; | |
| private final String readTimeoutMillis; | |
| private final String connectTimeoutMillis; | |
| private final boolean useConnectionPooling; | |
| private final boolean groupsVisibleToAll; | |
| private final Timer0 loginLatencyTimer; | |
| private final Timer0 userSearchLatencyTimer; | |
| private final Timer0 groupSearchLatencyTimer; | |
| private final Timer0 groupExpansionLatencyTimer; | |
| @Inject | |
| Helper( | |
| @GerritServerConfig Config config, | |
| @Named(LdapModule.PARENT_GROUPS_CACHE) Cache<String, ImmutableSet<String>> parentGroups, | |
| MetricMaker metricMaker) { | |
| this.config = config; | |
| this.server = LdapRealm.optional(config, "server"); | |
| this.username = LdapRealm.optional(config, "username"); | |
| this.password = LdapRealm.optional(config, "password", ""); | |
| this.referral = LdapRealm.optional(config, "referral", "ignore"); | |
| this.startTls = config.getBoolean("ldap", "startTls", false); | |
| this.supportAnonymous = config.getBoolean("ldap", "supportAnonymous", true); | |
| this.sslVerify = config.getBoolean("ldap", "sslverify", true); | |
| this.groupsVisibleToAll = config.getBoolean("ldap", "groupsVisibleToAll", false); | |
| this.authentication = LdapRealm.optional(config, "authentication", "simple"); | |
| String readTimeout = LdapRealm.optional(config, "readTimeout"); | |
| if (readTimeout != null) { | |
| readTimeoutMillis = | |
| Long.toString(ConfigUtil.getTimeUnit(readTimeout, 0, TimeUnit.MILLISECONDS)); | |
| } else { | |
| readTimeoutMillis = null; | |
| } | |
| String connectTimeout = LdapRealm.optional(config, "connectTimeout"); | |
| if (connectTimeout != null) { | |
| connectTimeoutMillis = | |
| Long.toString(ConfigUtil.getTimeUnit(connectTimeout, 0, TimeUnit.MILLISECONDS)); | |
| } else { | |
| connectTimeoutMillis = null; | |
| } | |
| this.parentGroups = parentGroups; | |
| this.useConnectionPooling = LdapRealm.optional(config, "useConnectionPooling", false); | |
| this.loginLatencyTimer = | |
| metricMaker.newTimer( | |
| "ldap/login_latency", | |
| new Description("Latency of logins").setCumulative().setUnit(Units.NANOSECONDS)); | |
| this.userSearchLatencyTimer = | |
| metricMaker.newTimer( | |
| "ldap/user_search_latency", | |
| new Description("Latency for searching the user account") | |
| .setCumulative() | |
| .setUnit(Units.NANOSECONDS)); | |
| this.groupSearchLatencyTimer = | |
| metricMaker.newTimer( | |
| "ldap/group_search_latency", | |
| new Description("Latency for querying the groups membership of an account") | |
| .setCumulative() | |
| .setUnit(Units.NANOSECONDS)); | |
| this.groupExpansionLatencyTimer = | |
| metricMaker.newTimer( | |
| "ldap/group_expansion_latency", | |
| new Description("Latency for expanding nested groups") | |
| .setCumulative() | |
| .setUnit(Units.NANOSECONDS)); | |
| } | |
| Timer0 getGroupSearchLatencyTimer() { | |
| return groupSearchLatencyTimer; | |
| } | |
| private Properties createContextProperties() { | |
| final Properties env = new Properties(); | |
| env.put(Context.INITIAL_CONTEXT_FACTORY, LdapRealm.LDAP); | |
| env.put(Context.PROVIDER_URL, server); | |
| if (server.startsWith("ldaps:") && !sslVerify) { | |
| Class<? extends SSLSocketFactory> factory = BlindSSLSocketFactory.class; | |
| env.put("java.naming.ldap.factory.socket", factory.getName()); | |
| } | |
| if (readTimeoutMillis != null) { | |
| env.put("com.sun.jndi.ldap.read.timeout", readTimeoutMillis); | |
| } | |
| if (connectTimeoutMillis != null) { | |
| env.put("com.sun.jndi.ldap.connect.timeout", connectTimeoutMillis); | |
| } | |
| if (useConnectionPooling) { | |
| env.put("com.sun.jndi.ldap.connect.pool", "true"); | |
| } | |
| return env; | |
| } | |
| private LdapContext createContext(Properties env) throws IOException, NamingException { | |
| LdapContext ctx = new InitialLdapContext(env, null); | |
| if (startTls) { | |
| StartTlsResponse tls = (StartTlsResponse) ctx.extendedOperation(new StartTlsRequest()); | |
| SSLSocketFactory sslfactory = null; | |
| if (!sslVerify) { | |
| sslfactory = (SSLSocketFactory) BlindSSLSocketFactory.getDefault(); | |
| tls.setHostnameVerifier(BlindHostnameVerifier.getInstance()); | |
| } | |
| tls.negotiate(sslfactory); | |
| ctx.addToEnvironment(STARTTLS_PROPERTY, tls); | |
| } | |
| return ctx; | |
| } | |
| void close(DirContext ctx) { | |
| try { | |
| StartTlsResponse tls = (StartTlsResponse) ctx.removeFromEnvironment(STARTTLS_PROPERTY); | |
| if (tls != null) { | |
| tls.close(); | |
| } | |
| } catch (IOException | NamingException e) { | |
| logger.atWarning().withCause(e).log("Cannot close LDAP startTls handle"); | |
| } | |
| try { | |
| ctx.close(); | |
| } catch (NamingException e) { | |
| logger.atWarning().withCause(e).log("Cannot close LDAP handle"); | |
| } | |
| } | |
| DirContext open() throws IOException, NamingException, LoginException { | |
| final Properties env = createContextProperties(); | |
| env.put(Context.SECURITY_AUTHENTICATION, authentication); | |
| env.put(Context.REFERRAL, referral); | |
| if ("GSSAPI".equals(authentication)) { | |
| return kerberosOpen(env); | |
| } | |
| if (!supportAnonymous && username != null) { | |
| env.put(Context.SECURITY_PRINCIPAL, username); | |
| env.put(Context.SECURITY_CREDENTIALS, password); | |
| } | |
| LdapContext ctx = createContext(env); | |
| if (supportAnonymous && username != null) { | |
| ctx.addToEnvironment(Context.SECURITY_PRINCIPAL, username); | |
| ctx.addToEnvironment(Context.SECURITY_CREDENTIALS, password); | |
| ctx.reconnect(null); | |
| } | |
| return ctx; | |
| } | |
| @Nullable | |
| private DirContext kerberosOpen(Properties env) | |
| throws IOException, LoginException, NamingException { | |
| LoginContext ctx = new LoginContext("KerberosLogin"); | |
| try (Timer0.Context ignored = loginLatencyTimer.start()) { | |
| ctx.login(); | |
| } | |
| Subject subject = ctx.getSubject(); | |
| try { | |
| return Subject.doAs( | |
| subject, (PrivilegedExceptionAction<DirContext>) () -> createContext(env)); | |
| } catch (PrivilegedActionException e) { | |
| Throwables.throwIfInstanceOf(e.getException(), IOException.class); | |
| Throwables.throwIfInstanceOf(e.getException(), NamingException.class); | |
| Throwables.throwIfInstanceOf(e.getException(), RuntimeException.class); | |
| logger.atWarning().withCause(e.getException()).log("Internal error"); | |
| return null; | |
| } finally { | |
| ctx.logout(); | |
| } | |
| } | |
| DirContext authenticate(String dn, String password) throws AccountException { | |
| final Properties env = createContextProperties(); | |
| try (Timer0.Context ignored = loginLatencyTimer.start()) { | |
| env.put(Context.REFERRAL, referral); | |
| if (!supportAnonymous) { | |
| env.put(Context.SECURITY_AUTHENTICATION, "simple"); | |
| env.put(Context.SECURITY_PRINCIPAL, dn); | |
| env.put(Context.SECURITY_CREDENTIALS, password); | |
| } | |
| LdapContext ctx = createContext(env); | |
| if (supportAnonymous) { | |
| ctx.addToEnvironment(Context.SECURITY_AUTHENTICATION, "simple"); | |
| ctx.addToEnvironment(Context.SECURITY_PRINCIPAL, dn); | |
| ctx.addToEnvironment(Context.SECURITY_CREDENTIALS, password); | |
| ctx.reconnect(null); | |
| } | |
| return ctx; | |
| } catch (IOException | NamingException e) { | |
| throw new AuthenticationFailedException("Incorrect username or password", e); | |
| } | |
| } | |
| LdapSchema getSchema(DirContext ctx) { | |
| if (ldapSchema == null) { | |
| synchronized (this) { | |
| if (ldapSchema == null) { | |
| ldapSchema = new LdapSchema(ctx); | |
| } | |
| } | |
| } | |
| return ldapSchema; | |
| } | |
| LdapQuery.Result findAccount( | |
| Helper.LdapSchema schema, DirContext ctx, String username, boolean fetchMemberOf) | |
| throws NamingException, AccountException { | |
| final HashMap<String, String> params = new HashMap<>(); | |
| params.put(LdapRealm.USERNAME, username); | |
| List<LdapQuery> accountQueryList; | |
| if (fetchMemberOf && schema.type.accountMemberField() != null) { | |
| accountQueryList = schema.accountWithMemberOfQueryList; | |
| } else { | |
| accountQueryList = schema.accountQueryList; | |
| } | |
| for (LdapQuery accountQuery : accountQueryList) { | |
| List<LdapQuery.Result> res = accountQuery.query(ctx, params, userSearchLatencyTimer); | |
| if (res.size() == 1) { | |
| return res.get(0); | |
| } else if (res.size() > 1) { | |
| throw new AccountException("Duplicate users: " + username); | |
| } | |
| } | |
| throw new NoSuchUserException(username); | |
| } | |
| Set<AccountGroup.UUID> queryForGroups( | |
| final DirContext ctx, String username, LdapQuery.Result account) throws NamingException { | |
| final LdapSchema schema = getSchema(ctx); | |
| final Set<String> groupDNs = new HashSet<>(); | |
| if (!schema.groupMemberQueryList.isEmpty()) { | |
| final HashMap<String, String> params = new HashMap<>(); | |
| if (account == null) { | |
| try { | |
| account = findAccount(schema, ctx, username, false); | |
| } catch (AccountException e) { | |
| return Collections.emptySet(); | |
| } | |
| } | |
| for (String name : schema.groupMemberQueryList.get(0).getParameters()) { | |
| params.put(name, account.get(name)); | |
| } | |
| params.put(LdapRealm.USERNAME, username); | |
| for (LdapQuery groupMemberQuery : schema.groupMemberQueryList) { | |
| for (LdapQuery.Result r : groupMemberQuery.query(ctx, params, groupSearchLatencyTimer)) { | |
| try (Timer0.Context ignored = groupExpansionLatencyTimer.start()) { | |
| recursivelyExpandGroups(groupDNs, schema, ctx, r.getDN()); | |
| } | |
| } | |
| } | |
| } | |
| if (schema.accountMemberField != null) { | |
| if (account == null || account.getAll(schema.accountMemberField) == null) { | |
| try { | |
| account = findAccount(schema, ctx, username, true); | |
| } catch (AccountException e) { | |
| return Collections.emptySet(); | |
| } | |
| } | |
| final Attribute groupAtt = account.getAll(schema.accountMemberField); | |
| if (groupAtt != null) { | |
| final NamingEnumeration<?> groups = groupAtt.getAll(); | |
| try { | |
| while (groups.hasMore()) { | |
| final String nextDN = (String) groups.next(); | |
| recursivelyExpandGroups(groupDNs, schema, ctx, nextDN); | |
| } | |
| } catch (PartialResultException e) { | |
| // Ignored | |
| } | |
| } | |
| } | |
| final Set<AccountGroup.UUID> actual = new HashSet<>(); | |
| for (String dn : groupDNs) { | |
| actual.add(AccountGroup.uuid(LDAP_UUID + dn)); | |
| } | |
| if (actual.isEmpty()) { | |
| return Collections.emptySet(); | |
| } | |
| return ImmutableSet.copyOf(actual); | |
| } | |
| private void recursivelyExpandGroups( | |
| final Set<String> groupDNs, | |
| final LdapSchema schema, | |
| final DirContext ctx, | |
| final String groupDN) { | |
| if (groupDNs.add(groupDN) | |
| && schema.accountMemberField != null | |
| && schema.accountMemberExpandGroups) { | |
| ImmutableSet<String> cachedParentsDNs = parentGroups.getIfPresent(groupDN); | |
| if (cachedParentsDNs == null) { | |
| // Recursively identify the groups it is a member of. | |
| ImmutableSet.Builder<String> dns = ImmutableSet.builder(); | |
| try { | |
| final Name compositeGroupName = new CompositeName().add(groupDN); | |
| final Attribute in = | |
| ctx.getAttributes(compositeGroupName, schema.accountMemberFieldArray) | |
| .get(schema.accountMemberField); | |
| if (in != null) { | |
| final NamingEnumeration<?> groups = in.getAll(); | |
| try { | |
| while (groups.hasMore()) { | |
| dns.add((String) groups.next()); | |
| } | |
| } catch (PartialResultException e) { | |
| // Ignored | |
| } | |
| } | |
| } catch (NamingException e) { | |
| logger.atWarning().withCause(e).log("Could not find group %s", groupDN); | |
| } | |
| cachedParentsDNs = dns.build(); | |
| parentGroups.put(groupDN, cachedParentsDNs); | |
| } | |
| for (String dn : cachedParentsDNs) { | |
| recursivelyExpandGroups(groupDNs, schema, ctx, dn); | |
| } | |
| } | |
| } | |
| public boolean groupsVisibleToAll() { | |
| return this.groupsVisibleToAll; | |
| } | |
| class LdapSchema { | |
| final LdapType type; | |
| final ParameterizedString accountFullName; | |
| final ParameterizedString accountEmailAddress; | |
| final ParameterizedString accountSshUserName; | |
| final String accountMemberField; | |
| final boolean accountMemberExpandGroups; | |
| final String[] accountMemberFieldArray; | |
| final List<LdapQuery> accountQueryList; | |
| final List<LdapQuery> accountWithMemberOfQueryList; | |
| final List<String> groupBases; | |
| final SearchScope groupScope; | |
| final ParameterizedString groupPattern; | |
| final ParameterizedString groupName; | |
| final List<LdapQuery> groupMemberQueryList; | |
| LdapSchema(DirContext ctx) { | |
| type = discoverLdapType(ctx); | |
| groupMemberQueryList = new ArrayList<>(); | |
| accountQueryList = new ArrayList<>(); | |
| accountWithMemberOfQueryList = new ArrayList<>(); | |
| final Set<String> accountAtts = new HashSet<>(); | |
| // Group query | |
| // | |
| groupBases = LdapRealm.optionalList(config, "groupBase"); | |
| groupScope = LdapRealm.scope(config, "groupScope"); | |
| groupPattern = LdapRealm.paramString(config, "groupPattern", type.groupPattern()); | |
| groupName = LdapRealm.paramString(config, "groupName", type.groupName()); | |
| final String groupMemberPattern = | |
| LdapRealm.optdef(config, "groupMemberPattern", type.groupMemberPattern()); | |
| for (String groupBase : groupBases) { | |
| if (groupMemberPattern != null) { | |
| final LdapQuery groupMemberQuery = | |
| new LdapQuery( | |
| groupBase, | |
| groupScope, | |
| new ParameterizedString(groupMemberPattern), | |
| Collections.emptySet()); | |
| if (groupMemberQuery.getParameters().isEmpty()) { | |
| throw new IllegalArgumentException("No variables in ldap.groupMemberPattern"); | |
| } | |
| accountAtts.addAll(groupMemberQuery.getParameters()); | |
| groupMemberQueryList.add(groupMemberQuery); | |
| } | |
| } | |
| // Account query | |
| // | |
| accountFullName = LdapRealm.paramString(config, "accountFullName", type.accountFullName()); | |
| if (accountFullName != null) { | |
| accountAtts.addAll(accountFullName.getParameterNames()); | |
| } | |
| accountEmailAddress = | |
| LdapRealm.paramString(config, "accountEmailAddress", type.accountEmailAddress()); | |
| if (accountEmailAddress != null) { | |
| accountAtts.addAll(accountEmailAddress.getParameterNames()); | |
| } | |
| accountSshUserName = | |
| LdapRealm.paramString(config, "accountSshUserName", type.accountSshUserName()); | |
| if (accountSshUserName != null) { | |
| accountAtts.addAll(accountSshUserName.getParameterNames()); | |
| } | |
| accountMemberField = | |
| LdapRealm.optdef(config, "accountMemberField", type.accountMemberField()); | |
| if (accountMemberField != null) { | |
| accountMemberFieldArray = new String[] {accountMemberField}; | |
| } else { | |
| accountMemberFieldArray = null; | |
| } | |
| accountMemberExpandGroups = | |
| LdapRealm.optional(config, "accountMemberExpandGroups", type.accountMemberExpandGroups()); | |
| final SearchScope accountScope = LdapRealm.scope(config, "accountScope"); | |
| final String accountPattern = | |
| LdapRealm.reqdef(config, "accountPattern", type.accountPattern()); | |
| Set<String> accountWithMemberOfAtts; | |
| if (accountMemberField != null) { | |
| accountWithMemberOfAtts = new HashSet<>(accountAtts); | |
| accountWithMemberOfAtts.add(accountMemberField); | |
| } else { | |
| accountWithMemberOfAtts = null; | |
| } | |
| for (String accountBase : LdapRealm.requiredList(config, "accountBase")) { | |
| LdapQuery accountQuery = | |
| new LdapQuery( | |
| accountBase, accountScope, new ParameterizedString(accountPattern), accountAtts); | |
| if (accountQuery.getParameters().isEmpty()) { | |
| throw new IllegalArgumentException("No variables in ldap.accountPattern"); | |
| } | |
| accountQueryList.add(accountQuery); | |
| if (accountWithMemberOfAtts != null) { | |
| LdapQuery accountWithMemberOfQuery = | |
| new LdapQuery( | |
| accountBase, | |
| accountScope, | |
| new ParameterizedString(accountPattern), | |
| accountWithMemberOfAtts); | |
| accountWithMemberOfQueryList.add(accountWithMemberOfQuery); | |
| } | |
| } | |
| } | |
| LdapType discoverLdapType(DirContext ctx) { | |
| try { | |
| return LdapType.guessType(ctx); | |
| } catch (NamingException e) { | |
| logger.atWarning().withCause(e).log( | |
| "Cannot discover type of LDAP server at %s," | |
| + " assuming the server is RFC 2307 compliant.", | |
| server); | |
| return LdapType.RFC_2307; | |
| } | |
| } | |
| } | |
| } |