mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-10 15:32:05 -03:30
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:
parent
1f0983be69
commit
aca84824c0
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
Loading…
x
Reference in New Issue
Block a user