Merge branch 'stable-3.1' into stable-3.2 * stable-3.1: Create HTTP session only for login requests Change-Id: I79f23cc0f4f770bd14178b75f12066e0a001a1f0
diff --git a/README.md b/README.md index 0425eaf..9a01e03 100644 --- a/README.md +++ b/README.md
@@ -190,6 +190,22 @@ Default is not set. +**saml.memberOfAttr**: Gerrit will look for an attribute with this name in the +assertion to find the groups the user is member of. + +The user will receive these groups prefixed with `saml/` in gerrit. When the +groups do not exist, they will be created. When a user its membership is removed +this group will also be removed from this user on his next login. + +As group membership is only updated when a user logs in on the UI, so when a +user loses membership to a group in SAML, he will still be able to execute his +rights as if he is part of that group as long as he does not log in to the UI. +So enabling this feature can be seen as a security risk in certain environments. + +When this attribute is not set or empty, SAML membership synchronization is disabled. + +Default is not set. + **saml.useNameQualifier**: By SAML specification, the authentication request must not contain a NameQualifier, if the SP entity is in the format nameid-format:entity. However, some IdP require that information to be present. You can force a NameQualifier in the request with the useNameQualifier parameter. For ADFS 3.0 support, set this to `false`. Default is true.
diff --git a/src/main/java/com/googlesource/gerrit/plugins/saml/SamlConfig.java b/src/main/java/com/googlesource/gerrit/plugins/saml/SamlConfig.java index 98f09ea..45b26f3 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/saml/SamlConfig.java +++ b/src/main/java/com/googlesource/gerrit/plugins/saml/SamlConfig.java
@@ -38,6 +38,7 @@ private final String lastNameAttr; private final int maxAuthLifetimeDefault = 24 * 60 * 60; // 24h; private final boolean useNameQualifier; + private final String memberOfAttr; @Inject SamlConfig(@GerritServerConfig Config cfg) { @@ -55,6 +56,7 @@ firstNameAttr = getGetStringWithDefault(cfg, "firstNameAttr", "FirstName"); lastNameAttr = getGetStringWithDefault(cfg, "lastNameAttr", "LastName"); useNameQualifier = cfg.getBoolean(SAML_SECTION, "useNameQualifier", true); + memberOfAttr = getString(cfg, "memberOfAttr"); } public String getMetadataPath() { @@ -124,4 +126,8 @@ public String getIdentityProviderEntityId() { return identityProviderEntityId; } + + public String getMemberOfAttr() { + return memberOfAttr; + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/saml/SamlMembership.java b/src/main/java/com/googlesource/gerrit/plugins/saml/SamlMembership.java new file mode 100644 index 0000000..e1ba2c0 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/saml/SamlMembership.java
@@ -0,0 +1,187 @@ +// Copyright (C) 2020 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.googlesource.gerrit.plugins.saml; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; +import com.google.gerrit.entities.Account; +import com.google.gerrit.entities.AccountGroup; +import com.google.gerrit.server.GerritPersonIdent; +import com.google.gerrit.server.IdentifiedUser; +import com.google.gerrit.server.ServerInitiated; +import com.google.gerrit.server.account.*; +import com.google.gerrit.server.group.InternalGroup; +import com.google.gerrit.server.group.db.GroupsUpdate; +import com.google.gerrit.server.group.db.InternalGroupCreation; +import com.google.gerrit.server.group.db.InternalGroupUpdate; +import com.google.gerrit.server.notedb.Sequences; +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; +import java.io.IOException; +import java.lang.invoke.MethodHandles; +import java.util.*; +import java.util.stream.Collectors; +import org.eclipse.jgit.lib.PersonIdent; +import org.pac4j.saml.profile.SAML2Profile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +/** + * This class maps the membership attributes in the SAML document onto Internal groups prefixed with + * the saml group prefix. + */ +public class SamlMembership { + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + private static final String GROUP_PREFIX = "saml/"; + + private final String memberAttr; + private final PersonIdent serverIdent; + private final AccountManager accountManager; + private final GroupCache groupCache; + private final IdentifiedUser.GenericFactory userFactory; + private final Provider<GroupsUpdate> groupsUpdateProvider; + private final Sequences sequences; + + @Inject + SamlMembership( + SamlConfig samlConfig, + @GerritPersonIdent PersonIdent serverIdent, + AccountManager accountManager, + GroupCache groupCache, + IdentifiedUser.GenericFactory userFactory, + @ServerInitiated Provider<GroupsUpdate> groupsUpdateProvider, + Sequences sequences) { + this.memberAttr = samlConfig.getMemberOfAttr(); + this.serverIdent = serverIdent; + this.accountManager = accountManager; + this.groupCache = groupCache; + this.userFactory = userFactory; + this.groupsUpdateProvider = groupsUpdateProvider; + this.sequences = sequences; + } + + /** + * Synchronises the groups of a user with those in LDAP. + * + * @param user gerrit user + * @param profile SAML profile + */ + public void sync(AuthenticatedUser user, SAML2Profile profile) throws IOException { + Set<AccountGroup.UUID> samlMembership = + Optional.ofNullable((List<?>) profile.getAttribute(memberAttr, List.class)) + .orElse(Collections.emptyList()).stream() + .map(m -> getOrCreateGroup(m.toString())) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toSet()); + IdentifiedUser identifiedUser = userFactory.create(getOrCreateAccountId(user)); + Set<AccountGroup.UUID> userMembership = + identifiedUser.getEffectiveGroups().getKnownGroups().stream() + .filter( + uuid -> + groupCache + .get(uuid) + .filter(g -> g.getName().startsWith(GROUP_PREFIX)) + .isPresent()) + .collect(Collectors.toSet()); + + log.debug( + "User {} is member of {} in saml and {} in gerrit", + user.getUsername(), + samlMembership, + userMembership); + + Set<Account.Id> accountIdSet = ImmutableSet.of(identifiedUser.getAccountId()); + samlMembership.stream() + .filter(g -> !userMembership.contains(g)) + .forEach(g -> this.updateMembers(g, members -> Sets.union(members, accountIdSet))); + userMembership.stream() + .filter(g -> !samlMembership.contains(g)) + .forEach( + g -> + this.updateMembers( + g, + members -> + Sets.difference(members, ImmutableSet.of(identifiedUser.getAccountId())))); + } + + /** + * test if membership syncing is enabled. + * + * @return true when it is enabled. + */ + public boolean isEnabled() { + return !Strings.isNullOrEmpty(memberAttr); + } + + private void updateMembers( + AccountGroup.UUID group, InternalGroupUpdate.MemberModification memberModification) { + InternalGroupUpdate update = + InternalGroupUpdate.builder().setMemberModification(memberModification).build(); + try { + groupsUpdateProvider.get().updateGroup(group, update); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private Optional<AccountGroup.UUID> getOrCreateGroup(String samlGroup) { + return samlGroupToName(samlGroup) + .map(name -> groupCache.get(name).orElseGet(() -> createGroup(name, samlGroup))) + .map(InternalGroup::getGroupUUID); + } + + private InternalGroup createGroup(AccountGroup.NameKey name, String samlGroup) { + try { + AccountGroup.Id groupId = AccountGroup.id(sequences.nextGroupId()); + AccountGroup.UUID uuid = GroupUuid.make(name.get(), serverIdent); + InternalGroupCreation groupCreation = + InternalGroupCreation.builder() + .setGroupUUID(uuid) + .setNameKey(name) + .setId(groupId) + .build(); + InternalGroupUpdate.Builder groupUpdateBuilder = + InternalGroupUpdate.builder() + .setVisibleToAll(false) + .setDescription(samlGroup + " (imported by the SAML plugin)"); + return groupsUpdateProvider.get().createGroup(groupCreation, groupUpdateBuilder.build()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private Optional<AccountGroup.NameKey> samlGroupToName(String samlGroup) { + return Optional.of(samlGroup) + .filter(s -> !s.isEmpty()) + .map(GROUP_PREFIX::concat) + .map(AccountGroup::nameKey); + } + + private Account.Id getOrCreateAccountId(AuthenticatedUser user) throws IOException { + AuthRequest authRequest = AuthRequest.forUser(user.getUsername()); + authRequest.setUserName(user.getUsername()); + authRequest.setEmailAddress(user.getEmail()); + authRequest.setDisplayName(user.getDisplayName()); + try { + return accountManager.authenticate(authRequest).getAccountId(); + } catch (AccountException e) { + throw new RuntimeException(e); + } + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/saml/SamlWebFilter.java b/src/main/java/com/googlesource/gerrit/plugins/saml/SamlWebFilter.java index 2c6549f..a58ef8a 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/saml/SamlWebFilter.java +++ b/src/main/java/com/googlesource/gerrit/plugins/saml/SamlWebFilter.java
@@ -73,11 +73,17 @@ private final String httpExternalIdHeader; private final HashSet<String> authHeaders; private final boolean userNameToLowerCase; + private final SamlMembership samlMembership; @Inject - SamlWebFilter(@GerritServerConfig Config gerritConfig, SitePaths sitePaths, SamlConfig samlConfig) + SamlWebFilter( + @GerritServerConfig Config gerritConfig, + SitePaths sitePaths, + SamlConfig samlConfig, + SamlMembership samlMembership) throws IOException { this.samlConfig = samlConfig; + this.samlMembership = samlMembership; log.debug("Max Authentication Lifetime: " + samlConfig.getMaxAuthLifetimeAttr()); SAML2Configuration samlClientConfig = new SAML2Configuration( @@ -185,13 +191,16 @@ getUserName(user), user.getAttributes()); HttpSession s = context.getRequest().getSession(); - s.setAttribute( - SESSION_ATTR_USER, + AuthenticatedUser authenticatedUser = new AuthenticatedUser( getUserName(user), getDisplayName(user), getEmailAddress(user), - String.format("%s/%s", SAML, user.getId()))); + String.format("%s/%s", SAML, user.getId())); + s.setAttribute(SESSION_ATTR_USER, authenticatedUser); + if (samlMembership.isEnabled()) { + samlMembership.sync(authenticatedUser, user); + } String redirectUri = context.getRequest().getParameter("RelayState"); if (null == redirectUri || redirectUri.isEmpty()) {