Allow enforce that users are members of organizations when authenticating

Closes #34275

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2025-01-10 11:45:33 -03:00 committed by Alexander Schwartz
parent 1f0983be69
commit aca84824c0
9 changed files with 141 additions and 5 deletions

View File

@ -106,6 +106,18 @@ Change the *first broker login* flow by following these steps:
.Organizations first broker flow
image:images/organizations-first-broker-flow.png[alt="Organizations first broker flow"]
You should now be able to authenticate using any identity provider associated with an organization
and have the user joining the organization as a member as soon as they complete the first browser login flow.
== Configuring how users authenticate
If the flow supports organizations, you can configure some of the steps to change how users authenticate to the realm.
For example, some use cases will require users to authenticate to a realm only if they are a member of any or a specific organization in the realm.
To enable this behavior, you need to enable the `Requires user membership` setting on the `Organization Identity-First Login` execution step by clicking on its settings.
If enabled, and after the user provides the username or email in the identity-first login page, the server will
try to resolve a organization where the user is a member by looking at any existing membership or based on the semantics of the <<_mapping_organization_claims_,organization>> scope,
if requested by the client. If not a member of an organization, an error page will be shown.

View File

@ -1,5 +1,6 @@
[id="mapping-organization-claims_{context}"]
[[_mapping_organization_claims_]]
= Mapping organization claims
[role="_abstract"]
To map organization-specific claims into tokens, a client needs to request the *organization* scope when sending

View File

@ -1376,7 +1376,11 @@ public class RealmAdapter implements CachedRealmModel {
@Override
public Stream<AuthenticationExecutionModel> getAuthenticationExecutionsStream(String flowId) {
if (isUpdated()) return updated.getAuthenticationExecutionsStream(flowId);
return cached.getAuthenticationExecutions().get(flowId).stream();
List<AuthenticationExecutionModel> executions = cached.getAuthenticationExecutions().get(flowId);
if (executions == null) {
return Stream.empty();
}
return executions.stream();
}
@Override

View File

@ -30,6 +30,7 @@ import java.util.function.Predicate;
import java.util.stream.Stream;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
@ -39,6 +40,7 @@ import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean;
import org.keycloak.forms.login.freemarker.model.IdentityProviderBean;
import org.keycloak.http.HttpRequest;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
@ -104,6 +106,17 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
return;
}
if (user != null && isRequiresMembership(context) && !organization.isMember(user)) {
String errorMessage = "notMemberOfOrganization";
// do not show try another way
context.setAuthenticationSelections(List.of());
Response challenge = context.form()
.setError(errorMessage, organization.getName())
.createErrorPage(Response.Status.FORBIDDEN);
context.failure(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, challenge, "User " + user.getUsername() + " not a member of organization " + organization.getAlias(), errorMessage);
return;
}
// make sure the organization is set to the session to make it available to templates
session.getContext().setOrganization(organization);
@ -329,4 +342,12 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
private OrganizationProvider getOrganizationProvider() {
return session.getProvider(OrganizationProvider.class);
}
private boolean isRequiresMembership(AuthenticationFlowContext context) {
return Boolean.parseBoolean(getConfig(context).getOrDefault(OrganizationAuthenticatorFactory.REQUIRES_USER_MEMBERSHIP, Boolean.FALSE.toString()));
}
private Map<String, String> getConfig(AuthenticationFlowContext context) {
return Optional.ofNullable(context.getAuthenticatorConfig()).map(AuthenticatorConfigModel::getConfig).orElse(Map.of());
}
}

View File

@ -17,6 +17,9 @@
package org.keycloak.organization.authentication.authenticators.browser;
import static org.keycloak.provider.ProviderConfigProperty.BOOLEAN_TYPE;
import java.util.Collections;
import java.util.List;
import org.keycloak.Config.Scope;
@ -34,6 +37,7 @@ import org.keycloak.provider.ProviderConfigProperty;
public class OrganizationAuthenticatorFactory extends IdentityProviderAuthenticatorFactory implements EnvironmentDependentProviderFactory {
public static final String ID = "organization";
public static final String REQUIRES_USER_MEMBERSHIP = "requiresUserMembership";
@Override
public String getId() {
@ -61,7 +65,7 @@ public class OrganizationAuthenticatorFactory extends IdentityProviderAuthentica
}
@Override
public List<ProviderConfigProperty> getConfigProperties() { // org identity-first login
return List.of();
public List<ProviderConfigProperty> getConfigProperties() {
return Collections.singletonList(new ProviderConfigProperty(REQUIRES_USER_MEMBERSHIP, "Requires user membership", "Enforces that users authenticating in the scope of an organization are members. If not a member, the user won't be able to proceed authenticating to the realm", BOOLEAN_TYPE, null));
}
}
}

View File

@ -52,6 +52,7 @@ import org.keycloak.testsuite.broker.BrokerConfiguration;
import org.keycloak.testsuite.broker.KcOidcBrokerConfiguration;
import org.keycloak.testsuite.organization.broker.BrokerConfigurationWrapper;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.IdpConfirmLinkPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.SelectOrganizationPage;
@ -72,6 +73,9 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest {
@Page
protected LoginPage loginPage;
@Page
protected ErrorPage errorPage;
@Page
protected SelectOrganizationPage selectOrganizationPage;

View File

@ -27,11 +27,17 @@ import org.hamcrest.Matcher;
import org.hamcrest.Matchers;
import org.junit.Test;
import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel.RequiredAction;
import org.keycloak.models.utils.DefaultAuthenticationFlows;
import org.keycloak.organization.authentication.authenticators.browser.OrganizationAuthenticatorFactory;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.organization.admin.AbstractOrganizationTest;
import org.keycloak.testsuite.runonserver.RunOnServer;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.FlowUtil;
public class OrganizationAuthenticationTest extends AbstractOrganizationTest {
@ -134,4 +140,43 @@ public class OrganizationAuthenticationTest extends AbstractOrganizationTest {
oauth.maxAge(null);
}
}
@Test
public void testRequiresUserMembership() {
runOnServer(setAuthenticatorConfig(OrganizationAuthenticatorFactory.REQUIRES_USER_MEMBERSHIP, Boolean.TRUE.toString()));
try {
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
UserRepresentation member = addMember(organization);
organization.members().member(member.getId()).delete().close();
oauth.clientId("broker-app");
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(member.getEmail());
// user is not a member of any organization
assertThat(errorPage.getError(), Matchers.containsString("User is not a member of the organization " + organization.toRepresentation().getName()));
organization.members().addMember(member.getId()).close();
OrganizationRepresentation orgB = createOrganization("org-b");
oauth.clientId("broker-app");
oauth.scope("organization:org-b");
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(member.getEmail());
// user is not a member of the organization selected by the client
assertThat(errorPage.getError(), Matchers.containsString("User is not a member of the organization " + orgB.getName()));
errorPage.assertTryAnotherWayLinkAvailability(false);
} finally {
runOnServer(setAuthenticatorConfig(OrganizationAuthenticatorFactory.REQUIRES_USER_MEMBERSHIP, Boolean.FALSE.toString()));
}
}
private void runOnServer(RunOnServer function) {
testingClient.server(bc.consumerRealmName()).run(function);
}
public static RunOnServer setAuthenticatorConfig(String key, String value) {
return session -> {
RealmModel realm = session.getContext().getRealm();
FlowUtil.setAuthenticatorConfig(session, realm.getFlowByAlias(DefaultAuthenticationFlows.BROWSER_FLOW).getId(), OrganizationAuthenticatorFactory.ID, key, value);
};
}
}

View File

@ -12,6 +12,7 @@ import org.keycloak.services.resources.admin.AuthenticationManagementResource;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Random;
@ -19,6 +20,7 @@ import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.keycloak.models.utils.DefaultAuthenticationFlows.BROWSER_FLOW;
import static org.keycloak.models.utils.DefaultAuthenticationFlows.DIRECT_GRANT_FLOW;
@ -329,4 +331,46 @@ public class FlowUtil {
setFlow.accept(realm.getFlowByAlias(defaultFlowAlias));
}
}
/**
* <p>Sets the given {@code key} and {@code value} to an execution that maps to the given {@code authenticatorId}.
*
* <p>This method will try to find the given {@code authenticatorId} recursively by going through all the subflows, if there are any.
*
* @param session the session
* @param flowId the parent flow
* @param authenticatorId the authenticator id
* @param key the key
* @param value the value
*/
public static void setAuthenticatorConfig(KeycloakSession session, String flowId, String authenticatorId, String key, String value) {
RealmModel realm = session.getContext().getRealm();
for (AuthenticationExecutionModel execution : Optional.ofNullable(realm.getAuthenticationExecutionsStream(flowId)).orElse(Stream.empty()).toList()) {
if (execution.isAuthenticatorFlow()) {
setAuthenticatorConfig(session, execution.getFlowId(), authenticatorId, key, value);
} else if (authenticatorId.equals(execution.getAuthenticator())) {
AuthenticatorConfigModel configModel;
String configId = execution.getAuthenticatorConfig();
if (configId == null) {
configModel = new AuthenticatorConfigModel();
configModel.setAlias(authenticatorId + flowId);
configModel = realm.addAuthenticatorConfig(configModel);
execution.setAuthenticatorConfig(configModel.getId());
realm.updateAuthenticatorExecution(execution);
} else {
configModel = realm.getAuthenticatorConfigById(configId);
}
Map<String, String> config = new HashMap<>(configModel.getConfig());
configModel.setConfig(config);
config.put(key, value);
configModel.setConfig(config);
realm.updateAuthenticatorConfig(configModel);
}
}
}
}

View File

@ -530,3 +530,4 @@ organization.confirm-membership.title=You are about to join organization ${kc.or
organization.confirm-membership=By clicking on the link below, you will become a member of the {0} organization:
organization.member.register.title=Create an account to join the ${kc.org.name} organization
organization.select=Select an organization to proceed:
notMemberOfOrganization=User is not a member of the organization {0}