Extends the conditional user attribute authenticator to check the attributes of the joined groups (#20189)

Closes #20007
This commit is contained in:
Daniele Martinoli 2023-06-19 15:22:35 +02:00 committed by GitHub
parent 3daeee15f6
commit d9b271c22a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 223 additions and 2 deletions

View file

@ -24,7 +24,7 @@ This checks if the other executions in the flow are configured for the user.
The Execution requirements section includes an example of the OTP form.
`Condition - User Attribute`::
This checks if the user has set up the required attribute.
This checks if the user has set up the required attribute: optionally, the check can also evaluate the group attributes.
There is a possibility to negate output, which means the user should not have the attribute.
The xref:proc-configuring-user-attributes_{context}[User Attributes] section shows how to add a custom attribute.
You can provide these fields:
@ -38,6 +38,9 @@ Name of the attribute to check.
Expected attribute value:::
Expected value in the attribute.
Include group attributes:::
If On, the condition checks if any of the joined group has one attribute matching the configured name and value: this option can affect performance
Negate output:::
You can negate the output.
In other words, the attribute should not be present.

View file

@ -23,6 +23,7 @@ import org.keycloak.authentication.AuthenticationFlowException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import java.util.Map;
import java.util.Objects;
@ -37,6 +38,7 @@ public class ConditionalUserAttributeValue implements ConditionalAuthenticator {
Map<String, String> config = context.getAuthenticatorConfig().getConfig();
String attributeName = config.get(ConditionalUserAttributeValueFactory.CONF_ATTRIBUTE_NAME);
String attributeValue = config.get(ConditionalUserAttributeValueFactory.CONF_ATTRIBUTE_EXPECTED_VALUE);
boolean includeGroupAttributes = Boolean.parseBoolean(config.get(ConditionalUserAttributeValueFactory.CONF_INCLUDE_GROUP_ATTRIBUTES));
boolean negateOutput = Boolean.parseBoolean(config.get(ConditionalUserAttributeValueFactory.CONF_NOT));
UserModel user = context.getUser();
@ -45,6 +47,9 @@ public class ConditionalUserAttributeValue implements ConditionalAuthenticator {
}
boolean result = user.getAttributeStream(attributeName).anyMatch(attr -> Objects.equals(attr, attributeValue));
if (!result && includeGroupAttributes) {
result = KeycloakModelUtils.resolveAttribute(user, attributeName, true).stream().anyMatch(attr -> Objects.equals(attr, attributeValue));
}
return negateOutput != result;
}

View file

@ -31,6 +31,7 @@ public class ConditionalUserAttributeValueFactory implements ConditionalAuthenti
public static final String CONF_ATTRIBUTE_NAME = "attribute_name";
public static final String CONF_ATTRIBUTE_EXPECTED_VALUE = "attribute_expected_value";
public static final String CONF_INCLUDE_GROUP_ATTRIBUTES = "include_group_attributes";
public static final String CONF_NOT = "not";
private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
@ -96,13 +97,19 @@ public class ConditionalUserAttributeValueFactory implements ConditionalAuthenti
authNoteExpectedValue.setLabel("Expected attribute value");
authNoteExpectedValue.setHelpText("Expected value in the attribute");
ProviderConfigProperty includeGroupAttributes = new ProviderConfigProperty();
includeGroupAttributes.setType(ProviderConfigProperty.BOOLEAN_TYPE);
includeGroupAttributes.setName(CONF_INCLUDE_GROUP_ATTRIBUTES);
includeGroupAttributes.setLabel("Include group attributes");
includeGroupAttributes.setHelpText("If On, the condition checks if any of the joined groups has one attribute matching the configured name and value (this option can affect performance)");
ProviderConfigProperty negateOutput = new ProviderConfigProperty();
negateOutput.setType(ProviderConfigProperty.BOOLEAN_TYPE);
negateOutput.setName(CONF_NOT);
negateOutput.setLabel("Negate output");
negateOutput.setHelpText("Apply a not to the check result");
return Arrays.asList(authNoteName, authNoteExpectedValue, negateOutput);
return Arrays.asList(authNoteName, authNoteExpectedValue, includeGroupAttributes, negateOutput);
}
@Override

View file

@ -0,0 +1,206 @@
package org.keycloak.testsuite.forms;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.authentication.authenticators.access.AllowAccessAuthenticatorFactory;
import org.keycloak.authentication.authenticators.access.DenyAccessAuthenticatorFactory;
import org.keycloak.authentication.authenticators.browser.PasswordFormFactory;
import org.keycloak.authentication.authenticators.browser.UsernameFormFactory;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.authentication.authenticators.conditional.ConditionalUserAttributeValueFactory;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.LoginUsernameOnlyPage;
import org.keycloak.testsuite.pages.PasswordPage;
import org.keycloak.testsuite.util.AccountHelper;
import org.keycloak.testsuite.util.FlowUtil;
import org.keycloak.testsuite.util.GroupBuilder;
import org.keycloak.testsuite.util.UserBuilder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.keycloak.testsuite.forms.BrowserFlowTest.revertFlows;
/**
* @author <a href="mailto:dmartino@redhat.com">Daniele Martinoli</a>
*/
public class ConditionalUserAttributeAuthenticatorTest extends AbstractTestRealmKeycloakTest {
private final static String X_APPROVE_ATTR = "x-approved";
private final static String X_APPROVE_ATTR_VALUE = Boolean.toString(true);
private final static String APPROVED_GROUP = "approved";
private final static String SUBGROUP = "subgroup";
private final static String APPROVED_USER = "approved";
private final static String APPROVED_BY_GROUP_USER = "approved-by-group";
private final static String APPROVED_BY_SUBGROUP_USER = "approved-by-subgroup";
private final static String PASSWORD = "password";
@Page
protected LoginUsernameOnlyPage loginUsernameOnlyPage;
@Page
protected PasswordPage passwordPage;
@Page
protected ErrorPage errorPage;
@Rule
public AssertEvents events = new AssertEvents(this);
@Override
public void configureTestRealm(RealmRepresentation testRealm) {}
private void createUsers() {
GroupRepresentation subGroup = GroupBuilder.create().name(SUBGROUP).build();
testRealm().groups().add(subGroup);
GroupRepresentation approvedGroup = GroupBuilder.create().name(APPROVED_GROUP).subGroups(List.of(subGroup))
.attributes(Map.of(X_APPROVE_ATTR, List.of(X_APPROVE_ATTR_VALUE)))
.build();
testRealm().groups().add(approvedGroup);
UserRepresentation approved = UserBuilder.create().username(APPROVED_USER).password(PASSWORD)
.addAttribute(X_APPROVE_ATTR, X_APPROVE_ATTR_VALUE)
.build();
testRealm().users().create(approved);
UserRepresentation approvedByGroup = UserBuilder.create().username(APPROVED_BY_GROUP_USER).password(PASSWORD)
.addAttribute(X_APPROVE_ATTR, X_APPROVE_ATTR_VALUE)
.addGroups(APPROVED_GROUP)
.build();
testRealm().users().create(approvedByGroup);
UserRepresentation approvedBySubgroup = UserBuilder.create().username(APPROVED_BY_SUBGROUP_USER).password(PASSWORD)
.addAttribute(X_APPROVE_ATTR, X_APPROVE_ATTR_VALUE)
.addGroups(SUBGROUP)
.build();
testRealm().users().create(approvedBySubgroup);
}
@Test
public void testAllowedUsersWithApprovedAttribute(){
final String flowAlias = "browser - user attribute condition";
final String errorMessage = "You don't have necessary attribute.";
createUsers();
configureBrowserFlowWithConditionalUserAttribute(flowAlias, errorMessage);
for (String user : List.of(APPROVED_USER, APPROVED_BY_GROUP_USER, APPROVED_BY_SUBGROUP_USER)) {
loginUsernameOnlyPage.open();
loginUsernameOnlyPage.assertCurrent();
loginUsernameOnlyPage.login(user);
final String testUserId = testRealm().users().search(user).get(0).getId();
passwordPage.assertCurrent();
passwordPage.login(PASSWORD);
events.expectLogin()
.user(testUserId)
.detail(Details.USERNAME, user)
.removeDetail(Details.CONSENT)
.assertEvent();
AccountHelper.logout(testRealm(), user);
}
}
/**
* This test checks that if user does not have specific attribute, then the access is denied.
*/
@Test
public void testDenyUserWithoutApprovedAttribute() {
final String flowAlias = "browser - user attribute condition";
final String errorMessage = "You don't have necessary attribute.";
final String user = "test-user@localhost";
configureBrowserFlowWithConditionalUserAttribute(flowAlias, errorMessage);
try {
loginUsernameOnlyPage.open();
loginUsernameOnlyPage.assertCurrent();
loginUsernameOnlyPage.login(user);
errorPage.assertCurrent();
assertThat(errorPage.getError(), is(errorMessage));
events.expectLogin()
.user((String) null)
.session((String) null)
.error(Errors.ACCESS_DENIED)
.detail(Details.USERNAME, user)
.removeDetail(Details.CONSENT)
.assertEvent();
} finally {
revertFlows(testRealm(), flowAlias);
}
}
/**
* This flow contains:
* UsernameForm REQUIRED
* Subflow CONDITIONAL
* ** conditional user attribute
* ** Allow Access REQUIRED
* Subflow CONDITIONAL
* ** conditional user attribute-negated
* ** Deny Access REQUIRED
* Password REQUIRED
*
* @param newFlowAlias
* @param conditionProviderId
* @param conditionConfig
* @param denyConfig
*/
private void configureBrowserFlowWithConditionalUserAttribute(String newFlowAlias, String errorMessage) {
Map<String, String> hasApproveAttributeConfigMap = new HashMap<>();
hasApproveAttributeConfigMap.put(ConditionalUserAttributeValueFactory.CONF_ATTRIBUTE_NAME, X_APPROVE_ATTR);
hasApproveAttributeConfigMap.put(ConditionalUserAttributeValueFactory.CONF_ATTRIBUTE_EXPECTED_VALUE, X_APPROVE_ATTR_VALUE);
hasApproveAttributeConfigMap.put(ConditionalUserAttributeValueFactory.CONF_INCLUDE_GROUP_ATTRIBUTES, Boolean.toString(true));
hasApproveAttributeConfigMap.put(ConditionalUserAttributeValueFactory.CONF_NOT, Boolean.toString(false));
Map<String, String> missApproveAttributeConfigMap = new HashMap<>(hasApproveAttributeConfigMap);
missApproveAttributeConfigMap.put(ConditionalUserAttributeValueFactory.CONF_NOT, Boolean.toString(true));
Map<String, String> denyAccessConfigMap = new HashMap<>();
denyAccessConfigMap.put(DenyAccessAuthenticatorFactory.ERROR_MESSAGE, errorMessage);
testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias));
testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session)
.selectFlow(newFlowAlias)
.inForms(forms -> forms
.clear()
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, UsernameFormFactory.PROVIDER_ID)
.addSubFlowExecution(AuthenticationExecutionModel.Requirement.CONDITIONAL, subflow -> subflow
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED,
ConditionalUserAttributeValueFactory.PROVIDER_ID,
config -> config.setConfig(hasApproveAttributeConfigMap))
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED,
AllowAccessAuthenticatorFactory.PROVIDER_ID, config -> {})
)
.addSubFlowExecution(AuthenticationExecutionModel.Requirement.CONDITIONAL, subflow -> subflow
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED,
ConditionalUserAttributeValueFactory.PROVIDER_ID,
config -> config.setConfig(missApproveAttributeConfigMap))
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED,
DenyAccessAuthenticatorFactory.PROVIDER_ID, config -> config.setConfig(denyAccessConfigMap))
)
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, PasswordFormFactory.PROVIDER_ID)
)
.defineAsBrowserFlow() // Activate this new flow
);
}
}