| // 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.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; |
| } |
| |
| 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; |
| } |
| } |
| } |
| } |