Prevent users to unlink from their home identity provider when they are a managed member

Closes #30092

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>

Co-authored-by: Vlasta Ramik <vramik@users.noreply.github.com>
This commit is contained in:
Pedro Igor 2024-06-03 18:22:19 -03:00 committed by Alexander Schwartz
parent 0bf613782f
commit 94c194f1f4
7 changed files with 174 additions and 44 deletions

View file

@ -21,6 +21,7 @@ import static org.keycloak.organization.utils.Organizations.isEnabledAndOrganiza
import static org.keycloak.organization.utils.Organizations.resolveBroker;
import java.util.List;
import jakarta.ws.rs.core.MultivaluedMap;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
@ -86,14 +87,14 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
return;
}
IdentityProviderModel broker = resolveBroker(session, user);
List<IdentityProviderModel> broker = resolveBroker(session, user);
if (broker == null) {
if (broker.isEmpty()) {
// not a managed member, continue with the regular flow
context.attempted();
} else {
} else if (broker.size() == 1) {
// user is a managed member and associated with a broker, redirect automatically
redirect(context, broker.getAlias(), user.getEmail());
redirect(context, broker.get(0).getAlias(), user.getEmail());
}
return;

View file

@ -66,19 +66,18 @@ public class Organizations {
return true;
}
public static IdentityProviderModel resolveBroker(KeycloakSession session, UserModel user) {
public static List<IdentityProviderModel> resolveBroker(KeycloakSession session, UserModel user) {
OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
RealmModel realm = session.getContext().getRealm();
OrganizationModel organization = provider.getByMember(user);
if (organization == null || !organization.isEnabled()) {
return null;
return List.of();
}
if (provider.isManagedMember(organization, user)) {
// user is a managed member, try to resolve the origin broker and redirect automatically
List<IdentityProviderModel> organizationBrokers = organization.getIdentityProviders().toList();
List<IdentityProviderModel> brokers = session.users().getFederatedIdentitiesStream(realm, user)
return session.users().getFederatedIdentitiesStream(realm, user)
.map(f -> {
IdentityProviderModel broker = realm.getIdentityProviderByAlias(f.getIdentityProvider());
@ -95,11 +94,9 @@ public class Organizations {
return null;
}).filter(Objects::nonNull)
.toList();
return brokers.size() == 1 ? brokers.get(0) : null;
}
return null;
return List.of();
}
public static Consumer<GroupModel> removeGroup(KeycloakSession session, RealmModel realm) {

View file

@ -21,7 +21,11 @@ import static java.util.Optional.ofNullable;
import static org.keycloak.organization.utils.Organizations.resolveBroker;
import static org.keycloak.validate.BuiltinValidators.emailValidator;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.keycloak.Config.Scope;
import org.keycloak.broker.provider.BrokeredIdentityContext;
@ -92,7 +96,7 @@ public class OrganizationMemberValidator extends AbstractSimpleValidator impleme
AttributeContext attributeContext = upContext.getAttributeContext();
UserModel user = attributeContext.getUser();
String emailDomain = email.substring(email.indexOf('@') + 1);
List<String> expectedDomains = organization.getDomains().map(OrganizationDomainModel::getName).toList();
Set<String> expectedDomains = organization.getDomains().map(OrganizationDomainModel::getName).collect(Collectors.toSet());
if (expectedDomains.isEmpty()) {
// no domain to check
@ -116,24 +120,33 @@ public class OrganizationMemberValidator extends AbstractSimpleValidator impleme
context.addError(new ValidationError(ID, inputHint, "Email domain does not match any domain from the organization"));
}
private static List<String> resolveExpectedDomainsForManagedUser(ValidationContext context, UserModel user) {
IdentityProviderModel broker = resolveBroker(context.getSession(), user);
private static Set<String> resolveExpectedDomainsForManagedUser(ValidationContext context, UserModel user) {
List<IdentityProviderModel> brokers = resolveBroker(context.getSession(), user);
if (broker == null) {
return List.of();
if (brokers.isEmpty()) {
return Set.of();
}
Set<String> domains = new HashSet<>();
for (IdentityProviderModel broker : brokers) {
String domain = broker.getConfig().get(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE);
return ofNullable(domain).map(List::of).orElse(List.of());
if (domain != null) {
domains.add(domain);
}
}
private static List<String> resolveExpectedDomainsWhenReviewingFederatedUserProfile(OrganizationModel organization, AttributeContext attributeContext) {
return Collections.unmodifiableSet(domains);
}
private static Set<String> resolveExpectedDomainsWhenReviewingFederatedUserProfile(OrganizationModel organization, AttributeContext attributeContext) {
// validating in the context of the brokering flow
KeycloakSession session = attributeContext.getSession();
BrokeredIdentityContext brokerContext = (BrokeredIdentityContext) session.getAttribute(BrokeredIdentityContext.class.getName());
if (brokerContext == null) {
return List.of();
return Set.of();
}
String alias = brokerContext.getIdpConfig().getAlias();
@ -144,12 +157,12 @@ public class OrganizationMemberValidator extends AbstractSimpleValidator impleme
if (broker == null) {
// the broker the user is authenticating is not linked to the organization
return List.of();
return Set.of();
}
// expect the email domain to match the domain set to the broker or none if not set
String brokerDomain = broker.getConfig().get(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE);
return ofNullable(brokerDomain).map(List::of).orElse(List.of());
return ofNullable(brokerDomain).map(Set::of).orElse(Set.of());
}
private OrganizationModel resolveOrganization(ValidationContext context, KeycloakSession session) {

View file

@ -245,6 +245,8 @@ public class Messages {
public static final String FEDERATED_IDENTITY_REMOVING_LAST_PROVIDER = "federatedIdentityRemovingLastProviderMessage";
public static final String FEDERATED_IDENTITY_BOUND_ORGANIZATION = "federatedIdentityBoundOrganization";
public static final String IDENTITY_PROVIDER_REDIRECT_ERROR = "identityProviderRedirectErrorMessage";
public static final String IDENTITY_PROVIDER_REMOVED = "identityProviderRemovedMessage";

View file

@ -38,6 +38,8 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import org.jboss.logging.Logger;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.http.HttpRequest;
import org.keycloak.broker.social.SocialIdentityProvider;
import org.keycloak.common.util.Base64Url;
@ -52,6 +54,7 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.representations.account.AccountLinkUriRepresentation;
import org.keycloak.representations.account.LinkedAccountRepresentation;
@ -206,6 +209,14 @@ public class LinkedAccountsResource {
throw ErrorResponse.error(translateErrorMessage(Messages.FEDERATED_IDENTITY_NOT_ACTIVE), Response.Status.BAD_REQUEST);
}
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
if (Organizations.resolveBroker(session, user).stream()
.map(IdentityProviderModel::getAlias)
.anyMatch(providerAlias::equals)) {
throw ErrorResponse.error(translateErrorMessage(Messages.FEDERATED_IDENTITY_BOUND_ORGANIZATION), Response.Status.BAD_REQUEST);
}
}
// Removing last social provider is not possible if you don't have other possibility to authenticate
if (!(session.users().getFederatedIdentitiesStream(realm, user).count() > 1 || user.getFederationLink() != null || isPasswordSet())) {
throw ErrorResponse.error(translateErrorMessage(Messages.FEDERATED_IDENTITY_REMOVING_LAST_PROVIDER), Response.Status.BAD_REQUEST);

View file

@ -0,0 +1,105 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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 org.keycloak.testsuite.organization.admin;
import java.io.IOException;
import java.util.SortedSet;
import com.fasterxml.jackson.core.type.TypeReference;
import jakarta.ws.rs.core.Response.Status;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.broker.provider.util.SimpleHttp.Response;
import org.keycloak.common.Profile.Feature;
import org.keycloak.representations.account.LinkedAccountRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.broker.util.SimpleHttpDefault;
import org.keycloak.testsuite.util.TokenUtil;
@EnableFeature(Feature.ORGANIZATION)
public class OrganizationAccountTest extends AbstractOrganizationTest {
@Rule
public TokenUtil tokenUtil = new TokenUtil(bc.getUserEmail(), bc.getUserPassword());
private CloseableHttpClient client;
@Before
public void before() {
client = HttpClientBuilder.create().build();
}
@After
public void after() {
try {
client.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Test
public void testFailUnlinkIdentityProvider() throws IOException {
// federate user
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
assertBrokerRegistration(organization, bc.getUserEmail());
// reset password to obtain a token and access the account api
UserRepresentation user = ApiUtil.findUserByUsername(realmsResouce().realm(bc.consumerRealmName()), bc.getUserLogin());
ApiUtil.resetUserPassword(realmsResouce().realm(bc.consumerRealmName()).users().get(user.getId()), bc.getUserPassword(), false);
LinkedAccountRepresentation link = findLinkedAccount(bc.getIDPAlias());
Assert.assertNotNull(link);
try (Response response = SimpleHttpDefault.doDelete(getAccountUrl("linked-accounts/" + link.getProviderAlias()), client).auth(tokenUtil.getToken()).acceptJson().asResponse()) {
Assert.assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
ErrorRepresentation error = response.asJson(ErrorRepresentation.class);
Assert.assertEquals("You cannot remove the link to an identity provider associated with an organization.", error.getErrorMessage());
}
// broker no longer linked to the organization
organization.identityProviders().get(bc.getIDPAlias()).delete().close();
try (Response response = SimpleHttpDefault.doDelete(getAccountUrl("linked-accounts/" + link.getProviderAlias()), client).auth(tokenUtil.getToken()).acceptJson().asResponse()) {
Assert.assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
}
}
private SortedSet<LinkedAccountRepresentation> linkedAccountsRep() throws IOException {
return SimpleHttpDefault.doGet(getAccountUrl("linked-accounts"), client).auth(tokenUtil.getToken())
.asJson(new TypeReference<>() {});
}
private String getAccountUrl(String resource) {
return suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/test/account" + (resource != null ? "/" + resource : "");
}
private LinkedAccountRepresentation findLinkedAccount(String providerAlias) throws IOException {
for (LinkedAccountRepresentation account : linkedAccountsRep()) {
if (account.getProviderAlias().equals(providerAlias)) return account;
}
return null;
}
}

View file

@ -213,6 +213,7 @@ invalidFederatedIdentityActionMessage=Invalid or missing action.
identityProviderNotFoundMessage=Specified identity provider not found.
federatedIdentityLinkNotActiveMessage=This identity is not active anymore.
federatedIdentityRemovingLastProviderMessage=You can''t remove last federated identity as you don''t have a password.
federatedIdentityBoundOrganization=You cannot remove the link to an identity provider associated with an organization.
identityProviderRedirectErrorMessage=Failed to redirect to identity provider.
identityProviderRemovedMessage=Identity provider removed successfully.
identityProviderAlreadyLinkedMessage=Federated identity returned by {0} is already linked to another user.