From b84f6d306d5e0d5cfbaefe281ab44a3bba943eae Mon Sep 17 00:00:00 2001 From: Frederik Libert Date: Mon, 24 Apr 2017 11:29:55 +0200 Subject: [PATCH 01/18] KEYCLOAK-4781 Support for an AttributeStatement Mapper --- .../mappers/UserAttributeStatementMapper.java | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 services/src/main/java/org/keycloak/broker/saml/mappers/UserAttributeStatementMapper.java diff --git a/services/src/main/java/org/keycloak/broker/saml/mappers/UserAttributeStatementMapper.java b/services/src/main/java/org/keycloak/broker/saml/mappers/UserAttributeStatementMapper.java new file mode 100644 index 00000000000..4856fb64730 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/saml/mappers/UserAttributeStatementMapper.java @@ -0,0 +1,220 @@ +/* + * Copyright (c) eHealth + */ +package org.keycloak.broker.saml.mappers; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.keycloak.broker.provider.AbstractIdentityProviderMapper; +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.broker.saml.SAMLEndpoint; +import org.keycloak.broker.saml.SAMLIdentityProviderFactory; +import org.keycloak.common.util.CollectionUtil; +import org.keycloak.dom.saml.v2.assertion.AssertionType; +import org.keycloak.dom.saml.v2.assertion.AttributeStatementType.ASTChoiceType; +import org.keycloak.dom.saml.v2.assertion.AttributeType; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.provider.ProviderConfigProperty; + +/** + * @author Frederik Libert + * + */ +public class UserAttributeStatementMapper extends AbstractIdentityProviderMapper { + + private static final String USER_ATTR_LOCALE = "locale"; + + private static final String[] COMPATIBLE_PROVIDERS = {SAMLIdentityProviderFactory.PROVIDER_ID}; + + private static final List CONFIG_PROPERTIES = new ArrayList<>(); + + public static final String ATTRIBUTE_NAME_PATTERN = "attribute.name.pattern"; + + public static final String USER_ATTRIBUTE_FIRST_NAME = "user.attribute.firstName"; + + public static final String USER_ATTRIBUTE_LAST_NAME = "user.attribute.lastName"; + + public static final String USER_ATTRIBUTE_EMAIL = "user.attribute.email"; + + public static final String USER_ATTRIBUTE_LANGUAGE = "user.attribute.language"; + + private static final String USE_FRIENDLY_NAMES = "use.friendly.names"; + + static { + ProviderConfigProperty property; + property = new ProviderConfigProperty(); + property.setName(ATTRIBUTE_NAME_PATTERN); + property.setLabel("Attribute Name Pattern"); + property.setHelpText("Pattern of attribute names in assertion that must be mapped. Leave blank to map all attributes."); + property.setType(ProviderConfigProperty.STRING_TYPE); + CONFIG_PROPERTIES.add(property); + property = new ProviderConfigProperty(); + property.setName(USER_ATTRIBUTE_FIRST_NAME); + property.setLabel("User Attribute FirstName"); + property.setHelpText("Define which saml Attribute must be mapped to the User property firstName."); + property.setType(ProviderConfigProperty.STRING_TYPE); + CONFIG_PROPERTIES.add(property); + property = new ProviderConfigProperty(); + property.setName(USER_ATTRIBUTE_LAST_NAME); + property.setLabel("User Attribute LastName"); + property.setHelpText("Define which saml Attribute must be mapped to the User property lastName."); + property.setType(ProviderConfigProperty.STRING_TYPE); + CONFIG_PROPERTIES.add(property); + property = new ProviderConfigProperty(); + property.setName(USER_ATTRIBUTE_EMAIL); + property.setLabel("User Attribute Email"); + property.setHelpText("Define which saml Attribute must be mapped to the User property email."); + property.setType(ProviderConfigProperty.STRING_TYPE); + CONFIG_PROPERTIES.add(property); + property = new ProviderConfigProperty(); + property.setName(USER_ATTRIBUTE_LANGUAGE); + property.setLabel("User Attribute Language"); + property.setHelpText("Define which saml Attribute must be mapped to the User attribute locale."); + property.setType(ProviderConfigProperty.STRING_TYPE); + CONFIG_PROPERTIES.add(property); + property = new ProviderConfigProperty(); + property.setName(USE_FRIENDLY_NAMES); + property.setLabel("Use Attribute Friendly Name"); + property.setHelpText("Define which name to give to each mapped user attribute: name or friendlyName."); + property.setType(ProviderConfigProperty.BOOLEAN_TYPE); + CONFIG_PROPERTIES.add(property); + } + + public static final String PROVIDER_ID = "saml-user-attributestatement-idp-mapper"; + + @Override + public List getConfigProperties() { + return CONFIG_PROPERTIES; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String[] getCompatibleProviders() { + return COMPATIBLE_PROVIDERS.clone(); + } + + @Override + public String getDisplayCategory() { + return "AttributeStatement Importer"; + } + + @Override + public String getDisplayType() { + return "AttributeStatement Importer"; + } + + @Override + public void preprocessFederatedIdentity(KeycloakSession session, RealmModel realm, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { + String firstNameAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_FIRST_NAME); + String lastNameAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_LAST_NAME); + String emailAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_EMAIL); + String langAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_LANGUAGE); + Boolean useFriendlyNames = Boolean.valueOf(mapperModel.getConfig().get(USE_FRIENDLY_NAMES)); + List attributesInContext = findAttributesInContext(context, getAttributePattern(mapperModel)); + for (AttributeType a : attributesInContext) { + String attribute = useFriendlyNames ? a.getFriendlyName() : a.getName(); + List attributeValuesInContext = a.getAttributeValue().stream().filter(Objects::nonNull).map(Object::toString).collect(Collectors.toList()); + if (!attributeValuesInContext.isEmpty()) { + // set as attribute anyway + context.setUserAttribute(attribute, attributeValuesInContext); + // set as special field ? + if (Objects.equals(attribute, emailAttribute)) { + setIfNotEmpty(context::setEmail, attributeValuesInContext); + } else if (Objects.equals(attribute, firstNameAttribute)) { + setIfNotEmpty(context::setFirstName, attributeValuesInContext); + } else if (Objects.equals(attribute, lastNameAttribute)) { + setIfNotEmpty(context::setLastName, attributeValuesInContext); + } else if (Objects.equals(attribute, langAttribute)) { + context.setUserAttribute(USER_ATTR_LOCALE, attributeValuesInContext); + } + } + } + } + + @Override + public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { + String firstNameAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_FIRST_NAME); + String lastNameAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_LAST_NAME); + String emailAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_EMAIL); + String langAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_LANGUAGE); + Boolean useFriendlyNames = Boolean.valueOf(mapperModel.getConfig().get(USE_FRIENDLY_NAMES)); + List attributesInContext = findAttributesInContext(context, getAttributePattern(mapperModel)); + + Set assertedUserAttributes = new HashSet(); + for (AttributeType a : attributesInContext) { + String attribute = useFriendlyNames ? a.getFriendlyName() : a.getName(); + List attributeValuesInContext = a.getAttributeValue().stream().filter(Objects::nonNull).map(Object::toString).collect(Collectors.toList()); + List currentAttributeValues = user.getAttributes().get(attribute); + if (attributeValuesInContext == null) { + // attribute no longer sent by brokered idp, remove it + user.removeAttribute(attribute); + } else if (currentAttributeValues == null) { + // new attribute sent by brokered idp, add it + user.setAttribute(attribute, attributeValuesInContext); + } else if (!CollectionUtil.collectionEquals(attributeValuesInContext, currentAttributeValues)) { + // attribute sent by brokered idp has different values as before, update it + user.setAttribute(attribute, attributeValuesInContext); + } + if (Objects.equals(attribute, emailAttribute)) { + setIfNotEmpty(context::setEmail, attributeValuesInContext); + } else if (Objects.equals(attribute, firstNameAttribute)) { + setIfNotEmpty(context::setFirstName, attributeValuesInContext); + } else if (Objects.equals(attribute, lastNameAttribute)) { + setIfNotEmpty(context::setLastName, attributeValuesInContext); + } else if (Objects.equals(attribute, langAttribute)) { + if(attributeValuesInContext == null) { + user.removeAttribute(USER_ATTR_LOCALE); + } else { + user.setAttribute(USER_ATTR_LOCALE, attributeValuesInContext); + } + assertedUserAttributes.add(USER_ATTR_LOCALE); + } + // Mark attribute as handled + assertedUserAttributes.add(attribute); + } + // Remove user attributes that were not referenced in assertion. + user.getAttributes().keySet().stream().filter(a -> !assertedUserAttributes.contains(a)).forEach(a -> user.removeAttribute(a)); + } + + @Override + public String getHelpText() { + return "Import all saml attributes found in attributestatements in assertion into user properties or attributes."; + } + + private Optional getAttributePattern(IdentityProviderMapperModel mapperModel) { + String attributePatternConfig = mapperModel.getConfig().get(ATTRIBUTE_NAME_PATTERN); + return Optional.ofNullable(attributePatternConfig != null ? Pattern.compile(attributePatternConfig) : null); + } + + private List findAttributesInContext(BrokeredIdentityContext context, Optional attributePattern) { + AssertionType assertion = (AssertionType) context.getContextData().get(SAMLEndpoint.SAML_ASSERTION); + + return assertion.getAttributeStatements().stream()// + .flatMap(statement -> statement.getAttributes().stream())// + .filter(item -> !attributePattern.isPresent() || attributePattern.get().matcher(item.getAttribute().getName()).matches())// + .map(ASTChoiceType::getAttribute)// + .collect(Collectors.toList()); + } + + private void setIfNotEmpty(Consumer consumer, List values) { + if (values != null && !values.isEmpty()) { + consumer.accept(values.get(0)); + } + } + +} From acd78ee407887dc6d0f869f1d905c1638bc994c6 Mon Sep 17 00:00:00 2001 From: Mohammad Rezai Date: Tue, 23 May 2017 12:30:48 +0430 Subject: [PATCH 02/18] KEYCLOAK-4956: Fix incorrect PKCE S256 code challenge generation --- .../protocol/oidc/endpoints/TokenEndpoint.java | 10 +++------- .../oauth/OAuthProofKeyForCodeExchangeTest.java | 12 ++++-------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java index 83570efd750..f642da1a0d8 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java @@ -543,13 +543,9 @@ public class TokenEndpoint { // https://tools.ietf.org/html/rfc7636#section-4.6 private String generateS256CodeChallenge(String codeVerifier) throws Exception { MessageDigest md = MessageDigest.getInstance("SHA-256"); - md.update(codeVerifier.getBytes()); - StringBuilder sb = new StringBuilder(); - for (byte b : md.digest()) { - String hex = String.format("%02x", b); - sb.append(hex); - } - String codeVerifierEncoded = Base64Url.encode(sb.toString().getBytes()); + md.update(codeVerifier.getBytes("ISO_8859_1")); + byte[] digestBytes = md.digest(); + String codeVerifierEncoded = Base64Url.encode(digestBytes); return codeVerifierEncoded; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuthProofKeyForCodeExchangeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuthProofKeyForCodeExchangeTest.java index a72aa3a8d0c..20757c170d0 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuthProofKeyForCodeExchangeTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuthProofKeyForCodeExchangeTest.java @@ -444,14 +444,10 @@ public class OAuthProofKeyForCodeExchangeTest extends AbstractKeycloakTest { private String generateS256CodeChallenge(String codeVerifier) throws Exception { MessageDigest md = MessageDigest.getInstance("SHA-256"); - md.update(codeVerifier.getBytes()); - StringBuilder sb = new StringBuilder(); - for (byte b : md.digest()) { - String hex = String.format("%02x", b); - sb.append(hex); - } - String codeChallenge = Base64Url.encode(sb.toString().getBytes()); - return codeChallenge; + md.update(codeVerifier.getBytes("ISO_8859_1")); + byte[] digestBytes = md.digest(); + String codeChallenge = Base64Url.encode(digestBytes); + return codeChallenge; } private void expectSuccessfulResponseFromTokenEndpoint(String codeId, String sessionId, String code) throws Exception { From 7d017b4edff33ad017256a6f6c0eb7b65c8d5855 Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Thu, 1 Jun 2017 12:05:02 +0200 Subject: [PATCH 03/18] Easy Spring security annotation --- .../springsecurity/KeycloakConfiguration.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/KeycloakConfiguration.java diff --git a/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/KeycloakConfiguration.java b/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/KeycloakConfiguration.java new file mode 100644 index 00000000000..9aabedbefc7 --- /dev/null +++ b/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/KeycloakConfiguration.java @@ -0,0 +1,24 @@ +package org.keycloak.adapters.springsecurity; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Add this annotation to a class that extends {@code KeycloakWebSecurityConfigurerAdapter} to provide + * a keycloak based Spring security configuration. + * + */ +@Retention(value = RUNTIME) +@Target(value = { TYPE }) +@Configuration +@ComponentScan(basePackageClasses = KeycloakSecurityComponents.class) +@EnableWebSecurity +public @interface KeycloakConfiguration { +} From 169280b6a1fc57a1eae878e1673d4676419b353f Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Tue, 13 Jun 2017 19:05:44 -0300 Subject: [PATCH 04/18] [KEYCLOAK-3168] - Group-Based Access Control --- .../provider/group/GroupPolicyProvider.java | 80 +++++ .../group/GroupPolicyProviderFactory.java | 193 +++++++++++ ...tion.policy.provider.PolicyProviderFactory | 3 +- .../GroupPolicyRepresentation.java | 141 ++++++++ .../resource/GroupPoliciesResource.java | 51 +++ .../client/resource/GroupPolicyResource.java | 70 ++++ .../client/resource/PoliciesResource.java | 3 + .../authorization/attribute/Attributes.java | 4 + .../GroupPolicyManagementTest.java | 210 ++++++++++++ .../testsuite/authz/GroupNamePolicyTest.java | 302 ++++++++++++++++++ .../testsuite/authz/GroupPathPolicyTest.java | 284 ++++++++++++++++ .../testsuite/util/ClientBuilder.java | 12 +- .../authorization/policy/GroupPolicy.java | 42 +++ .../authorization/policy/GroupPolicyForm.java | 153 +++++++++ .../authorization/policy/Policies.java | 14 + .../GroupPolicyManagementTest.java | 159 +++++++++ .../messages/admin-messages_en.properties | 7 + .../admin/resources/js/authz/authz-app.js | 24 +- .../resources/js/authz/authz-controller.js | 113 +++++++ .../resource-server-policy-group-detail.html | 124 +++++++ 20 files changed, 1986 insertions(+), 3 deletions(-) create mode 100644 authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/group/GroupPolicyProvider.java create mode 100644 authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/group/GroupPolicyProviderFactory.java create mode 100644 core/src/main/java/org/keycloak/representations/idm/authorization/GroupPolicyRepresentation.java create mode 100644 integration/admin-client/src/main/java/org/keycloak/admin/client/resource/GroupPoliciesResource.java create mode 100644 integration/admin-client/src/main/java/org/keycloak/admin/client/resource/GroupPolicyResource.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/GroupPolicyManagementTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/GroupNamePolicyTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/GroupPathPolicyTest.java create mode 100644 testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/clients/authorization/policy/GroupPolicy.java create mode 100644 testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/clients/authorization/policy/GroupPolicyForm.java create mode 100644 testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/authorization/GroupPolicyManagementTest.java create mode 100644 themes/src/main/resources/theme/base/admin/resources/partials/authz/policy/provider/resource-server-policy-group-detail.html diff --git a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/group/GroupPolicyProvider.java b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/group/GroupPolicyProvider.java new file mode 100644 index 00000000000..2ae1bdc4b39 --- /dev/null +++ b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/group/GroupPolicyProvider.java @@ -0,0 +1,80 @@ +/* + * Copyright 2017 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.authorization.policy.provider.group; + +import static org.keycloak.models.utils.ModelToRepresentation.buildGroupPath; + +import java.util.function.Function; + +import org.keycloak.authorization.attribute.Attributes; +import org.keycloak.authorization.model.Policy; +import org.keycloak.authorization.policy.evaluation.Evaluation; +import org.keycloak.authorization.policy.provider.PolicyProvider; +import org.keycloak.models.GroupModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.ModelToRepresentation; +import org.keycloak.representations.idm.authorization.GroupPolicyRepresentation; + +/** + * @author Pedro Igor + */ +public class GroupPolicyProvider implements PolicyProvider { + + private final Function representationFunction; + + public GroupPolicyProvider(Function representationFunction) { + this.representationFunction = representationFunction; + } + + @Override + public void evaluate(Evaluation evaluation) { + GroupPolicyRepresentation policy = representationFunction.apply(evaluation.getPolicy()); + RealmModel realm = evaluation.getAuthorizationProvider().getRealm(); + Attributes.Entry groupsClaim = evaluation.getContext().getIdentity().getAttributes().getValue(policy.getGroupsClaim()); + + if (groupsClaim == null || groupsClaim.isEmpty()) { + return; + } + + for (GroupPolicyRepresentation.GroupDefinition definition : policy.getGroups()) { + GroupModel allowedGroup = realm.getGroupById(definition.getId()); + + for (int i = 0; i < groupsClaim.size(); i++) { + String group = groupsClaim.asString(i); + + if (group.indexOf('/') != -1) { + String allowedGroupPath = buildGroupPath(allowedGroup); + if (group.equals(allowedGroupPath) || (definition.isExtendChildren() && group.startsWith(allowedGroupPath))) { + evaluation.grant(); + return; + } + } + + // in case the group from the claim does not represent a path, we just check an exact name match + if (group.equals(allowedGroup.getName())) { + evaluation.grant(); + return; + } + } + } + } + + @Override + public void close() { + + } +} \ No newline at end of file diff --git a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/group/GroupPolicyProviderFactory.java b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/group/GroupPolicyProviderFactory.java new file mode 100644 index 00000000000..d101c798d33 --- /dev/null +++ b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/group/GroupPolicyProviderFactory.java @@ -0,0 +1,193 @@ +/* + * Copyright 2017 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.authorization.policy.provider.group; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.keycloak.Config; +import org.keycloak.authorization.AuthorizationProvider; +import org.keycloak.authorization.model.Policy; +import org.keycloak.authorization.policy.provider.PolicyProvider; +import org.keycloak.authorization.policy.provider.PolicyProviderFactory; +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.representations.idm.authorization.GroupPolicyRepresentation; +import org.keycloak.representations.idm.authorization.PolicyRepresentation; +import org.keycloak.representations.idm.authorization.RolePolicyRepresentation; +import org.keycloak.util.JsonSerialization; + +/** + * @author Pedro Igor + */ +public class GroupPolicyProviderFactory implements PolicyProviderFactory { + + private GroupPolicyProvider provider = new GroupPolicyProvider(policy -> toRepresentation(policy, new GroupPolicyRepresentation())); + + @Override + public String getId() { + return "group"; + } + + @Override + public String getName() { + return "Group"; + } + + @Override + public String getGroup() { + return "Identity Based"; + } + + @Override + public PolicyProvider create(AuthorizationProvider authorization) { + return provider; + } + + @Override + public PolicyProvider create(KeycloakSession session) { + return provider; + } + + @Override + public GroupPolicyRepresentation toRepresentation(Policy policy, GroupPolicyRepresentation representation) { + representation.setGroupsClaim(policy.getConfig().get("groupsClaim")); + try { + representation.setGroups(new HashSet<>(Arrays.asList(JsonSerialization.readValue(policy.getConfig().get("groups"), GroupPolicyRepresentation.GroupDefinition[].class)))); + } catch (IOException cause) { + throw new RuntimeException("Failed to deserialize groups", cause); + } + return representation; + } + + @Override + public Class getRepresentationType() { + return GroupPolicyRepresentation.class; + } + + @Override + public void onCreate(Policy policy, GroupPolicyRepresentation representation, AuthorizationProvider authorization) { + updatePolicy(policy, representation.getGroupsClaim(), representation.getGroups(), authorization); + } + + @Override + public void onUpdate(Policy policy, GroupPolicyRepresentation representation, AuthorizationProvider authorization) { + updatePolicy(policy, representation.getGroupsClaim(), representation.getGroups(), authorization); + } + + @Override + public void onImport(Policy policy, PolicyRepresentation representation, AuthorizationProvider authorization) { + try { + updatePolicy(policy, representation.getConfig().get("groupsClaim"), JsonSerialization.readValue(representation.getConfig().get("groups"), Set.class), authorization); + } catch (IOException cause) { + throw new RuntimeException("Failed to deserialize groups", cause); + } + } + + @Override + public void onExport(Policy policy, PolicyRepresentation representation, AuthorizationProvider authorizationProvider) { + + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + factory.register(event -> { + }); + } + + @Override + public void close() { + + } + + private void updatePolicy(Policy policy, String groupsClaim, Set groups, AuthorizationProvider authorization) { + if (groupsClaim == null) { + throw new RuntimeException("Group claims property not provided"); + } + + if (groups == null || groups.isEmpty()) { + throw new RuntimeException("You must provide at least one group"); + } + + Map config = new HashMap<>(policy.getConfig()); + + config.put("groupsClaim", groupsClaim); + + List topLevelGroups = authorization.getRealm().getTopLevelGroups(); + + for (GroupPolicyRepresentation.GroupDefinition definition : groups) { + GroupModel group = null; + + if (definition.getId() != null) { + group = authorization.getRealm().getGroupById(definition.getId()); + } + + if (group == null) { + String path = definition.getPath(); + String canonicalPath = path.startsWith("/") ? path.substring(1, path.length()) : path; + + if (canonicalPath != null) { + String[] parts = canonicalPath.split("/"); + GroupModel parent = null; + + for (String part : parts) { + if ("".trim().equals(part)) { + continue; + } + if (parent == null) { + parent = topLevelGroups.stream().filter(groupModel -> groupModel.getName().equals(part)).findFirst().orElseThrow(() -> new RuntimeException("Top level group with name [" + part + "] not found")); + } else { + group = parent.getSubGroups().stream().filter(groupModel -> groupModel.getName().equals(part)).findFirst().orElseThrow(() -> new RuntimeException("Group with name [" + part + "] not found")); + parent = group; + } + } + + if (parts.length == 1) { + group = parent; + } + } + } + + if (group == null) { + throw new RuntimeException("Group with id [" + definition.getId() + "] not found"); + } + + definition.setId(group.getId()); + definition.setPath(null); + } + + try { + config.put("groups", JsonSerialization.writeValueAsString(groups)); + } catch (IOException cause) { + throw new RuntimeException("Failed to serialize groups", cause); + } + + policy.setConfig(config); + } +} diff --git a/authz/policy/common/src/main/resources/META-INF/services/org.keycloak.authorization.policy.provider.PolicyProviderFactory b/authz/policy/common/src/main/resources/META-INF/services/org.keycloak.authorization.policy.provider.PolicyProviderFactory index e4588f87a6c..e6fa1cc9e48 100644 --- a/authz/policy/common/src/main/resources/META-INF/services/org.keycloak.authorization.policy.provider.PolicyProviderFactory +++ b/authz/policy/common/src/main/resources/META-INF/services/org.keycloak.authorization.policy.provider.PolicyProviderFactory @@ -41,4 +41,5 @@ org.keycloak.authorization.policy.provider.role.RolePolicyProviderFactory org.keycloak.authorization.policy.provider.scope.ScopePolicyProviderFactory org.keycloak.authorization.policy.provider.time.TimePolicyProviderFactory org.keycloak.authorization.policy.provider.user.UserPolicyProviderFactory -org.keycloak.authorization.policy.provider.client.ClientPolicyProviderFactory \ No newline at end of file +org.keycloak.authorization.policy.provider.client.ClientPolicyProviderFactory +org.keycloak.authorization.policy.provider.group.GroupPolicyProviderFactory \ No newline at end of file diff --git a/core/src/main/java/org/keycloak/representations/idm/authorization/GroupPolicyRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/authorization/GroupPolicyRepresentation.java new file mode 100644 index 00000000000..c063f8f87b6 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/idm/authorization/GroupPolicyRepresentation.java @@ -0,0 +1,141 @@ +/* + * Copyright 2017 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.representations.idm.authorization; + +import java.util.HashSet; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * @author Pedro Igor + */ +public class GroupPolicyRepresentation extends AbstractPolicyRepresentation { + + private String groupsClaim; + private Set groups; + + @Override + public String getType() { + return "group"; + } + + public String getGroupsClaim() { + return groupsClaim; + } + + public void setGroupsClaim(String groupsClaim) { + this.groupsClaim = groupsClaim; + } + + public Set getGroups() { + return groups; + } + + public void setGroups(Set groups) { + this.groups = groups; + } + + public void addGroup(String... ids) { + for (String id : ids) { + addGroup(id, false); + } + } + + public void addGroup(String id, boolean extendChildren) { + if (groups == null) { + groups = new HashSet<>(); + } + groups.add(new GroupDefinition(id, extendChildren)); + } + + public void addGroupPath(String... paths) { + for (String path : paths) { + addGroupPath(path, false); + } + } + + public void addGroupPath(String path, boolean extendChildren) { + if (groups == null) { + groups = new HashSet<>(); + } + groups.add(new GroupDefinition(null, path, extendChildren)); + } + + public void removeGroup(String... ids) { + if (groups != null) { + for (final String id : ids) { + if (!groups.remove(id)) { + for (GroupDefinition group : new HashSet<>(groups)) { + if (group.getPath().startsWith(id)) { + groups.remove(group); + } + } + } + } + } + } + + public static class GroupDefinition { + + private String id; + private String path; + private boolean extendChildren; + + public GroupDefinition() { + this(null); + } + + public GroupDefinition(String id) { + this(id, false); + } + + public GroupDefinition(String id, boolean extendChildren) { + this(id, null, extendChildren); + } + + public GroupDefinition(String id, String path, boolean extendChildren) { + this.id = id; + this.path = path; + this.extendChildren = extendChildren; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public boolean isExtendChildren() { + return extendChildren; + } + + public void setExtendChildren(boolean extendChildren) { + this.extendChildren = extendChildren; + } + } +} diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/GroupPoliciesResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/GroupPoliciesResource.java new file mode 100644 index 00000000000..1cc51b00400 --- /dev/null +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/GroupPoliciesResource.java @@ -0,0 +1,51 @@ +/* + * Copyright 2017 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.admin.client.resource; + +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import org.jboss.resteasy.annotations.cache.NoCache; +import org.keycloak.representations.idm.authorization.GroupPolicyRepresentation; +import org.keycloak.representations.idm.authorization.RolePolicyRepresentation; + +/** + * @author Pedro Igor + */ +public interface GroupPoliciesResource { + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + Response create(GroupPolicyRepresentation representation); + + @Path("{id}") + GroupPolicyResource findById(@PathParam("id") String id); + + @Path("/search") + @GET + @Produces(MediaType.APPLICATION_JSON) + @NoCache + GroupPolicyRepresentation findByName(@QueryParam("name") String name); +} diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/GroupPolicyResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/GroupPolicyResource.java new file mode 100644 index 00000000000..6171868b65d --- /dev/null +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/GroupPolicyResource.java @@ -0,0 +1,70 @@ +/* + * Copyright 2017 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.admin.client.resource; + +import java.util.List; + +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +import org.jboss.resteasy.annotations.cache.NoCache; +import org.keycloak.representations.idm.authorization.GroupPolicyRepresentation; +import org.keycloak.representations.idm.authorization.PolicyRepresentation; +import org.keycloak.representations.idm.authorization.ResourceRepresentation; +import org.keycloak.representations.idm.authorization.RolePolicyRepresentation; + +/** + * @author Pedro Igor + */ +public interface GroupPolicyResource { + + @GET + @Produces(MediaType.APPLICATION_JSON) + @NoCache + GroupPolicyRepresentation toRepresentation(); + + @PUT + @Consumes(MediaType.APPLICATION_JSON) + void update(GroupPolicyRepresentation representation); + + @DELETE + void remove(); + + @Path("/associatedPolicies") + @GET + @Produces(MediaType.APPLICATION_JSON) + @NoCache + List associatedPolicies(); + + @Path("/dependentPolicies") + @GET + @Produces(MediaType.APPLICATION_JSON) + @NoCache + List dependentPolicies(); + + @Path("/resources") + @GET + @Produces("application/json") + @NoCache + List resources(); + +} diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/PoliciesResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/PoliciesResource.java index a0af5d4c62a..9ced12c2905 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/PoliciesResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/PoliciesResource.java @@ -89,4 +89,7 @@ public interface PoliciesResource { @Path("client") ClientPoliciesResource client(); + + @Path("group") + GroupPoliciesResource group(); } diff --git a/server-spi-private/src/main/java/org/keycloak/authorization/attribute/Attributes.java b/server-spi-private/src/main/java/org/keycloak/authorization/attribute/Attributes.java index c83d9f81464..0719bab2593 100644 --- a/server-spi-private/src/main/java/org/keycloak/authorization/attribute/Attributes.java +++ b/server-spi-private/src/main/java/org/keycloak/authorization/attribute/Attributes.java @@ -107,6 +107,10 @@ public interface Attributes { return values.length; } + public boolean isEmpty() { + return values.length == 0; + } + public String asString(int idx) { if (idx >= values.length) { throw new IllegalArgumentException("Invalid index [" + idx + "]. Values are [" + values + "]."); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/GroupPolicyManagementTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/GroupPolicyManagementTest.java new file mode 100644 index 00000000000..57d86a79ee0 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/GroupPolicyManagementTest.java @@ -0,0 +1,210 @@ +/* + * Copyright 2017 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.admin.client.authorization; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.Arrays; +import java.util.Collections; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import javax.ws.rs.NotFoundException; +import javax.ws.rs.core.Response; + +import org.junit.Test; +import org.keycloak.admin.client.resource.AuthorizationResource; +import org.keycloak.admin.client.resource.GroupPoliciesResource; +import org.keycloak.admin.client.resource.GroupPolicyResource; +import org.keycloak.admin.client.resource.PolicyResource; +import org.keycloak.admin.client.resource.RolePoliciesResource; +import org.keycloak.admin.client.resource.RolePolicyResource; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.RoleRepresentation; +import org.keycloak.representations.idm.authorization.DecisionStrategy; +import org.keycloak.representations.idm.authorization.GroupPolicyRepresentation; +import org.keycloak.representations.idm.authorization.Logic; +import org.keycloak.representations.idm.authorization.PolicyRepresentation; +import org.keycloak.representations.idm.authorization.RolePolicyRepresentation; +import org.keycloak.testsuite.util.GroupBuilder; +import org.keycloak.testsuite.util.RealmBuilder; + +/** + * @author Pedro Igor + */ +public class GroupPolicyManagementTest extends AbstractPolicyManagementTest { + + @Override + protected RealmBuilder createTestRealm() { + return super.createTestRealm().group(GroupBuilder.create().name("Group A") + .subGroups(Arrays.asList("Group B", "Group D").stream().map(name -> { + if ("Group B".equals(name)) { + return GroupBuilder.create().name(name).subGroups(Arrays.asList("Group C", "Group E").stream().map(new Function() { + @Override + public GroupRepresentation apply(String name) { + return GroupBuilder.create().name(name).build(); + } + }).collect(Collectors.toList())).build(); + } + return GroupBuilder.create().name(name).build(); + }).collect(Collectors.toList())) + .build()).group(GroupBuilder.create().name("Group E").build()); + } + + @Test + public void testCreate() { + AuthorizationResource authorization = getClient().authorization(); + GroupPolicyRepresentation representation = new GroupPolicyRepresentation(); + + representation.setName("Group Policy"); + representation.setDescription("description"); + representation.setDecisionStrategy(DecisionStrategy.CONSENSUS); + representation.setLogic(Logic.NEGATIVE); + representation.setGroupsClaim("groups"); + representation.addGroupPath("/Group A/Group B/Group C", true); + representation.addGroupPath("Group E"); + + assertCreated(authorization, representation); + } + + @Test + public void testUpdate() { + AuthorizationResource authorization = getClient().authorization(); + GroupPolicyRepresentation representation = new GroupPolicyRepresentation(); + + representation.setName("Update Group Policy"); + representation.setDescription("description"); + representation.setDecisionStrategy(DecisionStrategy.CONSENSUS); + representation.setLogic(Logic.NEGATIVE); + representation.setGroupsClaim("groups"); + representation.addGroupPath("/Group A/Group B/Group C", true); + representation.addGroupPath("Group E"); + + assertCreated(authorization, representation); + + representation.setName("changed"); + representation.setDescription("changed"); + representation.setDecisionStrategy(DecisionStrategy.AFFIRMATIVE); + representation.setLogic(Logic.POSITIVE); + representation.removeGroup("/Group A/Group B"); + + GroupPoliciesResource policies = authorization.policies().group(); + GroupPolicyResource permission = policies.findById(representation.getId()); + + permission.update(representation); + assertRepresentation(representation, permission); + + for (GroupPolicyRepresentation.GroupDefinition roleDefinition : representation.getGroups()) { + if (roleDefinition.getPath().equals("Group E")) { + roleDefinition.setExtendChildren(true); + } + } + + permission.update(representation); + assertRepresentation(representation, permission); + + representation.getGroups().clear(); + representation.addGroupPath("/Group A/Group B"); + + permission.update(representation); + assertRepresentation(representation, permission); + } + + @Test + public void testDelete() { + AuthorizationResource authorization = getClient().authorization(); + GroupPolicyRepresentation representation = new GroupPolicyRepresentation(); + + representation.setName("Delete Group Policy"); + representation.setGroupsClaim("groups"); + representation.addGroupPath("/Group A/Group B/Group C", true); + representation.addGroupPath("Group E"); + + GroupPoliciesResource policies = authorization.policies().group(); + Response response = policies.create(representation); + GroupPolicyRepresentation created = response.readEntity(GroupPolicyRepresentation.class); + + policies.findById(created.getId()).remove(); + + GroupPolicyResource removed = policies.findById(created.getId()); + + try { + removed.toRepresentation(); + fail("Permission not removed"); + } catch (NotFoundException ignore) { + + } + } + + @Test + public void testGenericConfig() { + AuthorizationResource authorization = getClient().authorization(); + GroupPolicyRepresentation representation = new GroupPolicyRepresentation(); + + representation.setName("Test Generic Config Permission"); + representation.setGroupsClaim("groups"); + representation.addGroupPath("/Group A"); + + GroupPoliciesResource policies = authorization.policies().group(); + Response response = policies.create(representation); + GroupPolicyRepresentation created = response.readEntity(GroupPolicyRepresentation.class); + + PolicyResource policy = authorization.policies().policy(created.getId()); + PolicyRepresentation genericConfig = policy.toRepresentation(); + + assertNotNull(genericConfig.getConfig()); + assertNotNull(genericConfig.getConfig().get("groups")); + + GroupRepresentation group = getRealm().groups().groups().stream().filter(groupRepresentation -> groupRepresentation.getName().equals("Group A")).findFirst().get(); + + assertTrue(genericConfig.getConfig().get("groups").contains(group.getId())); + } + + private void assertCreated(AuthorizationResource authorization, GroupPolicyRepresentation representation) { + GroupPoliciesResource policies = authorization.policies().group(); + Response response = policies.create(representation); + GroupPolicyRepresentation created = response.readEntity(GroupPolicyRepresentation.class); + GroupPolicyResource policy = policies.findById(created.getId()); + assertRepresentation(representation, policy); + } + + private void assertRepresentation(GroupPolicyRepresentation representation, GroupPolicyResource permission) { + GroupPolicyRepresentation actual = permission.toRepresentation(); + assertRepresentation(representation, actual, () -> permission.resources(), () -> Collections.emptyList(), () -> permission.associatedPolicies()); + assertEquals(representation.getGroups().size(), actual.getGroups().size()); + assertEquals(0, actual.getGroups().stream().filter(actualDefinition -> !representation.getGroups().stream() + .filter(groupDefinition -> getGroupPath(actualDefinition.getId()).equals(getCanonicalGroupPath(groupDefinition.getPath())) && actualDefinition.isExtendChildren() == groupDefinition.isExtendChildren()) + .findFirst().isPresent()) + .count()); + } + + private String getGroupPath(String id) { + return getRealm().groups().group(id).toRepresentation().getPath(); + } + + private String getCanonicalGroupPath(String path) { + if (path.charAt(0) == '/') { + return path; + } + return "/" + path; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/GroupNamePolicyTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/GroupNamePolicyTest.java new file mode 100644 index 00000000000..cc4b9118f96 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/GroupNamePolicyTest.java @@ -0,0 +1,302 @@ +/* + * Copyright 2017 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.authz; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.junit.Before; +import org.junit.Test; +import org.keycloak.admin.client.resource.AuthorizationResource; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.admin.client.resource.ClientsResource; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.authorization.client.AuthorizationDeniedException; +import org.keycloak.authorization.client.AuthzClient; +import org.keycloak.authorization.client.Configuration; +import org.keycloak.authorization.client.representation.AuthorizationRequest; +import org.keycloak.authorization.client.representation.AuthorizationResponse; +import org.keycloak.authorization.client.representation.PermissionRequest; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.mappers.GroupMembershipMapper; +import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.ProtocolMapperRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.idm.authorization.GroupPolicyRepresentation; +import org.keycloak.representations.idm.authorization.ResourcePermissionRepresentation; +import org.keycloak.representations.idm.authorization.ResourceRepresentation; +import org.keycloak.testsuite.AbstractKeycloakTest; +import org.keycloak.testsuite.util.AdminClientUtil; +import org.keycloak.testsuite.util.ClientBuilder; +import org.keycloak.testsuite.util.GroupBuilder; +import org.keycloak.testsuite.util.RealmBuilder; +import org.keycloak.testsuite.util.RoleBuilder; +import org.keycloak.testsuite.util.RolesBuilder; +import org.keycloak.testsuite.util.UserBuilder; +import org.keycloak.util.JsonSerialization; + +/** + * @author Pedro Igor + */ +public class GroupNamePolicyTest extends AbstractKeycloakTest { + + @Override + public void addTestRealms(List testRealms) { + ProtocolMapperRepresentation groupProtocolMapper = new ProtocolMapperRepresentation(); + + groupProtocolMapper.setName("groups"); + groupProtocolMapper.setProtocolMapper(GroupMembershipMapper.PROVIDER_ID); + groupProtocolMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + groupProtocolMapper.setConsentRequired(false); + Map config = new HashMap<>(); + config.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, "groups"); + config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true"); + config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); + groupProtocolMapper.setConfig(config); + + testRealms.add(RealmBuilder.create().name("authz-test") + .roles(RolesBuilder.create() + .realmRole(RoleBuilder.create().name("uma_authorization").build()) + ) + .group(GroupBuilder.create().name("Group A") + .subGroups(Arrays.asList("Group B", "Group D").stream().map(name -> { + if ("Group B".equals(name)) { + return GroupBuilder.create().name(name).subGroups(Arrays.asList("Group C", "Group E").stream().map(new Function() { + @Override + public GroupRepresentation apply(String name) { + return GroupBuilder.create().name(name).build(); + } + }).collect(Collectors.toList())).build(); + } + return GroupBuilder.create().name(name).build(); + }).collect(Collectors.toList())).build()) + .group(GroupBuilder.create().name("Group E").build()) + .user(UserBuilder.create().username("marta").password("password").addRoles("uma_authorization").addGroups("Group A")) + .user(UserBuilder.create().username("alice").password("password").addRoles("uma_authorization")) + .user(UserBuilder.create().username("kolo").password("password").addRoles("uma_authorization")) + .client(ClientBuilder.create().clientId("resource-server-test") + .secret("secret") + .authorizationServicesEnabled(true) + .redirectUris("http://localhost/resource-server-test") + .defaultRoles("uma_protection") + .directAccessGrants() + .protocolMapper(groupProtocolMapper)) + .build()); + } + + @Before + public void configureAuthorization() throws Exception { + createResource("Resource A"); + createResource("Resource B"); + createResource("Resource C"); + + createGroupPolicy("Only Group A Policy", "/Group A", true); + createGroupPolicy("Only Group B Policy", "/Group A/Group B", false); + createGroupPolicy("Only Group C Policy", "/Group A/Group B/Group C", false); + + createResourcePermission("Resource A Permission", "Resource A", "Only Group A Policy"); + createResourcePermission("Resource B Permission", "Resource B", "Only Group B Policy"); + createResourcePermission("Resource C Permission", "Resource C", "Only Group C Policy"); + + RealmResource realm = getRealm(); + GroupRepresentation group = getGroup("/Group A/Group B/Group C"); + UserRepresentation user = realm.users().search("kolo").get(0); + + realm.users().get(user.getId()).joinGroup(group.getId()); + + group = getGroup("/Group A/Group B"); + user = realm.users().search("alice").get(0); + + realm.users().get(user.getId()).joinGroup(group.getId()); + } + + @Test + public void testExactNameMatch() { + AuthzClient authzClient = getAuthzClient(); + PermissionRequest request = new PermissionRequest(); + + request.setResourceSetName("Resource A"); + + String ticket = authzClient.protection().permission().forResource(request).getTicket(); + AuthorizationResponse response = authzClient.authorization("marta", "password").authorize(new AuthorizationRequest(ticket)); + + assertNotNull(response.getRpt()); + + try { + authzClient.authorization("kolo", "password").authorize(new AuthorizationRequest(ticket)); + fail("Should fail because user is not granted with expected group"); + } catch (AuthorizationDeniedException ignore) { + + } + + try { + authzClient.authorization("alice", "password").authorize(new AuthorizationRequest(ticket)); + fail("Should fail because user is not granted with expected group"); + } catch (AuthorizationDeniedException ignore) { + + } + } + + @Test + public void testOnlyChildrenPolicy() throws Exception { + RealmResource realm = getRealm(); + AuthzClient authzClient = getAuthzClient(); + PermissionRequest request = new PermissionRequest(); + + request.setResourceSetName("Resource B"); + + String ticket = authzClient.protection().permission().forResource(request).getTicket(); + + try { + authzClient.authorization("kolo", "password").authorize(new AuthorizationRequest(ticket)); + fail("Should fail because user is not granted with expected group"); + } catch (AuthorizationDeniedException ignore) { + + } + + AuthorizationResponse response = authzClient.authorization("alice", "password").authorize(new AuthorizationRequest(ticket)); + + assertNotNull(response.getRpt()); + + try { + authzClient.authorization("marta", "password").authorize(new AuthorizationRequest(ticket)); + fail("Should fail because user is not granted with expected role"); + } catch (AuthorizationDeniedException ignore) { + + } + + request = new PermissionRequest(); + + request.setResourceSetName("Resource C"); + + ticket = authzClient.protection().permission().forResource(request).getTicket(); + + response = authzClient.authorization("kolo", "password").authorize(new AuthorizationRequest(ticket)); + + assertNotNull(response.getRpt()); + } + + private void createGroupPolicy(String name, String groupPath, boolean extendChildren) { + GroupPolicyRepresentation policy = new GroupPolicyRepresentation(); + + policy.setName(name); + policy.setGroupsClaim("groups"); + policy.addGroupPath(groupPath, extendChildren); + + getClient().authorization().policies().group().create(policy); + } + + private void createResourcePermission(String name, String resource, String... policies) { + ResourcePermissionRepresentation permission = new ResourcePermissionRepresentation(); + + permission.setName(name); + permission.addResource(resource); + permission.addPolicy(policies); + + getClient().authorization().permissions().resource().create(permission); + } + + private void createResource(String name) { + AuthorizationResource authorization = getClient().authorization(); + ResourceRepresentation resource = new ResourceRepresentation(name); + + authorization.resources().create(resource); + } + + private RealmResource getRealm() { + try { + return AdminClientUtil.createAdminClient().realm("authz-test"); + } catch (Exception e) { + throw new RuntimeException("Failed to create admin client"); + } + } + + private ClientResource getClient(RealmResource realm) { + ClientsResource clients = realm.clients(); + return clients.findByClientId("resource-server-test").stream().map(representation -> clients.get(representation.getId())).findFirst().orElseThrow(() -> new RuntimeException("Expected client [resource-server-test]")); + } + + private AuthzClient getAuthzClient() { + try { + return AuthzClient.create(JsonSerialization.readValue(getClass().getResourceAsStream("/authorization-test/default-keycloak.json"), Configuration.class)); + } catch (IOException cause) { + throw new RuntimeException("Failed to create authz client", cause); + } + } + + private ClientResource getClient() { + return getClient(getRealm()); + } + + private GroupRepresentation getGroup(String path) { + String[] parts = path.split("/"); + RealmResource realm = getRealm(); + GroupRepresentation parent = null; + + for (String part : parts) { + if ("".equals(part)) { + continue; + } + if (parent == null) { + parent = realm.groups().groups().stream().filter(new Predicate() { + @Override + public boolean test(GroupRepresentation groupRepresentation) { + return part.equals(groupRepresentation.getName()); + } + }).findFirst().get(); + continue; + } + + GroupRepresentation group = getGroup(part, parent.getSubGroups()); + + if (path.endsWith(group.getName())) { + return group; + } + + parent = group; + } + + return null; + } + + private GroupRepresentation getGroup(String name, List groups) { + for (GroupRepresentation group : groups) { + if (name.equals(group.getName())) { + return group; + } + + GroupRepresentation child = getGroup(name, group.getSubGroups()); + + if (child != null && name.equals(child.getName())) { + return child; + } + } + + return null; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/GroupPathPolicyTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/GroupPathPolicyTest.java new file mode 100644 index 00000000000..19f74b42fc7 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/GroupPathPolicyTest.java @@ -0,0 +1,284 @@ +/* + * Copyright 2017 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.authz; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.junit.Before; +import org.junit.Test; +import org.keycloak.admin.client.resource.AuthorizationResource; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.admin.client.resource.ClientsResource; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.authorization.client.AuthorizationDeniedException; +import org.keycloak.authorization.client.AuthzClient; +import org.keycloak.authorization.client.Configuration; +import org.keycloak.authorization.client.representation.AuthorizationRequest; +import org.keycloak.authorization.client.representation.AuthorizationResponse; +import org.keycloak.authorization.client.representation.PermissionRequest; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.mappers.GroupMembershipMapper; +import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.ProtocolMapperRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.RoleRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.idm.authorization.GroupPolicyRepresentation; +import org.keycloak.representations.idm.authorization.ResourcePermissionRepresentation; +import org.keycloak.representations.idm.authorization.ResourceRepresentation; +import org.keycloak.representations.idm.authorization.RolePolicyRepresentation; +import org.keycloak.testsuite.AbstractKeycloakTest; +import org.keycloak.testsuite.util.AdminClientUtil; +import org.keycloak.testsuite.util.ClientBuilder; +import org.keycloak.testsuite.util.GroupBuilder; +import org.keycloak.testsuite.util.RealmBuilder; +import org.keycloak.testsuite.util.RoleBuilder; +import org.keycloak.testsuite.util.RolesBuilder; +import org.keycloak.testsuite.util.UserBuilder; +import org.keycloak.util.JsonSerialization; + +/** + * @author Pedro Igor + */ +public class GroupPathPolicyTest extends AbstractKeycloakTest { + + @Override + public void addTestRealms(List testRealms) { + ProtocolMapperRepresentation groupProtocolMapper = new ProtocolMapperRepresentation(); + + groupProtocolMapper.setName("groups"); + groupProtocolMapper.setProtocolMapper(GroupMembershipMapper.PROVIDER_ID); + groupProtocolMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + groupProtocolMapper.setConsentRequired(false); + Map config = new HashMap<>(); + config.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, "groups"); + config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true"); + config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); + config.put("full.path", "true"); + groupProtocolMapper.setConfig(config); + + testRealms.add(RealmBuilder.create().name("authz-test") + .roles(RolesBuilder.create() + .realmRole(RoleBuilder.create().name("uma_authorization").build()) + ) + .group(GroupBuilder.create().name("Group A") + .subGroups(Arrays.asList("Group B", "Group D").stream().map(name -> { + if ("Group B".equals(name)) { + return GroupBuilder.create().name(name).subGroups(Arrays.asList("Group C", "Group E").stream().map(new Function() { + @Override + public GroupRepresentation apply(String name) { + return GroupBuilder.create().name(name).build(); + } + }).collect(Collectors.toList())).build(); + } + return GroupBuilder.create().name(name).build(); + }).collect(Collectors.toList())).build()) + .group(GroupBuilder.create().name("Group E").build()) + .user(UserBuilder.create().username("marta").password("password").addRoles("uma_authorization").addGroups("Group A")) + .user(UserBuilder.create().username("alice").password("password").addRoles("uma_authorization")) + .user(UserBuilder.create().username("kolo").password("password").addRoles("uma_authorization")) + .client(ClientBuilder.create().clientId("resource-server-test") + .secret("secret") + .authorizationServicesEnabled(true) + .redirectUris("http://localhost/resource-server-test") + .defaultRoles("uma_protection") + .directAccessGrants() + .protocolMapper(groupProtocolMapper)) + .build()); + } + + @Before + public void configureAuthorization() throws Exception { + createResource("Resource A"); + createResource("Resource B"); + + createGroupPolicy("Parent And Children Policy", "/Group A", true); + createGroupPolicy("Only Children Policy", "/Group A/Group B/Group C", false); + + createResourcePermission("Resource A Permission", "Resource A", "Parent And Children Policy"); + createResourcePermission("Resource B Permission", "Resource B", "Only Children Policy"); + } + + @Test + public void testAllowParentAndChildren() { + AuthzClient authzClient = getAuthzClient(); + PermissionRequest request = new PermissionRequest(); + + request.setResourceSetName("Resource A"); + + String ticket = authzClient.protection().permission().forResource(request).getTicket(); + AuthorizationResponse response = authzClient.authorization("marta", "password").authorize(new AuthorizationRequest(ticket)); + + assertNotNull(response.getRpt()); + + RealmResource realm = getRealm(); + GroupRepresentation group = getGroup("/Group A/Group B/Group C"); + UserRepresentation user = realm.users().search("kolo").get(0); + + realm.users().get(user.getId()).joinGroup(group.getId()); + + ticket = authzClient.protection().permission().forResource(request).getTicket(); + response = authzClient.authorization("kolo", "password").authorize(new AuthorizationRequest(ticket)); + + assertNotNull(response.getRpt()); + } + + @Test + public void testOnlyChildrenPolicy() throws Exception { + RealmResource realm = getRealm(); + AuthzClient authzClient = getAuthzClient(); + PermissionRequest request = new PermissionRequest(); + + request.setResourceSetName("Resource B"); + + String ticket = authzClient.protection().permission().forResource(request).getTicket(); + + try { + authzClient.authorization("kolo", "password").authorize(new AuthorizationRequest(ticket)); + fail("Should fail because user is not granted with expected role"); + } catch (AuthorizationDeniedException ignore) { + + } + + GroupRepresentation group = getGroup("/Group A/Group B/Group C"); + UserRepresentation user = realm.users().search("kolo").get(0); + + realm.users().get(user.getId()).joinGroup(group.getId()); + + AuthorizationResponse response = authzClient.authorization("kolo", "password").authorize(new AuthorizationRequest(ticket)); + + assertNotNull(response.getRpt()); + + try { + authzClient.authorization("marta", "password").authorize(new AuthorizationRequest(ticket)); + fail("Should fail because user is not granted with expected role"); + } catch (AuthorizationDeniedException ignore) { + + } + } + + private void createGroupPolicy(String name, String groupPath, boolean extendChildren) { + GroupPolicyRepresentation policy = new GroupPolicyRepresentation(); + + policy.setName(name); + policy.setGroupsClaim("groups"); + policy.addGroupPath(groupPath, extendChildren); + + getClient().authorization().policies().group().create(policy); + } + + private void createResourcePermission(String name, String resource, String... policies) { + ResourcePermissionRepresentation permission = new ResourcePermissionRepresentation(); + + permission.setName(name); + permission.addResource(resource); + permission.addPolicy(policies); + + getClient().authorization().permissions().resource().create(permission); + } + + private void createResource(String name) { + AuthorizationResource authorization = getClient().authorization(); + ResourceRepresentation resource = new ResourceRepresentation(name); + + authorization.resources().create(resource); + } + + private RealmResource getRealm() { + try { + return AdminClientUtil.createAdminClient().realm("authz-test"); + } catch (Exception e) { + throw new RuntimeException("Failed to create admin client"); + } + } + + private ClientResource getClient(RealmResource realm) { + ClientsResource clients = realm.clients(); + return clients.findByClientId("resource-server-test").stream().map(representation -> clients.get(representation.getId())).findFirst().orElseThrow(() -> new RuntimeException("Expected client [resource-server-test]")); + } + + private AuthzClient getAuthzClient() { + try { + return AuthzClient.create(JsonSerialization.readValue(getClass().getResourceAsStream("/authorization-test/default-keycloak.json"), Configuration.class)); + } catch (IOException cause) { + throw new RuntimeException("Failed to create authz client", cause); + } + } + + private ClientResource getClient() { + return getClient(getRealm()); + } + + private GroupRepresentation getGroup(String path) { + String[] parts = path.split("/"); + RealmResource realm = getRealm(); + GroupRepresentation parent = null; + + for (String part : parts) { + if ("".equals(part)) { + continue; + } + if (parent == null) { + parent = realm.groups().groups().stream().filter(new Predicate() { + @Override + public boolean test(GroupRepresentation groupRepresentation) { + return part.equals(groupRepresentation.getName()); + } + }).findFirst().get(); + continue; + } + + GroupRepresentation group = getGroup(part, parent.getSubGroups()); + + if (path.endsWith(group.getName())) { + return group; + } + + parent = group; + } + + return null; + } + + private GroupRepresentation getGroup(String name, List groups) { + for (GroupRepresentation group : groups) { + if (name.equals(group.getName())) { + return group; + } + + GroupRepresentation child = getGroup(name, group.getSubGroups()); + + if (child != null && name.equals(child.getName())) { + return child; + } + } + + return null; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientBuilder.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientBuilder.java index b4f313008cc..842e4062a7b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientBuilder.java @@ -18,7 +18,9 @@ package org.keycloak.testsuite.util; import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ProtocolMapperRepresentation; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.LinkedList; @@ -175,7 +177,15 @@ public class ClientBuilder { } public ClientBuilder authorizationServicesEnabled(boolean enable) { - rep.setAuthorizationServicesEnabled(true); + rep.setAuthorizationServicesEnabled(enable); + return this; + } + + public ClientBuilder protocolMapper(ProtocolMapperRepresentation... mappers) { + if (rep.getProtocolMappers() == null) { + rep.setProtocolMappers(new ArrayList<>()); + } + rep.getProtocolMappers().addAll(Arrays.asList(mappers)); return this; } } diff --git a/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/clients/authorization/policy/GroupPolicy.java b/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/clients/authorization/policy/GroupPolicy.java new file mode 100644 index 00000000000..2fd68f4fcfc --- /dev/null +++ b/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/clients/authorization/policy/GroupPolicy.java @@ -0,0 +1,42 @@ +/* + * Copyright 2017 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.console.page.clients.authorization.policy; + +import org.jboss.arquillian.graphene.page.Page; +import org.keycloak.representations.idm.authorization.GroupPolicyRepresentation; +import org.keycloak.representations.idm.authorization.RolePolicyRepresentation; + +/** + * @author Pedro Igor + */ +public class GroupPolicy implements PolicyTypeUI { + + @Page + private GroupPolicyForm form; + + public GroupPolicyForm form() { + return form; + } + + public GroupPolicyRepresentation toRepresentation() { + return form.toRepresentation(); + } + + public void update(GroupPolicyRepresentation expected) { + form().populate(expected); + } +} diff --git a/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/clients/authorization/policy/GroupPolicyForm.java b/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/clients/authorization/policy/GroupPolicyForm.java new file mode 100644 index 00000000000..389a214d578 --- /dev/null +++ b/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/clients/authorization/policy/GroupPolicyForm.java @@ -0,0 +1,153 @@ +/* + * Copyright 2017 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.console.page.clients.authorization.policy; + +import static org.openqa.selenium.By.tagName; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.jboss.arquillian.drone.api.annotation.Drone; +import org.keycloak.representations.idm.authorization.GroupPolicyRepresentation; +import org.keycloak.representations.idm.authorization.Logic; +import org.keycloak.testsuite.page.Form; +import org.openqa.selenium.By; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.ui.Select; + +/** + * @author Pedro Igor + */ +public class GroupPolicyForm extends Form { + + @FindBy(id = "name") + private WebElement name; + + @FindBy(id = "description") + private WebElement description; + + @FindBy(id = "groupsClaim") + private WebElement groupsClaim; + + @FindBy(id = "logic") + private Select logic; + + @FindBy(xpath = "//i[contains(@class,'pficon-delete')]") + private WebElement deleteButton; + + @FindBy(xpath = ACTIVE_DIV_XPATH + "/button[text()='Delete']") + private WebElement confirmDelete; + + @FindBy(id = "selectGroup") + private WebElement selectGroupButton; + + @Drone + private WebDriver driver; + + public void populate(GroupPolicyRepresentation expected) { + setInputValue(name, expected.getName()); + setInputValue(description, expected.getDescription()); + setInputValue(groupsClaim, expected.getGroupsClaim()); + logic.selectByValue(expected.getLogic().name()); + + + for (GroupPolicyRepresentation.GroupDefinition definition : toRepresentation().getGroups()) { + boolean isExpected = false; + + for (GroupPolicyRepresentation.GroupDefinition expectedDef : expected.getGroups()) { + if (definition.getPath().equals(expectedDef.getPath())) { + isExpected = true; + break; + } + } + + if (!isExpected) { + unselect(definition.getPath()); + } + } + + for (GroupPolicyRepresentation.GroupDefinition definition : expected.getGroups()) { + String path = definition.getPath(); + String groupName = path.substring(path.lastIndexOf('/') + 1); + WebElement element = driver.findElement(By.xpath("//span[text()='" + groupName + "']")); + element.click(); + selectGroupButton.click(); + driver.findElements(By.xpath("(//table[@id='selected-groups'])/tbody/tr")).stream() + .filter(webElement -> webElement.findElements(tagName("td")).size() > 1) + .map(webElement -> webElement.findElements(tagName("td"))) + .filter(tds -> tds.get(0).getText().equals(definition.getPath())) + .forEach(tds -> { + if (!tds.get(1).findElement(By.tagName("input")).isSelected()) { + if (definition.isExtendChildren()) { + tds.get(1).findElement(By.tagName("input")).click(); + } + } else { + if (!definition.isExtendChildren() && tds.get(1).findElement(By.tagName("input")).isSelected()) { + tds.get(1).findElement(By.tagName("input")).click(); + } + } + }); + } + + save(); + } + + private void unselect(String path) { + for (WebElement webElement : driver.findElements(By.xpath("(//table[@id='selected-groups'])/tbody/tr"))) { + List tds = webElement.findElements(tagName("td")); + + if (tds.size() > 1) { + if (tds.get(0).getText().equals(path)) { + tds.get(2).findElement(By.tagName("button")).click(); + return; + } + } + } + } + + public void delete() { + deleteButton.click(); + confirmDelete.click(); + } + + public GroupPolicyRepresentation toRepresentation() { + GroupPolicyRepresentation representation = new GroupPolicyRepresentation(); + + representation.setName(getInputValue(name)); + representation.setDescription(getInputValue(description)); + representation.setGroupsClaim(getInputValue(groupsClaim)); + representation.setLogic(Logic.valueOf(logic.getFirstSelectedOption().getText().toUpperCase())); + representation.setGroups(new HashSet<>()); + + driver.findElements(By.xpath("(//table[@id='selected-groups'])/tbody/tr")).stream() + .filter(webElement -> webElement.findElements(tagName("td")).size() > 1) + .forEach(webElement -> { + List tds = webElement.findElements(tagName("td")); + representation.addGroupPath(tds.get(0).getText(), tds.get(1).findElement(By.tagName("input")).isSelected()); + }); + + return representation; + } +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/clients/authorization/policy/Policies.java b/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/clients/authorization/policy/Policies.java index af2a5402bd9..7be563e114f 100644 --- a/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/clients/authorization/policy/Policies.java +++ b/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/clients/authorization/policy/Policies.java @@ -22,6 +22,7 @@ import org.jboss.arquillian.graphene.page.Page; import org.keycloak.representations.idm.authorization.AbstractPolicyRepresentation; import org.keycloak.representations.idm.authorization.AggregatePolicyRepresentation; import org.keycloak.representations.idm.authorization.ClientPolicyRepresentation; +import org.keycloak.representations.idm.authorization.GroupPolicyRepresentation; import org.keycloak.representations.idm.authorization.JSPolicyRepresentation; import org.keycloak.representations.idm.authorization.PolicyRepresentation; import org.keycloak.representations.idm.authorization.RolePolicyRepresentation; @@ -66,6 +67,9 @@ public class Policies extends Form { @Page private ClientPolicy clientPolicy; + @Page + private GroupPolicy groupPolicy; + public PoliciesTable policies() { return table; } @@ -103,6 +107,10 @@ public class Policies extends Form { clientPolicy.form().populate((ClientPolicyRepresentation) expected); clientPolicy.form().save(); return (P) clientPolicy; + } else if ("group".equals(type)) { + groupPolicy.form().populate((GroupPolicyRepresentation) expected); + groupPolicy.form().save(); + return (P) groupPolicy; } return null; @@ -130,6 +138,8 @@ public class Policies extends Form { rulePolicy.form().populate((RulePolicyRepresentation) representation); } else if ("client".equals(type)) { clientPolicy.form().populate((ClientPolicyRepresentation) representation); + } else if ("group".equals(type)) { + groupPolicy.form().populate((GroupPolicyRepresentation) representation); } return; @@ -158,6 +168,8 @@ public class Policies extends Form { return (P) rulePolicy; } else if ("client".equals(type)) { return (P) clientPolicy; + } else if ("group".equals(type)) { + return (P) groupPolicy; } } } @@ -187,6 +199,8 @@ public class Policies extends Form { rulePolicy.form().delete(); } else if ("client".equals(type)) { clientPolicy.form().delete(); + } else if ("group".equals(type)) { + groupPolicy.form().delete(); } return; diff --git a/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/authorization/GroupPolicyManagementTest.java b/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/authorization/GroupPolicyManagementTest.java new file mode 100644 index 00000000000..e8b05bf45ba --- /dev/null +++ b/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/authorization/GroupPolicyManagementTest.java @@ -0,0 +1,159 @@ +/* + * Copyright 2017 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.console.authorization; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import java.util.Arrays; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.junit.Before; +import org.junit.Test; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.RolesResource; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.RoleRepresentation; +import org.keycloak.representations.idm.authorization.GroupPolicyRepresentation; +import org.keycloak.representations.idm.authorization.Logic; +import org.keycloak.representations.idm.authorization.RolePolicyRepresentation; +import org.keycloak.representations.idm.authorization.UserPolicyRepresentation; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.console.page.clients.authorization.policy.GroupPolicy; +import org.keycloak.testsuite.console.page.clients.authorization.policy.RolePolicy; +import org.keycloak.testsuite.console.page.clients.authorization.policy.UserPolicy; +import org.keycloak.testsuite.util.GroupBuilder; + +/** + * @author Pedro Igor + */ +public class GroupPolicyManagementTest extends AbstractAuthorizationSettingsTest { + + @Before + public void configureTest() { + super.configureTest(); + RealmResource realmResource = testRealmResource(); + String groupAId = ApiUtil.getCreatedId(realmResource.groups().add(GroupBuilder.create().name("Group A").build())); + String groupBId = ApiUtil.getCreatedId(realmResource.groups().group(groupAId).subGroup(GroupBuilder.create().name("Group B").build())); + realmResource.groups().group(groupBId).subGroup(GroupBuilder.create().name("Group D").build()); + realmResource.groups().group(groupBId).subGroup(GroupBuilder.create().name("Group E").build()); + realmResource.groups().group(groupAId).subGroup(GroupBuilder.create().name("Group C").build()); + realmResource.groups().add(GroupBuilder.create().name("Group F").build()); + } + + @Test + public void testUpdate() throws InterruptedException { + authorizationPage.navigateTo(); + GroupPolicyRepresentation expected = new GroupPolicyRepresentation(); + + expected.setName("Test Group Policy"); + expected.setDescription("description"); + expected.setGroupsClaim("groups"); + expected.addGroupPath("/Group A", true); + expected.addGroupPath("/Group A/Group B/Group D"); + expected.addGroupPath("Group F"); + + expected = createPolicy(expected); + + String previousName = expected.getName(); + + expected.setName("Changed Test Group Policy"); + expected.setDescription("Changed description"); + expected.setLogic(Logic.NEGATIVE); + + authorizationPage.navigateTo(); + authorizationPage.authorizationTabs().policies().update(previousName, expected); + assertAlertSuccess(); + + authorizationPage.navigateTo(); + GroupPolicy actual = authorizationPage.authorizationTabs().policies().name(expected.getName()); + + assertPolicy(expected, actual); + + expected.getGroups().clear(); + expected.addGroupPath("/Group A", false); + expected.addGroupPath("/Group A/Group B/Group D"); + + authorizationPage.navigateTo(); + authorizationPage.authorizationTabs().policies().update(expected.getName(), expected); + assertAlertSuccess(); + + authorizationPage.navigateTo(); + actual = authorizationPage.authorizationTabs().policies().name(expected.getName()); + + assertPolicy(expected, actual); + + expected.getGroups().clear(); + expected.addGroupPath("/Group E"); + expected.addGroupPath("/Group A/Group B", true); + expected.addGroupPath("/Group A/Group C"); + + + authorizationPage.navigateTo(); + authorizationPage.authorizationTabs().policies().update(expected.getName(), expected); + assertAlertSuccess(); + + authorizationPage.navigateTo(); + actual = authorizationPage.authorizationTabs().policies().name(expected.getName()); + + assertPolicy(expected, actual); + } + + @Test + public void testDelete() throws InterruptedException { + authorizationPage.navigateTo(); + GroupPolicyRepresentation expected = new GroupPolicyRepresentation(); + + expected.setName("Test Delete Group Policy"); + expected.setDescription("description"); + expected.setGroupsClaim("groups"); + expected.addGroupPath("/Group A", true); + expected.addGroupPath("/Group A/Group B/Group D"); + expected.addGroupPath("Group F"); + + expected = createPolicy(expected); + authorizationPage.navigateTo(); + authorizationPage.authorizationTabs().policies().delete(expected.getName()); + assertAlertSuccess(); + authorizationPage.navigateTo(); + assertNull(authorizationPage.authorizationTabs().policies().policies().findByName(expected.getName())); + } + + private GroupPolicyRepresentation createPolicy(GroupPolicyRepresentation expected) { + GroupPolicy policy = authorizationPage.authorizationTabs().policies().create(expected); + assertAlertSuccess(); + return assertPolicy(expected, policy); + } + + private GroupPolicyRepresentation assertPolicy(GroupPolicyRepresentation expected, GroupPolicy policy) { + GroupPolicyRepresentation actual = policy.toRepresentation(); + + assertEquals(expected.getName(), actual.getName()); + assertEquals(expected.getDescription(), actual.getDescription()); + assertEquals(expected.getLogic(), actual.getLogic()); + + assertNotNull(actual.getGroups()); + assertEquals(expected.getGroups().size(), actual.getGroups().size()); + assertEquals(0, actual.getGroups().stream().filter(actualDefinition -> !expected.getGroups().stream() + .filter(groupDefinition -> actualDefinition.getPath().contains(groupDefinition.getPath()) && actualDefinition.isExtendChildren() == groupDefinition.isExtendChildren()) + .findFirst().isPresent()) + .count()); + return actual; + } +} diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index af76c9c9c9e..1a0de6e9bbf 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -1209,6 +1209,13 @@ authz-policy-js-code.tooltip=The JavaScript code providing the conditions for th authz-aggregated=Aggregated authz-add-aggregated-policy=Add Aggregated Policy +# Authz Group Policy Detail +authz-add-group-policy=Add Group Policy +authz-no-groups-assigned=No groups assigned. +authz-policy-group-claim=Groups Claim +authz-policy-group-claim.tooltip=A claim to use as the source for user’s group. If the claim is present it must be an array of strings. +authz-policy-group-groups.tooltip=Specifies the groups allowed by this policy. + # Authz Permission List authz-no-permissions-available=No permissions available. diff --git a/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-app.js b/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-app.js index 7c5e7fa1766..2b92bc5b113 100644 --- a/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-app.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-app.js @@ -324,7 +324,29 @@ module.config(['$routeProvider', function ($routeProvider) { } }, controller: 'ResourceServerPolicyRoleDetailCtrl' - }).when('/realms/:realm/clients/:client/authz/resource-server/policy/js/create', { + }).when('/realms/:realm/clients/:client/authz/resource-server/policy/group/create', { + templateUrl: resourceUrl + '/partials/authz/policy/provider/resource-server-policy-group-detail.html', + resolve: { + realm: function (RealmLoader) { + return RealmLoader(); + }, + client : function(ClientLoader) { + return ClientLoader(); + } + }, + controller: 'ResourceServerPolicyGroupDetailCtrl' + }).when('/realms/:realm/clients/:client/authz/resource-server/policy/group/:id', { + templateUrl: resourceUrl + '/partials/authz/policy/provider/resource-server-policy-group-detail.html', + resolve: { + realm: function (RealmLoader) { + return RealmLoader(); + }, + client : function(ClientLoader) { + return ClientLoader(); + } + }, + controller: 'ResourceServerPolicyGroupDetailCtrl' + }).when('/realms/:realm/clients/:client/authz/resource-server/policy/js/create', { templateUrl: resourceUrl + '/partials/authz/policy/provider/resource-server-policy-js-detail.html', resolve: { realm: function (RealmLoader) { diff --git a/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-controller.js b/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-controller.js index 2cce1381b61..258a9806848 100644 --- a/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-controller.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-controller.js @@ -1728,6 +1728,119 @@ module.controller('ResourceServerPolicyRoleDetailCtrl', function($scope, $route, } }); +module.controller('ResourceServerPolicyGroupDetailCtrl', function($scope, $route, realm, client, Client, Groups, Group, PolicyController) { + PolicyController.onInit({ + getPolicyType : function() { + return "group"; + }, + + onInit : function() { + $scope.tree = []; + + Groups.query({realm: $route.current.params.realm}, function(groups) { + $scope.groups = groups; + $scope.groupList = [ + {"id" : "realm", "name": "Groups", + "subGroups" : groups} + ]; + }); + + var isLeaf = function(node) { + return node.id != "realm" && (!node.subGroups || node.subGroups.length == 0); + } + + $scope.getGroupClass = function(node) { + if (node.id == "realm") { + return 'pficon pficon-users'; + } + if (isLeaf(node)) { + return 'normal'; + } + if (node.subGroups.length && node.collapsed) return 'collapsed'; + if (node.subGroups.length && !node.collapsed) return 'expanded'; + return 'collapsed'; + + } + + $scope.getSelectedClass = function(node) { + if (node.selected) { + return 'selected'; + } else if ($scope.cutNode && $scope.cutNode.id == node.id) { + return 'cut'; + } + return undefined; + } + + $scope.selectGroup = function(group) { + for (i = 0; i < $scope.selectedGroups.length; i++) { + if ($scope.selectedGroups[i].id == group.id) { + return + } + } + $scope.selectedGroups.push({id: group.id, path: group.path}); + $scope.changed = true; + } + + $scope.extendChildren = function(group) { + $scope.changed = true; + } + + $scope.removeFromList = function(group) { + var index = $scope.selectedGroups.indexOf(group); + if (index != -1) { + $scope.selectedGroups.splice(index, 1); + $scope.changed = true; + } + } + }, + + onInitCreate : function(policy) { + var selectedGroups = []; + + $scope.selectedGroups = angular.copy(selectedGroups); + + $scope.$watch('selectedGroups', function() { + if (!angular.equals($scope.selectedGroups, selectedGroups)) { + $scope.changed = true; + } else { + $scope.changed = false; + } + }, true); + }, + + onInitUpdate : function(policy) { + $scope.selectedGroups = policy.groups; + + angular.forEach($scope.selectedGroups, function(group, index){ + Group.get({realm: $route.current.params.realm, groupId: group.id}, function (existing) { + group.path = existing.path; + }); + }); + + $scope.$watch('selectedGroups', function() { + if (!$scope.changed) { + return; + } + if (!angular.equals($scope.selectedGroups, selectedGroups)) { + $scope.changed = true; + } else { + $scope.changed = false; + } + }, true); + }, + + onUpdate : function() { + $scope.policy.groups = $scope.selectedGroups; + delete $scope.policy.config; + }, + + onCreate : function() { + $scope.policy.groups = $scope.selectedGroups; + delete $scope.policy.config; + } + }, realm, client, $scope); +}); + module.controller('ResourceServerPolicyJSDetailCtrl', function($scope, $route, $location, realm, PolicyController, client) { PolicyController.onInit({ getPolicyType : function() { diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/authz/policy/provider/resource-server-policy-group-detail.html b/themes/src/main/resources/theme/base/admin/resources/partials/authz/policy/provider/resource-server-policy-group-detail.html new file mode 100644 index 00000000000..61af0f157d2 --- /dev/null +++ b/themes/src/main/resources/theme/base/admin/resources/partials/authz/policy/provider/resource-server-policy-group-detail.html @@ -0,0 +1,124 @@ + + +
+ + + +

{{:: 'authz-add-group-policy' | translate}}

+

{{originalPolicy.name|capitalize}}

+ +
+
+
+ +
+ +
+ {{:: 'authz-policy-name.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'authz-policy-description.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'authz-policy-group-claim.tooltip' | translate}} +
+
+ +
+
+
+ + +
+ {{:: 'authz-policy-user-users.tooltip' | translate}} +
+
+ +
+ + + + + + + + + + + + + + + + + + +
{{:: 'path' | translate}}Extend to Children{{:: 'actions' | translate}}
{{group.path}} + + + +
{{:: 'authz-no-groups-assigned' | translate}}
+
+
+
+ + +
+ +
+ + {{:: 'authz-policy-logic.tooltip' | translate}} +
+ +
+
+
+ + +
+
+
+
+ + \ No newline at end of file From 5028c05cc24612d3703d8ffcb63fe8fbcd8d1600 Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Wed, 14 Jun 2017 09:33:59 -0300 Subject: [PATCH 05/18] [KEYCLOAK-3168] - Export and import tests --- .../group/GroupPolicyProviderFactory.java | 31 +++++++++-- .../exportimport/ExportImportUtil.java | 3 +- .../src/test/resources/model/testrealm.json | 52 +++++++++++++++++++ 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/group/GroupPolicyProviderFactory.java b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/group/GroupPolicyProviderFactory.java index d101c798d33..f55844978dc 100644 --- a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/group/GroupPolicyProviderFactory.java +++ b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/group/GroupPolicyProviderFactory.java @@ -30,9 +30,12 @@ import org.keycloak.authorization.AuthorizationProvider; import org.keycloak.authorization.model.Policy; import org.keycloak.authorization.policy.provider.PolicyProvider; import org.keycloak.authorization.policy.provider.PolicyProviderFactory; +import org.keycloak.models.ClientModel; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RoleModel; +import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.representations.idm.authorization.GroupPolicyRepresentation; import org.keycloak.representations.idm.authorization.PolicyRepresentation; import org.keycloak.representations.idm.authorization.RolePolicyRepresentation; @@ -74,7 +77,7 @@ public class GroupPolicyProviderFactory implements PolicyProviderFactory(Arrays.asList(JsonSerialization.readValue(policy.getConfig().get("groups"), GroupPolicyRepresentation.GroupDefinition[].class)))); + representation.setGroups(getGroupsDefinition(policy.getConfig())); } catch (IOException cause) { throw new RuntimeException("Failed to deserialize groups", cause); } @@ -99,7 +102,7 @@ public class GroupPolicyProviderFactory implements PolicyProviderFactory config = new HashMap<>(); + GroupPolicyRepresentation groupPolicy = toRepresentation(policy, new GroupPolicyRepresentation()); + Set groups = groupPolicy.getGroups(); + for (GroupPolicyRepresentation.GroupDefinition definition: groups) { + GroupModel group = authorizationProvider.getRealm().getGroupById(definition.getId()); + definition.setId(null); + definition.setPath(ModelToRepresentation.buildGroupPath(group)); + } + + try { + config.put("groupsClaim", groupPolicy.getGroupsClaim()); + config.put("groups", JsonSerialization.writeValueAsString(groups)); + } catch (IOException cause) { + throw new RuntimeException("Failed to export group policy [" + policy.getName() + "]", cause); + } + + representation.setConfig(config); } @Override @@ -157,9 +177,6 @@ public class GroupPolicyProviderFactory implements PolicyProviderFactory groupModel.getName().equals(part)).findFirst().orElseThrow(() -> new RuntimeException("Top level group with name [" + part + "] not found")); } else { @@ -190,4 +207,8 @@ public class GroupPolicyProviderFactory implements PolicyProviderFactory getGroupsDefinition(Map config) throws IOException { + return new HashSet<>(Arrays.asList(JsonSerialization.readValue(config.get("groups"), GroupPolicyRepresentation.GroupDefinition[].class))); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java index b29abc143f4..677430d23a5 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java @@ -627,12 +627,13 @@ public class ExportImportUtil { assertPredicate(scopes, scopePredicates); List policies = authzResource.policies().policies(); - Assert.assertEquals(13, policies.size()); + Assert.assertEquals(14, policies.size()); List> policyPredicates = new ArrayList<>(); policyPredicates.add(policyRepresentation -> "Any Admin Policy".equals(policyRepresentation.getName())); policyPredicates.add(policyRepresentation -> "Any User Policy".equals(policyRepresentation.getName())); policyPredicates.add(representation -> "Client and Realm Role Policy".equals(representation.getName())); policyPredicates.add(representation -> "Client Test Policy".equals(representation.getName())); + policyPredicates.add(representation -> "Group Policy Test".equals(representation.getName())); policyPredicates.add(policyRepresentation -> "Only Premium User Policy".equals(policyRepresentation.getName())); policyPredicates.add(policyRepresentation -> "wburke policy".equals(policyRepresentation.getName())); policyPredicates.add(policyRepresentation -> "All Users Policy".equals(policyRepresentation.getName())); diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/model/testrealm.json b/testsuite/integration-arquillian/tests/base/src/test/resources/model/testrealm.json index 5f84e38b511..fb1a7e00028 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/model/testrealm.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/model/testrealm.json @@ -71,6 +71,50 @@ } } ], + "groups": [ + { + "name": "Group A", + "path": "/Group A", + "attributes": { + "topAttribute": [ + "true" + ] + }, + "subGroups": [ + { + "name": "Group B", + "path": "/Group A/Group B", + "attributes": { + "level2Attribute": [ + "true" + ] + }, + "subGroups": [] + } + ] + }, + { + "name": "Group C", + "path": "/Group C", + "attributes": { + "topAttribute": [ + "true" + ] + }, + "subGroups": [ + { + "name": "Group D", + "path": "/Group C/Group D", + "attributes": { + "level2Attribute": [ + "true" + ] + }, + "subGroups": [] + } + ] + } + ], "users": [ { "username": "wburke", @@ -298,6 +342,14 @@ "clients": "[\"broker\",\"admin-cli\"]" } }, + { + "name": "Group Policy Test", + "type": "group", + "config": { + "groupsClaim": "groups", + "groups": "[{\"path\":\"/Group A\",\"extendChildren\":true},{\"path\":\"/Group A/Group B\",\"extendChildren\":false},{\"path\":\"/Group C/Group D\",\"extendChildren\":true}]" + } + }, { "name": "Only Premium User Policy", "description": "Defines that only premium users can do something", From 91585f8563ebefb8df8a8d5763331d2733e4d69f Mon Sep 17 00:00:00 2001 From: emilienbondu Date: Mon, 12 Jun 2017 18:19:39 +0200 Subject: [PATCH 06/18] Changing request matcher to attempt auth on /sso/login or Auhtorization header Add default login URL. Throwing exception if login fails to enable auth entry point Adding a test for invalid token and bearer-only handle redirect correctly --- ...eycloakAuthenticationProcessingFilter.java | 39 ++++++++++++------- ...oakAuthenticationProcessingFilterTest.java | 17 +++++--- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/filter/KeycloakAuthenticationProcessingFilter.java b/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/filter/KeycloakAuthenticationProcessingFilter.java index fabf30e62d2..7e235ae5204 100644 --- a/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/filter/KeycloakAuthenticationProcessingFilter.java +++ b/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/filter/KeycloakAuthenticationProcessingFilter.java @@ -61,15 +61,19 @@ import org.springframework.util.Assert; * @version $Revision: 1 $ */ public class KeycloakAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter implements ApplicationContextAware { - + + public static final String DEFAULT_LOGIN_URL = "/sso/login"; public static final String AUTHORIZATION_HEADER = "Authorization"; public static final String SCHEME_BEARER = "bearer "; public static final String SCHEME_BASIC = "basic "; + /** - * Request matcher that matches all requests. + * Request matcher that matches requests to the {@link KeycloakAuthenticationEntryPoint#DEFAULT_LOGIN_URI default login URI} + * and any request with a Authorization header. */ - private static RequestMatcher DEFAULT_REQUEST_MATCHER = new AntPathRequestMatcher("/**"); + public static final RequestMatcher DEFAULT_REQUEST_MATCHER = + new OrRequestMatcher(new AntPathRequestMatcher(DEFAULT_LOGIN_URL), new RequestHeaderRequestMatcher(AUTHORIZATION_HEADER)); private static final Logger log = LoggerFactory.getLogger(KeycloakAuthenticationProcessingFilter.class); @@ -107,7 +111,7 @@ public class KeycloakAuthenticationProcessingFilter extends AbstractAuthenticati * */ public KeycloakAuthenticationProcessingFilter(AuthenticationManager authenticationManager, RequestMatcher - requiresAuthenticationRequestMatcher) { + requiresAuthenticationRequestMatcher) { super(requiresAuthenticationRequestMatcher); Assert.notNull(authenticationManager, "authenticationManager cannot be null"); this.authenticationManager = authenticationManager; @@ -138,20 +142,27 @@ public class KeycloakAuthenticationProcessingFilter extends AbstractAuthenticati log.debug("Auth outcome: {}", result); if (AuthOutcome.FAILED.equals(result)) { - AuthChallenge challenge = authenticator.getChallenge(); + AuthChallenge challenge = authenticator.getChallenge(); if (challenge != null) { challenge.challenge(facade); } throw new KeycloakAuthenticationException("Invalid authorization header, see WWW-Authenticate header for details"); } + if (AuthOutcome.NOT_ATTEMPTED.equals(result)) { - AuthChallenge challenge = authenticator.getChallenge(); + AuthChallenge challenge = authenticator.getChallenge(); if (challenge != null) { challenge.challenge(facade); } - throw new KeycloakAuthenticationException("Authorization header not found, see WWW-Authenticate header"); + if (deployment.isBearerOnly()) { + // no redirection in this mode, throwing exception for the spring handler + throw new KeycloakAuthenticationException("Authorization header not found, see WWW-Authenticate header"); + } else { + // let continue if challenged, it may redirect + return null; + } } - + else if (AuthOutcome.AUTHENTICATED.equals(result)) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Assert.notNull(authentication, "Authentication SecurityContextHolder was null"); @@ -193,7 +204,7 @@ public class KeycloakAuthenticationProcessingFilter extends AbstractAuthenticati @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, - Authentication authResult) throws IOException, ServletException { + Authentication authResult) throws IOException, ServletException { if (!(this.isBearerTokenRequest(request) || this.isBasicAuthRequest(request))) { super.successfulAuthentication(request, response, chain, authResult); @@ -220,10 +231,10 @@ public class KeycloakAuthenticationProcessingFilter extends AbstractAuthenticati } @Override - protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, - AuthenticationException failed) throws IOException, ServletException { - super.unsuccessfulAuthentication(request, response, failed); - } + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, + AuthenticationException failed) throws IOException, ServletException { + super.unsuccessfulAuthentication(request, response, failed); + } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { @@ -259,4 +270,4 @@ public class KeycloakAuthenticationProcessingFilter extends AbstractAuthenticati public final void setContinueChainBeforeSuccessfulAuthentication(boolean continueChainBeforeSuccessfulAuthentication) { throw new UnsupportedOperationException("This filter does not support explicitly setting a continue chain before success policy"); } -} +} \ No newline at end of file diff --git a/adapters/oidc/spring-security/src/test/java/org/keycloak/adapters/springsecurity/filter/KeycloakAuthenticationProcessingFilterTest.java b/adapters/oidc/spring-security/src/test/java/org/keycloak/adapters/springsecurity/filter/KeycloakAuthenticationProcessingFilterTest.java index 2414c38f24d..a6a378a34c8 100755 --- a/adapters/oidc/spring-security/src/test/java/org/keycloak/adapters/springsecurity/filter/KeycloakAuthenticationProcessingFilterTest.java +++ b/adapters/oidc/spring-security/src/test/java/org/keycloak/adapters/springsecurity/filter/KeycloakAuthenticationProcessingFilterTest.java @@ -159,12 +159,10 @@ public class KeycloakAuthenticationProcessingFilterTest { when(keycloakDeployment.getStateCookieName()).thenReturn("kc-cookie"); when(keycloakDeployment.getSslRequired()).thenReturn(SslRequired.NONE); when(keycloakDeployment.isBearerOnly()).thenReturn(Boolean.FALSE); - try { - filter.attemptAuthentication(request, response); - } catch (KeycloakAuthenticationException e) { - verify(response).setStatus(302); - verify(response).setHeader(eq("Location"), startsWith("http://localhost:8080/auth")); - } + + filter.attemptAuthentication(request, response); + verify(response).setStatus(302); + verify(response).setHeader(eq("Location"), startsWith("http://localhost:8080/auth")); } @Test(expected = KeycloakAuthenticationException.class) @@ -173,6 +171,13 @@ public class KeycloakAuthenticationProcessingFilterTest { filter.attemptAuthentication(request, response); } + @Test(expected = KeycloakAuthenticationException.class) + public void testAttemptAuthenticationWithInvalidTokenBearerOnly() throws Exception { + when(keycloakDeployment.isBearerOnly()).thenReturn(Boolean.TRUE); + request.addHeader("Authorization", "Bearer xxx"); + filter.attemptAuthentication(request, response); + } + @Test public void testSuccessfulAuthenticationInteractive() throws Exception { Authentication authentication = new KeycloakAuthenticationToken(keycloakAccount, authorities); From 98a5c57e6541e8d7ae7a5b800d556175870f77d1 Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Thu, 15 Jun 2017 13:00:24 +0200 Subject: [PATCH 07/18] Author added --- .../keycloak/adapters/springsecurity/KeycloakConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/KeycloakConfiguration.java b/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/KeycloakConfiguration.java index 9aabedbefc7..d91d5f9c107 100644 --- a/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/KeycloakConfiguration.java +++ b/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/KeycloakConfiguration.java @@ -13,7 +13,7 @@ import static java.lang.annotation.RetentionPolicy.RUNTIME; /** * Add this annotation to a class that extends {@code KeycloakWebSecurityConfigurerAdapter} to provide * a keycloak based Spring security configuration. - * + * @author Hendrik Ebbers */ @Retention(value = RUNTIME) @Target(value = { TYPE }) From 0ac92c4bfa14d660185b6e8df8681bfe877e5a27 Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Thu, 15 Jun 2017 13:01:05 +0200 Subject: [PATCH 08/18] new line in doc --- .../keycloak/adapters/springsecurity/KeycloakConfiguration.java | 1 + 1 file changed, 1 insertion(+) diff --git a/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/KeycloakConfiguration.java b/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/KeycloakConfiguration.java index d91d5f9c107..f434b97baa5 100644 --- a/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/KeycloakConfiguration.java +++ b/adapters/oidc/spring-security/src/main/java/org/keycloak/adapters/springsecurity/KeycloakConfiguration.java @@ -13,6 +13,7 @@ import static java.lang.annotation.RetentionPolicy.RUNTIME; /** * Add this annotation to a class that extends {@code KeycloakWebSecurityConfigurerAdapter} to provide * a keycloak based Spring security configuration. + * * @author Hendrik Ebbers */ @Retention(value = RUNTIME) From 93105a2182478308f09b9b3f81c2149149dd60c0 Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Thu, 15 Jun 2017 09:49:20 -0300 Subject: [PATCH 09/18] [KEYCLOAK-5056] - @NoCache to scope admin api --- .../org/keycloak/authorization/admin/ResourceSetService.java | 3 ++- .../java/org/keycloak/authorization/admin/ScopeService.java | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/services/src/main/java/org/keycloak/authorization/admin/ResourceSetService.java b/services/src/main/java/org/keycloak/authorization/admin/ResourceSetService.java index b0666f11444..3c7f34fbde9 100644 --- a/services/src/main/java/org/keycloak/authorization/admin/ResourceSetService.java +++ b/services/src/main/java/org/keycloak/authorization/admin/ResourceSetService.java @@ -86,6 +86,7 @@ public class ResourceSetService { } @POST + @NoCache @Consumes("application/json") @Produces("application/json") public Response create(@Context UriInfo uriInfo, ResourceRepresentation resource) { @@ -288,8 +289,8 @@ public class ResourceSetService { @Path("/search") @GET - @Produces("application/json") @NoCache + @Produces("application/json") public Response find(@QueryParam("name") String name) { this.auth.requireView(); StoreFactory storeFactory = authorization.getStoreFactory(); diff --git a/services/src/main/java/org/keycloak/authorization/admin/ScopeService.java b/services/src/main/java/org/keycloak/authorization/admin/ScopeService.java index d59a6de32e3..a2a2320d7c0 100644 --- a/services/src/main/java/org/keycloak/authorization/admin/ScopeService.java +++ b/services/src/main/java/org/keycloak/authorization/admin/ScopeService.java @@ -77,6 +77,7 @@ public class ScopeService { } @POST + @NoCache @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public Response create(@Context UriInfo uriInfo, ScopeRepresentation scope) { @@ -150,6 +151,7 @@ public class ScopeService { @Path("{id}") @GET + @NoCache @Produces(MediaType.APPLICATION_JSON) public Response findById(@PathParam("id") String id) { this.auth.requireView(); @@ -164,6 +166,7 @@ public class ScopeService { @Path("{id}/resources") @GET + @NoCache @Produces(MediaType.APPLICATION_JSON) public Response getResources(@PathParam("id") String id) { this.auth.requireView(); @@ -186,6 +189,7 @@ public class ScopeService { @Path("{id}/permissions") @GET + @NoCache @Produces(MediaType.APPLICATION_JSON) public Response getPermissions(@PathParam("id") String id) { this.auth.requireView(); @@ -231,6 +235,7 @@ public class ScopeService { } @GET + @NoCache @Produces("application/json") public Response findAll(@QueryParam("scopeId") String id, @QueryParam("name") String name, From 0b5e6b0d497a596fa24e56f367037ed60a716b78 Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Fri, 16 Jun 2017 11:49:32 -0300 Subject: [PATCH 10/18] JS policy should use ScriptingSPI --- .../policy/provider/js/JSPolicyProvider.java | 44 ++++++++++++------- .../provider/js/JSPolicyProviderFactory.java | 8 +--- .../scripting/InvocableScriptAdapter.java | 8 ++++ 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProvider.java b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProvider.java index f87573136bc..74befcbbfe1 100644 --- a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProvider.java +++ b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProvider.java @@ -17,37 +17,27 @@ */ package org.keycloak.authorization.policy.provider.js; -import java.util.function.Supplier; - -import javax.script.ScriptEngine; -import javax.script.ScriptException; - +import org.keycloak.authorization.AuthorizationProvider; import org.keycloak.authorization.model.Policy; import org.keycloak.authorization.policy.evaluation.Evaluation; import org.keycloak.authorization.policy.provider.PolicyProvider; +import org.keycloak.models.RealmModel; +import org.keycloak.models.ScriptModel; +import org.keycloak.scripting.InvocableScriptAdapter; +import org.keycloak.scripting.ScriptingProvider; /** * @author Pedro Igor */ public class JSPolicyProvider implements PolicyProvider { - private Supplier engineProvider; - - public JSPolicyProvider(Supplier engineProvider) { - this.engineProvider = engineProvider; - } - @Override public void evaluate(Evaluation evaluation) { - ScriptEngine engine = engineProvider.get(); - - engine.put("$evaluation", evaluation); - Policy policy = evaluation.getPolicy(); try { - engine.eval(policy.getConfig().get("code")); - } catch (ScriptException e) { + getInvocableScriptAdapter(policy, evaluation).eval(); + } catch (Exception e) { throw new RuntimeException("Error evaluating JS Policy [" + policy.getName() + "].", e); } } @@ -56,4 +46,24 @@ public class JSPolicyProvider implements PolicyProvider { public void close() { } + + private InvocableScriptAdapter getInvocableScriptAdapter(Policy policy, Evaluation evaluation) { + String scriptName = policy.getName(); + String scriptCode = policy.getConfig().get("code"); + String scriptDescription = policy.getDescription(); + + AuthorizationProvider authorization = evaluation.getAuthorizationProvider(); + RealmModel realm = authorization.getRealm(); + + ScriptingProvider scripting = authorization.getKeycloakSession().getProvider(ScriptingProvider.class); + + //TODO lookup script by scriptId instead of creating it every time + ScriptModel script = scripting.createScript(realm.getId(), ScriptModel.TEXT_JAVASCRIPT, scriptName, scriptCode, scriptDescription); + + //how to deal with long running scripts -> timeout? + return scripting.prepareInvocableScript(script, bindings -> { + bindings.put("script", script); + bindings.put("$evaluation", evaluation); + }); + } } diff --git a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProviderFactory.java b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProviderFactory.java index 3e68d7f603e..a26129654a2 100644 --- a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProviderFactory.java +++ b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProviderFactory.java @@ -1,9 +1,5 @@ package org.keycloak.authorization.policy.provider.js; -import java.util.Map; - -import javax.script.ScriptEngineManager; - import org.keycloak.Config; import org.keycloak.authorization.AuthorizationProvider; import org.keycloak.authorization.model.Policy; @@ -19,9 +15,7 @@ import org.keycloak.representations.idm.authorization.PolicyRepresentation; */ public class JSPolicyProviderFactory implements PolicyProviderFactory { - private static final String ENGINE = "nashorn"; - - private JSPolicyProvider provider = new JSPolicyProvider(() -> new ScriptEngineManager().getEngineByName(ENGINE)); + private JSPolicyProvider provider = new JSPolicyProvider(); @Override public String getName() { diff --git a/server-spi-private/src/main/java/org/keycloak/scripting/InvocableScriptAdapter.java b/server-spi-private/src/main/java/org/keycloak/scripting/InvocableScriptAdapter.java index c3859aba357..30644f0e768 100644 --- a/server-spi-private/src/main/java/org/keycloak/scripting/InvocableScriptAdapter.java +++ b/server-spi-private/src/main/java/org/keycloak/scripting/InvocableScriptAdapter.java @@ -78,6 +78,14 @@ public class InvocableScriptAdapter implements Invocable { } } + public Object eval() throws ScriptExecutionException { + try { + return scriptEngine.eval(scriptModel.getCode()); + } catch (ScriptException e) { + throw new ScriptExecutionException(scriptModel, e); + } + } + @Override public T getInterface(Class clazz) { return getInvocableEngine().getInterface(clazz); From 7614ff8c6fa2ed12f7ab1042d0dd80ef0e634f05 Mon Sep 17 00:00:00 2001 From: Jay Anslow Date: Mon, 19 Jun 2017 14:48:50 +0100 Subject: [PATCH 11/18] Extract EvaluatebleScriptAdapter Precursor for InvocableScriptAdapter, which compiles/evaluates a script without affecting the engine's bindings. This allows the same script to be compiled once and then evaluated multiple times (with the same ScriptEngine). --- .../policy/provider/js/JSPolicyProvider.java | 30 ++++---- .../scripting/EvaluatableScriptAdapter.java | 14 ++++ .../scripting/InvocableScriptAdapter.java | 21 +---- .../keycloak/scripting/ScriptingProvider.java | 8 ++ .../AbstractEvaluatableScriptAdapter.java | 76 +++++++++++++++++++ .../CompiledEvaluatableScriptAdapter.java | 40 ++++++++++ .../scripting/DefaultScriptingProvider.java | 72 +++++++++--------- .../UncompiledEvaluatableScriptAdapter.java | 39 ++++++++++ 8 files changed, 231 insertions(+), 69 deletions(-) create mode 100644 server-spi-private/src/main/java/org/keycloak/scripting/EvaluatableScriptAdapter.java create mode 100644 services/src/main/java/org/keycloak/scripting/AbstractEvaluatableScriptAdapter.java create mode 100644 services/src/main/java/org/keycloak/scripting/CompiledEvaluatableScriptAdapter.java create mode 100644 services/src/main/java/org/keycloak/scripting/UncompiledEvaluatableScriptAdapter.java diff --git a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProvider.java b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProvider.java index 74befcbbfe1..47992865ae5 100644 --- a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProvider.java +++ b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProvider.java @@ -23,7 +23,7 @@ import org.keycloak.authorization.policy.evaluation.Evaluation; import org.keycloak.authorization.policy.provider.PolicyProvider; import org.keycloak.models.RealmModel; import org.keycloak.models.ScriptModel; -import org.keycloak.scripting.InvocableScriptAdapter; +import org.keycloak.scripting.EvaluatableScriptAdapter; import org.keycloak.scripting.ScriptingProvider; /** @@ -35,9 +35,18 @@ public class JSPolicyProvider implements PolicyProvider { public void evaluate(Evaluation evaluation) { Policy policy = evaluation.getPolicy(); + AuthorizationProvider authorization = evaluation.getAuthorizationProvider(); + ScriptModel script = getScriptModel(policy, authorization); + final EvaluatableScriptAdapter adapter = getScriptingProvider(authorization).prepareEvaluatableScript(script); + try { - getInvocableScriptAdapter(policy, evaluation).eval(); - } catch (Exception e) { + //how to deal with long running scripts -> timeout? + adapter.eval(bindings -> { + bindings.put("script", adapter.getScriptModel()); + bindings.put("$evaluation", evaluation); + }); + } + catch (Exception e) { throw new RuntimeException("Error evaluating JS Policy [" + policy.getName() + "].", e); } } @@ -47,23 +56,18 @@ public class JSPolicyProvider implements PolicyProvider { } - private InvocableScriptAdapter getInvocableScriptAdapter(Policy policy, Evaluation evaluation) { + private ScriptModel getScriptModel(final Policy policy, final AuthorizationProvider authorization) { String scriptName = policy.getName(); String scriptCode = policy.getConfig().get("code"); String scriptDescription = policy.getDescription(); - AuthorizationProvider authorization = evaluation.getAuthorizationProvider(); RealmModel realm = authorization.getRealm(); - ScriptingProvider scripting = authorization.getKeycloakSession().getProvider(ScriptingProvider.class); - //TODO lookup script by scriptId instead of creating it every time - ScriptModel script = scripting.createScript(realm.getId(), ScriptModel.TEXT_JAVASCRIPT, scriptName, scriptCode, scriptDescription); + return getScriptingProvider(authorization).createScript(realm.getId(), ScriptModel.TEXT_JAVASCRIPT, scriptName, scriptCode, scriptDescription); + } - //how to deal with long running scripts -> timeout? - return scripting.prepareInvocableScript(script, bindings -> { - bindings.put("script", script); - bindings.put("$evaluation", evaluation); - }); + private ScriptingProvider getScriptingProvider(final AuthorizationProvider authorization) { + return authorization.getKeycloakSession().getProvider(ScriptingProvider.class); } } diff --git a/server-spi-private/src/main/java/org/keycloak/scripting/EvaluatableScriptAdapter.java b/server-spi-private/src/main/java/org/keycloak/scripting/EvaluatableScriptAdapter.java new file mode 100644 index 00000000000..2a76add9261 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/scripting/EvaluatableScriptAdapter.java @@ -0,0 +1,14 @@ +package org.keycloak.scripting; + +import org.keycloak.models.ScriptModel; + +/** + * Wraps a {@link ScriptModel} so it can be evaluated with custom bindings. + * + * @author Jay Anslow + */ +public interface EvaluatableScriptAdapter { + ScriptModel getScriptModel(); + + Object eval(ScriptBindingsConfigurer bindingsConfigurer) throws ScriptExecutionException; +} diff --git a/server-spi-private/src/main/java/org/keycloak/scripting/InvocableScriptAdapter.java b/server-spi-private/src/main/java/org/keycloak/scripting/InvocableScriptAdapter.java index 30644f0e768..17bb4a1822a 100644 --- a/server-spi-private/src/main/java/org/keycloak/scripting/InvocableScriptAdapter.java +++ b/server-spi-private/src/main/java/org/keycloak/scripting/InvocableScriptAdapter.java @@ -56,7 +56,7 @@ public class InvocableScriptAdapter implements Invocable { } this.scriptModel = scriptModel; - this.scriptEngine = loadScriptIntoEngine(scriptModel, scriptEngine); + this.scriptEngine = scriptEngine; } @Override @@ -78,14 +78,6 @@ public class InvocableScriptAdapter implements Invocable { } } - public Object eval() throws ScriptExecutionException { - try { - return scriptEngine.eval(scriptModel.getCode()); - } catch (ScriptException e) { - throw new ScriptExecutionException(scriptModel, e); - } - } - @Override public T getInterface(Class clazz) { return getInvocableEngine().getInterface(clazz); @@ -109,17 +101,6 @@ public class InvocableScriptAdapter implements Invocable { return candidate != null; } - private ScriptEngine loadScriptIntoEngine(ScriptModel script, ScriptEngine engine) { - - try { - engine.eval(script.getCode()); - } catch (ScriptException se) { - throw new ScriptExecutionException(script, se); - } - - return engine; - } - private Invocable getInvocableEngine() { return (Invocable) scriptEngine; } diff --git a/server-spi-private/src/main/java/org/keycloak/scripting/ScriptingProvider.java b/server-spi-private/src/main/java/org/keycloak/scripting/ScriptingProvider.java index 67bad5a9c92..ef2990f6ca7 100644 --- a/server-spi-private/src/main/java/org/keycloak/scripting/ScriptingProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/scripting/ScriptingProvider.java @@ -38,6 +38,14 @@ public interface ScriptingProvider extends Provider { */ InvocableScriptAdapter prepareInvocableScript(ScriptModel scriptModel, ScriptBindingsConfigurer bindingsConfigurer); + /** + * Returns an {@link EvaluatableScriptAdapter} based on the given {@link ScriptModel}. + *

The {@code EvaluatableScriptAdapter} wraps a dedicated {@link ScriptEngine} that was populated with empty bindings.

+ * + * @param scriptModel the scriptModel to wrap + */ + EvaluatableScriptAdapter prepareEvaluatableScript(ScriptModel scriptModel); + /** * Creates a new {@link ScriptModel} instance. * diff --git a/services/src/main/java/org/keycloak/scripting/AbstractEvaluatableScriptAdapter.java b/services/src/main/java/org/keycloak/scripting/AbstractEvaluatableScriptAdapter.java new file mode 100644 index 00000000000..534883a879f --- /dev/null +++ b/services/src/main/java/org/keycloak/scripting/AbstractEvaluatableScriptAdapter.java @@ -0,0 +1,76 @@ +package org.keycloak.scripting; + +import javax.script.Bindings; +import javax.script.ScriptContext; +import javax.script.ScriptEngine; +import javax.script.ScriptException; + +import org.keycloak.models.ScriptModel; + +/** + * Abstract class for wrapping a {@link ScriptModel} to make it evaluatable. + * + * @author Jay Anslow + */ +abstract class AbstractEvaluatableScriptAdapter implements EvaluatableScriptAdapter { + /** + * Holds the {@link ScriptModel}. + */ + private final ScriptModel scriptModel; + + AbstractEvaluatableScriptAdapter(final ScriptModel scriptModel) { + if (scriptModel == null) { + throw new IllegalArgumentException("scriptModel must not be null"); + } + this.scriptModel = scriptModel; + } + + @Override + public Object eval(final ScriptBindingsConfigurer bindingsConfigurer) throws ScriptExecutionException { + return evalUnchecked(createBindings(bindingsConfigurer)); + } + + @Override + public ScriptModel getScriptModel() { + return scriptModel; + } + + /** + * Note, calling this method modifies the underlying {@link ScriptEngine}, + * preventing concurrent use of the ScriptEngine (Nashorn's {@link ScriptEngine} and + * {@link javax.script.CompiledScript} is thread-safe, but {@link Bindings} isn't). + */ + InvocableScriptAdapter prepareInvokableScript(final ScriptBindingsConfigurer bindingsConfigurer) { + final Bindings bindings = createBindings(bindingsConfigurer); + evalUnchecked(bindings); + final ScriptEngine engine = getEngine(); + engine.setBindings(bindings, ScriptContext.ENGINE_SCOPE); + return new InvocableScriptAdapter(scriptModel, engine); + } + + protected String getCode() { + return scriptModel.getCode(); + } + + protected abstract ScriptEngine getEngine(); + + protected abstract Object eval(Bindings bindings) throws ScriptException; + + private Object evalUnchecked(final Bindings bindings) { + try { + return eval(bindings); + } + catch (ScriptException e) { + throw new ScriptExecutionException(scriptModel, e); + } + } + + private Bindings createBindings(final ScriptBindingsConfigurer bindingsConfigurer) { + if (bindingsConfigurer == null) { + throw new IllegalArgumentException("bindingsConfigurer must not be null"); + } + final Bindings bindings = getEngine().createBindings(); + bindingsConfigurer.configureBindings(bindings); + return bindings; + } +} diff --git a/services/src/main/java/org/keycloak/scripting/CompiledEvaluatableScriptAdapter.java b/services/src/main/java/org/keycloak/scripting/CompiledEvaluatableScriptAdapter.java new file mode 100644 index 00000000000..7359dc9233e --- /dev/null +++ b/services/src/main/java/org/keycloak/scripting/CompiledEvaluatableScriptAdapter.java @@ -0,0 +1,40 @@ +package org.keycloak.scripting; + +import javax.script.Bindings; +import javax.script.CompiledScript; +import javax.script.ScriptEngine; +import javax.script.ScriptException; + +import org.keycloak.models.ScriptModel; + +/** + * Wraps a compiled {@link ScriptModel} so it can be evaluated. + * + * @author Jay Anslow + */ +class CompiledEvaluatableScriptAdapter extends AbstractEvaluatableScriptAdapter { + /** + * Holds the {@link CompiledScript} for the {@link ScriptModel}. + */ + private final CompiledScript compiledScript; + + CompiledEvaluatableScriptAdapter(final ScriptModel scriptModel, final CompiledScript compiledScript) { + super(scriptModel); + + if (compiledScript == null) { + throw new IllegalArgumentException("compiledScript must not be null"); + } + + this.compiledScript = compiledScript; + } + + @Override + protected ScriptEngine getEngine() { + return compiledScript.getEngine(); + } + + @Override + protected Object eval(final Bindings bindings) throws ScriptException { + return compiledScript.eval(bindings); + } +} diff --git a/services/src/main/java/org/keycloak/scripting/DefaultScriptingProvider.java b/services/src/main/java/org/keycloak/scripting/DefaultScriptingProvider.java index 601da8e5d5d..d781460183a 100644 --- a/services/src/main/java/org/keycloak/scripting/DefaultScriptingProvider.java +++ b/services/src/main/java/org/keycloak/scripting/DefaultScriptingProvider.java @@ -16,12 +16,14 @@ */ package org.keycloak.scripting; -import org.keycloak.models.ScriptModel; - import javax.script.Bindings; -import javax.script.ScriptContext; +import javax.script.Compilable; +import javax.script.CompiledScript; import javax.script.ScriptEngine; import javax.script.ScriptEngineManager; +import javax.script.ScriptException; + +import org.keycloak.models.ScriptModel; /** * A {@link ScriptingProvider} that uses a {@link ScriptEngineManager} to evaluate scripts with a {@link ScriptEngine}. @@ -32,8 +34,7 @@ public class DefaultScriptingProvider implements ScriptingProvider { private final ScriptEngineManager scriptEngineManager; - public DefaultScriptingProvider(ScriptEngineManager scriptEngineManager) { - + DefaultScriptingProvider(ScriptEngineManager scriptEngineManager) { if (scriptEngineManager == null) { throw new IllegalStateException("scriptEngineManager must not be null!"); } @@ -44,13 +45,22 @@ public class DefaultScriptingProvider implements ScriptingProvider { /** * Wraps the provided {@link ScriptModel} in a {@link javax.script.Invocable} instance with bindings configured through the {@link ScriptBindingsConfigurer}. * - * @param scriptModel must not be {@literal null} + * @param scriptModel must not be {@literal null} * @param bindingsConfigurer must not be {@literal null} - * @return */ @Override public InvocableScriptAdapter prepareInvocableScript(ScriptModel scriptModel, ScriptBindingsConfigurer bindingsConfigurer) { + final AbstractEvaluatableScriptAdapter evaluatable = prepareEvaluatableScript(scriptModel); + return evaluatable.prepareInvokableScript(bindingsConfigurer); + } + /** + * Wraps the provided {@link ScriptModel} in a {@link javax.script.Invocable} instance with bindings configured through the {@link ScriptBindingsConfigurer}. + * + * @param scriptModel must not be {@literal null} + */ + @Override + public AbstractEvaluatableScriptAdapter prepareEvaluatableScript(ScriptModel scriptModel) { if (scriptModel == null) { throw new IllegalArgumentException("script must not be null"); } @@ -59,13 +69,18 @@ public class DefaultScriptingProvider implements ScriptingProvider { throw new IllegalArgumentException("script must not be null or empty"); } - if (bindingsConfigurer == null) { - throw new IllegalArgumentException("bindingsConfigurer must not be null"); + ScriptEngine engine = createPreparedScriptEngine(scriptModel); + + if (engine instanceof Compilable) { + try { + final CompiledScript compiledScript = ((Compilable) engine).compile(scriptModel.getCode()); + return new CompiledEvaluatableScriptAdapter(scriptModel, compiledScript); + } + catch (ScriptException e) { + throw new ScriptExecutionException(scriptModel, e); + } } - - ScriptEngine engine = createPreparedScriptEngine(scriptModel, bindingsConfigurer); - - return new InvocableScriptAdapter(scriptModel, engine); + return new UncompiledEvaluatableScriptAdapter(scriptModel, engine); } //TODO allow scripts to be maintained independently of other components, e.g. with dedicated persistence @@ -74,38 +89,27 @@ public class DefaultScriptingProvider implements ScriptingProvider { @Override public ScriptModel createScript(String realmId, String mimeType, String scriptName, String scriptCode, String scriptDescription) { + return new Script(null /* scriptId */, realmId, scriptName, mimeType, scriptCode, scriptDescription); + } - ScriptModel script = new Script(null /* scriptId */, realmId, scriptName, mimeType, scriptCode, scriptDescription); - return script; + @Override + public void close() { + //NOOP } /** * Looks-up a {@link ScriptEngine} with prepared {@link Bindings} for the given {@link ScriptModel Script}. - * - * @param script - * @param bindingsConfigurer - * @return */ - private ScriptEngine createPreparedScriptEngine(ScriptModel script, ScriptBindingsConfigurer bindingsConfigurer) { - + private ScriptEngine createPreparedScriptEngine(ScriptModel script) { ScriptEngine scriptEngine = lookupScriptEngineFor(script); if (scriptEngine == null) { throw new IllegalStateException("Could not find ScriptEngine for script: " + script); } - configureBindings(bindingsConfigurer, scriptEngine); - return scriptEngine; } - private void configureBindings(ScriptBindingsConfigurer bindingsConfigurer, ScriptEngine engine) { - - Bindings bindings = engine.createBindings(); - bindingsConfigurer.configureBindings(bindings); - engine.setBindings(bindings, ScriptContext.ENGINE_SCOPE); - } - /** * Looks-up a {@link ScriptEngine} based on the MIME-type provided by the given {@link Script}. */ @@ -114,13 +118,9 @@ public class DefaultScriptingProvider implements ScriptingProvider { try { Thread.currentThread().setContextClassLoader(DefaultScriptingProvider.class.getClassLoader()); return scriptEngineManager.getEngineByMimeType(script.getMimeType()); - } finally { + } + finally { Thread.currentThread().setContextClassLoader(cl); } } - - @Override - public void close() { - //NOOP - } } diff --git a/services/src/main/java/org/keycloak/scripting/UncompiledEvaluatableScriptAdapter.java b/services/src/main/java/org/keycloak/scripting/UncompiledEvaluatableScriptAdapter.java new file mode 100644 index 00000000000..8464fdf9799 --- /dev/null +++ b/services/src/main/java/org/keycloak/scripting/UncompiledEvaluatableScriptAdapter.java @@ -0,0 +1,39 @@ +package org.keycloak.scripting; + +import javax.script.Bindings; +import javax.script.ScriptEngine; +import javax.script.ScriptException; + +import org.keycloak.models.ScriptModel; + +/** + * Wraps an uncompiled {@link ScriptModel} so it can be evaluated. + * + * @author Jay Anslow + */ +class UncompiledEvaluatableScriptAdapter extends AbstractEvaluatableScriptAdapter { + /** + * Holds the {@link ScriptEngine} instance. + */ + private final ScriptEngine scriptEngine; + + UncompiledEvaluatableScriptAdapter(final ScriptModel scriptModel, final ScriptEngine scriptEngine) { + super(scriptModel); + if (scriptEngine == null) { + throw new IllegalArgumentException("scriptEngine must not be null"); + } + + this.scriptEngine = scriptEngine; + } + + @Override + protected ScriptEngine getEngine() { + return scriptEngine; + } + + @Override + protected Object eval(final Bindings bindings) throws ScriptException { + return getEngine().eval(getCode(), bindings); + } + +} From a04d79c5760eda51e410dd3289d9eb5c352f8efa Mon Sep 17 00:00:00 2001 From: Jay Anslow Date: Mon, 19 Jun 2017 16:53:07 +0100 Subject: [PATCH 12/18] Cache compiled scripts in JSPolicyProviderFactory --- .../policy/provider/js/JSPolicyProvider.java | 33 +++++------------ .../provider/js/JSPolicyProviderFactory.java | 37 +++++++++++++++++-- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProvider.java b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProvider.java index 47992865ae5..944ae02a90b 100644 --- a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProvider.java +++ b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProvider.java @@ -17,27 +17,30 @@ */ package org.keycloak.authorization.policy.provider.js; +import java.util.function.BiFunction; + import org.keycloak.authorization.AuthorizationProvider; import org.keycloak.authorization.model.Policy; import org.keycloak.authorization.policy.evaluation.Evaluation; import org.keycloak.authorization.policy.provider.PolicyProvider; -import org.keycloak.models.RealmModel; -import org.keycloak.models.ScriptModel; import org.keycloak.scripting.EvaluatableScriptAdapter; -import org.keycloak.scripting.ScriptingProvider; /** * @author Pedro Igor */ -public class JSPolicyProvider implements PolicyProvider { +class JSPolicyProvider implements PolicyProvider { + + private final BiFunction evaluatableScript; + + JSPolicyProvider(final BiFunction evaluatableScript) { + this.evaluatableScript = evaluatableScript; + } @Override public void evaluate(Evaluation evaluation) { Policy policy = evaluation.getPolicy(); - AuthorizationProvider authorization = evaluation.getAuthorizationProvider(); - ScriptModel script = getScriptModel(policy, authorization); - final EvaluatableScriptAdapter adapter = getScriptingProvider(authorization).prepareEvaluatableScript(script); + final EvaluatableScriptAdapter adapter = evaluatableScript.apply(authorization, policy); try { //how to deal with long running scripts -> timeout? @@ -53,21 +56,5 @@ public class JSPolicyProvider implements PolicyProvider { @Override public void close() { - - } - - private ScriptModel getScriptModel(final Policy policy, final AuthorizationProvider authorization) { - String scriptName = policy.getName(); - String scriptCode = policy.getConfig().get("code"); - String scriptDescription = policy.getDescription(); - - RealmModel realm = authorization.getRealm(); - - //TODO lookup script by scriptId instead of creating it every time - return getScriptingProvider(authorization).createScript(realm.getId(), ScriptModel.TEXT_JAVASCRIPT, scriptName, scriptCode, scriptDescription); - } - - private ScriptingProvider getScriptingProvider(final AuthorizationProvider authorization) { - return authorization.getKeycloakSession().getProvider(ScriptingProvider.class); } } diff --git a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProviderFactory.java b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProviderFactory.java index a26129654a2..18dae2a2a06 100644 --- a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProviderFactory.java +++ b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProviderFactory.java @@ -1,5 +1,9 @@ package org.keycloak.authorization.policy.provider.js; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + import org.keycloak.Config; import org.keycloak.authorization.AuthorizationProvider; import org.keycloak.authorization.model.Policy; @@ -7,15 +11,20 @@ import org.keycloak.authorization.policy.provider.PolicyProvider; import org.keycloak.authorization.policy.provider.PolicyProviderFactory; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.ScriptModel; import org.keycloak.representations.idm.authorization.JSPolicyRepresentation; import org.keycloak.representations.idm.authorization.PolicyRepresentation; +import org.keycloak.scripting.EvaluatableScriptAdapter; +import org.keycloak.scripting.ScriptingProvider; /** * @author Pedro Igor */ public class JSPolicyProviderFactory implements PolicyProviderFactory { - private JSPolicyProvider provider = new JSPolicyProvider(); + private final JSPolicyProvider provider = new JSPolicyProvider(this::getEvaluatableScript); + private final Map scripts = Collections.synchronizedMap(new HashMap<>()); @Override public String getName() { @@ -63,8 +72,9 @@ public class JSPolicyProviderFactory implements PolicyProviderFactory { + final ScriptingProvider scripting = authz.getKeycloakSession().getProvider(ScriptingProvider.class); + ScriptModel script = getScriptModel(policy, authz.getRealm(), scripting); + return scripting.prepareEvaluatableScript(script); + }); + } + + private ScriptModel getScriptModel(final Policy policy, final RealmModel realm, final ScriptingProvider scripting) { + String scriptName = policy.getName(); + String scriptCode = policy.getConfig().get("code"); + String scriptDescription = policy.getDescription(); + + //TODO lookup script by scriptId instead of creating it every time + return scripting.createScript(realm.getId(), ScriptModel.TEXT_JAVASCRIPT, scriptName, scriptCode, scriptDescription); + } + + private void updatePolicy(Policy policy, String code) { + policy.putConfig("code", code); + } } From f363dbcad0c7e85dc9bfd2ed42561db946f5991e Mon Sep 17 00:00:00 2001 From: mposolda Date: Tue, 20 Jun 2017 09:21:41 +0200 Subject: [PATCH 13/18] KEYCLOAK-4327 Switching language on User consent gives error --- .../forms/login/LoginFormsProvider.java | 2 + .../AuthenticationProcessor.java | 1 + .../FormAuthenticationFlow.java | 1 + .../RequiredActionContextResult.java | 7 +- .../IdpEmailVerificationAuthenticator.java | 1 + .../FreeMarkerLoginFormsProvider.java | 20 +++- .../managers/AuthenticationManager.java | 5 +- .../util/AuthenticationFlowURLHelper.java | 7 +- .../pages/LanguageComboboxAwarePage.java | 44 +++++++++ .../keycloak/testsuite/pages/LoginPage.java | 18 +--- .../pages/LoginPasswordUpdatePage.java | 2 +- .../testsuite/pages/OAuthGrantPage.java | 2 +- .../testsuite/i18n/LoginPageTest.java | 98 ++++++++++++++++++- 13 files changed, 180 insertions(+), 28 deletions(-) create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LanguageComboboxAwarePage.java diff --git a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java index ac435e3b9c7..cb203c17ce4 100755 --- a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java @@ -120,4 +120,6 @@ public interface LoginFormsProvider extends Provider { public LoginFormsProvider setStatus(Response.Status status); LoginFormsProvider setActionUri(URI requestUri); + + LoginFormsProvider setExecution(String execution); } diff --git a/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java b/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java index 23d06e3d0f8..af7d2f7a3c5 100755 --- a/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java +++ b/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java @@ -471,6 +471,7 @@ public class AuthenticationProcessor { LoginFormsProvider provider = getSession().getProvider(LoginFormsProvider.class) .setUser(getUser()) .setActionUri(action) + .setExecution(getExecution().getId()) .setFormData(request.getDecodedFormParameters()) .setClientSessionCode(accessCode); if (getForwardedErrorMessage() != null) { diff --git a/services/src/main/java/org/keycloak/authentication/FormAuthenticationFlow.java b/services/src/main/java/org/keycloak/authentication/FormAuthenticationFlow.java index 82c12ec5009..575677d0b36 100755 --- a/services/src/main/java/org/keycloak/authentication/FormAuthenticationFlow.java +++ b/services/src/main/java/org/keycloak/authentication/FormAuthenticationFlow.java @@ -270,6 +270,7 @@ public class FormAuthenticationFlow implements AuthenticationFlow { URI actionUrl = getActionUrl(executionId, code); LoginFormsProvider form = processor.getSession().getProvider(LoginFormsProvider.class) .setActionUri(actionUrl) + .setExecution(executionId) .setClientSessionCode(code) .setFormData(formData) .setErrors(errors); diff --git a/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java b/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java index 1d9475a80d4..3afb34ce8e3 100755 --- a/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java +++ b/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java @@ -137,11 +137,15 @@ public class RequiredActionContextResult implements RequiredActionContext { ClientModel client = authenticationSession.getClient(); return LoginActionsService.requiredActionProcessor(getUriInfo()) .queryParam(OAuth2Constants.CODE, code) - .queryParam(Constants.EXECUTION, factory.getId()) + .queryParam(Constants.EXECUTION, getExecution()) .queryParam(Constants.CLIENT_ID, client.getClientId()) .build(getRealm().getName()); } + private String getExecution() { + return factory.getId(); + } + @Override public String generateCode() { ClientSessionCode accessCode = new ClientSessionCode<>(session, getRealm(), getAuthenticationSession()); @@ -164,6 +168,7 @@ public class RequiredActionContextResult implements RequiredActionContext { LoginFormsProvider provider = getSession().getProvider(LoginFormsProvider.class) .setUser(getUser()) .setActionUri(action) + .setExecution(getExecution()) .setClientSessionCode(accessCode); return provider; } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java index 7189b955f6c..ca841d02f0b 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java @@ -169,6 +169,7 @@ public class IdpEmailVerificationAuthenticator extends AbstractIdpAuthenticator .setStatus(Response.Status.OK) .setAttribute(LoginFormsProvider.IDENTITY_PROVIDER_BROKER_CONTEXT, brokerContext) .setActionUri(action) + .setExecution(context.getExecution().getId()) .createIdpLinkEmailPage(); context.forceChallenge(challenge); } diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java index affaf204b0b..d7eb01cf1c2 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java @@ -76,6 +76,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { private Map httpResponseHeaders = new HashMap(); private String accessRequestMessage; private URI actionUri; + private String execution; private List messages = null; private MessageType messageType = MessageType.ERROR; @@ -230,6 +231,11 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { b = UriBuilder.fromUri(baseUri).path(uriInfo.getPath()); break; } + + if (execution != null) { + b.queryParam(Constants.EXECUTION, execution); + } + attributes.put("locale", new LocaleBean(realm, locale, b, messagesBundle)); } } @@ -366,7 +372,13 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { attributes.put("requiredActionUrl", new RequiredActionUrlFormatterMethod(realm, baseUri)); if (realm.isInternationalizationEnabled()) { - UriBuilder b = UriBuilder.fromUri(baseUri).path(uriInfo.getPath()); + UriBuilder b = UriBuilder.fromUri(baseUri) + .path(uriInfo.getPath()); + + if (execution != null) { + b.queryParam(Constants.EXECUTION, execution); + } + attributes.put("locale", new LocaleBean(realm, locale, b, messagesBundle)); } } @@ -590,6 +602,12 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { return this; } + @Override + public LoginFormsProvider setExecution(String execution) { + this.execution = execution; + return this; + } + @Override public LoginFormsProvider setResponseHeader(String headerName, String headerValue) { this.httpResponseHeaders.put(headerName, headerValue); diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java index 07bd1f60bcc..6c917591c3f 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -644,12 +644,15 @@ public class AuthenticationManager { // Skip grant screen if everything was already approved by this user if (realmRoles.size() > 0 || resourceRoles.size() > 0 || protocolMappers.size() > 0) { + String execution = AuthenticatedClientSessionModel.Action.OAUTH_GRANT.name(); + accessCode. setAction(AuthenticatedClientSessionModel.Action.REQUIRED_ACTIONS.name()); - authSession.setAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION, AuthenticatedClientSessionModel.Action.OAUTH_GRANT.name()); + authSession.setAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION, execution); return session.getProvider(LoginFormsProvider.class) + .setExecution(execution) .setClientSessionCode(accessCode.getCode()) .setAccessRequest(realmRoles, resourceRoles, protocolMappers) .createOAuthGrant(); diff --git a/services/src/main/java/org/keycloak/services/util/AuthenticationFlowURLHelper.java b/services/src/main/java/org/keycloak/services/util/AuthenticationFlowURLHelper.java index 3726b99f29b..489f73f5845 100644 --- a/services/src/main/java/org/keycloak/services/util/AuthenticationFlowURLHelper.java +++ b/services/src/main/java/org/keycloak/services/util/AuthenticationFlowURLHelper.java @@ -58,6 +58,7 @@ public class AuthenticationFlowURLHelper { return session.getProvider(LoginFormsProvider.class) .setActionUri(lastStepUrl) + .setExecution(getExecutionId(authSession)) .createLoginExpiredPage(); } @@ -76,7 +77,7 @@ public class AuthenticationFlowURLHelper { public URI getLastExecutionUrl(AuthenticationSessionModel authSession) { - String executionId = authSession.getAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION); + String executionId = getExecutionId(authSession); String latestFlowPath = authSession.getAuthNote(AuthenticationProcessor.CURRENT_FLOW_PATH); if (latestFlowPath == null) { @@ -90,4 +91,8 @@ public class AuthenticationFlowURLHelper { return getLastExecutionUrl(latestFlowPath, executionId, authSession.getClient().getClientId()); } + private String getExecutionId(AuthenticationSessionModel authSession) { + return authSession.getAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION); + } + } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LanguageComboboxAwarePage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LanguageComboboxAwarePage.java new file mode 100644 index 00000000000..29d512e79d2 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LanguageComboboxAwarePage.java @@ -0,0 +1,44 @@ +/* + * Copyright 2016 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.pages; + +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +/** + * @author Marek Posolda + */ +public abstract class LanguageComboboxAwarePage extends AbstractPage { + + @FindBy(id = "kc-current-locale-link") + private WebElement languageText; + + @FindBy(id = "kc-locale-dropdown") + private WebElement localeDropdown; + + public String getLanguageDropdownText() { + return languageText.getText(); + } + + public void openLanguage(String language){ + WebElement langLink = localeDropdown.findElement(By.xpath("//a[text()='" + language + "']")); + String url = langLink.getAttribute("href"); + driver.navigate().to(url); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPage.java index 11d8fb2f7bf..b025ec74d5f 100755 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPage.java @@ -26,7 +26,7 @@ import org.openqa.selenium.support.FindBy; /** * @author Stian Thorgersen */ -public class LoginPage extends AbstractPage { +public class LoginPage extends LanguageComboboxAwarePage { @ArquillianResource protected OAuthClient oauth; @@ -75,12 +75,6 @@ public class LoginPage extends AbstractPage { private WebElement instruction; - @FindBy(id = "kc-current-locale-link") - private WebElement languageText; - - @FindBy(id = "kc-locale-dropdown") - private WebElement localeDropdown; - public void login(String username, String password) { usernameInput.clear(); usernameInput.sendKeys(username); @@ -191,14 +185,4 @@ public class LoginPage extends AbstractPage { assertCurrent(); } - public String getLanguageDropdownText() { - return languageText.getText(); - } - - public void openLanguage(String language){ - WebElement langLink = localeDropdown.findElement(By.xpath("//a[text()='" +language +"']")); - String url = langLink.getAttribute("href"); - driver.navigate().to(url); - } - } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPasswordUpdatePage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPasswordUpdatePage.java index 93d203d97d0..7a963e13362 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPasswordUpdatePage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPasswordUpdatePage.java @@ -22,7 +22,7 @@ import org.openqa.selenium.support.FindBy; /** * @author Stian Thorgersen */ -public class LoginPasswordUpdatePage extends AbstractPage { +public class LoginPasswordUpdatePage extends LanguageComboboxAwarePage { @FindBy(id = "password-new") private WebElement newPasswordInput; diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/OAuthGrantPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/OAuthGrantPage.java index 1a550eca9d1..cfb1f065eac 100755 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/OAuthGrantPage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/OAuthGrantPage.java @@ -22,7 +22,7 @@ import org.openqa.selenium.support.FindBy; /** * @author Stian Thorgersen */ -public class OAuthGrantPage extends AbstractPage { +public class OAuthGrantPage extends LanguageComboboxAwarePage { @FindBy(css = "input[name=\"accept\"]") private WebElement acceptButton; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageTest.java index 5c9ff74971d..2d927198b69 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageTest.java @@ -16,19 +16,30 @@ */ package org.keycloak.testsuite.i18n; +import java.util.Arrays; + import org.apache.http.impl.client.DefaultHttpClient; import org.jboss.resteasy.client.jaxrs.ResteasyClient; import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder; import org.jboss.resteasy.client.jaxrs.engines.ApacheHttpClient4Engine; import org.junit.Assert; import org.junit.Test; +import org.keycloak.OAuth2Constants; import org.keycloak.adapters.HttpClientBuilder; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.models.UserModel; import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.pages.AppPage; +import org.keycloak.testsuite.pages.LanguageComboboxAwarePage; import org.keycloak.testsuite.pages.LoginPage; import javax.ws.rs.core.Response; import org.jboss.arquillian.graphene.page.Page; import org.keycloak.testsuite.ProfileAssume; +import org.keycloak.testsuite.pages.LoginPasswordUpdatePage; +import org.keycloak.testsuite.pages.OAuthGrantPage; import org.keycloak.testsuite.util.IdentityProviderBuilder; /** @@ -37,9 +48,19 @@ import org.keycloak.testsuite.util.IdentityProviderBuilder; */ public class LoginPageTest extends AbstractI18NTest { + @Page + protected AppPage appPage; + @Page protected LoginPage loginPage; + @Page + protected LoginPasswordUpdatePage changePasswordPage; + + @Page + protected OAuthGrantPage grantPage; + + @Override public void configureTestRealm(RealmRepresentation testRealm) { testRealm.addIdentityProvider(IdentityProviderBuilder.create() @@ -63,11 +84,7 @@ public class LoginPageTest extends AbstractI18NTest { loginPage.open(); Assert.assertEquals("English", loginPage.getLanguageDropdownText()); - loginPage.openLanguage("Deutsch"); - Assert.assertEquals("Deutsch", loginPage.getLanguageDropdownText()); - - loginPage.openLanguage("English"); - Assert.assertEquals("English", loginPage.getLanguageDropdownText()); + switchLanguageToGermanAndBack("Username or email", "Benutzername oder E-Mail", loginPage); } @Test @@ -109,6 +126,8 @@ public class LoginPageTest extends AbstractI18NTest { response = client.target(driver.getCurrentUrl()).request().acceptLanguage("en").get(); Assert.assertTrue(response.readEntity(String.class).contains("Log in to test")); + + client.close(); } @Test @@ -119,4 +138,73 @@ public class LoginPageTest extends AbstractI18NTest { Assert.assertEquals("MyOIDC", loginPage.findSocialButton("myoidc").getText()); } + + + // KEYCLOAK-3887 + @Test + public void languageChangeRequiredActions() { + UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost"); + UserRepresentation userRep = user.toRepresentation(); + userRep.setRequiredActions(Arrays.asList(UserModel.RequiredAction.UPDATE_PASSWORD.toString())); + user.update(userRep); + + loginPage.open(); + + loginPage.login("test-user@localhost", "password"); + changePasswordPage.assertCurrent(); + Assert.assertEquals("English", changePasswordPage.getLanguageDropdownText()); + + // Switch language + switchLanguageToGermanAndBack("Update password", "Passwort aktualisieren", changePasswordPage); + + // Update password + changePasswordPage.changePassword("password", "password"); + + Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + } + + + // KEYCLOAK-3887 + @Test + public void languageChangeConsentScreen() { + // Set client, which requires consent + oauth.clientId("third-party"); + + loginPage.open(); + + loginPage.login("test-user@localhost", "password"); + + grantPage.assertCurrent(); + Assert.assertEquals("English", grantPage.getLanguageDropdownText()); + + // Switch language + switchLanguageToGermanAndBack("Do you grant these access privileges?", "Wollen Sie diese Zugriffsrechte", changePasswordPage); + + // Confirm grant + grantPage.accept(); + + Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + + // Revert client + oauth.clientId("test-app"); + } + + + private void switchLanguageToGermanAndBack(String expectedEnglishMessage, String expectedGermanMessage, LanguageComboboxAwarePage page) { + // Switch language to Deutsch + page.openLanguage("Deutsch"); + Assert.assertEquals("Deutsch", page.getLanguageDropdownText()); + String pageSource = driver.getPageSource(); + Assert.assertFalse(pageSource.contains(expectedEnglishMessage)); + Assert.assertTrue(pageSource.contains(expectedGermanMessage)); + + // Revert language + page.openLanguage("English"); + Assert.assertEquals("English", page.getLanguageDropdownText()); + pageSource = driver.getPageSource(); + Assert.assertTrue(pageSource.contains(expectedEnglishMessage)); + Assert.assertFalse(pageSource.contains(expectedGermanMessage)); + } } From 2e2d15be9fa9361b8efc5e330ff83110823912fd Mon Sep 17 00:00:00 2001 From: Hynek Mlnarik Date: Tue, 20 Jun 2017 00:29:19 +0200 Subject: [PATCH 14/18] KEYCLOAK-4189 Infinispan cache and channel statistics for Cross-DC-testing --- ...ltInfinispanConnectionProviderFactory.java | 15 +- .../InfinispanConnectionProvider.java | 1 + ...mote-store.xsl => add-keycloak-caches.xsl} | 8 + .../servers/cache-server/jboss/pom.xml | 22 +- .../java/org/keycloak/testsuite/Retry.java | 0 .../arquillian/AuthServerTestEnricher.java | 1 + .../CacheStatisticsControllerEnricher.java | 356 ++++++++++++++++++ .../arquillian/InfinispanStatistics.java | 88 +++++ .../KeycloakArquillianExtension.java | 4 + .../JmxInfinispanCacheStatistics.java | 66 ++++ .../JmxInfinispanChannelStatistics.java | 55 +++ .../containers/RegistryCreator.java | 11 +- .../arquillian/jmx/JmxConnectorRegistry.java | 30 ++ .../jmx/JmxConnectorRegistryCreator.java | 73 ++++ .../LoadBalancerControllerProvider.java | 6 - .../crossdc/AbstractAdminCrossDCTest.java | 34 ++ .../crossdc/AbstractCrossDCTest.java | 72 +++- .../crossdc/ActionTokenCrossDCTest.java | 57 ++- .../base/src/test/resources/arquillian.xml | 60 ++- .../integration-arquillian/tests/pom.xml | 23 ++ 20 files changed, 933 insertions(+), 49 deletions(-) rename testsuite/integration-arquillian/servers/cache-server/jboss/common/{add-keycloak-remote-store.xsl => add-keycloak-caches.xsl} (80%) rename testsuite/integration-arquillian/tests/base/src/{test => main}/java/org/keycloak/testsuite/Retry.java (100%) create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/CacheStatisticsControllerEnricher.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/InfinispanStatistics.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/annotation/JmxInfinispanCacheStatistics.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/annotation/JmxInfinispanChannelStatistics.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/jmx/JmxConnectorRegistry.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/jmx/JmxConnectorRegistryCreator.java diff --git a/model/infinispan/src/main/java/org/keycloak/connections/infinispan/DefaultInfinispanConnectionProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/connections/infinispan/DefaultInfinispanConnectionProviderFactory.java index 76b07793673..53e496f06c0 100755 --- a/model/infinispan/src/main/java/org/keycloak/connections/infinispan/DefaultInfinispanConnectionProviderFactory.java +++ b/model/infinispan/src/main/java/org/keycloak/connections/infinispan/DefaultInfinispanConnectionProviderFactory.java @@ -156,8 +156,12 @@ public class DefaultInfinispanConnectionProviderFactory implements InfinispanCon String nodeName = config.get("nodeName", System.getProperty(InfinispanConnectionProvider.JBOSS_NODE_NAME)); String jgroupsUdpMcastAddr = config.get("jgroupsUdpMcastAddr", System.getProperty(InfinispanConnectionProvider.JGROUPS_UDP_MCAST_ADDR)); configureTransport(gcb, nodeName, jgroupsUdpMcastAddr); + gcb.globalJmxStatistics() + .jmxDomain(InfinispanConnectionProvider.JMX_DOMAIN + "-" + nodeName); } - gcb.globalJmxStatistics().allowDuplicateDomains(allowDuplicateJMXDomains); + gcb.globalJmxStatistics() + .allowDuplicateDomains(allowDuplicateJMXDomains) + .enable(); cacheManager = new DefaultCacheManager(gcb.build()); containerManaged = false; @@ -339,8 +343,13 @@ public class DefaultInfinispanConnectionProviderFactory implements InfinispanCon channel.setName(nodeName); JGroupsTransport transport = new JGroupsTransport(channel); - gcb.transport().nodeName(nodeName); - gcb.transport().transport(transport); + gcb.transport() + .nodeName(nodeName) + .transport(transport) + .globalJmxStatistics() + .jmxDomain(InfinispanConnectionProvider.JMX_DOMAIN + "-" + nodeName) + .enable() + ; logger.infof("Configured jgroups transport with the channel name: %s", nodeName); } catch (Exception e) { diff --git a/model/infinispan/src/main/java/org/keycloak/connections/infinispan/InfinispanConnectionProvider.java b/model/infinispan/src/main/java/org/keycloak/connections/infinispan/InfinispanConnectionProvider.java index 7fd26521b36..e8cdbf68853 100755 --- a/model/infinispan/src/main/java/org/keycloak/connections/infinispan/InfinispanConnectionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/connections/infinispan/InfinispanConnectionProvider.java @@ -55,6 +55,7 @@ public interface InfinispanConnectionProvider extends Provider { String JBOSS_NODE_NAME = "jboss.node.name"; String JGROUPS_UDP_MCAST_ADDR = "jgroups.udp.mcast_addr"; + String JMX_DOMAIN = "jboss.datagrid-infinispan"; Cache getCache(String name); diff --git a/testsuite/integration-arquillian/servers/cache-server/jboss/common/add-keycloak-remote-store.xsl b/testsuite/integration-arquillian/servers/cache-server/jboss/common/add-keycloak-caches.xsl similarity index 80% rename from testsuite/integration-arquillian/servers/cache-server/jboss/common/add-keycloak-remote-store.xsl rename to testsuite/integration-arquillian/servers/cache-server/jboss/common/add-keycloak-caches.xsl index 0d6f8332be8..ee9c29d419b 100644 --- a/testsuite/integration-arquillian/servers/cache-server/jboss/common/add-keycloak-remote-store.xsl +++ b/testsuite/integration-arquillian/servers/cache-server/jboss/common/add-keycloak-caches.xsl @@ -33,6 +33,14 @@ + + + + + + + diff --git a/testsuite/integration-arquillian/servers/cache-server/jboss/pom.xml b/testsuite/integration-arquillian/servers/cache-server/jboss/pom.xml index 8c7f8303553..9c2d1f9909c 100644 --- a/testsuite/integration-arquillian/servers/cache-server/jboss/pom.xml +++ b/testsuite/integration-arquillian/servers/cache-server/jboss/pom.xml @@ -100,7 +100,7 @@ xml-maven-plugin - configure-adapter-debug-log + configure-keycloak-caches process-test-resources transform @@ -111,8 +111,9 @@ ${cache.server.jboss.home}/standalone/configuration standalone.xml + clustered.xml - ${common.resources}/add-keycloak-remote-store.xsl + ${common.resources}/add-keycloak-caches.xsl ${cache.server.jboss.home}/standalone/configuration @@ -173,6 +174,23 @@ true + + copy-cache-server-configuration-for-dc-2 + process-resources + + copy-resources + + + ${cache.server.jboss.home}/standalone-dc-2/deployments + true + + + ${cache.server.jboss.home}/standalone/deployments + + + true + + diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/Retry.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/Retry.java similarity index 100% rename from testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/Retry.java rename to testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/Retry.java diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/AuthServerTestEnricher.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/AuthServerTestEnricher.java index 94293ddc5d4..97347d9081a 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/AuthServerTestEnricher.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/AuthServerTestEnricher.java @@ -142,6 +142,7 @@ public class AuthServerTestEnricher { containers.stream() .filter(c -> c.getQualifier().startsWith(AUTH_SERVER_CONTAINER + "-cross-dc-")) + .sorted((a, b) -> a.getQualifier().compareTo(b.getQualifier())) .forEach(c -> { String portOffsetString = c.getArquillianContainer().getContainerConfiguration().getContainerProperties().getOrDefault("bindHttpPortOffset", "0"); String dcString = c.getArquillianContainer().getContainerConfiguration().getContainerProperties().getOrDefault("dataCenter", "0"); diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/CacheStatisticsControllerEnricher.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/CacheStatisticsControllerEnricher.java new file mode 100644 index 00000000000..4091ca4db41 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/CacheStatisticsControllerEnricher.java @@ -0,0 +1,356 @@ +package org.keycloak.testsuite.arquillian; + +import org.keycloak.connections.infinispan.InfinispanConnectionProvider; +import org.keycloak.testsuite.Retry; +import java.util.Map; +import org.jboss.arquillian.core.api.Instance; +import org.jboss.arquillian.core.api.annotation.Inject; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.MalformedURLException; +import javax.management.MBeanServerConnection; +import javax.management.ObjectName; +import javax.management.remote.JMXConnector; +import javax.management.remote.JMXServiceURL; +import org.jboss.arquillian.container.spi.Container; +import org.jboss.arquillian.container.spi.ContainerRegistry; +import org.jboss.arquillian.test.spi.TestEnricher; +import java.io.IOException; +import java.lang.reflect.Parameter; +import java.util.Arrays; +import java.util.stream.Collectors; +import javax.management.Attribute; +import javax.management.AttributeNotFoundException; +import javax.management.InstanceNotFoundException; +import javax.management.IntrospectionException; +import javax.management.MBeanAttributeInfo; +import javax.management.MBeanException; +import javax.management.MBeanInfo; +import javax.management.MalformedObjectNameException; +import javax.management.ReflectionException; +import org.keycloak.testsuite.arquillian.annotation.JmxInfinispanCacheStatistics; +import java.util.Set; +import org.keycloak.testsuite.arquillian.annotation.JmxInfinispanChannelStatistics; +import org.keycloak.testsuite.arquillian.jmx.JmxConnectorRegistry; +import org.keycloak.testsuite.arquillian.undertow.KeycloakOnUndertow; +import java.io.NotSerializableException; +import java.lang.management.ManagementFactory; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import org.apache.commons.lang3.reflect.FieldUtils; +import org.jboss.arquillian.core.spi.Validate; +import org.jboss.logging.Logger; + +/** + * + * @author hmlnarik + */ +public class CacheStatisticsControllerEnricher implements TestEnricher { + + private static final Logger LOG = Logger.getLogger(CacheStatisticsControllerEnricher.class); + + @Inject + private Instance registry; + + @Inject + private Instance jmxConnectorRegistry; + + @Inject + private Instance suiteContext; + + @Override + public void enrich(Object testCase) { + Validate.notNull(registry.get(), "registry should not be null"); + Validate.notNull(jmxConnectorRegistry.get(), "jmxConnectorRegistry should not be null"); + Validate.notNull(suiteContext.get(), "suiteContext should not be null"); + + for (Field field : FieldUtils.getAllFields(testCase.getClass())) { + JmxInfinispanCacheStatistics annotation = field.getAnnotation(JmxInfinispanCacheStatistics.class); + + if (annotation == null) { + continue; + } + + try { + FieldUtils.writeField(field, testCase, getInfinispanCacheStatistics(annotation), true); + } catch (IOException | IllegalAccessException | MalformedObjectNameException e) { + throw new RuntimeException("Could not set value on field " + field); + } + } + } + + private InfinispanStatistics getInfinispanCacheStatistics(JmxInfinispanCacheStatistics annotation) throws MalformedObjectNameException, IOException, MalformedURLException { + MBeanServerConnection mbsc = getJmxServerConnection(annotation); + + ObjectName mbeanName = new ObjectName(String.format( + "%s:type=%s,name=\"%s(%s)\",manager=\"%s\",component=%s", + annotation.domain().isEmpty() ? getDefaultDomain(annotation.dcIndex(), annotation.dcNodeIndex()) : InfinispanConnectionProvider.JMX_DOMAIN, + annotation.type(), + annotation.cacheName(), + annotation.cacheMode(), + annotation.cacheManagerName(), + annotation.component() + )); + + InfinispanStatistics value = new InfinispanCacheStatisticsImpl(mbsc, mbeanName); + + if (annotation.domain().isEmpty()) { + try { + Retry.execute(() -> value.reset(), 2, 150); + } catch (RuntimeException ex) { + if (annotation.dcIndex() != -1 && annotation.dcNodeIndex() != -1 + && suiteContext.get().getAuthServerBackendsInfo(annotation.dcIndex()).get(annotation.dcNodeIndex()).isStarted()) { + LOG.warn("Could not reset statistics for " + mbeanName); + } + } + } + + return value; + } + + private InfinispanStatistics getJGroupsChannelStatistics(JmxInfinispanChannelStatistics annotation) throws MalformedObjectNameException, IOException, MalformedURLException { + MBeanServerConnection mbsc = getJmxServerConnection(annotation); + + ObjectName mbeanName = new ObjectName(String.format( + "%s:type=%s,cluster=\"%s\"", + annotation.domain().isEmpty() ? getDefaultDomain(annotation.dcIndex(), annotation.dcNodeIndex()) : InfinispanConnectionProvider.JMX_DOMAIN, + annotation.type(), + annotation.cluster() + )); + + InfinispanStatistics value = new InfinispanChannelStatisticsImpl(mbsc, mbeanName); + + if (annotation.domain().isEmpty()) { + try { + Retry.execute(() -> value.reset(), 2, 150); + } catch (RuntimeException ex) { + if (annotation.dcIndex() != -1 && annotation.dcNodeIndex() != -1 + && suiteContext.get().getAuthServerBackendsInfo(annotation.dcIndex()).get(annotation.dcNodeIndex()).isStarted()) { + LOG.warn("Could not reset statistics for " + mbeanName); + } + } + } + + return value; + } + + @Override + public Object[] resolve(Method method) { + Object[] values = new Object[method.getParameterCount()]; + + for (int i = 0; i < method.getParameterCount(); i ++) { + Parameter param = method.getParameters()[i]; + + JmxInfinispanCacheStatistics annotation = param.getAnnotation(JmxInfinispanCacheStatistics.class); + if (annotation != null) try { + values[i] = getInfinispanCacheStatistics(annotation); + } catch (IOException | MalformedObjectNameException e) { + throw new RuntimeException("Could not set value on field " + param); + } + + JmxInfinispanChannelStatistics channelAnnotation = param.getAnnotation(JmxInfinispanChannelStatistics.class); + if (channelAnnotation != null) try { + values[i] = getJGroupsChannelStatistics(channelAnnotation); + } catch (IOException | MalformedObjectNameException e) { + throw new RuntimeException("Could not set value on field " + param); + } + } + + return values; + } + + private String getDefaultDomain(int dcIndex, int dcNodeIndex) { + if (dcIndex != -1 && dcNodeIndex != -1) { + return InfinispanConnectionProvider.JMX_DOMAIN + "-" + suiteContext.get().getAuthServerBackendsInfo(dcIndex).get(dcNodeIndex).getQualifier(); + } + return InfinispanConnectionProvider.JMX_DOMAIN; + } + + private MBeanServerConnection getJmxServerConnection(JmxInfinispanCacheStatistics annotation) throws MalformedURLException, IOException { + final String host; + final int port; + + if (annotation.dcIndex() != -1 && annotation.dcNodeIndex() != -1) { + ContainerInfo node = suiteContext.get().getAuthServerBackendsInfo(annotation.dcIndex()).get(annotation.dcNodeIndex()); + Container container = node.getArquillianContainer(); + if (container.getDeployableContainer() instanceof KeycloakOnUndertow) { + return ManagementFactory.getPlatformMBeanServer(); + } + host = "localhost"; + port = container.getContainerConfiguration().getContainerProperties().containsKey("managementPort") + ? Integer.valueOf(container.getContainerConfiguration().getContainerProperties().get("managementPort")) + : 9990; + } else { + host = annotation.host().isEmpty() + ? System.getProperty((annotation.hostProperty().isEmpty() + ? "keycloak.connectionsInfinispan.remoteStoreServer" + : annotation.hostProperty())) + : annotation.host(); + + port = annotation.managementPort() == -1 + ? Integer.valueOf(System.getProperty((annotation.managementPortProperty().isEmpty() + ? "cache.server.management.port" + : annotation.managementPortProperty()))) + : annotation.managementPort(); + } + + JMXServiceURL url = new JMXServiceURL("service:jmx:remote+http://" + host + ":" + port); + JMXConnector jmxc = jmxConnectorRegistry.get().getConnection(url); + + return jmxc.getMBeanServerConnection(); + } + + private MBeanServerConnection getJmxServerConnection(JmxInfinispanChannelStatistics annotation) throws MalformedURLException, IOException { + final String host; + final int port; + + if (annotation.dcIndex() != -1 && annotation.dcNodeIndex() != -1) { + ContainerInfo node = suiteContext.get().getAuthServerBackendsInfo(annotation.dcIndex()).get(annotation.dcNodeIndex()); + Container container = node.getArquillianContainer(); + if (container.getDeployableContainer() instanceof KeycloakOnUndertow) { + return ManagementFactory.getPlatformMBeanServer(); + } + host = "localhost"; + port = container.getContainerConfiguration().getContainerProperties().containsKey("managementPort") + ? Integer.valueOf(container.getContainerConfiguration().getContainerProperties().get("managementPort")) + : 9990; + } else { + host = annotation.host().isEmpty() + ? System.getProperty((annotation.hostProperty().isEmpty() + ? "keycloak.connectionsInfinispan.remoteStoreServer" + : annotation.hostProperty())) + : annotation.host(); + + port = annotation.managementPort() == -1 + ? Integer.valueOf(System.getProperty((annotation.managementPortProperty().isEmpty() + ? "cache.server.management.port" + : annotation.managementPortProperty()))) + : annotation.managementPort(); + } + + JMXServiceURL url = new JMXServiceURL("service:jmx:remote+http://" + host + ":" + port); + JMXConnector jmxc = jmxConnectorRegistry.get().getConnection(url); + + return jmxc.getMBeanServerConnection(); + } + + private static abstract class CacheStatisticsImpl implements InfinispanStatistics { + + protected final MBeanServerConnection mbsc; + private final ObjectName mbeanNameTemplate; + private ObjectName mbeanName; + + public CacheStatisticsImpl(MBeanServerConnection mbsc, ObjectName mbeanNameTemplate) { + this.mbsc = mbsc; + this.mbeanNameTemplate = mbeanNameTemplate; + } + + @Override + public boolean exists() { + try { + getMbeanName(); + return true; + } catch (Exception ex) { + return false; + } + } + + @Override + public Map getStatistics() { + try { + MBeanInfo mBeanInfo = mbsc.getMBeanInfo(getMbeanName()); + String[] statAttrs = Arrays.asList(mBeanInfo.getAttributes()).stream() + .filter(MBeanAttributeInfo::isReadable) + .map(MBeanAttributeInfo::getName) + .collect(Collectors.toList()) + .toArray(new String[] {}); + return mbsc.getAttributes(getMbeanName(), statAttrs) + .asList() + .stream() + .collect(Collectors.toMap(Attribute::getName, Attribute::getValue)); + } catch (IOException | InstanceNotFoundException | ReflectionException | IntrospectionException ex) { + throw new RuntimeException(ex); + } + } + + protected ObjectName getMbeanName() throws IOException, RuntimeException { + if (this.mbeanName == null) { + Set queryNames = mbsc.queryNames(mbeanNameTemplate, null); + if (queryNames.isEmpty()) { + throw new RuntimeException("No MBean of template " + mbeanNameTemplate + " found at JMX server"); + } + this.mbeanName = queryNames.iterator().next(); + } + + return this.mbeanName; + } + + @Override + public Comparable getSingleStatistics(String statisticsName) { + try { + return (Comparable) mbsc.getAttribute(getMbeanName(), statisticsName); + } catch (IOException | InstanceNotFoundException | MBeanException | ReflectionException | AttributeNotFoundException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void waitToBecomeAvailable(int time, TimeUnit unit) { + long timeInMillis = TimeUnit.MILLISECONDS.convert(time, unit); + Retry.execute(() -> { + try { + getMbeanName(); + if (! isAvailable()) throw new RuntimeException("Not available"); + } catch (Exception ex) { + throw new RuntimeException("Timed out while waiting for " + mbeanNameTemplate + " to become available", ex); + } + }, 1 + (int) timeInMillis / 100, 100); + } + + protected abstract boolean isAvailable(); + } + + private static class InfinispanCacheStatisticsImpl extends CacheStatisticsImpl { + + public InfinispanCacheStatisticsImpl(MBeanServerConnection mbsc, ObjectName mbeanName) { + super(mbsc, mbeanName); + } + + @Override + public void reset() { + try { + mbsc.invoke(getMbeanName(), "resetStatistics", new Object[] {}, new String[] {}); + } catch (IOException | InstanceNotFoundException | MBeanException | ReflectionException ex) { + throw new RuntimeException(ex); + } + } + + @Override + protected boolean isAvailable() { + return getSingleStatistics(Constants.STAT_CACHE_ELAPSED_TIME) != null; + } + } + + private static class InfinispanChannelStatisticsImpl extends CacheStatisticsImpl { + + public InfinispanChannelStatisticsImpl(MBeanServerConnection mbsc, ObjectName mbeanName) { + super(mbsc, mbeanName); + } + + @Override + public void reset() { + try { + mbsc.invoke(getMbeanName(), "resetStats", new Object[] {}, new String[] {}); + } catch (NotSerializableException ex) { + // Ignore return value not serializable, the invocation has already done its job + } catch (IOException | InstanceNotFoundException | MBeanException | ReflectionException ex) { + throw new RuntimeException(ex); + } + } + + @Override + protected boolean isAvailable() { + return Objects.equals(getSingleStatistics(Constants.STAT_CHANNEL_CONNECTED), Boolean.TRUE); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/InfinispanStatistics.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/InfinispanStatistics.java new file mode 100644 index 00000000000..b315937d360 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/InfinispanStatistics.java @@ -0,0 +1,88 @@ +/* + * Copyright 2017 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.arquillian; + +import org.keycloak.connections.infinispan.InfinispanConnectionProvider; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * + * @author hmlnarik + */ +public interface InfinispanStatistics { + + public static class Constants { + public static final String DOMAIN_INFINISPAN_DATAGRID = InfinispanConnectionProvider.JMX_DOMAIN; + + public static final String TYPE_CHANNEL = "channel"; + public static final String TYPE_CACHE = "Cache"; + public static final String TYPE_CACHE_MANAGER = "CacheManager"; + + public static final String COMPONENT_STATISTICS = "Statistics"; + + /** Cache statistics */ + public static final String STAT_CACHE_AVERAGE_READ_TIME = "averageReadTime"; + public static final String STAT_CACHE_AVERAGE_WRITE_TIME = "averageWriteTime"; + public static final String STAT_CACHE_ELAPSED_TIME = "elapsedTime"; + public static final String STAT_CACHE_EVICTIONS = "evictions"; + public static final String STAT_CACHE_HITS = "hits"; + public static final String STAT_CACHE_HIT_RATIO = "hitRatio"; + public static final String STAT_CACHE_MISSES = "misses"; + public static final String STAT_CACHE_NUMBER_OF_ENTRIES = "numberOfEntries"; + public static final String STAT_CACHE_NUMBER_OF_ENTRIES_IN_MEMORY = "numberOfEntriesInMemory"; + public static final String STAT_CACHE_READ_WRITE_RATIO = "readWriteRatio"; + public static final String STAT_CACHE_REMOVE_HITS = "removeHits"; + public static final String STAT_CACHE_REMOVE_MISSES = "removeMisses"; + public static final String STAT_CACHE_STORES = "stores"; + public static final String STAT_CACHE_TIME_SINCE_RESET = "timeSinceReset"; + + /** JGroups channel statistics */ + public static final String STAT_CHANNEL_ADDRESS = "address"; + public static final String STAT_CHANNEL_ADDRESS_UUID = "address_uuid"; + public static final String STAT_CHANNEL_CLOSED = "closed"; + public static final String STAT_CHANNEL_CLUSTER_NAME = "cluster_name"; + public static final String STAT_CHANNEL_CONNECTED = "connected"; + public static final String STAT_CHANNEL_CONNECTING = "connecting"; + public static final String STAT_CHANNEL_DISCARD_OWN_MESSAGES = "discard_own_messages"; + public static final String STAT_CHANNEL_OPEN = "open"; + public static final String STAT_CHANNEL_RECEIVED_BYTES = "received_bytes"; + public static final String STAT_CHANNEL_RECEIVED_MESSAGES = "received_messages"; + public static final String STAT_CHANNEL_SENT_BYTES = "sent_bytes"; + public static final String STAT_CHANNEL_SENT_MESSAGES = "sent_messages"; + public static final String STAT_CHANNEL_STATE = "state"; + public static final String STAT_CHANNEL_STATS = "stats"; + public static final String STAT_CHANNEL_VIEW = "view"; + + } + + Map getStatistics(); + + Comparable getSingleStatistics(String statisticsName); + + void waitToBecomeAvailable(int time, TimeUnit unit); + + /** + * Resets the statistics counters. + */ + void reset(); + + /** + * Returns {@code true} iff the statistics represented by this object can be retrieved from the server. + */ + boolean exists(); +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/KeycloakArquillianExtension.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/KeycloakArquillianExtension.java index 7757b076d11..33dc8c21401 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/KeycloakArquillianExtension.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/KeycloakArquillianExtension.java @@ -32,6 +32,7 @@ import org.jboss.arquillian.graphene.location.CustomizableURLResourceProvider; import org.jboss.arquillian.test.spi.enricher.resource.ResourceProvider; import org.jboss.arquillian.test.spi.execution.TestExecutionDecider; import org.keycloak.testsuite.arquillian.h2.H2TestEnricher; +import org.keycloak.testsuite.arquillian.jmx.JmxConnectorRegistryCreator; import org.keycloak.testsuite.arquillian.karaf.CustomKarafContainer; import org.keycloak.testsuite.arquillian.migration.MigrationTestExecutionDecider; import org.keycloak.testsuite.arquillian.provider.AdminClientProvider; @@ -44,6 +45,7 @@ import org.keycloak.testsuite.drone.HtmlUnitScreenshots; import org.keycloak.testsuite.drone.KeycloakDronePostSetup; import org.keycloak.testsuite.drone.KeycloakHtmlUnitInstantiator; import org.keycloak.testsuite.drone.KeycloakWebDriverConfigurator; +import org.jboss.arquillian.test.spi.TestEnricher; /** * @@ -65,6 +67,8 @@ public class KeycloakArquillianExtension implements LoadableExtension { .service(DeploymentScenarioGenerator.class, DeploymentTargetModifier.class) .service(ApplicationArchiveProcessor.class, DeploymentArchiveProcessor.class) .service(DeployableContainer.class, CustomKarafContainer.class) + .service(TestEnricher.class, CacheStatisticsControllerEnricher.class) + .observer(JmxConnectorRegistryCreator.class) .observer(AuthServerTestEnricher.class) .observer(AppServerTestEnricher.class) .observer(H2TestEnricher.class); diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/annotation/JmxInfinispanCacheStatistics.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/annotation/JmxInfinispanCacheStatistics.java new file mode 100644 index 00000000000..2dd7bbc2305 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/annotation/JmxInfinispanCacheStatistics.java @@ -0,0 +1,66 @@ +/* + * Copyright 2017 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.arquillian.annotation; + +import org.keycloak.testsuite.arquillian.AuthServerTestEnricher; +import org.keycloak.testsuite.arquillian.InfinispanStatistics; +import org.keycloak.testsuite.arquillian.InfinispanStatistics.Constants; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation for a field / method parameter annotating {@link InfinispanStatistics} object that would be used + * to access statistics via JMX. By default, the access to "work" cache at remote infinispan / JDG server is requested + * yet the same annotation is used for other caches as well. + * + * @author hmlnarik + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +public @interface JmxInfinispanCacheStatistics { + + /** JMX domain. Should be set to default (@{code ""}) if the node to get the statistics from should be obtained from {@link #dcIndex()} and {@link #dcNodeIndex()}. */ + String domain() default ""; + + // JMX address properties + String type() default Constants.TYPE_CACHE; + String cacheName() default "work"; + String cacheMode() default "*"; + String cacheManagerName() default "*"; + String component() default Constants.COMPONENT_STATISTICS; + + // Host address - either given by arrangement of DC ... + + /** Index of the data center, starting from 0 */ + int dcIndex() default -1; + /** Index of the node within data center, starting from 0. Nodes are ordered by arquillian qualifier as per {@link AuthServerTestEnricher} */ + int dcNodeIndex() default -1; + + // ... or by specific host/port + + /** Port for management */ + int managementPort() default -1; + /** Name of system property to obtain management port from */ + String managementPortProperty() default ""; + /** Host name to connect to */ + String host() default ""; + /** Name of system property to obtain host name from */ + String hostProperty() default ""; + +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/annotation/JmxInfinispanChannelStatistics.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/annotation/JmxInfinispanChannelStatistics.java new file mode 100644 index 00000000000..41e9f20f512 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/annotation/JmxInfinispanChannelStatistics.java @@ -0,0 +1,55 @@ +/* + * Copyright 2017 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.arquillian.annotation; + +import org.keycloak.testsuite.arquillian.InfinispanStatistics.Constants; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * + * @author hmlnarik + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +public @interface JmxInfinispanChannelStatistics { + + /** JMX domain. Should be set to default (@{code ""}) if the node to get the statistics from should be obtained from {@link #dcIndex()} and {@link #dcNodeIndex()}. */ + String domain() default ""; + + // JMX address properties + String type() default Constants.TYPE_CHANNEL; + String cluster() default "*"; + + // Host address - either given by arrangement of DC ... + + /** Index of the data center, starting from 0 */ + int dcIndex() default -1; + /** Index of the node within data center, starting from 0. Nodes are ordered by arquillian qualifier as per {@link AuthServerTestEnricher} */ + int dcNodeIndex() default -1; + + /** Port for management */ + int managementPort() default -1; + /** Name of system property to obtain management port from */ + String managementPortProperty() default ""; + /** Host name to connect to */ + String host() default ""; + /** Name of system property to obtain host name from */ + String hostProperty() default ""; +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/containers/RegistryCreator.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/containers/RegistryCreator.java index a2b6ea735d1..41278fc8784 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/containers/RegistryCreator.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/containers/RegistryCreator.java @@ -35,6 +35,7 @@ import java.util.Collection; import java.util.List; import java.util.Map; +import org.mvel2.MVEL; import static org.keycloak.testsuite.arquillian.containers.SecurityActions.isClassPresent; import static org.keycloak.testsuite.arquillian.containers.SecurityActions.loadClass; @@ -97,10 +98,14 @@ public class RegistryCreator { private static final String ENABLED = "enabled"; - private boolean isEnabled(ContainerDef containerDef) { + private static boolean isEnabled(ContainerDef containerDef) { Map props = containerDef.getContainerProperties(); - return !props.containsKey(ENABLED) - || (props.containsKey(ENABLED) && props.get(ENABLED).equals("true")); + try { + return !props.containsKey(ENABLED) + || (props.containsKey(ENABLED) && ! props.get(ENABLED).isEmpty() && MVEL.evalToBoolean(props.get(ENABLED), (Object) null)); + } catch (Exception ex) { + return false; + } } @SuppressWarnings("rawtypes") diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/jmx/JmxConnectorRegistry.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/jmx/JmxConnectorRegistry.java new file mode 100644 index 00000000000..3a87c5bd93b --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/jmx/JmxConnectorRegistry.java @@ -0,0 +1,30 @@ +/* + * Copyright 2017 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.arquillian.jmx; + +import javax.management.remote.JMXConnector; +import javax.management.remote.JMXServiceURL; + +/** + * + * @author hmlnarik + */ +public interface JmxConnectorRegistry { + JMXConnector getConnection(JMXServiceURL url); + + void closeAll(); +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/jmx/JmxConnectorRegistryCreator.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/jmx/JmxConnectorRegistryCreator.java new file mode 100644 index 00000000000..50c9b965c65 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/jmx/JmxConnectorRegistryCreator.java @@ -0,0 +1,73 @@ +/* + * Copyright 2017 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.arquillian.jmx; + +import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import javax.management.remote.JMXConnector; +import javax.management.remote.JMXConnectorFactory; +import javax.management.remote.JMXServiceURL; +import org.jboss.arquillian.core.api.InstanceProducer; +import org.jboss.arquillian.core.api.annotation.ApplicationScoped; +import org.jboss.arquillian.core.api.annotation.Inject; +import org.jboss.arquillian.core.api.annotation.Observes; +import org.jboss.arquillian.test.spi.event.suite.BeforeSuite; + +/** + * + * @author hmlnarik + */ +public class JmxConnectorRegistryCreator { + + @Inject + @ApplicationScoped + private InstanceProducer connectorRegistry; + + public void configureJmxConnectorRegistry(@Observes BeforeSuite event) { + if (connectorRegistry.get() == null) { + connectorRegistry.set(new JmxConnectorRegistry() { + + private volatile ConcurrentMap connectors = new ConcurrentHashMap<>(); + + @Override + public JMXConnector getConnection(JMXServiceURL url) { + JMXConnector res = connectors.get(url); + if (res == null) { + try { + final JMXConnector conn = JMXConnectorFactory.newJMXConnector(url, null); + res = connectors.putIfAbsent(url, conn); + if (res == null) { + res = conn; + } + res.connect(); + } catch (IOException ex) { + throw new RuntimeException("Could not instantiate JMX connector for " + url, ex); + } + } + return res; + } + + @Override + public void closeAll() { + connectors.values().forEach(c -> { try { c.close(); } catch (IOException e) {} }); + connectors.clear(); + } + }); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/provider/LoadBalancerControllerProvider.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/provider/LoadBalancerControllerProvider.java index 4f99feb06fd..af1703d6cbf 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/provider/LoadBalancerControllerProvider.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/provider/LoadBalancerControllerProvider.java @@ -2,14 +2,8 @@ package org.keycloak.testsuite.arquillian.provider; import org.keycloak.testsuite.arquillian.annotation.LoadBalancer; import java.lang.annotation.Annotation; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import org.jboss.arquillian.container.spi.event.KillContainer; -import org.jboss.arquillian.container.spi.event.StartContainer; -import org.jboss.arquillian.container.spi.event.StopContainer; import org.jboss.arquillian.core.api.Instance; import org.jboss.arquillian.core.api.annotation.Inject; -import org.jboss.arquillian.core.api.annotation.Observes; import org.jboss.arquillian.test.api.ArquillianResource; import org.jboss.arquillian.test.spi.enricher.resource.ResourceProvider; import org.keycloak.testsuite.arquillian.LoadBalancerController; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/AbstractAdminCrossDCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/AbstractAdminCrossDCTest.java index 84527f74b13..2baa336cf96 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/AbstractAdminCrossDCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/AbstractAdminCrossDCTest.java @@ -19,13 +19,20 @@ package org.keycloak.testsuite.crossdc; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.events.log.JBossLoggingEventListenerProviderFactory; import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.testsuite.Retry; +import org.keycloak.testsuite.arquillian.InfinispanStatistics; import org.keycloak.testsuite.events.EventsListenerProviderFactory; import org.keycloak.testsuite.util.TestCleanup; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import org.hamcrest.Matcher; import org.junit.Before; +import static org.junit.Assert.assertThat; /** * @@ -78,4 +85,31 @@ public abstract class AbstractAdminCrossDCTest extends AbstractCrossDCTest { protected TestCleanup getCleanup() { return getCleanup(REALM_NAME); } + + protected void assertSingleStatistics(InfinispanStatistics stats, String key, Runnable testedCode, Function> matcherOnOldStat) { + stats.reset(); + + T oldStat = (T) stats.getSingleStatistics(key); + testedCode.run(); + + Retry.execute(() -> { + T newStat = (T) stats.getSingleStatistics(key); + + Matcher matcherInstance = matcherOnOldStat.apply(oldStat); + assertThat(newStat, matcherInstance); + }, 5, 200); + } + + protected void assertStatistics(InfinispanStatistics stats, Runnable testedCode, BiConsumer, Map> assertionOnStats) { + stats.reset(); + + Map oldStat = stats.getStatistics(); + testedCode.run(); + + Retry.execute(() -> { + Map newStat = stats.getStatistics(); + assertionOnStats.accept(oldStat, newStat); + }, 5, 200); + } + } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/AbstractCrossDCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/AbstractCrossDCTest.java index aa674caa411..c88c0c14aa1 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/AbstractCrossDCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/AbstractCrossDCTest.java @@ -32,14 +32,21 @@ import org.jboss.arquillian.test.api.ArquillianResource; import org.junit.After; import org.junit.Before; +import static org.hamcrest.Matchers.lessThan; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; + /** * * @author hmlnarik */ public abstract class AbstractCrossDCTest extends AbstractTestRealmKeycloakTest { + // Keep the following constants in sync with arquillian + public static final String QUALIFIER_NODE_BALANCER = "auth-server-balancer-cross-dc"; + @ArquillianResource - @LoadBalancer(value = "auth-server-balancer-cross-dc") + @LoadBalancer(value = QUALIFIER_NODE_BALANCER) protected LoadBalancerController loadBalancerCtrl; @ArquillianResource @@ -103,6 +110,11 @@ public abstract class AbstractCrossDCTest extends AbstractTestRealmKeycloakTest return Keycloak.getInstance(node.getContextRoot() + "/auth", AuthRealm.MASTER, AuthRealm.ADMIN, AuthRealm.ADMIN, Constants.ADMIN_CLI_CLIENT_ID); } + /** + * Creates admin client directed to the given node. + * @param node + * @return + */ protected Keycloak getAdminClientFor(ContainerInfo node) { Keycloak adminClient = backendAdminClients.get(node); if (adminClient == null && node.equals(suiteContext.getAuthServerInfo())) { @@ -111,13 +123,17 @@ public abstract class AbstractCrossDCTest extends AbstractTestRealmKeycloakTest return adminClient; } + /** + * Disables routing requests to the given data center in the load balancer. + * @param dcIndex + */ public void disableDcOnLoadBalancer(int dcIndex) { log.infof("Disabling load balancer for dc=%d", dcIndex); this.suiteContext.getDcAuthServerBackendsInfo().get(dcIndex).forEach(c -> loadBalancerCtrl.disableBackendNodeByName(c.getQualifier())); } /** - * Enables all started nodes in the given data center + * Enables routing requests to all started nodes to the given data center in the load balancer. * @param dcIndex */ public void enableDcOnLoadBalancer(int dcIndex) { @@ -132,11 +148,21 @@ public abstract class AbstractCrossDCTest extends AbstractTestRealmKeycloakTest } } + /** + * Disables routing requests to the given node within the given data center in the load balancer. + * @param dcIndex + * @param nodeIndex + */ public void disableLoadBalancerNode(int dcIndex, int nodeIndex) { log.infof("Disabling load balancer for dc=%d, node=%d", dcIndex, nodeIndex); loadBalancerCtrl.disableBackendNodeByName(this.suiteContext.getDcAuthServerBackendsInfo().get(dcIndex).get(nodeIndex).getQualifier()); } + /** + * Enables routing requests to the given node within the given data center in the load balancer. + * @param dcIndex + * @param nodeIndex + */ public void enableLoadBalancerNode(int dcIndex, int nodeIndex) { log.infof("Enabling load balancer for dc=%d, node=%d", dcIndex, nodeIndex); final ContainerInfo backendNode = this.suiteContext.getDcAuthServerBackendsInfo().get(dcIndex).get(nodeIndex); @@ -149,11 +175,53 @@ public abstract class AbstractCrossDCTest extends AbstractTestRealmKeycloakTest loadBalancerCtrl.enableBackendNodeByName(backendNode.getQualifier()); } + /** + * Starts a manually-controlled backend auth-server node in cross-DC scenario. + * @param dcIndex + * @param nodeIndex + * @return Started instance descriptor. + */ + public ContainerInfo startBackendNode(int dcIndex, int nodeIndex) { + assertThat((Integer) dcIndex, lessThan(this.suiteContext.getDcAuthServerBackendsInfo().size())); + final List dcNodes = this.suiteContext.getDcAuthServerBackendsInfo().get(dcIndex); + assertThat((Integer) nodeIndex, lessThan(dcNodes.size())); + ContainerInfo dcNode = dcNodes.get(nodeIndex); + assertTrue("Node " + dcNode.getQualifier() + " has to be controlled manually", dcNode.isManual()); + containerController.start(dcNode.getQualifier()); + return dcNode; + } + + /** + * Stops a manually-controlled backend auth-server node in cross-DC scenario. + * @param dcIndex + * @param nodeIndex + * @return Stopped instance descriptor. + */ + public ContainerInfo stopBackendNode(int dcIndex, int nodeIndex) { + assertThat((Integer) dcIndex, lessThan(this.suiteContext.getDcAuthServerBackendsInfo().size())); + final List dcNodes = this.suiteContext.getDcAuthServerBackendsInfo().get(dcIndex); + assertThat((Integer) nodeIndex, lessThan(dcNodes.size())); + ContainerInfo dcNode = dcNodes.get(nodeIndex); + assertTrue("Node " + dcNode.getQualifier() + " has to be controlled manually", dcNode.isManual()); + containerController.stop(dcNode.getQualifier()); + return dcNode; + } + + /** + * Returns stream of all nodes in the given dc that are started manually. + * @param dcIndex + * @return + */ public Stream getManuallyStartedBackendNodes(int dcIndex) { final List dcNodes = this.suiteContext.getDcAuthServerBackendsInfo().get(dcIndex); return dcNodes.stream().filter(ContainerInfo::isManual); } + /** + * Returns stream of all nodes in the given dc that are started automatically. + * @param dcIndex + * @return + */ public Stream getAutomaticallyStartedBackendNodes(int dcIndex) { final List dcNodes = this.suiteContext.getDcAuthServerBackendsInfo().get(dcIndex); return dcNodes.stream().filter(c -> ! c.isManual()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/ActionTokenCrossDCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/ActionTokenCrossDCTest.java index 45e757149fa..dbef2fcc9bb 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/ActionTokenCrossDCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/ActionTokenCrossDCTest.java @@ -17,16 +17,13 @@ package org.keycloak.testsuite.crossdc; import org.keycloak.admin.client.resource.UserResource; -import org.keycloak.events.admin.OperationType; -import org.keycloak.events.admin.ResourceType; +import org.keycloak.connections.infinispan.InfinispanConnectionProvider; import org.keycloak.models.UserModel; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.Retry; import org.keycloak.testsuite.admin.ApiUtil; -import org.keycloak.testsuite.arquillian.ContainerInfo; import org.keycloak.testsuite.page.LoginPasswordUpdatePage; import org.keycloak.testsuite.pages.ErrorPage; -import org.keycloak.testsuite.util.AdminEventPaths; import org.keycloak.testsuite.util.GreenMailRule; import org.keycloak.testsuite.util.MailUtils; import java.io.IOException; @@ -36,12 +33,20 @@ import javax.mail.MessagingException; import javax.mail.internet.MimeMessage; import javax.ws.rs.core.Response; import org.jboss.arquillian.graphene.page.Page; -import org.jboss.arquillian.test.api.ArquillianResource; import org.junit.Assert; import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import static org.junit.Assert.assertEquals; +import org.keycloak.testsuite.arquillian.annotation.JmxInfinispanCacheStatistics; +import org.keycloak.testsuite.arquillian.annotation.JmxInfinispanChannelStatistics; +import org.keycloak.testsuite.arquillian.InfinispanStatistics; +import org.keycloak.testsuite.arquillian.InfinispanStatistics.Constants; +import java.util.concurrent.TimeUnit; +import org.hamcrest.Matchers; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThat; /** * @@ -69,7 +74,16 @@ public class ActionTokenCrossDCTest extends AbstractAdminCrossDCTest { } @Test - public void sendResetPasswordEmailSuccessWorksInCrossDc() throws IOException, MessagingException { + public void sendResetPasswordEmailSuccessWorksInCrossDc( + @JmxInfinispanCacheStatistics(dcIndex=0, dcNodeIndex=0, cacheName=InfinispanConnectionProvider.ACTION_TOKEN_CACHE) InfinispanStatistics cacheDc0Node0Statistics, + @JmxInfinispanCacheStatistics(dcIndex=0, dcNodeIndex=1, cacheName=InfinispanConnectionProvider.ACTION_TOKEN_CACHE) InfinispanStatistics cacheDc0Node1Statistics, + @JmxInfinispanCacheStatistics(dcIndex=1, dcNodeIndex=0, cacheName=InfinispanConnectionProvider.ACTION_TOKEN_CACHE) InfinispanStatistics cacheDc1Node0Statistics, + @JmxInfinispanChannelStatistics() InfinispanStatistics channelStatisticsCrossDc) throws Exception { + startBackendNode(0, 1); + cacheDc0Node1Statistics.waitToBecomeAvailable(10, TimeUnit.SECONDS); + + Comparable originalNumberOfEntries = cacheDc0Node0Statistics.getSingleStatistics(Constants.STAT_CACHE_NUMBER_OF_ENTRIES); + UserRepresentation userRep = new UserRepresentation(); userRep.setEnabled(true); userRep.setUsername("user1"); @@ -88,21 +102,33 @@ public class ActionTokenCrossDCTest extends AbstractAdminCrossDCTest { String link = MailUtils.getPasswordResetEmailLink(message); - driver.navigate().to(link); + Retry.execute(() -> channelStatisticsCrossDc.reset(), 3, 100); + + assertSingleStatistics(cacheDc0Node0Statistics, Constants.STAT_CACHE_NUMBER_OF_ENTRIES, + () -> driver.navigate().to(link), + Matchers::is + ); passwordUpdatePage.assertCurrent(); - passwordUpdatePage.changePassword("new-pass", "new-pass"); + // Verify that there was at least one message sent via the channel + assertSingleStatistics(channelStatisticsCrossDc, Constants.STAT_CHANNEL_SENT_MESSAGES, + () -> passwordUpdatePage.changePassword("new-pass", "new-pass"), + old -> greaterThan((Comparable) 0l) + ); + + // Verify that the caches are synchronized + assertThat(cacheDc0Node0Statistics.getSingleStatistics(Constants.STAT_CACHE_NUMBER_OF_ENTRIES), greaterThan(originalNumberOfEntries)); + assertThat(cacheDc0Node0Statistics.getSingleStatistics(Constants.STAT_CACHE_NUMBER_OF_ENTRIES), + is(cacheDc1Node0Statistics.getSingleStatistics(Constants.STAT_CACHE_NUMBER_OF_ENTRIES))); assertEquals("Your account has been updated.", driver.getTitle()); disableDcOnLoadBalancer(0); enableDcOnLoadBalancer(1); - Retry.execute(() -> { - driver.navigate().to(link); - errorPage.assertCurrent(); - }, 3, 400); + driver.navigate().to(link); + errorPage.assertCurrent(); } @Ignore("KEYCLOAK-5030") @@ -144,9 +170,10 @@ public class ActionTokenCrossDCTest extends AbstractAdminCrossDCTest { loadBalancerCtrl.enableBackendNodeByName(c.getQualifier()); }); - driver.navigate().to(link); - - errorPage.assertCurrent(); + Retry.execute(() -> { + driver.navigate().to(link); + errorPage.assertCurrent(); + }, 3, 400); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/arquillian.xml b/testsuite/integration-arquillian/tests/base/src/test/resources/arquillian.xml index ecf986fadad..aa8649e3c7f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/arquillian.xml +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/arquillian.xml @@ -49,7 +49,7 @@ - ${auth.server.undertow} + ${auth.server.undertow} && ! ${auth.server.undertow.crossdc} localhost org.keycloak.testsuite.arquillian.undertow.KeycloakOnUndertow ${auth.server.http.port} @@ -169,12 +169,12 @@ - + ${auth.server.undertow.crossdc} org.jboss.as.arquillian.container.managed.ManagedDeployableContainer ${cache.server.home} - standalone.xml + clustered.xml -Djboss.socket.binding.port-offset=${cache.server.port.offset} -Djboss.default.multicast.address=234.56.78.99 @@ -192,30 +192,54 @@ + + + ${auth.server.undertow.crossdc} + org.jboss.as.arquillian.container.managed.ManagedDeployableContainer + ${cache.server.home} + true + ${cache.server.home}/standalone-dc-2 + clustered.xml + + -Djboss.socket.binding.port-offset=${cache.server.2.port.offset} + -Djboss.default.multicast.address=234.56.78.99 + -Djboss.node.name=cache-server-dc-2 + ${adapter.test.props} + ${auth.server.profile} + + + ${auth.server.memory.settings} + -Djava.net.preferIPv4Stack=true + + ${cache.server.console.output} + ${cache.server.2.management.port} + ${auth.server.jboss.startup.timeout} + + + ${auth.server.undertow.crossdc} org.keycloak.testsuite.arquillian.undertow.lb.SimpleUndertowLoadBalancerContainer localhost ${auth.server.http.port} - 5 auth-server-undertow-cross-dc-0.1=http://localhost:8101,auth-server-undertow-cross-dc-0.2-manual=http://localhost:8102,auth-server-undertow-cross-dc-1.1=http://localhost:8111,auth-server-undertow-cross-dc-1.2-manual=http://localhost:8112 - + ${auth.server.undertow.crossdc} org.keycloak.testsuite.arquillian.undertow.KeycloakOnUndertow localhost ${auth.server.http.port} -79 - auth-server-undertow-cross-dc-0.1 + auth-server-undertow-cross-dc-0_1 ${undertow.remote} 0 { "keycloak.connectionsInfinispan.jgroupsUdpMcastAddr": "234.56.78.1", - "keycloak.connectionsInfinispan.nodeName": "auth-server-undertow-cross-dc-0.1", + "keycloak.connectionsInfinispan.nodeName": "auth-server-undertow-cross-dc-0_1", "keycloak.connectionsInfinispan.clustered": "${keycloak.connectionsInfinispan.clustered:true}", "keycloak.connectionsInfinispan.remoteStoreServer": "${keycloak.connectionsInfinispan.remoteStoreServer:localhost}", "keycloak.connectionsInfinispan.remoteStorePort": "${keycloak.connectionsInfinispan.remoteStorePort:11222}", @@ -226,19 +250,19 @@ } - + ${auth.server.undertow.crossdc} org.keycloak.testsuite.arquillian.undertow.KeycloakOnUndertow localhost ${auth.server.http.port} -78 - auth-server-undertow-cross-dc-0.2 + auth-server-undertow-cross-dc-0_2-manual ${undertow.remote} 0 { "keycloak.connectionsInfinispan.jgroupsUdpMcastAddr": "234.56.78.1", - "keycloak.connectionsInfinispan.nodeName": "auth-server-undertow-cross-dc-0.2", + "keycloak.connectionsInfinispan.nodeName": "auth-server-undertow-cross-dc-0_2-manual", "keycloak.connectionsInfinispan.clustered": "${keycloak.connectionsInfinispan.clustered:true}", "keycloak.connectionsInfinispan.remoteStoreServer": "${keycloak.connectionsInfinispan.remoteStoreServer:localhost}", "keycloak.connectionsInfinispan.remoteStorePort": "${keycloak.connectionsInfinispan.remoteStorePort:11222}", @@ -250,22 +274,22 @@ - + ${auth.server.undertow.crossdc} org.keycloak.testsuite.arquillian.undertow.KeycloakOnUndertow localhost ${auth.server.http.port} -69 - auth-server-undertow-cross-dc-1.1 + auth-server-undertow-cross-dc-1_1 ${undertow.remote} 1 { "keycloak.connectionsInfinispan.jgroupsUdpMcastAddr": "234.56.78.2", - "keycloak.connectionsInfinispan.nodeName": "auth-server-undertow-cross-dc-1.1", + "keycloak.connectionsInfinispan.nodeName": "auth-server-undertow-cross-dc-1_1", "keycloak.connectionsInfinispan.clustered": "${keycloak.connectionsInfinispan.clustered:true}", "keycloak.connectionsInfinispan.remoteStoreServer": "${keycloak.connectionsInfinispan.remoteStoreServer:localhost}", - "keycloak.connectionsInfinispan.remoteStorePort": "${keycloak.connectionsInfinispan.remoteStorePort:11222}", + "keycloak.connectionsInfinispan.remoteStorePort": "${keycloak.connectionsInfinispan.remoteStorePort.2:11222}", "keycloak.connectionsInfinispan.remoteStoreEnabled": "${keycloak.connectionsInfinispan.remoteStoreEnabled:true}", "keycloak.connectionsJpa.url": "${keycloak.connectionsJpa.url.crossdc:jdbc:h2:mem:test-dc-shared}", "keycloak.connectionsJpa.driver": "${keycloak.connectionsJpa.driver.crossdc:org.h2.Driver}", @@ -273,22 +297,22 @@ } - + ${auth.server.undertow.crossdc} org.keycloak.testsuite.arquillian.undertow.KeycloakOnUndertow localhost ${auth.server.http.port} -68 - auth-server-undertow-cross-dc-1.2 + auth-server-undertow-cross-dc-1_2-manual ${undertow.remote} 1 { "keycloak.connectionsInfinispan.jgroupsUdpMcastAddr": "234.56.78.2", - "keycloak.connectionsInfinispan.nodeName": "auth-server-undertow-cross-dc-1.2", + "keycloak.connectionsInfinispan.nodeName": "auth-server-undertow-cross-dc-1_2-manual", "keycloak.connectionsInfinispan.clustered": "${keycloak.connectionsInfinispan.clustered:true}", "keycloak.connectionsInfinispan.remoteStoreServer": "${keycloak.connectionsInfinispan.remoteStoreServer:localhost}", - "keycloak.connectionsInfinispan.remoteStorePort": "${keycloak.connectionsInfinispan.remoteStorePort:11222}", + "keycloak.connectionsInfinispan.remoteStorePort": "${keycloak.connectionsInfinispan.remoteStorePort.2:11222}", "keycloak.connectionsInfinispan.remoteStoreEnabled": "${keycloak.connectionsInfinispan.remoteStoreEnabled:true}", "keycloak.connectionsJpa.url": "${keycloak.connectionsJpa.url.crossdc:jdbc:h2:mem:test-dc-shared}", "keycloak.connectionsJpa.driver": "${keycloak.connectionsJpa.driver.crossdc:org.h2.Driver}", diff --git a/testsuite/integration-arquillian/tests/pom.xml b/testsuite/integration-arquillian/tests/pom.xml index 5ff3e023d0d..e5bba34e6c5 100755 --- a/testsuite/integration-arquillian/tests/pom.xml +++ b/testsuite/integration-arquillian/tests/pom.xml @@ -73,9 +73,12 @@ ${containers.home}/${cache.server.container} 1010 11000 + 2010 + 12000 true localhost 12232 + 13232 jdbc:h2:mem:test-dc-shared @@ -176,6 +179,23 @@ + + maven-antrun-plugin + + + clean-second-cache-server-arquillian-bug-workaround + process-test-resources + run + + + ${cache.server.home}/standalone-dc-2 + + + + + + + maven-surefire-plugin @@ -252,8 +272,11 @@ ${cache.server.home} ${cache.server.console.output} ${cache.server.management.port} + ${cache.server.2.port.offset} + ${cache.server.2.management.port} ${keycloak.connectionsInfinispan.remoteStorePort} + ${keycloak.connectionsInfinispan.remoteStorePort.2} ${keycloak.connectionsInfinispan.remoteStoreServer} ${keycloak.connectionsJpa.url.crossdc} From 32cf8b7cad2c6a1cd2d582766373db05d9490e7c Mon Sep 17 00:00:00 2001 From: mposolda Date: Tue, 20 Jun 2017 16:38:57 +0200 Subject: [PATCH 15/18] KEYCLOAK-3316 Fixes for OAuth2 requests without 'scope=openid' --- .../oidc/endpoints/AuthorizationEndpoint.java | 10 +- .../protocol/oidc/utils/RedirectUtils.java | 31 ++++++- .../org/keycloak/services/ServicesLogger.java | 2 +- .../keycloak/testsuite/util/OAuthClient.java | 4 +- .../OAuth2OnlyTest.java} | 92 ++++++++++++++++--- 5 files changed, 120 insertions(+), 19 deletions(-) rename testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/{oidc/ScopeParameterTest.java => oauth/OAuth2OnlyTest.java} (55%) diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java index 26d012b0e11..3a7e4c0e369 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java @@ -269,6 +269,12 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase { } private Response checkOIDCParams() { + // If request is not OIDC request, but pure OAuth2 request and response_type is just 'token', then 'nonce' is not mandatory + boolean isOIDCRequest = TokenUtil.isOIDCRequest(request.getScope()); + if (!isOIDCRequest && parsedResponseType.toString().equals(OIDCResponseType.TOKEN)) { + return null; + } + if (parsedResponseType.isImplicitOrHybridFlow() && request.getNonce() == null) { ServicesLogger.LOGGER.missingParameter(OIDCLoginProtocol.NONCE_PARAM); event.error(Errors.INVALID_REQUEST); @@ -354,10 +360,12 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase { private void checkRedirectUri() { String redirectUriParam = request.getRedirectUriParam(); + boolean isOIDCRequest = TokenUtil.isOIDCRequest(request.getScope()); event.detail(Details.REDIRECT_URI, redirectUriParam); - redirectUri = RedirectUtils.verifyRedirectUri(uriInfo, redirectUriParam, realm, client); + // redirect_uri parameter is required per OpenID Connect, but optional per OAuth2 + redirectUri = RedirectUtils.verifyRedirectUri(uriInfo, redirectUriParam, realm, client, isOIDCRequest); if (redirectUri == null) { event.error(Errors.INVALID_REDIRECT_URI); throw new ErrorPageException(session, Messages.INVALID_PARAMETER, OIDCLoginProtocol.REDIRECT_URI_PARAM); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/RedirectUtils.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/RedirectUtils.java index 60f5493c22d..c61bdd072cc 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/utils/RedirectUtils.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/utils/RedirectUtils.java @@ -26,6 +26,7 @@ import org.keycloak.services.Urls; import javax.ws.rs.core.UriInfo; import java.net.URI; +import java.util.Collection; import java.util.HashSet; import java.util.Set; @@ -38,12 +39,16 @@ public class RedirectUtils { public static String verifyRealmRedirectUri(UriInfo uriInfo, String redirectUri, RealmModel realm) { Set validRedirects = getValidateRedirectUris(uriInfo, realm); - return verifyRedirectUri(uriInfo, null, redirectUri, realm, validRedirects); + return verifyRedirectUri(uriInfo, null, redirectUri, realm, validRedirects, true); } public static String verifyRedirectUri(UriInfo uriInfo, String redirectUri, RealmModel realm, ClientModel client) { + return verifyRedirectUri(uriInfo, redirectUri, realm, client, true); + } + + public static String verifyRedirectUri(UriInfo uriInfo, String redirectUri, RealmModel realm, ClientModel client, boolean requireRedirectUri) { if (client != null) - return verifyRedirectUri(uriInfo, client.getRootUrl(), redirectUri, realm, client.getRedirectUris()); + return verifyRedirectUri(uriInfo, client.getRootUrl(), redirectUri, realm, client.getRedirectUris(), requireRedirectUri); return null; } @@ -69,10 +74,16 @@ public class RedirectUtils { return redirects; } - private static String verifyRedirectUri(UriInfo uriInfo, String rootUrl, String redirectUri, RealmModel realm, Set validRedirects) { + private static String verifyRedirectUri(UriInfo uriInfo, String rootUrl, String redirectUri, RealmModel realm, Set validRedirects, boolean requireRedirectUri) { if (redirectUri == null) { - logger.debug("No Redirect URI parameter specified"); - return null; + if (!requireRedirectUri) { + redirectUri = getSingleValidRedirectUri(validRedirects); + } + + if (redirectUri == null) { + logger.debug("No Redirect URI parameter specified"); + return null; + } } else if (validRedirects.isEmpty()) { logger.debug("No Redirect URIs supplied"); redirectUri = null; @@ -149,4 +160,14 @@ public class RedirectUtils { return false; } + private static String getSingleValidRedirectUri(Collection validRedirects) { + if (validRedirects.size() != 1) return null; + String validRedirect = validRedirects.iterator().next(); + int idx = validRedirect.indexOf("/*"); + if (idx > -1) { + validRedirect = validRedirect.substring(0, idx); + } + return validRedirect; + } + } diff --git a/services/src/main/java/org/keycloak/services/ServicesLogger.java b/services/src/main/java/org/keycloak/services/ServicesLogger.java index bd239a6e519..2d6c4738429 100644 --- a/services/src/main/java/org/keycloak/services/ServicesLogger.java +++ b/services/src/main/java/org/keycloak/services/ServicesLogger.java @@ -406,7 +406,7 @@ public interface ServicesLogger extends BasicLogger { void failedToCloseProviderSession(@Cause Throwable t); @LogMessage(level = WARN) - @Message(id=91, value="Request is missing scope 'openid' so it's not treated as OIDC, but just pure OAuth2 request. This can have impact in future versions (eg. removed IDToken from the Token Response)") + @Message(id=91, value="Request is missing scope 'openid' so it's not treated as OIDC, but just pure OAuth2 request.") @Once void oidcScopeMissing(); diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java index 4c89eaa6bbb..207a317e963 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -102,7 +102,7 @@ public class OAuthClient { private String maxAge; - private String responseType = OAuth2Constants.CODE; + private String responseType; private String responseMode; @@ -171,6 +171,8 @@ public class OAuthClient { clientSessionState = null; clientSessionHost = null; maxAge = null; + responseType = OAuth2Constants.CODE; + responseMode = null; nonce = null; request = null; requestUri = null; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/ScopeParameterTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuth2OnlyTest.java similarity index 55% rename from testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/ScopeParameterTest.java rename to testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuth2OnlyTest.java index 6096ab2f0d5..ca4dacdf074 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/ScopeParameterTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuth2OnlyTest.java @@ -15,19 +15,25 @@ * limitations under the License. */ -package org.keycloak.testsuite.oidc; +package org.keycloak.testsuite.oauth; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import javax.ws.rs.core.UriBuilder; +import org.hamcrest.Matchers; import org.jboss.arquillian.graphene.page.Page; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.keycloak.OAuth2Constants; import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.models.ClientModel; import org.keycloak.representations.AccessToken; +import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; @@ -35,6 +41,7 @@ import org.keycloak.testsuite.ActionURIUtils; import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.admin.AbstractAdminTest; +import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.pages.AccountUpdateProfilePage; import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.ErrorPage; @@ -46,9 +53,11 @@ import org.keycloak.testsuite.util.OAuthClient; import static org.junit.Assert.assertEquals; /** + * Test for scenarios when 'scope=openid' is missing. Which means we have pure OAuth2 request (not OpenID Connect) + * * @author Marek Posolda */ -public class ScopeParameterTest extends AbstractTestRealmKeycloakTest { +public class OAuth2OnlyTest extends AbstractTestRealmKeycloakTest { @Rule public AssertEvents events = new AssertEvents(this); @@ -71,6 +80,18 @@ public class ScopeParameterTest extends AbstractTestRealmKeycloakTest { @Override public void configureTestRealm(RealmRepresentation testRealm) { + ClientRepresentation client = new ClientRepresentation(); + client.setClientId("more-uris-client"); + client.setEnabled(true); + client.setRedirectUris(Arrays.asList("http://localhost:8180/auth/realms/master/app/auth", "http://localhost:8180/foo")); + client.setBaseUrl("http://localhost:8180/auth/realms/master/app/auth"); + + testRealm.getClients().add(client); + + ClientRepresentation testApp = testRealm.getClients().stream() + .filter(cl -> cl.getClientId().equals("test-app")) + .findFirst().get(); + testApp.setImplicitFlowEnabled(true); } @Before @@ -82,20 +103,13 @@ public class ScopeParameterTest extends AbstractTestRealmKeycloakTest { * will faile and the clientID will always be "sample-public-client * @see AccessTokenTest#testAuthorizationNegotiateHeaderIgnored() */ - oauth.clientId("test-app"); - oauth.maxAge(null); - } - - @Override - public void addTestRealms(List testRealms) { - RealmRepresentation realm = AbstractAdminTest.loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class); - testRealms.add(realm); + oauth.init(adminClient, driver); } // If scope=openid is missing, IDToken won't be present @Test - public void testMissingScopeOpenid() { + public void testMissingIDToken() { String loginFormUrl = oauth.getLoginFormUrl(); loginFormUrl = ActionURIUtils.removeQueryParamFromURI(loginFormUrl, OAuth2Constants.SCOPE); @@ -139,4 +153,60 @@ public class ScopeParameterTest extends AbstractTestRealmKeycloakTest { Assert.assertEquals(accessToken.getPreferredUsername(), "test-user@localhost"); } + + + // In OAuth2, it is allowed that redirect_uri is not mandatory as long as client has just 1 redirect_uri configured without wildcard + @Test + public void testMissingRedirectUri() throws Exception { + // OAuth2 login without redirect_uri. It will be allowed. + String loginFormUrl = oauth.getLoginFormUrl(); + loginFormUrl = ActionURIUtils.removeQueryParamFromURI(loginFormUrl, OAuth2Constants.SCOPE); + loginFormUrl = ActionURIUtils.removeQueryParamFromURI(loginFormUrl, OAuth2Constants.REDIRECT_URI); + + driver.navigate().to(loginFormUrl); + loginPage.assertCurrent(); + oauth.fillLoginForm("test-user@localhost", "password"); + events.expectLogin().assertEvent(); + + // Client 'more-uris-client' has 2 redirect uris. OAuth2 login without redirect_uri won't be allowed + oauth.clientId("more-uris-client"); + loginFormUrl = oauth.getLoginFormUrl(); + loginFormUrl = ActionURIUtils.removeQueryParamFromURI(loginFormUrl, OAuth2Constants.SCOPE); + loginFormUrl = ActionURIUtils.removeQueryParamFromURI(loginFormUrl, OAuth2Constants.REDIRECT_URI); + + driver.navigate().to(loginFormUrl); + errorPage.assertCurrent(); + Assert.assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); + events.expectLogin() + .error(Errors.INVALID_REDIRECT_URI) + .client("more-uris-client") + .user(Matchers.nullValue(String.class)) + .session(Matchers.nullValue(String.class)) + .removeDetail(Details.REDIRECT_URI) + .removeDetail(Details.CODE_ID) + .removeDetail(Details.CONSENT) + .assertEvent(); + } + + + // In OAuth2 (when response_type=token and no scope=openid) we don't treat nonce parameter mandatory + @Test + public void testMissingNonceInOAuth2ImplicitFlow() throws Exception { + oauth.responseType("token"); + oauth.nonce(null); + String loginFormUrl = oauth.getLoginFormUrl(); + loginFormUrl = ActionURIUtils.removeQueryParamFromURI(loginFormUrl, OAuth2Constants.SCOPE); + + driver.navigate().to(loginFormUrl); + loginPage.assertCurrent(); + oauth.fillLoginForm("test-user@localhost", "password"); + events.expectLogin().assertEvent(); + + OAuthClient.AuthorizationEndpointResponse response = new OAuthClient.AuthorizationEndpointResponse(oauth); + Assert.assertNull(response.getError()); + Assert.assertNull(response.getCode()); + Assert.assertNull(response.getIdToken()); + Assert.assertNotNull(response.getAccessToken()); + } + } From c7a20d76202288305e35083a6e56cff59a506c8e Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Tue, 20 Jun 2017 15:26:18 -0300 Subject: [PATCH 16/18] [KEYCLOAK-5702] - Caching scripts and removing when updating --- .../provider/js/JSPolicyProviderFactory.java | 15 +- .../policy/provider/js/ScriptCache.java | 173 ++++++++++++++++++ 2 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/ScriptCache.java diff --git a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProviderFactory.java b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProviderFactory.java index 18dae2a2a06..e3e82ce6757 100644 --- a/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProviderFactory.java +++ b/authz/policy/common/src/main/java/org/keycloak/authorization/policy/provider/js/JSPolicyProviderFactory.java @@ -1,9 +1,5 @@ package org.keycloak.authorization.policy.provider.js; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - import org.keycloak.Config; import org.keycloak.authorization.AuthorizationProvider; import org.keycloak.authorization.model.Policy; @@ -24,7 +20,7 @@ import org.keycloak.scripting.ScriptingProvider; public class JSPolicyProviderFactory implements PolicyProviderFactory { private final JSPolicyProvider provider = new JSPolicyProvider(this::getEvaluatableScript); - private final Map scripts = Collections.synchronizedMap(new HashMap<>()); + private ScriptCache scriptCache; @Override public String getName() { @@ -74,12 +70,14 @@ public class JSPolicyProviderFactory implements PolicyProviderFactory { + return scriptCache.computeIfAbsent(policy.getId(), id -> { final ScriptingProvider scripting = authz.getKeycloakSession().getProvider(ScriptingProvider.class); ScriptModel script = getScriptModel(policy, authz.getRealm(), scripting); return scripting.prepareEvaluatableScript(script); @@ -115,6 +113,7 @@ public class JSPolicyProviderFactory implements PolicyProviderFactoryPedro Igor + */ +public class ScriptCache { + + /** + * The load factor. + */ + private static final float DEFAULT_LOAD_FACTOR = 0.75f; + + private final Map cache; + + private final AtomicBoolean writing = new AtomicBoolean(false); + + private final long maxAge; + + /** + * Creates a new instance. + * + * @param maxEntries the maximum number of entries to keep in the cache + */ + public ScriptCache(int maxEntries) { + this(maxEntries, -1); + } + + /** + * Creates a new instance. + * + * @param maxEntries the maximum number of entries to keep in the cache + * @param maxAge the time in milliseconds that an entry can stay in the cache. If {@code -1}, entries never expire + */ + public ScriptCache(final int maxEntries, long maxAge) { + cache = new LinkedHashMap(16, DEFAULT_LOAD_FACTOR, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return cache.size() > maxEntries; + } + }; + this.maxAge = maxAge; + } + + public EvaluatableScriptAdapter computeIfAbsent(String id, Function function) { + try { + if (parkForWriteAndCheckInterrupt()) { + return null; + } + + CacheEntry entry = cache.computeIfAbsent(id, key -> new CacheEntry(key, function.apply(id), maxAge)); + + if (entry != null) { + return entry.value(); + } + + return null; + } finally { + writing.lazySet(false); + } + } + + public EvaluatableScriptAdapter get(String uri) { + if (parkForReadAndCheckInterrupt()) { + return null; + } + + CacheEntry cached = cache.get(uri); + + if (cached != null) { + return removeIfExpired(cached); + } + + return null; + } + + public void remove(String key) { + try { + if (parkForWriteAndCheckInterrupt()) { + return; + } + + cache.remove(key); + } finally { + writing.lazySet(false); + } + } + + private EvaluatableScriptAdapter removeIfExpired(CacheEntry cached) { + if (cached == null) { + return null; + } + + if (cached.isExpired()) { + remove(cached.key()); + return null; + } + + return cached.value(); + } + + private boolean parkForWriteAndCheckInterrupt() { + while (!writing.compareAndSet(false, true)) { + LockSupport.parkNanos(1L); + if (Thread.interrupted()) { + return true; + } + } + return false; + } + + private boolean parkForReadAndCheckInterrupt() { + while (writing.get()) { + LockSupport.parkNanos(1L); + if (Thread.interrupted()) { + return true; + } + } + return false; + } + + private static final class CacheEntry { + + final String key; + final EvaluatableScriptAdapter value; + final long expiration; + + CacheEntry(String key, EvaluatableScriptAdapter value, long maxAge) { + this.key = key; + this.value = value; + if(maxAge == -1) { + expiration = -1; + } else { + expiration = System.currentTimeMillis() + maxAge; + } + } + + String key() { + return key; + } + + EvaluatableScriptAdapter value() { + return value; + } + + boolean isExpired() { + return expiration != -1 ? System.currentTimeMillis() > expiration : false; + } + } +} From e91dd011c5fbcdce36499c00214ce089b842d5a7 Mon Sep 17 00:00:00 2001 From: mposolda Date: Tue, 20 Jun 2017 14:50:00 +0200 Subject: [PATCH 17/18] KEYCLOAK-4438 Disable kerberos flow when provider removed --- .../util/ConcurrentMultivaluedHashMap.java | 19 +++-- .../KerberosFederationProviderFactory.java | 5 ++ .../ldap/LDAPStorageProviderFactory.java | 10 ++- .../org/keycloak/models/jpa/RealmAdapter.java | 6 +- .../keycloak/models/utils/ComponentUtil.java | 12 ++++ .../keycloak/component/ComponentFactory.java | 12 ++++ .../testsuite/arquillian/TestContext.java | 9 ++- .../keycloak/testsuite/util/TestCleanup.java | 69 ++++++++----------- .../testsuite/admin/UserStorageRestTest.java | 59 ++++++++++++++++ 9 files changed, 151 insertions(+), 50 deletions(-) diff --git a/common/src/main/java/org/keycloak/common/util/ConcurrentMultivaluedHashMap.java b/common/src/main/java/org/keycloak/common/util/ConcurrentMultivaluedHashMap.java index c092c6f9d17..258f0765467 100755 --- a/common/src/main/java/org/keycloak/common/util/ConcurrentMultivaluedHashMap.java +++ b/common/src/main/java/org/keycloak/common/util/ConcurrentMultivaluedHashMap.java @@ -31,9 +31,9 @@ public class ConcurrentMultivaluedHashMap extends ConcurrentHashMap list = new CopyOnWriteArrayList<>(); + List list = createListInstance(); list.add(value); - put(key, list); + put(key, list); // Just override with new List instance } public void addAll(K key, V... newValues) @@ -84,8 +84,15 @@ public class ConcurrentMultivaluedHashMap extends ConcurrentHashMap getList(K key) { List list = get(key); - if (list == null) - put(key, list = new CopyOnWriteArrayList()); + + if (list == null) { + list = createListInstance(); + List existing = putIfAbsent(key, list); + if (existing != null) { + list = existing; + } + } + return list; } @@ -97,4 +104,8 @@ public class ConcurrentMultivaluedHashMap extends ConcurrentHashMap createListInstance() { + return new CopyOnWriteArrayList<>(); + } + } diff --git a/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosFederationProviderFactory.java b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosFederationProviderFactory.java index e061f5eeb39..75e9d181bc4 100755 --- a/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosFederationProviderFactory.java +++ b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosFederationProviderFactory.java @@ -156,4 +156,9 @@ public class KerberosFederationProviderFactory implements UserStorageProviderFac AuthenticationExecutionModel.Requirement.ALTERNATIVE, AuthenticationExecutionModel.Requirement.DISABLED); } + @Override + public void preRemove(KeycloakSession session, RealmModel realm, ComponentModel model) { + CredentialHelper.setOrReplaceAuthenticationRequirement(session, realm, CredentialRepresentation.KERBEROS, + AuthenticationExecutionModel.Requirement.DISABLED, null); + } } diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProviderFactory.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProviderFactory.java index c962af886dd..0d4c07bdd52 100755 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProviderFactory.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProviderFactory.java @@ -384,8 +384,14 @@ public class LDAPStorageProviderFactory implements UserStorageProviderFactory { ComponentEntity c = em.find(ComponentEntity.class, component.getId()); if (c == null) return; session.users().preRemove(this, component); + ComponentUtil.notifyPreRemove(session, this, component); removeComponents(component.getId()); getEntity().getComponents().remove(c); } @@ -1896,7 +1897,10 @@ public class RealmAdapter implements RealmModel, JpaModel { getEntity().getComponents().stream() .filter(sameParent) .map(this::entityToModel) - .forEach(c -> session.users().preRemove(this, c)); + .forEach((ComponentModel c) -> { + session.users().preRemove(this, c); + ComponentUtil.notifyPreRemove(session, this, c); + }); getEntity().getComponents().removeIf(sameParent); } diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ComponentUtil.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ComponentUtil.java index 1d41f0dd4b1..0c603c44c6d 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ComponentUtil.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ComponentUtil.java @@ -17,6 +17,7 @@ package org.keycloak.models.utils; +import org.jboss.logging.Logger; import org.keycloak.component.ComponentFactory; import org.keycloak.component.ComponentModel; import org.keycloak.models.KeycloakSession; @@ -38,6 +39,8 @@ import java.util.Map; */ public class ComponentUtil { + private static final Logger logger = Logger.getLogger(ComponentUtil.class); + public static Map getComponentConfigProperties(KeycloakSession session, ComponentRepresentation component) { return getComponentConfigProperties(session, component.getProviderType(), component.getProviderId()); } @@ -102,5 +105,14 @@ public class ComponentUtil { ((OnUpdateComponent)session.userStorageManager()).onUpdate(session, realm, oldModel, newModel); } } + public static void notifyPreRemove(KeycloakSession session, RealmModel realm, ComponentModel model) { + try { + ComponentFactory factory = getComponentFactory(session, model); + factory.preRemove(session, realm, model); + } catch (IllegalArgumentException iae) { + // We allow to remove broken providers without throwing an exception + logger.warn(iae.getMessage()); + } + } } diff --git a/server-spi/src/main/java/org/keycloak/component/ComponentFactory.java b/server-spi/src/main/java/org/keycloak/component/ComponentFactory.java index 695637fc0a9..b4c6f446109 100644 --- a/server-spi/src/main/java/org/keycloak/component/ComponentFactory.java +++ b/server-spi/src/main/java/org/keycloak/component/ComponentFactory.java @@ -79,6 +79,18 @@ public interface ComponentFactory ex } + /** + * Called before the component is removed. + * + * @param session + * @param realm + * @param model model of the component, which is going to be removed + */ + default + void preRemove(KeycloakSession session, RealmModel realm, ComponentModel model) { + + } + /** * These are config properties that are common across all implementation of this component type * diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/TestContext.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/TestContext.java index 30b0405c51c..cfe6d84565c 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/TestContext.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/TestContext.java @@ -20,6 +20,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import javax.ws.rs.NotFoundException; @@ -54,7 +55,7 @@ public final class TestContext { private boolean initialized; // Key is realmName, value are objects to clean after the test method - private Map cleanups = new HashMap<>(); + private Map cleanups = new ConcurrentHashMap<>(); public TestContext(SuiteContext suiteContext, Class testClass) { this.suiteContext = suiteContext; @@ -146,7 +147,11 @@ public final class TestContext { TestCleanup cleanup = cleanups.get(realmName); if (cleanup == null) { cleanup = new TestCleanup(adminClient, realmName); - cleanups.put(realmName, cleanup); + TestCleanup existing = cleanups.putIfAbsent(realmName, cleanup); + + if (existing != null) { + cleanup = existing; + } } return cleanup; } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/TestCleanup.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/TestCleanup.java index 17ff44a9c61..192712e1801 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/TestCleanup.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/TestCleanup.java @@ -17,13 +17,13 @@ package org.keycloak.testsuite.util; -import java.util.LinkedList; import java.util.List; import javax.ws.rs.NotFoundException; import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.common.util.ConcurrentMultivaluedHashMap; /** * Enlist resources to be cleaned after test method @@ -32,18 +32,21 @@ import org.keycloak.admin.client.resource.RealmResource; */ public class TestCleanup { + private static final String IDENTITY_PROVIDER_ALIASES = "IDENTITY_PROVIDER_ALIASES"; + private static final String USER_IDS = "USER_IDS"; + private static final String COMPONENT_IDS = "COMPONENT_IDS"; + private static final String CLIENT_UUIDS = "CLIENT_UUIDS"; + private static final String ROLE_IDS = "ROLE_IDS"; + private static final String GROUP_IDS = "GROUP_IDS"; + private static final String AUTH_FLOW_IDS = "AUTH_FLOW_IDS"; + private static final String AUTH_CONFIG_IDS = "AUTH_CONFIG_IDS"; + private final Keycloak adminClient; private final String realmName; + // Key is kind of entity (eg. "client", "role", "user" etc), Values are all kind of entities of given type to cleanup + private ConcurrentMultivaluedHashMap entities = new ConcurrentMultivaluedHashMap<>(); - private List identityProviderAliases; - private List userIds; - private List componentIds; - private List clientUuids; - private List roleIds; - private List groupIds; - private List authFlowIds; - private List authConfigIds; public TestCleanup(Keycloak adminClient, String realmName) { this.adminClient = adminClient; @@ -52,72 +55,49 @@ public class TestCleanup { public void addUserId(String userId) { - if (userIds == null) { - userIds = new LinkedList<>(); - } - userIds.add(userId); + entities.add(USER_IDS, userId); } public void addIdentityProviderAlias(String identityProviderAlias) { - if (identityProviderAliases == null) { - identityProviderAliases = new LinkedList<>(); - } - identityProviderAliases.add(identityProviderAlias); + entities.add(IDENTITY_PROVIDER_ALIASES, identityProviderAlias); } public void addComponentId(String componentId) { - if (componentIds == null) { - componentIds = new LinkedList<>(); - } - componentIds.add(componentId); + entities.add(COMPONENT_IDS, componentId); } public void addClientUuid(String clientUuid) { - if (clientUuids == null) { - clientUuids = new LinkedList<>(); - } - clientUuids.add(clientUuid); + entities.add(CLIENT_UUIDS, clientUuid); } public void addRoleId(String roleId) { - if (roleIds == null) { - roleIds = new LinkedList<>(); - } - roleIds.add(roleId); + entities.add(ROLE_IDS, roleId); } public void addGroupId(String groupId) { - if (groupIds == null) { - groupIds = new LinkedList<>(); - } - groupIds.add(groupId); + entities.add(GROUP_IDS, groupId); } public void addAuthenticationFlowId(String flowId) { - if (authFlowIds == null) { - authFlowIds = new LinkedList<>(); - } - authFlowIds.add(flowId); + entities.add(AUTH_FLOW_IDS, flowId); } public void addAuthenticationConfigId(String executionConfigId) { - if (authConfigIds == null) { - authConfigIds = new LinkedList<>(); - } - authConfigIds.add(executionConfigId); + entities.add(AUTH_CONFIG_IDS, executionConfigId); } public void executeCleanup() { RealmResource realm = adminClient.realm(realmName); + List userIds = entities.get(USER_IDS); if (userIds != null) { for (String userId : userIds) { try { @@ -128,6 +108,7 @@ public class TestCleanup { } } + List identityProviderAliases = entities.get(IDENTITY_PROVIDER_ALIASES); if (identityProviderAliases != null) { for (String idpAlias : identityProviderAliases) { try { @@ -138,6 +119,7 @@ public class TestCleanup { } } + List componentIds = entities.get(COMPONENT_IDS); if (componentIds != null) { for (String componentId : componentIds) { try { @@ -148,6 +130,7 @@ public class TestCleanup { } } + List clientUuids = entities.get(CLIENT_UUIDS); if (clientUuids != null) { for (String clientUuId : clientUuids) { try { @@ -158,6 +141,7 @@ public class TestCleanup { } } + List roleIds = entities.get(ROLE_IDS); if (roleIds != null) { for (String roleId : roleIds) { try { @@ -168,6 +152,7 @@ public class TestCleanup { } } + List groupIds = entities.get(GROUP_IDS); if (groupIds != null) { for (String groupId : groupIds) { try { @@ -178,6 +163,7 @@ public class TestCleanup { } } + List authFlowIds = entities.get(AUTH_FLOW_IDS); if (authFlowIds != null) { for (String flowId : authFlowIds) { try { @@ -188,6 +174,7 @@ public class TestCleanup { } } + List authConfigIds = entities.get(AUTH_CONFIG_IDS); if (authConfigIds != null) { for (String configId : authConfigIds) { try { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserStorageRestTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserStorageRestTest.java index 15f0564c449..d7f494f6c15 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserStorageRestTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserStorageRestTest.java @@ -156,6 +156,65 @@ public class UserStorageRestTest extends AbstractAdminTest { } + + // KEYCLOAK-4438 + @Test + public void testKerberosAuthenticatorDisabledWhenProviderRemoved() { + // Assert kerberos authenticator DISABLED + AuthenticationExecutionInfoRepresentation kerberosExecution = findKerberosExecution(); + Assert.assertEquals(kerberosExecution.getRequirement(), AuthenticationExecutionModel.Requirement.DISABLED.toString()); + + // create LDAP provider with kerberos + ComponentRepresentation ldapRep = new ComponentRepresentation(); + ldapRep.setName("ldap2"); + ldapRep.setProviderId("ldap"); + ldapRep.setProviderType(UserStorageProvider.class.getName()); + ldapRep.setConfig(new MultivaluedHashMap<>()); + ldapRep.getConfig().putSingle("priority", Integer.toString(2)); + ldapRep.getConfig().putSingle(KerberosConstants.ALLOW_KERBEROS_AUTHENTICATION, "true"); + + + String id = createComponent(ldapRep); + + // Assert kerberos authenticator ALTERNATIVE + kerberosExecution = findKerberosExecution(); + Assert.assertEquals(kerberosExecution.getRequirement(), AuthenticationExecutionModel.Requirement.ALTERNATIVE.toString()); + + // Remove LDAP provider + realm.components().component(id).remove(); + + // Assert kerberos authenticator DISABLED + kerberosExecution = findKerberosExecution(); + Assert.assertEquals(kerberosExecution.getRequirement(), AuthenticationExecutionModel.Requirement.DISABLED.toString()); + + // Add kerberos provider + ComponentRepresentation kerberosRep = new ComponentRepresentation(); + kerberosRep.setName("kerberos"); + kerberosRep.setProviderId("kerberos"); + kerberosRep.setProviderType(UserStorageProvider.class.getName()); + kerberosRep.setConfig(new MultivaluedHashMap<>()); + kerberosRep.getConfig().putSingle("priority", Integer.toString(2)); + + id = createComponent(kerberosRep); + + + // Assert kerberos authenticator ALTERNATIVE + kerberosExecution = findKerberosExecution(); + Assert.assertEquals(kerberosExecution.getRequirement(), AuthenticationExecutionModel.Requirement.ALTERNATIVE.toString()); + + // Switch kerberos authenticator to REQUIRED + kerberosExecution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED.toString()); + realm.flows().updateExecutions("browser", kerberosExecution); + + // Remove Kerberos provider + realm.components().component(id).remove(); + + // Assert kerberos authenticator DISABLED + kerberosExecution = findKerberosExecution(); + Assert.assertEquals(kerberosExecution.getRequirement(), AuthenticationExecutionModel.Requirement.DISABLED.toString()); + } + + @Test public void testValidateAndCreateLdapProvider() { // Invalid filter From bf43ccf6c1298e145df8007c30373ec1e1c066fb Mon Sep 17 00:00:00 2001 From: Hynek Mlnarik Date: Wed, 21 Jun 2017 15:19:23 +0200 Subject: [PATCH 18/18] KEYCLOAK-4993 Fix intermittent failures in ComponentsTest testConcurrencyWithChildren ... and seems that the KEYCLOAK-5020 as well --- .../tests/base/src/test/resources/META-INF/keycloak-server.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json b/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json index 2c80ddd2886..7a0e6b6a972 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json @@ -85,7 +85,7 @@ "connectionsJpa": { "default": { - "url": "${keycloak.connectionsJpa.url:jdbc:h2:mem:test}", + "url": "${keycloak.connectionsJpa.url:jdbc:h2:mem:test;MVCC=TRUE}", "driver": "${keycloak.connectionsJpa.driver:org.h2.Driver}", "driverDialect": "${keycloak.connectionsJpa.driverDialect:}", "user": "${keycloak.connectionsJpa.user:sa}",