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:
parent
0bf613782f
commit
94c194f1f4
7 changed files with 174 additions and 44 deletions
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
Loading…
Reference in a new issue