From ef3a0ee06c1d0b43d35eba234615c03df14b613e Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Tue, 1 Jun 2021 11:45:35 -0300 Subject: [PATCH] [KEYCLOAK-17399] - Declarative User Profile and UI Co-authored-by: Vlastimil Elias --- .../java/org/keycloak/common/Profile.java | 3 +- .../java/org/keycloak/common/ProfileTest.java | 8 +- .../idm/ErrorRepresentation.java | 27 + .../client/resource/UserProfileResource.java | 40 + .../admin/client/resource/UsersResource.java | 4 +- .../java/org/keycloak/events/EventType.java | 2 + .../keycloak/forms/login/LoginFormsPages.java | 2 +- .../userprofile/AttributeMetadata.java | 49 +- .../org/keycloak/userprofile/Attributes.java | 59 ++ .../userprofile/DefaultAttributes.java | 129 ++- .../userprofile/DefaultUserProfile.java | 12 +- ...UserProfileAttributeValidationContext.java | 12 + .../userprofile/UserProfileMetadata.java | 25 +- .../keycloak/userprofile/UserProfileSpi.java | 4 +- .../userprofile/ValidationException.java | 82 +- .../keycloak/validate/ValidationError.java | 19 + .../org/keycloak/validate/Validators.java | 5 + .../validators/AbstractNumberValidator.java | 34 +- .../validate/validators/DoubleValidator.java | 9 +- .../validate/validators/EmailValidator.java | 19 +- .../validate/validators/IntegerValidator.java | 9 +- .../validate/validators/LengthValidator.java | 37 +- .../validators/LocalDateValidator.java | 89 +++ .../validators/NotBlankValidator.java | 5 +- .../validators/NotEmptyValidator.java | 3 - .../validate/validators/PatternValidator.java | 29 +- .../validate/validators/UriValidator.java | 18 +- .../org.keycloak.validate.ValidatorFactory | 9 + .../org/keycloak/validate/ValidatorTest.java | 2 +- .../java/org/keycloak/models/UserModel.java | 3 +- .../requiredactions/VerifyUserProfile.java | 165 ++++ .../FreeMarkerLoginFormsProvider.java | 11 + .../forms/login/freemarker/Templates.java | 2 + .../freemarker/model/VerifyProfileBean.java | 91 +++ .../org/keycloak/services/ErrorResponse.java | 9 + .../resources/account/AccountRestService.java | 50 +- .../resources/admin/UserProfileResource.java | 26 +- .../resources/admin/UserResource.java | 26 +- .../resources/admin/UsersResource.java | 15 +- .../config/DeclarativeAttributes.java | 42 + .../config/DeclarativeUserProfileModel.java | 2 +- .../DeclarativeUserProfileProvider.java | 148 ++-- .../userprofile}/config/UPAttribute.java | 2 +- .../config/UPAttributePermissions.java | 7 +- .../config/UPAttributeRequired.java | 2 +- .../userprofile}/config/UPConfig.java | 2 +- .../userprofile}/config/UPConfigUtils.java | 6 +- .../legacy/AbstractUserProfileProvider.java | 30 +- .../AttributeRequiredByMetadataValidator.java | 12 +- .../validator/DuplicateEmailValidator.java | 4 +- .../validator/DuplicateUsernameValidator.java | 4 +- .../EmailExistsAsUsernameValidator.java | 4 +- .../ImmutableAttributeValidator.java | 75 ++ .../RegistrationUsernameExistsValidator.java | 4 +- ...cloak.authentication.RequiredActionFactory | 3 +- ...oak.userprofile.UserProfileProviderFactory | 32 +- .../org.keycloak.validate.ValidatorFactory | 1 + .../config/keycloak-default-user-profile.json | 26 + .../jboss-cli/keycloak-server-subsystem.cli | 3 + .../src/main/content/conf/keycloak.properties | 3 + ...oak.userprofile.UserProfileProviderFactory | 20 - .../config/keycloak-default-user-profile.json | 18 - .../arquillian/AuthServerTestEnricher.java | 8 +- .../annotation/SetDefaultProvider.java | 23 + .../KeycloakContainerFeaturesController.java | 25 +- ...cloakQuarkusServerDeployableContainer.java | 33 +- .../testsuite/pages/VerifyProfilePage.java | 137 ++++ .../util/SpiProvidersSwitchingUtils.java | 31 +- .../authentication/RequiredActionsTest.java | 2 +- .../userprofile/UserProfileAdminTest.java | 76 ++ .../forms/RegisterWithUserProfileTest.java | 260 ++++++ .../testsuite/forms/VerifyProfileTest.java | 600 ++++++++++++++ .../user/profile/AbstractUserProfileTest.java | 17 +- .../user/profile/UserProfileConfigTest.java | 633 --------------- .../user/profile/UserProfileTest.java | 752 +++++++++++++++++- .../profile/config/UPConfigParserTest.java | 9 +- .../profile/config/UPConfigUtilsTest.java | 5 +- .../testsuite/validation/ValidatorTest.java | 80 ++ .../resources/META-INF/keycloak-server.json | 5 + .../resources/META-INF/keycloak-server.json | 5 + .../account/messages/messages_en.properties | 13 + .../messages/admin-messages_en.properties | 21 + .../admin/messages/messages_en.properties | 16 + .../theme/base/admin/resources/js/app.js | 20 + .../admin/resources/js/controllers/realm.js | 226 ++++++ .../theme/base/admin/resources/js/services.js | 10 + .../partials/realm-user-profile.html | 209 +++++ .../resources/templates/kc-tabs-realm.html | 1 + .../theme/base/login/verify-profile.ftl | 47 ++ .../account/messages/messages_en.properties | 15 +- .../app/account-service/account.service.ts | 10 +- 91 files changed, 3884 insertions(+), 998 deletions(-) create mode 100644 integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserProfileResource.java create mode 100644 server-spi-private/src/main/java/org/keycloak/validate/validators/LocalDateValidator.java create mode 100644 server-spi-private/src/main/resources/META-INF/services/org.keycloak.validate.ValidatorFactory create mode 100644 services/src/main/java/org/keycloak/authentication/requiredactions/VerifyUserProfile.java create mode 100644 services/src/main/java/org/keycloak/forms/login/freemarker/model/VerifyProfileBean.java create mode 100644 services/src/main/java/org/keycloak/userprofile/config/DeclarativeAttributes.java rename {testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile => services/src/main/java/org/keycloak/userprofile}/config/DeclarativeUserProfileModel.java (95%) rename {testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile => services/src/main/java/org/keycloak/userprofile}/config/DeclarativeUserProfileProvider.java (74%) rename {testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile => services/src/main/java/org/keycloak/userprofile}/config/UPAttribute.java (98%) rename {testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile => services/src/main/java/org/keycloak/userprofile}/config/UPAttributePermissions.java (87%) rename {testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile => services/src/main/java/org/keycloak/userprofile}/config/UPAttributeRequired.java (97%) rename {testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile => services/src/main/java/org/keycloak/userprofile}/config/UPConfig.java (96%) rename {testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile => services/src/main/java/org/keycloak/userprofile}/config/UPConfigUtils.java (97%) create mode 100644 services/src/main/java/org/keycloak/userprofile/validator/ImmutableAttributeValidator.java create mode 100644 services/src/main/resources/org/keycloak/userprofile/config/keycloak-default-user-profile.json delete mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.userprofile.UserProfileProviderFactory delete mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/user/profile/config/keycloak-default-user-profile.json create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/VerifyProfilePage.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/userprofile/UserProfileAdminTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RegisterWithUserProfileTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/VerifyProfileTest.java delete mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileConfigTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/validation/ValidatorTest.java create mode 100755 themes/src/main/resources/theme/base/admin/resources/partials/realm-user-profile.html create mode 100755 themes/src/main/resources/theme/base/login/verify-profile.ftl diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index 3684fea4c2..29e3074944 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -61,7 +61,8 @@ public class Profile { WEB_AUTHN(Type.DEFAULT, Type.PREVIEW), CLIENT_POLICIES(Type.DEFAULT), CIBA(Type.PREVIEW), - MAP_STORAGE(Type.EXPERIMENTAL); + MAP_STORAGE(Type.EXPERIMENTAL), + DECLARATIVE_USER_PROFILE(Type.PREVIEW); private final Type typeProject; private final Type typeProduct; diff --git a/common/src/test/java/org/keycloak/common/ProfileTest.java b/common/src/test/java/org/keycloak/common/ProfileTest.java index 0261f15cbc..1d3d07c2b4 100644 --- a/common/src/test/java/org/keycloak/common/ProfileTest.java +++ b/common/src/test/java/org/keycloak/common/ProfileTest.java @@ -21,8 +21,8 @@ public class ProfileTest { @Test public void checkDefaultsKeycloak() { Assert.assertEquals("community", Profile.getName()); - assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.CIBA, Profile.Feature.MAP_STORAGE); - assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.CIBA); + assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.CIBA, Profile.Feature.MAP_STORAGE, Profile.Feature.DECLARATIVE_USER_PROFILE); + assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.CIBA, Profile.Feature.DECLARATIVE_USER_PROFILE); assertEquals(Profile.getDeprecatedFeatures(), Profile.Feature.UPLOAD_SCRIPTS); Assert.assertTrue(Profile.Feature.WEB_AUTHN.hasDifferentProductType()); @@ -37,8 +37,8 @@ public class ProfileTest { Profile.init(); Assert.assertEquals("product", Profile.getName()); - assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.WEB_AUTHN, Profile.Feature.CIBA, Profile.Feature.MAP_STORAGE); - assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.WEB_AUTHN, Profile.Feature.CIBA); + assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.WEB_AUTHN, Profile.Feature.CIBA, Profile.Feature.MAP_STORAGE, Profile.Feature.DECLARATIVE_USER_PROFILE); + assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.WEB_AUTHN, Profile.Feature.CIBA, Profile.Feature.DECLARATIVE_USER_PROFILE); assertEquals(Profile.getDeprecatedFeatures(), Profile.Feature.UPLOAD_SCRIPTS); Assert.assertTrue(Profile.Feature.WEB_AUTHN.hasDifferentProductType()); diff --git a/core/src/main/java/org/keycloak/representations/idm/ErrorRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/ErrorRepresentation.java index 64f3484889..0ad88a0f14 100644 --- a/core/src/main/java/org/keycloak/representations/idm/ErrorRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/ErrorRepresentation.java @@ -17,16 +17,35 @@ package org.keycloak.representations.idm; +import java.util.List; + /** * @author Stian Thorgersen */ public class ErrorRepresentation { + private String field; private String errorMessage; private Object[] params; + private List errors; public ErrorRepresentation() { } + public ErrorRepresentation(String errorMessage) { + this.errorMessage = errorMessage; + } + + public ErrorRepresentation(String field, String errorMessage, Object[] params) { + super(); + this.field = field; + this.errorMessage = errorMessage; + this.params = params; + } + + public String getField() { + return field; + } + public String getErrorMessage() { return errorMessage; } @@ -42,4 +61,12 @@ public class ErrorRepresentation { public void setParams(Object[] params) { this.params = params; } + + public void setErrors(List errors) { + this.errors = errors; + } + + public List getErrors() { + return errors; + } } diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserProfileResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserProfileResource.java new file mode 100644 index 0000000000..a9475a058f --- /dev/null +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserProfileResource.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021 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.PUT; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +/** + * @author Vlastimil Elias + */ +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +public interface UserProfileResource { + + @GET + @Consumes(MediaType.APPLICATION_JSON) + String getConfiguration(); + + @PUT + @Produces(MediaType.APPLICATION_JSON) + Response update(String text); +} diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UsersResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UsersResource.java index 5d117a313a..86dce41692 100755 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UsersResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UsersResource.java @@ -246,6 +246,8 @@ public interface UsersResource { @Path("{id}") @DELETE Response delete(@PathParam("id") String id); - + + @Path("profile") + UserProfileResource userProfile(); } diff --git a/server-spi-private/src/main/java/org/keycloak/events/EventType.java b/server-spi-private/src/main/java/org/keycloak/events/EventType.java index 1148fdfa89..6f54e8c010 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/EventType.java +++ b/server-spi-private/src/main/java/org/keycloak/events/EventType.java @@ -63,6 +63,8 @@ public enum EventType { UPDATE_TOTP_ERROR(true), VERIFY_EMAIL(true), VERIFY_EMAIL_ERROR(true), + VERIFY_PROFILE(true), + VERIFY_PROFILE_ERROR(true), REMOVE_TOTP(true), REMOVE_TOTP_ERROR(true), diff --git a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsPages.java b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsPages.java index c2f9333f97..7fba1dc899 100755 --- a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsPages.java +++ b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsPages.java @@ -26,6 +26,6 @@ public enum LoginFormsPages { LOGIN_IDP_LINK_CONFIRM, LOGIN_IDP_LINK_EMAIL, OAUTH_GRANT, LOGIN_RESET_PASSWORD, LOGIN_UPDATE_PASSWORD, LOGIN_SELECT_AUTHENTICATOR, REGISTER, INFO, ERROR, ERROR_WEBAUTHN, LOGIN_UPDATE_PROFILE, LOGIN_PAGE_EXPIRED, CODE, X509_CONFIRM, SAML_POST_FORM, - LOGIN_OAUTH2_DEVICE_VERIFY_USER_CODE; + LOGIN_OAUTH2_DEVICE_VERIFY_USER_CODE, VERIFY_PROFILE; } diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeMetadata.java b/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeMetadata.java index 852454a2f7..c8ece2d079 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeMetadata.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeMetadata.java @@ -43,25 +43,26 @@ public final class AttributeMetadata { private final String attributeName; private final Predicate selector; - private final Predicate readOnly; + private final Predicate writeAllowed; /** Predicate to decide if attribute is required, it is handled as required if predicate is null */ private final Predicate required; + private final Predicate readAllowed; private List validators; private Map annotations; AttributeMetadata(String attributeName) { - this(attributeName, ALWAYS_TRUE, ALWAYS_FALSE, ALWAYS_TRUE); + this(attributeName, ALWAYS_TRUE, ALWAYS_TRUE, ALWAYS_TRUE, ALWAYS_TRUE); } - AttributeMetadata(String attributeName, Predicate readOnly, Predicate required) { - this(attributeName, ALWAYS_TRUE, readOnly, required); + AttributeMetadata(String attributeName, Predicate writeAllowed, Predicate required) { + this(attributeName, ALWAYS_TRUE, writeAllowed, required, ALWAYS_TRUE); } AttributeMetadata(String attributeName, Predicate selector) { - this(attributeName, selector, ALWAYS_FALSE, ALWAYS_TRUE); + this(attributeName, selector, ALWAYS_FALSE, ALWAYS_TRUE, ALWAYS_TRUE); } - AttributeMetadata(String attributeName, List scopes, Predicate readOnly, Predicate required) { + AttributeMetadata(String attributeName, List scopes, Predicate writeAllowed, Predicate required) { this(attributeName, context -> { KeycloakSession session = context.getSession(); AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession(); @@ -81,14 +82,17 @@ public final class AttributeMetadata { return authSession.getClientScopes().stream() .map(id -> clientScopes.getClientScopeById(realm, id).getName()).anyMatch(scopes::contains); - }, readOnly, required); + }, writeAllowed, required, ALWAYS_TRUE); } - AttributeMetadata(String attributeName, Predicate selector, Predicate readOnly, Predicate required) { + AttributeMetadata(String attributeName, Predicate selector, Predicate writeAllowed, + Predicate required, + Predicate readAllowed) { this.attributeName = attributeName; this.selector = selector; - this.readOnly = readOnly; + this.writeAllowed = writeAllowed; this.required = required; + this.readAllowed = readAllowed; } public String getName() { @@ -100,10 +104,14 @@ public final class AttributeMetadata { } public boolean isReadOnly(AttributeContext context) { - return readOnly.test(context); + return !writeAllowed.test(context); } - /** + public boolean canView(AttributeContext context) { + return readAllowed.test(context); + } + + /** * Check if attribute is required based on it's predicate, it is handled as required if predicate is null * @param context to evaluate requirement of the attribute from * @return true if attribute is required in provided context @@ -140,7 +148,7 @@ public final class AttributeMetadata { if(this.annotations == null) { this.annotations = new HashMap<>(); } - + this.annotations.putAll(annotations); } return this; @@ -148,7 +156,7 @@ public final class AttributeMetadata { @Override public AttributeMetadata clone() { - AttributeMetadata cloned = new AttributeMetadata(attributeName, selector, readOnly, required); + AttributeMetadata cloned = new AttributeMetadata(attributeName, selector, writeAllowed, required, readAllowed); // we clone validators list to allow adding or removing validators. Validators // itself are not cloned as we do not expect them to be reconfigured. if (validators != null) { @@ -160,4 +168,19 @@ public final class AttributeMetadata { } return cloned; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || !(o instanceof AttributeMetadata)) return false; + + AttributeMetadata that = (AttributeMetadata) o; + + return that.getName().equals(getName()); + } + + @Override + public int hashCode() { + return attributeName.hashCode(); + } } diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/Attributes.java b/server-spi-private/src/main/java/org/keycloak/userprofile/Attributes.java index f1c349acb0..367f7f1f7e 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/Attributes.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/Attributes.java @@ -24,7 +24,9 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Consumer; +import java.util.stream.Collectors; +import org.keycloak.models.UserModel; import org.keycloak.validate.ValidationError; /** @@ -108,4 +110,61 @@ public interface Attributes { * @return the attributes */ Set>> attributeSet(); + + /** + *

Returns the metadata associated with the attribute with the given {@code name}. + * + *

The {@link AttributeMetadata} is a copy of the original metadata. The original metadata + * keeps immutable. + * + * @param name the attribute name + * @return the metadata + */ + AttributeMetadata getMetadata(String name); + + /** + * Returns whether the attribute with the given {@code name} is required. + * + * @param name the attribute name + * @return {@code true} if the attribute is required. Otherwise, {@code false}. + */ + boolean isRequired(String name); + + /** + * Similar to {{@link #getReadable(boolean)}} but with the possibility to add or remove + * the root attributes. + * + * @param includeBuiltin if the root attributes should be included. + * @return the attributes with read/write permission. + */ + default Map> getReadable(boolean includeBuiltin) { + return getReadable().entrySet().stream().filter(entry -> { + if (includeBuiltin) { + return true; + } + return !isRootAttribute(entry.getKey()); + }).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + /** + * Returns only the attributes that have read/write permissions. + * + * @return the attributes with read/write permission. + */ + Map> getReadable(); + + /** + * Returns whether the attribute with the given {@code name} is a root attribute. + * + * @param name the attribute name + * @return + */ + default boolean isRootAttribute(String name) { + return UserModel.USERNAME.equals(name) + || UserModel.EMAIL.equals(name) + || UserModel.FIRST_NAME.equals(name) + || UserModel.LAST_NAME.equals(name); + } + + Map> toMap(); } diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java index 954f036275..faa4934318 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java @@ -27,7 +27,6 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Consumer; -import java.util.stream.Collectors; import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; @@ -48,7 +47,7 @@ import org.keycloak.validate.ValidationError; * * @author Pedro Igor */ -public final class DefaultAttributes extends HashMap> implements Attributes { +public class DefaultAttributes extends HashMap> implements Attributes { /** * To reference dynamic attributes that can be configured as read-only when setting up the provider. @@ -59,7 +58,7 @@ public final class DefaultAttributes extends HashMap> imple private final UserProfileContext context; private final KeycloakSession session; private final Map metadataByAttribute; - private final UserModel user; + protected final UserModel user; public DefaultAttributes(UserProfileContext context, Map attributes, UserModel user, UserProfileMetadata profileMetadata, @@ -79,10 +78,22 @@ public final class DefaultAttributes extends HashMap> imple private boolean isReadOnlyFromMetadata(String attributeName) { AttributeMetadata attributeMetadata = metadataByAttribute.get(attributeName); - if (attributeMetadata != null && attributeMetadata.isReadOnly(createAttributeContext(attributeName, attributeMetadata))) { - return true; + if (attributeMetadata == null) { + return false; } - return false; + + return attributeMetadata.isReadOnly(createAttributeContext(attributeMetadata)); + } + + @Override + public boolean isRequired(String name) { + AttributeMetadata attributeMetadata = metadataByAttribute.get(name); + + if (attributeMetadata == null) { + return false; + } + + return attributeMetadata.isRequired(createAttributeContext(attributeMetadata)); } @Override @@ -95,31 +106,33 @@ public final class DefaultAttributes extends HashMap> imple metadatas.addAll(Optional.ofNullable(this.metadataByAttribute.get(READ_ONLY_ATTRIBUTE_KEY)) .map(Collections::singletonList).orElse(Collections.emptyList())); - List failingValidators = Collections.emptyList(); + Boolean result = null; for (AttributeMetadata metadata : metadatas) { + AttributeContext attributeContext = createAttributeContext(attribute, metadata); + for (AttributeValidatorMetadata validator : metadata.getValidators()) { - ValidationContext vc = validator.validate(createAttributeContext(attribute, metadata)); - if (!vc.isValid()) { - if (failingValidators.equals(Collections.emptyList())) { - failingValidators = new ArrayList<>(); - } - failingValidators.add(vc); - } - } - } + ValidationContext vc = validator.validate(attributeContext); - if (listeners != null) { - for (ValidationContext failingValidator : failingValidators) { - for (Consumer consumer : listeners) { - for(ValidationError err: failingValidator.getErrors()) { - consumer.accept(err); + if (vc.isValid()) { + continue; + } + + if (result == null) { + result = false; + } + + if (listeners != null) { + for (ValidationError error : vc.getErrors()) { + for (Consumer consumer : listeners) { + consumer.accept(error); + } } } } } - return failingValidators.isEmpty(); + return result == null; } @Override @@ -142,12 +155,43 @@ public final class DefaultAttributes extends HashMap> imple return entrySet(); } + @Override + public AttributeMetadata getMetadata(String name) { + AttributeMetadata metadata = metadataByAttribute.get(name); + + if (metadata == null) { + return null; + } + + return metadata.clone(); + } + + @Override + public Map> getReadable() { + Map> attributes = new HashMap<>(user.getAttributes()); + + if (attributes.isEmpty()) { + return null; + } + + return attributes; + } + + @Override + public Map> toMap() { + return this; + } + private AttributeContext createAttributeContext(Entry> attribute, AttributeMetadata metadata) { return new AttributeContext(context, session, attribute, user, metadata); } private AttributeContext createAttributeContext(String attributeName, AttributeMetadata metadata) { - return createAttributeContext(createAttribute(attributeName), metadata); + return new AttributeContext(context, session, createAttribute(attributeName), user, metadata); + } + + protected AttributeContext createAttributeContext(AttributeMetadata metadata) { + return createAttributeContext(createAttribute(metadata.getName()), metadata); } private Map configureMetadata(List attributes) { @@ -155,7 +199,7 @@ public final class DefaultAttributes extends HashMap> imple for (AttributeMetadata metadata : attributes) { // checks whether the attribute is selected for the current profile - if (metadata.isSelected(createAttributeContext(metadata.getName(), metadata))) { + if (metadata.isSelected(createAttributeContext(metadata))) { metadatas.put(metadata.getName(), metadata); } } @@ -190,9 +234,8 @@ public final class DefaultAttributes extends HashMap> imple Map> newAttributes = new HashMap<>(); RealmModel realm = session.getContext().getRealm(); - if (attributes != null && !attributes.isEmpty()) { + if (attributes != null) { for (Map.Entry entry : attributes.entrySet()) { - Object value = entry.getValue(); String key = entry.getKey(); if (!isSupportedAttribute(key)) { @@ -204,6 +247,7 @@ public final class DefaultAttributes extends HashMap> imple } List values; + Object value = entry.getValue(); if (value instanceof String) { values = Collections.singletonList((String) value); @@ -215,26 +259,27 @@ public final class DefaultAttributes extends HashMap> imple values = Collections.singletonList(values.get(0).toLowerCase()); } - if (isReadOnlyFromMetadata(key)) { - // only revert attribute values if not an internal read-only attribute - // for backward compatibility changing these attributes should cause validation errors - // ideally, we should just ignore and remove this check - if (user == null) { - values = EMPTY_VALUE; - } else { - values = user.getAttributeStream(key).collect(Collectors.toList()); - } - } - newAttributes.put(key, Collections.unmodifiableList(values)); } } // the profile should always hold all attributes defined in the config for (String attributeName : metadataByAttribute.keySet()) { - if (isSupportedAttribute(attributeName)) { - newAttributes.computeIfAbsent(attributeName, s -> EMPTY_VALUE); + if (!isSupportedAttribute(attributeName) || newAttributes.containsKey(attributeName)) { + continue; } + + List values = EMPTY_VALUE; + AttributeMetadata metadata = metadataByAttribute.get(attributeName); + + // if the attribute is not provided and does not have view permission, use the current values + // this check makes possible to decide whether or not validation should happen for read-only attributes + // when the context does not have access to such attributes + if (user != null && !metadata.canView(createAttributeContext(metadata))) { + values = user.getAttributes().get(attributeName); + } + + newAttributes.put(attributeName, values); } if (user != null) { @@ -287,7 +332,7 @@ public final class DefaultAttributes extends HashMap> imple } // checks whether the attribute is a core attribute - return UserModel.USERNAME.equals(name) || UserModel.EMAIL.equals(name) || UserModel.LAST_NAME.equals(name) || UserModel.FIRST_NAME.equals(name); + return isRootAttribute(name); } private boolean isReadOnlyInternalAttribute(String attributeName) { @@ -298,10 +343,10 @@ public final class DefaultAttributes extends HashMap> imple return false; } - SimpleImmutableEntry> attribute = createAttribute(attributeName); + AttributeContext attributeContext = createAttributeContext(attributeName, readonlyMetadata); for (AttributeValidatorMetadata validator : readonlyMetadata.getValidators()) { - ValidationContext vc = validator.validate(createAttributeContext(attribute, readonlyMetadata)); + ValidationContext vc = validator.validate(attributeContext); if (!vc.isValid()) { return true; } diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultUserProfile.java b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultUserProfile.java index d725d74c81..f91743791e 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultUserProfile.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultUserProfile.java @@ -28,6 +28,7 @@ import java.util.function.BiConsumer; import java.util.function.Function; import java.util.stream.Collectors; +import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelException; import org.keycloak.models.UserModel; @@ -43,22 +44,24 @@ public final class DefaultUserProfile implements UserProfile { private final Function userSupplier; private final Attributes attributes; + private final KeycloakSession session; private boolean validated; private UserModel user; - public DefaultUserProfile(Attributes attributes, Function userCreator, UserModel user) { + public DefaultUserProfile(Attributes attributes, Function userCreator, UserModel user, + KeycloakSession session) { this.userSupplier = userCreator; this.attributes = attributes; this.user = user; + this.session = session; } @Override public void validate() { - ValidationException validationException = new ValidationException(); + ValidationException validationException = new ValidationException(session, user); for (String attributeName : attributes.nameSet()) { - this.attributes.validate(attributeName, - (error) -> validationException.addError(error)); + this.attributes.validate(attributeName, validationException); } if (validationException.hasError()) { @@ -121,6 +124,7 @@ public final class DefaultUserProfile implements UserProfile { // the attribute map was sent. if (removeAttributes) { Set attrsToRemove = new HashSet<>(user.getAttributes().keySet()); + attrsToRemove.removeAll(attributes.nameSet()); for (String attr : attrsToRemove) { diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileAttributeValidationContext.java b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileAttributeValidationContext.java index 0e35495d87..de5b5ea4b4 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileAttributeValidationContext.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileAttributeValidationContext.java @@ -16,6 +16,10 @@ */ package org.keycloak.userprofile; +import java.util.Map; +import java.util.function.Function; + +import org.keycloak.models.UserModel; import org.keycloak.validate.ValidationContext; import org.keycloak.validate.Validator; @@ -46,4 +50,12 @@ public class UserProfileAttributeValidationContext extends ValidationContext { return attributeContext; } + @Override + public Map getAttributes() { + Map attributes = super.getAttributes(); + + attributes.put(UserModel.class.getName(), getAttributeContext().getUser()); + + return attributes; + } } \ No newline at end of file diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileMetadata.java b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileMetadata.java index d9fcd4f7b8..3267f26d63 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileMetadata.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileMetadata.java @@ -19,6 +19,9 @@ package org.keycloak.userprofile; +import static org.keycloak.userprofile.AttributeMetadata.ALWAYS_FALSE; +import static org.keycloak.userprofile.AttributeMetadata.ALWAYS_TRUE; + import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -42,10 +45,6 @@ public final class UserProfileMetadata implements Cloneable { return attributes; } - public void addAttributes(AttributeMetadata... metadata) { - addAttributes(Arrays.asList(metadata)); - } - public void addAttributes(List metadata) { if (attributes == null) { attributes = new ArrayList<>(); @@ -62,16 +61,20 @@ public final class UserProfileMetadata implements Cloneable { return addAttribute(name, Arrays.asList(validator)); } + public AttributeMetadata addAttribute(String name, Predicate writeAllowed, Predicate readAllowed, AttributeValidatorMetadata... validator) { + return addAttribute(new AttributeMetadata(name, ALWAYS_TRUE, writeAllowed, ALWAYS_TRUE, readAllowed).addValidator(Arrays.asList(validator))); + } + + public AttributeMetadata addAttribute(String name, Predicate writeAllowed, List validators) { + return addAttribute(new AttributeMetadata(name, ALWAYS_TRUE, writeAllowed, ALWAYS_TRUE, ALWAYS_TRUE).addValidator(validators)); + } + public AttributeMetadata addAttribute(String name, List validators) { return addAttribute(new AttributeMetadata(name).addValidator(validators)); } - public AttributeMetadata addAttribute(String name, List validator, Predicate required) { - return addAttribute(new AttributeMetadata(name, AttributeMetadata.ALWAYS_FALSE, required).addValidator(validator)); - } - - public AttributeMetadata addAttribute(String name, List validator, Predicate readOnly, Predicate required) { - return addAttribute(new AttributeMetadata(name, readOnly, required).addValidator(validator)); + public AttributeMetadata addAttribute(String name, List validator, Predicate writeAllowed, Predicate required, Predicate readAllowed) { + return addAttribute(new AttributeMetadata(name, ALWAYS_TRUE, writeAllowed, required, readAllowed).addValidator(validator)); } /** @@ -97,7 +100,7 @@ public final class UserProfileMetadata implements Cloneable { //deeply clone AttributeMetadata so we can modify them (add validators etc) if (attributes != null) { - metadata.addAttributes(attributes.stream().map((c)-> c.clone()).collect(Collectors.toList())); + metadata.addAttributes(attributes.stream().map(AttributeMetadata::clone).collect(Collectors.toList())); } return metadata; diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileSpi.java b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileSpi.java index 1be593239c..62f6cfc3b3 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileSpi.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileSpi.java @@ -26,6 +26,8 @@ import org.keycloak.provider.Spi; */ public class UserProfileSpi implements Spi { + public static final String ID = "userProfile"; + @Override public boolean isInternal() { return true; @@ -33,7 +35,7 @@ public class UserProfileSpi implements Spi { @Override public String getName() { - return "userProfile"; + return ID; } @Override diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/ValidationException.java b/server-spi-private/src/main/java/org/keycloak/userprofile/ValidationException.java index fe0edf8eb9..81a67aef03 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/ValidationException.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/ValidationException.java @@ -19,22 +19,39 @@ package org.keycloak.userprofile; +import javax.ws.rs.core.Response; +import java.io.IOException; import java.io.Serializable; +import java.text.MessageFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Properties; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import org.keycloak.models.KeycloakContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.theme.Theme; import org.keycloak.validate.ValidationError; /** * @author Pedro Igor */ -public final class ValidationException extends RuntimeException { +public final class ValidationException extends RuntimeException implements Consumer { private final Map> errors = new HashMap<>(); + private final BiFunction messageFormatter; + + public ValidationException(KeycloakSession session, UserModel user) { + this.messageFormatter = new MessageFormatter(session, user); + } public List getErrors() { return errors.values().stream().reduce(new ArrayList<>(), (l, r) -> { @@ -72,11 +89,16 @@ public final class ValidationException extends RuntimeException { return errors.values().stream().flatMap(Collection::stream).anyMatch(error -> names.contains(error.getAttribute())); } + @Override + public void accept(ValidationError error) { + addError(error); + } + void addError(ValidationError error) { List errors = this.errors.computeIfAbsent(error.getMessage(), (k) -> new ArrayList<>()); - errors.add(new Error(error)); + errors.add(new Error(error, messageFormatter)); } - + @Override public String toString() { return "ValidationException [errors=" + errors + "]"; @@ -87,12 +109,25 @@ public final class ValidationException extends RuntimeException { return toString(); } + public Response.Status getStatusCode() { + for (Map.Entry> entry : errors.entrySet()) { + for (Error error : entry.getValue()) { + if (!Response.Status.BAD_REQUEST.equals(error.getStatusCode())) { + return error.getStatusCode(); + } + } + } + return Response.Status.BAD_REQUEST; + } + public static class Error implements Serializable { private final ValidationError error; + private final BiFunction messageFormatter; - public Error(ValidationError error) { + public Error(ValidationError error, BiFunction messageFormatter) { this.error = error; + this.messageFormatter = messageFormatter; } public String getAttribute() { @@ -104,13 +139,48 @@ public final class ValidationException extends RuntimeException { } public Object[] getMessageParameters() { - return error.getMessageParameters(); + return error.getInputHintWithMessageParameters(); } @Override public String toString() { return "Error [error=" + error + "]"; } - + + public String getFormattedMessage() { + return messageFormatter.apply(getMessage(), getMessageParameters()); + } + + public Response.Status getStatusCode() { + return error.getStatusCode(); + } + } + + private final class MessageFormatter implements BiFunction { + + private final Locale locale; + private final Properties messages; + + public MessageFormatter(KeycloakSession session, UserModel user) { + try { + KeycloakContext context = session.getContext(); + locale = context.resolveLocale(user); + messages = getTheme(session).getMessages(locale); + RealmModel realm = context.getRealm(); + Map localizationTexts = realm.getRealmLocalizationTextsByLocale(locale.toLanguageTag()); + messages.putAll(localizationTexts); + } catch (IOException cause) { + throw new RuntimeException("Failed to configure error messages", cause); + } + } + + private Theme getTheme(KeycloakSession session) throws IOException { + return session.theme().getTheme(Theme.Type.ADMIN); + } + + @Override + public String apply(String s, Object[] objects) { + return new MessageFormat(messages.getProperty(s, s), locale).format(objects); + } } } diff --git a/server-spi-private/src/main/java/org/keycloak/validate/ValidationError.java b/server-spi-private/src/main/java/org/keycloak/validate/ValidationError.java index d1290356cf..50a258686e 100644 --- a/server-spi-private/src/main/java/org/keycloak/validate/ValidationError.java +++ b/server-spi-private/src/main/java/org/keycloak/validate/ValidationError.java @@ -16,10 +16,12 @@ */ package org.keycloak.validate; +import javax.ws.rs.core.Response; import java.io.Serializable; import java.util.Arrays; import java.util.Objects; import java.util.function.BiFunction; +import java.util.function.Function; /** * Denotes an error found during validation. @@ -60,6 +62,14 @@ public class ValidationError implements Serializable { */ private final Object[] messageParameters; + /** + * The status code associated with this error. This information serves as a hint so that + * callers can choose whether they want to respect the status defined for the error. + * + * TODO: Should be better to refactor {@code Messages} to bing messages to status code as well as any other metadata that might be associated with the message. + */ + private Response.Status statusCode = Response.Status.BAD_REQUEST; + public ValidationError(String validatorId, String inputHint, String message) { this(validatorId, inputHint, message, EMPTY_PARAMETERS); } @@ -145,4 +155,13 @@ public class ValidationError implements Serializable { public String toString() { return "ValidationError{" + "validatorId='" + validatorId + '\'' + ", inputHint='" + inputHint + '\'' + ", message='" + message + '\'' + ", messageParameters=" + Arrays.toString(messageParameters) + '}'; } + + public ValidationError setStatusCode(Response.Status statusCode) { + this.statusCode = statusCode; + return this; + } + + public Response.Status getStatusCode() { + return statusCode; + } } \ No newline at end of file diff --git a/server-spi-private/src/main/java/org/keycloak/validate/Validators.java b/server-spi-private/src/main/java/org/keycloak/validate/Validators.java index 2511ca224d..6439258641 100644 --- a/server-spi-private/src/main/java/org/keycloak/validate/Validators.java +++ b/server-spi-private/src/main/java/org/keycloak/validate/Validators.java @@ -24,6 +24,7 @@ import java.util.stream.Collectors; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.validate.validators.LocalDateValidator; import org.keycloak.validate.validators.EmailValidator; import org.keycloak.validate.validators.IntegerValidator; import org.keycloak.validate.validators.LengthValidator; @@ -154,6 +155,10 @@ public class Validators { return IntegerValidator.INSTANCE; } + public static LocalDateValidator dateValidator() { + return LocalDateValidator.INSTANCE; + } + public static ValidatorConfigValidator validatorConfigValidator() { return ValidatorConfigValidator.INSTANCE; } diff --git a/server-spi-private/src/main/java/org/keycloak/validate/validators/AbstractNumberValidator.java b/server-spi-private/src/main/java/org/keycloak/validate/validators/AbstractNumberValidator.java index 1d3fdeed5e..e0268b301c 100644 --- a/server-spi-private/src/main/java/org/keycloak/validate/validators/AbstractNumberValidator.java +++ b/server-spi-private/src/main/java/org/keycloak/validate/validators/AbstractNumberValidator.java @@ -16,10 +16,14 @@ */ package org.keycloak.validate.validators; +import java.util.ArrayList; import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; import org.keycloak.models.KeycloakSession; +import org.keycloak.provider.ConfiguredProvider; +import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.utils.StringUtil; import org.keycloak.validate.AbstractSimpleValidator; import org.keycloak.validate.ValidationContext; @@ -33,7 +37,7 @@ import org.keycloak.validate.ValidatorConfig; * * @author Vlastimil Elias */ -public abstract class AbstractNumberValidator extends AbstractSimpleValidator { +public abstract class AbstractNumberValidator extends AbstractSimpleValidator implements ConfiguredProvider { public static final String MESSAGE_INVALID_NUMBER = "error-invalid-number"; public static final String MESSAGE_NUMBER_OUT_OF_RANGE = "error-number-out-of-range"; @@ -42,6 +46,24 @@ public abstract class AbstractNumberValidator extends AbstractSimpleValidator { public static final String KEY_MAX = "max"; private final ValidatorConfig defaultConfig; + + protected static final List configProperties = new ArrayList<>(); + + static { + ProviderConfigProperty property; + property = new ProviderConfigProperty(); + property.setName(KEY_MIN); + property.setLabel("Minimum"); + property.setHelpText("The minimal allowed value - this config is optional."); + property.setType(ProviderConfigProperty.STRING_TYPE); + configProperties.add(property); + property = new ProviderConfigProperty(); + property.setName(KEY_MAX); + property.setLabel("Maximum"); + property.setHelpText("The maximal allowed value - this config is optional."); + property.setType(ProviderConfigProperty.STRING_TYPE); + configProperties.add(property); + } public AbstractNumberValidator() { // for reflection @@ -51,6 +73,10 @@ public abstract class AbstractNumberValidator extends AbstractSimpleValidator { public AbstractNumberValidator(ValidatorConfig config) { this.defaultConfig = config; } + + public List getConfigProperties() { + return configProperties; + } @Override protected boolean skipValidation(Object value, ValidatorConfig config) { @@ -77,7 +103,7 @@ public abstract class AbstractNumberValidator extends AbstractSimpleValidator { } if (number == null) { - context.addError(new ValidationError(getId(), inputHint, MESSAGE_INVALID_NUMBER, value)); + context.addError(new ValidationError(getId(), inputHint, MESSAGE_INVALID_NUMBER)); return; } @@ -85,12 +111,12 @@ public abstract class AbstractNumberValidator extends AbstractSimpleValidator { Number max = getMinMaxConfig(config, KEY_MAX); if (min != null && isFirstGreaterThanToSecond(min, number)) { - context.addError(new ValidationError(getId(), inputHint, MESSAGE_NUMBER_OUT_OF_RANGE, value, min, max)); + context.addError(new ValidationError(getId(), inputHint, MESSAGE_NUMBER_OUT_OF_RANGE, min, max)); return; } if (max != null && isFirstGreaterThanToSecond(number, max)) { - context.addError(new ValidationError(getId(), inputHint, MESSAGE_NUMBER_OUT_OF_RANGE, value, min, max)); + context.addError(new ValidationError(getId(), inputHint, MESSAGE_NUMBER_OUT_OF_RANGE, min, max)); return; } diff --git a/server-spi-private/src/main/java/org/keycloak/validate/validators/DoubleValidator.java b/server-spi-private/src/main/java/org/keycloak/validate/validators/DoubleValidator.java index 560ecf6c10..d8b3f063bb 100644 --- a/server-spi-private/src/main/java/org/keycloak/validate/validators/DoubleValidator.java +++ b/server-spi-private/src/main/java/org/keycloak/validate/validators/DoubleValidator.java @@ -16,6 +16,7 @@ */ package org.keycloak.validate.validators; +import org.keycloak.provider.ConfiguredProvider; import org.keycloak.validate.ValidatorConfig; /** @@ -24,7 +25,7 @@ import org.keycloak.validate.ValidatorConfig; * * @author Vlastimil Elias */ -public class DoubleValidator extends AbstractNumberValidator { +public class DoubleValidator extends AbstractNumberValidator implements ConfiguredProvider { public static final String ID = "double"; @@ -60,4 +61,10 @@ public class DoubleValidator extends AbstractNumberValidator { protected boolean isFirstGreaterThanToSecond(Number n1, Number n2) { return n1.doubleValue() > n2.doubleValue(); } + + @Override + public String getHelpText() { + return "Validator to check Double number format and optionally min and max values"; + } + } diff --git a/server-spi-private/src/main/java/org/keycloak/validate/validators/EmailValidator.java b/server-spi-private/src/main/java/org/keycloak/validate/validators/EmailValidator.java index 305470e7b8..e4a8af4b20 100644 --- a/server-spi-private/src/main/java/org/keycloak/validate/validators/EmailValidator.java +++ b/server-spi-private/src/main/java/org/keycloak/validate/validators/EmailValidator.java @@ -16,8 +16,12 @@ */ package org.keycloak.validate.validators; +import java.util.Collections; +import java.util.List; import java.util.regex.Pattern; +import org.keycloak.provider.ConfiguredProvider; +import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.validate.AbstractStringValidator; import org.keycloak.validate.ValidationContext; import org.keycloak.validate.ValidationError; @@ -27,7 +31,7 @@ import org.keycloak.validate.ValidatorConfig; * Email format validation - accepts plain string and collection of strings, for basic behavior like null/blank values * handling and collections support see {@link AbstractStringValidator}. */ -public class EmailValidator extends AbstractStringValidator { +public class EmailValidator extends AbstractStringValidator implements ConfiguredProvider { public static final String ID = "email"; @@ -38,9 +42,6 @@ public class EmailValidator extends AbstractStringValidator { // Actually allow same emails like angular. See ValidationTest.testEmailValidation() private static final Pattern EMAIL_PATTERN = Pattern.compile("[a-zA-Z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-zA-Z0-9-]+(\\.[a-zA-Z0-9-]+)*"); - private EmailValidator() { - } - @Override public String getId() { return ID; @@ -52,4 +53,14 @@ public class EmailValidator extends AbstractStringValidator { context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_EMAIL, value)); } } + + @Override + public String getHelpText() { + return "Email format validator"; + } + + @Override + public List getConfigProperties() { + return Collections.emptyList(); + } } diff --git a/server-spi-private/src/main/java/org/keycloak/validate/validators/IntegerValidator.java b/server-spi-private/src/main/java/org/keycloak/validate/validators/IntegerValidator.java index cca7bdf435..3e9a4d1e88 100644 --- a/server-spi-private/src/main/java/org/keycloak/validate/validators/IntegerValidator.java +++ b/server-spi-private/src/main/java/org/keycloak/validate/validators/IntegerValidator.java @@ -16,6 +16,7 @@ */ package org.keycloak.validate.validators; +import org.keycloak.provider.ConfiguredProvider; import org.keycloak.validate.ValidatorConfig; /** @@ -25,7 +26,7 @@ import org.keycloak.validate.ValidatorConfig; * * @author Vlastimil Elias */ -public class IntegerValidator extends AbstractNumberValidator { +public class IntegerValidator extends AbstractNumberValidator implements ConfiguredProvider { public static final String ID = "integer"; public static final IntegerValidator INSTANCE = new IntegerValidator(); @@ -60,4 +61,10 @@ public class IntegerValidator extends AbstractNumberValidator { protected boolean isFirstGreaterThanToSecond(Number n1, Number n2) { return n1.longValue() > n2.longValue(); } + + @Override + public String getHelpText() { + return "Validator to check Integer number format and optionally min and max values"; + } + } diff --git a/server-spi-private/src/main/java/org/keycloak/validate/validators/LengthValidator.java b/server-spi-private/src/main/java/org/keycloak/validate/validators/LengthValidator.java index 5a293420d0..5fe7b9caf5 100644 --- a/server-spi-private/src/main/java/org/keycloak/validate/validators/LengthValidator.java +++ b/server-spi-private/src/main/java/org/keycloak/validate/validators/LengthValidator.java @@ -16,10 +16,14 @@ */ package org.keycloak.validate.validators; +import java.util.ArrayList; import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; import org.keycloak.models.KeycloakSession; +import org.keycloak.provider.ConfiguredProvider; +import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.validate.AbstractStringValidator; import org.keycloak.validate.ValidationContext; import org.keycloak.validate.ValidationError; @@ -34,7 +38,7 @@ import org.keycloak.validate.ValidatorConfig; *

* Configuration have to be always provided, with at least one of {@link #KEY_MIN} and {@link #KEY_MAX}. */ -public class LengthValidator extends AbstractStringValidator { +public class LengthValidator extends AbstractStringValidator implements ConfiguredProvider { public static final LengthValidator INSTANCE = new LengthValidator(); @@ -46,7 +50,22 @@ public class LengthValidator extends AbstractStringValidator { public static final String KEY_MAX = "max"; public static final String KEY_TRIM_DISABLED = "trim-disabled"; - private LengthValidator() { + private static final List configProperties = new ArrayList<>(); + + static { + ProviderConfigProperty property; + property = new ProviderConfigProperty(); + property.setName(KEY_MIN); + property.setLabel("Minimum length"); + property.setHelpText("The minimum length"); + property.setType(ProviderConfigProperty.STRING_TYPE); + configProperties.add(property); + property = new ProviderConfigProperty(); + property.setName(KEY_MAX); + property.setLabel("Maximum length"); + property.setHelpText("The maximum length"); + property.setType(ProviderConfigProperty.STRING_TYPE); + configProperties.add(property); } @Override @@ -66,12 +85,12 @@ public class LengthValidator extends AbstractStringValidator { int length = value.length(); if (config.containsKey(KEY_MIN) && length < min.intValue()) { - context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_LENGTH, value, min, max)); + context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_LENGTH, min, max)); return; } if (config.containsKey(KEY_MAX) && length > max.intValue()) { - context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_LENGTH, value, min, max)); + context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_LENGTH, min, max)); return; } @@ -113,4 +132,14 @@ public class LengthValidator extends AbstractStringValidator { } return new ValidationResult(errors); } + + @Override + public String getHelpText() { + return "Length validator"; + } + + @Override + public List getConfigProperties() { + return configProperties; + } } diff --git a/server-spi-private/src/main/java/org/keycloak/validate/validators/LocalDateValidator.java b/server-spi-private/src/main/java/org/keycloak/validate/validators/LocalDateValidator.java new file mode 100644 index 0000000000..bf26c8d4dd --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/validate/validators/LocalDateValidator.java @@ -0,0 +1,89 @@ +/* + * Copyright 2021 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.validate.validators; + +import java.text.DateFormat; +import java.text.ParseException; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import org.keycloak.models.KeycloakContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; +import org.keycloak.provider.ConfiguredProvider; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.utils.StringUtil; +import org.keycloak.validate.AbstractStringValidator; +import org.keycloak.validate.ValidationContext; +import org.keycloak.validate.ValidationError; +import org.keycloak.validate.ValidationResult; +import org.keycloak.validate.ValidatorConfig; + +/** + * A date validator that only takes into account the format associated with the current locale. + */ +public class LocalDateValidator extends AbstractStringValidator implements ConfiguredProvider { + + public static final LocalDateValidator INSTANCE = new LocalDateValidator(); + + public static final String ID = "local-date"; + + public static final String MESSAGE_INVALID_DATE = "error-invalid-date"; + + @Override + public String getId() { + return ID; + } + + @Override + protected void doValidate(String value, String inputHint, ValidationContext context, ValidatorConfig config) { + UserModel user = (UserModel) context.getAttributes().get(UserModel.class.getName()); + KeycloakSession session = context.getSession(); + KeycloakContext keycloakContext = session.getContext(); + Locale locale = keycloakContext.resolveLocale(user); + DateFormat formatter = DateFormat.getDateInstance(DateFormat.SHORT, locale); + + formatter.setLenient(false); + + try { + formatter.parse(value); + } catch (ParseException e) { + context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_DATE)); + } + } + + @Override + public ValidationResult validateConfig(KeycloakSession session, ValidatorConfig config) { + return ValidationResult.OK; + } + + @Override + public String getHelpText() { + return "Validates date formats based on the realm or user locale."; + } + + @Override + public List getConfigProperties() { + return Collections.emptyList(); + } + + @Override + protected boolean isIgnoreEmptyValuesConfigured(ValidatorConfig config) { + return true; + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/validate/validators/NotBlankValidator.java b/server-spi-private/src/main/java/org/keycloak/validate/validators/NotBlankValidator.java index a73a56a92f..e671c5d2a0 100644 --- a/server-spi-private/src/main/java/org/keycloak/validate/validators/NotBlankValidator.java +++ b/server-spi-private/src/main/java/org/keycloak/validate/validators/NotBlankValidator.java @@ -33,15 +33,12 @@ import org.keycloak.validate.ValidatorConfig; */ public class NotBlankValidator implements SimpleValidator { - public static final String ID = "blank"; + public static final String ID = "not-blank"; public static final String MESSAGE_BLANK = "error-invalid-blank"; public static final NotBlankValidator INSTANCE = new NotBlankValidator(); - private NotBlankValidator() { - } - @Override public String getId() { return ID; diff --git a/server-spi-private/src/main/java/org/keycloak/validate/validators/NotEmptyValidator.java b/server-spi-private/src/main/java/org/keycloak/validate/validators/NotEmptyValidator.java index 6c97d5111e..14fd198e70 100644 --- a/server-spi-private/src/main/java/org/keycloak/validate/validators/NotEmptyValidator.java +++ b/server-spi-private/src/main/java/org/keycloak/validate/validators/NotEmptyValidator.java @@ -38,9 +38,6 @@ public class NotEmptyValidator implements SimpleValidator { public static final String MESSAGE_ERROR_EMPTY = "error-empty"; - private NotEmptyValidator() { - } - @Override public String getId() { return ID; diff --git a/server-spi-private/src/main/java/org/keycloak/validate/validators/PatternValidator.java b/server-spi-private/src/main/java/org/keycloak/validate/validators/PatternValidator.java index 739870a275..046f70c962 100644 --- a/server-spi-private/src/main/java/org/keycloak/validate/validators/PatternValidator.java +++ b/server-spi-private/src/main/java/org/keycloak/validate/validators/PatternValidator.java @@ -16,12 +16,16 @@ */ package org.keycloak.validate.validators; +import java.util.ArrayList; import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; import org.keycloak.models.KeycloakSession; +import org.keycloak.provider.ConfiguredProvider; +import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.validate.AbstractStringValidator; import org.keycloak.validate.ValidationContext; import org.keycloak.validate.ValidationError; @@ -32,7 +36,7 @@ import org.keycloak.validate.ValidatorConfig; * Validate String against configured RegEx pattern - accepts plain string and collection of strings, for basic behavior * like null/blank values handling and collections support see {@link AbstractStringValidator}. */ -public class PatternValidator extends AbstractStringValidator { +public class PatternValidator extends AbstractStringValidator implements ConfiguredProvider { public static final String ID = "pattern"; @@ -41,8 +45,17 @@ public class PatternValidator extends AbstractStringValidator { public static final String KEY_PATTERN = "pattern"; public static final String MESSAGE_NO_MATCH = "error-pattern-no-match"; + + private static final List configProperties = new ArrayList<>(); - private PatternValidator() { + static { + ProviderConfigProperty property; + property = new ProviderConfigProperty(); + property.setName(KEY_PATTERN); + property.setLabel("RegExp pattern"); + property.setHelpText("RegExp pattern the value must match. Java Pattern syntax is used."); + property.setType(ProviderConfigProperty.STRING_TYPE); + configProperties.add(property); } @Override @@ -55,7 +68,7 @@ public class PatternValidator extends AbstractStringValidator { Pattern pattern = config.getPattern(KEY_PATTERN); if (!pattern.matcher(value).matches()) { - context.addError(new ValidationError(ID, inputHint, MESSAGE_NO_MATCH, value, config.getString(KEY_PATTERN))); + context.addError(new ValidationError(ID, inputHint, MESSAGE_NO_MATCH, config.getString(KEY_PATTERN))); } } @@ -78,5 +91,15 @@ public class PatternValidator extends AbstractStringValidator { } return new ValidationResult(errors); } + + @Override + public String getHelpText() { + return "RegExp Pattern validator"; + } + + @Override + public List getConfigProperties() { + return configProperties; + } } diff --git a/server-spi-private/src/main/java/org/keycloak/validate/validators/UriValidator.java b/server-spi-private/src/main/java/org/keycloak/validate/validators/UriValidator.java index df801fbbe7..f9a6c78748 100644 --- a/server-spi-private/src/main/java/org/keycloak/validate/validators/UriValidator.java +++ b/server-spi-private/src/main/java/org/keycloak/validate/validators/UriValidator.java @@ -16,6 +16,8 @@ */ package org.keycloak.validate.validators; +import org.keycloak.provider.ConfiguredProvider; +import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.validate.SimpleValidator; import org.keycloak.validate.ValidationContext; import org.keycloak.validate.ValidationError; @@ -28,13 +30,14 @@ import java.net.URL; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Set; /** * URI validation - accepts {@link URI}, {@link URL} and single String. Null input is valid, use other validators (like * {@link NotBlankValidator} or {@link NotEmptyValidator} to force field as required. */ -public class UriValidator implements SimpleValidator { +public class UriValidator implements SimpleValidator, ConfiguredProvider { public static final UriValidator INSTANCE = new UriValidator(); @@ -56,9 +59,6 @@ public class UriValidator implements SimpleValidator { public static final String ID = "uri"; - private UriValidator() { - } - @Override public String getId() { return ID; @@ -136,4 +136,14 @@ public class UriValidator implements SimpleValidator { return valid; } + + @Override + public String getHelpText() { + return "Uri Validator"; + } + + @Override + public List getConfigProperties() { + return Collections.emptyList(); + } } diff --git a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.validate.ValidatorFactory b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.validate.ValidatorFactory new file mode 100644 index 0000000000..a1dce39ef5 --- /dev/null +++ b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.validate.ValidatorFactory @@ -0,0 +1,9 @@ +org.keycloak.validate.validators.LengthValidator +org.keycloak.validate.validators.NotEmptyValidator +org.keycloak.validate.validators.UriValidator +org.keycloak.validate.validators.EmailValidator +org.keycloak.validate.validators.NotBlankValidator +org.keycloak.validate.validators.PatternValidator +org.keycloak.validate.validators.DoubleValidator +org.keycloak.validate.validators.IntegerValidator +org.keycloak.validate.validators.LocalDateValidator \ No newline at end of file diff --git a/server-spi-private/src/test/java/org/keycloak/validate/ValidatorTest.java b/server-spi-private/src/test/java/org/keycloak/validate/ValidatorTest.java index f31cb50e05..ef6923ae4c 100644 --- a/server-spi-private/src/test/java/org/keycloak/validate/ValidatorTest.java +++ b/server-spi-private/src/test/java/org/keycloak/validate/ValidatorTest.java @@ -87,7 +87,7 @@ public class ValidatorTest { Assert.assertEquals(LengthValidator.ID, error.getValidatorId()); Assert.assertEquals(inputHint, error.getInputHint()); Assert.assertEquals(LengthValidator.MESSAGE_INVALID_LENGTH, error.getMessage()); - Assert.assertEquals(input, error.getMessageParameters()[0]); + Assert.assertEquals(new Integer(2), error.getMessageParameters()[0]); Assert.assertTrue(result.hasErrorsForValidatorId(LengthValidator.ID)); Assert.assertFalse(result.hasErrorsForValidatorId("unknown")); diff --git a/server-spi/src/main/java/org/keycloak/models/UserModel.java b/server-spi/src/main/java/org/keycloak/models/UserModel.java index 4e0d0d615d..af0eac0d9e 100755 --- a/server-spi/src/main/java/org/keycloak/models/UserModel.java +++ b/server-spi/src/main/java/org/keycloak/models/UserModel.java @@ -299,7 +299,8 @@ public interface UserModel extends RoleMapperModel { void setServiceAccountClientLink(String clientInternalId); enum RequiredAction { - VERIFY_EMAIL, UPDATE_PROFILE, CONFIGURE_TOTP, UPDATE_PASSWORD, TERMS_AND_CONDITIONS + VERIFY_EMAIL, UPDATE_PROFILE, CONFIGURE_TOTP, UPDATE_PASSWORD, TERMS_AND_CONDITIONS, + VERIFY_PROFILE } /** diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyUserProfile.java b/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyUserProfile.java new file mode 100644 index 0000000000..8c8703870f --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyUserProfile.java @@ -0,0 +1,165 @@ +/* + * Copyright 2021 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.authentication.requiredactions; + +import javax.ws.rs.HttpMethod; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; +import java.util.List; + +import org.keycloak.Config; +import org.keycloak.OAuth2Constants; +import org.keycloak.authentication.DisplayTypeRequiredActionFactory; +import org.keycloak.authentication.InitiatedActionSupport; +import org.keycloak.authentication.RequiredActionContext; +import org.keycloak.authentication.RequiredActionFactory; +import org.keycloak.authentication.RequiredActionProvider; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.FormMessage; +import org.keycloak.services.validation.Validation; +import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.UserProfileProvider; +import org.keycloak.userprofile.ValidationException; + +/** + * @author Pedro Igor + */ +public class VerifyUserProfile implements RequiredActionProvider, RequiredActionFactory, DisplayTypeRequiredActionFactory { + + @Override + public InitiatedActionSupport initiatedActionSupport() { + return InitiatedActionSupport.SUPPORTED; + } + + @Override + public void evaluateTriggers(RequiredActionContext context) { + UserModel user = context.getUser(); + UserProfileProvider provider = context.getSession().getProvider(UserProfileProvider.class); + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, user); + + try { + profile.validate(); + context.getAuthenticationSession().removeRequiredAction(getId()); + user.removeRequiredAction(getId()); + } catch (ValidationException e) { + context.getAuthenticationSession().addRequiredAction(getId()); + } + } + + @Override + public void requiredActionChallenge(RequiredActionContext context) { + UserProfileProvider provider = context.getSession().getProvider(UserProfileProvider.class); + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, context.getUser()); + + try { + profile.validate(); + context.success(); + } catch (ValidationException ve) { + List errors = Validation.getFormErrorsFromValidation(ve.getErrors()); + MultivaluedMap parameters; + + if (context.getHttpRequest().getHttpMethod().equalsIgnoreCase(HttpMethod.GET)) { + parameters = new MultivaluedHashMap<>(); + } else { + parameters = context.getHttpRequest().getDecodedFormParameters(); + } + + context.challenge(createResponse(context, profile, parameters, errors)); + } + } + + @Override + public void processAction(RequiredActionContext context) { + EventBuilder event = context.getEvent(); + event.event(EventType.VERIFY_PROFILE); + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + + if (!context.getRealm().isEditUsernameAllowed()) { + formData.putSingle(UserModel.USERNAME, context.getUser().getUsername()); + } + + UserProfileProvider provider = context.getSession().getProvider(UserProfileProvider.class); + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, formData, context.getUser()); + + try { + profile.update(); + context.success(); + } catch (ValidationException ve) { + List errors = Validation.getFormErrorsFromValidation(ve.getErrors()); + context.challenge(createResponse(context, profile, formData, errors)); + } + } + + + @Override + public void close() { + + } + + @Override + public RequiredActionProvider create(KeycloakSession session) { + return this; + } + + @Override + public RequiredActionProvider createDisplay(KeycloakSession session, String displayType) { + if (displayType == null) return this; + if (!OAuth2Constants.DISPLAY_CONSOLE.equalsIgnoreCase(displayType)) return null; + return ConsoleUpdateProfile.SINGLETON; + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public String getDisplayText() { + return "Verify Profile"; + } + + + @Override + public String getId() { + return UserModel.RequiredAction.VERIFY_PROFILE.name(); + } + + private Response createResponse(RequiredActionContext context, UserProfile profile, + MultivaluedMap formData, List errors) { + LoginFormsProvider form = context.form(); + + if (!errors.isEmpty()) { + form.setErrors(errors); + } + + return form.setFormData(formData) + .createResponse(UserModel.RequiredAction.VERIFY_PROFILE); + } +} 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 680cf6b76e..da95a13793 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 @@ -40,6 +40,7 @@ import org.keycloak.forms.login.freemarker.model.SAMLPostFormBean; import org.keycloak.forms.login.freemarker.model.TotpBean; import org.keycloak.forms.login.freemarker.model.TotpLoginBean; import org.keycloak.forms.login.freemarker.model.UrlBean; +import org.keycloak.forms.login.freemarker.model.VerifyProfileBean; import org.keycloak.forms.login.freemarker.model.X509ConfirmBean; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; @@ -159,6 +160,13 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { actionMessage = Messages.VERIFY_EMAIL; page = LoginFormsPages.LOGIN_VERIFY_EMAIL; break; + case VERIFY_PROFILE: + UpdateProfileContext verifyProfile = new UserUpdateProfileContext(realm, user); + this.attributes.put(UPDATE_PROFILE_CONTEXT_ATTR, verifyProfile); + + actionMessage = Messages.UPDATE_PROFILE; + page = LoginFormsPages.VERIFY_PROFILE; + break; default: return Response.serverError().build(); } @@ -238,6 +246,9 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { case SAML_POST_FORM: attributes.put("samlPost", new SAMLPostFormBean(formData)); break; + case VERIFY_PROFILE: + attributes.put("profile", new VerifyProfileBean(user, formData, session)); + break; } return processTemplate(theme, Templates.getTemplate(page), locale); diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java b/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java index 7a4fb0f4a2..5352ad5f1e 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java @@ -72,6 +72,8 @@ public class Templates { return "login-x509-info.ftl"; case SAML_POST_FORM: return "saml-post-form.ftl"; + case VERIFY_PROFILE: + return "verify-profile.ftl"; default: throw new IllegalArgumentException(); } diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/model/VerifyProfileBean.java b/services/src/main/java/org/keycloak/forms/login/freemarker/model/VerifyProfileBean.java new file mode 100644 index 0000000000..160a0aeada --- /dev/null +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/model/VerifyProfileBean.java @@ -0,0 +1,91 @@ +package org.keycloak.forms.login.freemarker.model; + +import static java.util.Collections.singletonList; + +import javax.ws.rs.core.MultivaluedMap; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; +import org.keycloak.userprofile.AttributeMetadata; +import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.UserProfileProvider; + +/** + * @author Pedro Igor + */ +public class VerifyProfileBean { + + private final UserModel user; + private final MultivaluedMap formData; + private final List attributes; + private final UserProfile profile; + + public VerifyProfileBean(UserModel user, MultivaluedMap formData, KeycloakSession session) { + this.user = user; + this.formData = formData; + UserProfileProvider provider = session.getProvider(UserProfileProvider.class); + this.profile = provider.create(UserProfileContext.UPDATE_PROFILE, user); + this.attributes = toAttributes(profile.getAttributes().getReadable()); + + } + + public List getAttributes() { + return attributes; + } + + public List getAllAttributes() { + return toAttributes(profile.getAttributes().toMap()); + } + + private List toAttributes(Map> readable) { + return readable.keySet().stream() + .map(name -> profile.getAttributes().getMetadata(name)).map(Attribute::new) + .sorted() + .collect(Collectors.toList()); + } + + public class Attribute implements Comparable { + + private final AttributeMetadata metadata; + + public Attribute(AttributeMetadata metadata) { + this.metadata = metadata; + } + + public String getName() { + return metadata.getName(); + } + + public String getValue() { + return formData.getOrDefault(getName(), singletonList(user.getFirstAttribute(getName()))).get(0); + } + + public boolean isRequired() { + return profile.getAttributes().isRequired(getName()); + } + + public boolean isReadOnly() { + return profile.getAttributes().isReadOnly(getName()); + } + + public Map getAnnotations() { + Map annotations = metadata.getAnnotations(); + + if (annotations == null) { + return Collections.emptyMap(); + } + + return annotations; + } + + @Override + public int compareTo(Attribute o) { + return getName().compareTo(o.getName()); + } + } +} diff --git a/services/src/main/java/org/keycloak/services/ErrorResponse.java b/services/src/main/java/org/keycloak/services/ErrorResponse.java index 492541fe23..4182a77c30 100755 --- a/services/src/main/java/org/keycloak/services/ErrorResponse.java +++ b/services/src/main/java/org/keycloak/services/ErrorResponse.java @@ -21,6 +21,7 @@ import org.keycloak.representations.idm.ErrorRepresentation; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import java.util.List; /** * @author Stian Thorgersen @@ -42,4 +43,12 @@ public class ErrorResponse { return Response.status(status).entity(error).type(MediaType.APPLICATION_JSON).build(); } + public static Response errors(List s, Response.Status status) { + if (s.size() == 1) { + return Response.status(status).entity(s.get(0)).type(MediaType.APPLICATION_JSON).build(); + } + ErrorRepresentation error = new ErrorRepresentation(); + error.setErrors(s); + return Response.status(status).entity(error).type(MediaType.APPLICATION_JSON).build(); + } } diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java index 71bc41d233..a70f704575 100755 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java @@ -37,6 +37,7 @@ import org.keycloak.representations.account.ClientRepresentation; import org.keycloak.representations.account.ConsentRepresentation; import org.keycloak.representations.account.ConsentScopeRepresentation; import org.keycloak.representations.account.UserRepresentation; +import org.keycloak.representations.idm.ErrorRepresentation; import org.keycloak.services.ErrorResponse; import org.keycloak.services.managers.Auth; import org.keycloak.services.managers.UserConsentManager; @@ -47,6 +48,7 @@ import org.keycloak.storage.ReadOnlyException; import org.keycloak.theme.Theme; import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.ValidationException; +import org.keycloak.userprofile.ValidationException.Error; import org.keycloak.userprofile.UserProfile; import org.keycloak.userprofile.UserProfileProvider; @@ -65,6 +67,7 @@ import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import java.io.IOException; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; @@ -136,13 +139,11 @@ public class AccountRestService { rep.setEmail(user.getEmail()); rep.setEmailVerified(user.isEmailVerified()); rep.setEmailVerified(user.isEmailVerified()); - Map> attributes = user.getAttributes(); - Map> copiedAttributes = new HashMap<>(attributes); - copiedAttributes.remove(UserModel.FIRST_NAME); - copiedAttributes.remove(UserModel.LAST_NAME); - copiedAttributes.remove(UserModel.EMAIL); - copiedAttributes.remove(UserModel.USERNAME); - rep.setAttributes(copiedAttributes); + + UserProfileProvider provider = session.getProvider(UserProfileProvider.class); + UserProfile profile = provider.create(UserProfileContext.ACCOUNT, user); + + rep.setAttributes(profile.getAttributes().getReadable(false)); return rep; } @@ -167,21 +168,36 @@ public class AccountRestService { return Response.noContent().build(); } catch (ValidationException pve) { - if (pve.hasError(Messages.READ_ONLY_USERNAME)) - return ErrorResponse.error(Messages.READ_ONLY_USERNAME, Response.Status.BAD_REQUEST); - if (pve.hasError(Messages.USERNAME_EXISTS)) - return ErrorResponse.exists(Messages.USERNAME_EXISTS); - if (pve.hasError(Messages.EMAIL_EXISTS)) - return ErrorResponse.exists(Messages.EMAIL_EXISTS); - - // Here should be possibility to somehow return all errors? - String firstErrorMessage = pve.getErrors().get(0).getMessage(); - return ErrorResponse.error(firstErrorMessage, Response.Status.BAD_REQUEST); + List errors = new ArrayList<>(); + for(Error err: pve.getErrors()) { + errors.add(new ErrorRepresentation(err.getAttribute(), err.getMessage(), validationErrorParamsToString(err.getMessageParameters()))); + } + return ErrorResponse.errors(errors, pve.getStatusCode()); } catch (ReadOnlyException e) { return ErrorResponse.error(Messages.READ_ONLY_USER, Response.Status.BAD_REQUEST); } } + private String[] validationErrorParamsToString(Object[] messageParameters) { + if(messageParameters == null) + return null; + String[] ret = new String[messageParameters.length]; + int i = 0; + for(Object p: messageParameters) { + if(p != null) { + //first parameter is field name, we add replacer code so it is localized in React UI + if(i==0) { + ret[i++] = "${"+p.toString()+"}"; + } else { + ret[i++] = p.toString(); + } + } else { + i++; + } + } + return ret; + } + /** * Get session information. * diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java index 0e50b24716..1c65102011 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java @@ -16,34 +16,29 @@ */ package org.keycloak.services.resources.admin; -import java.io.IOException; - import javax.ws.rs.Consumes; 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.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; -import javax.ws.rs.core.Response.Status; import org.keycloak.component.ComponentValidationException; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.services.ErrorResponse; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; import org.keycloak.userprofile.UserProfileProvider; /** - * * @author Vlastimil Elias - * */ public class UserProfileResource { - + @Context protected KeycloakSession session; - + protected RealmModel realm; private AdminPermissionEvaluator auth; @@ -52,31 +47,24 @@ public class UserProfileResource { this.auth = auth; } - @GET - @Path("configuration") @Produces(MediaType.APPLICATION_JSON) public String getConfiguration() { - auth.realm().requireViewRealm(); - - UserProfileProvider t = session.getProvider(UserProfileProvider.class); - return t.getConfiguration(); + return session.getProvider(UserProfileProvider.class).getConfiguration(); } @PUT - @Path("configuration") @Consumes(MediaType.APPLICATION_JSON) - public Response updateConfiguration(String text) throws IOException { - + public Response update(String text) { auth.realm().requireManageRealm(); - UserProfileProvider t = session.getProvider(UserProfileProvider.class); + try { t.setConfiguration(text); } catch (ComponentValidationException e) { //show validation result containing details about error - return Response.status(Status.BAD_REQUEST).type(MediaType.TEXT_PLAIN).entity(e.getMessage()).build(); + return ErrorResponse.error(e.getMessage(), Response.Status.BAD_REQUEST); } return Response.ok(t.getConfiguration()).type(MediaType.APPLICATION_JSON).build(); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java index 29361b8a60..f18a8484cb 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java @@ -54,6 +54,7 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.provider.ProviderFactory; import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.ErrorRepresentation; import org.keycloak.representations.idm.FederatedIdentityRepresentation; import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.UserConsentRepresentation; @@ -98,6 +99,7 @@ import javax.ws.rs.core.Response.Status; import javax.ws.rs.core.UriBuilder; import java.net.URI; import java.text.MessageFormat; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; @@ -171,7 +173,7 @@ public class UserResource { UserProfile profile = session.getProvider(UserProfileProvider.class).create(USER_API, rep.toAttributes(), user); - Response response = validateUserProfile(profile); + Response response = validateUserProfile(profile, user, session); if (response != null) { return response; } @@ -205,18 +207,17 @@ public class UserResource { } } - public static Response validateUserProfile(UserProfile profile) { + public static Response validateUserProfile(UserProfile profile, UserModel user, KeycloakSession session) { try { profile.validate(); } catch (ValidationException pve) { + List errors = new ArrayList<>(); + for (ValidationException.Error error : pve.getErrors()) { - StringBuilder s = new StringBuilder("Failed to update attribute " + error.getAttribute() + ": "); - - s.append(error.getMessage()).append(", "); - - logger.warn(s); + errors.add(new ErrorRepresentation(error.getFormattedMessage())); } - return ErrorResponse.error("Could not update user! See server log for more details", Response.Status.BAD_REQUEST); + + return ErrorResponse.errors(errors, Response.Status.BAD_REQUEST); } return null; @@ -281,6 +282,15 @@ public class UserResource { } rep.setAccess(auth.users().getAccess(user)); + UserProfileProvider provider = session.getProvider(UserProfileProvider.class); + UserProfile profile = provider.create(USER_API, user); + + Map> attributes = profile.getAttributes().getReadable(false); + + if (!attributes.isEmpty()) { + rep.setAttributes(attributes); + } + return rep; } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java index 11387f2bbf..06d2da903b 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java @@ -155,7 +155,7 @@ public class UsersResource { UserProfile profile = profileProvider.create(USER_API, rep.toAttributes()); try { - Response response = UserResource.validateUserProfile(profile); + Response response = UserResource.validateUserProfile(profile, null, session); if (response != null) { return response; } @@ -385,6 +385,19 @@ public class UsersResource { } } + /** + * Get representation of the user + * + * @param id User id + * @return + */ + @Path("profile") + public UserProfileResource userProfile() { + UserProfileResource resource = new UserProfileResource(realm, auth); + ResteasyProviderFactory.getInstance().injectProperties(resource); + return resource; + } + private Stream searchForUser(Map attributes, RealmModel realm, UserPermissionEvaluator usersEvaluator, Boolean briefRepresentation, Integer firstResult, Integer maxResults, Boolean includeServiceAccounts) { session.setAttribute(UserModel.INCLUDE_SERVICE_ACCOUNT, includeServiceAccounts); diff --git a/services/src/main/java/org/keycloak/userprofile/config/DeclarativeAttributes.java b/services/src/main/java/org/keycloak/userprofile/config/DeclarativeAttributes.java new file mode 100644 index 0000000000..c1acb9d00f --- /dev/null +++ b/services/src/main/java/org/keycloak/userprofile/config/DeclarativeAttributes.java @@ -0,0 +1,42 @@ +package org.keycloak.userprofile.config; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; +import org.keycloak.userprofile.AttributeMetadata; +import org.keycloak.userprofile.DefaultAttributes; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.UserProfileMetadata; + +/** + * Temporary implementation of {@link org.keycloak.userprofile.Attributes}. It should be removed once + * the {@link DeclarativeUserProfileProvider} becomes the default. + * + * @author Pedro Igor + */ +public class DeclarativeAttributes extends DefaultAttributes { + + public DeclarativeAttributes(UserProfileContext context, Map attributes, + UserModel user, UserProfileMetadata profileMetadata, + KeycloakSession session) { + super(context, attributes, user, profileMetadata, session); + } + + @Override + public Map> getReadable() { + Map> attributes = new HashMap<>(this); + + for (String name : nameSet()) { + AttributeMetadata metadata = getMetadata(name); + + if (metadata == null || !metadata.canView(createAttributeContext(metadata))) { + attributes.remove(name); + } + } + + return attributes; + } +} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/DeclarativeUserProfileModel.java b/services/src/main/java/org/keycloak/userprofile/config/DeclarativeUserProfileModel.java similarity index 95% rename from testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/DeclarativeUserProfileModel.java rename to services/src/main/java/org/keycloak/userprofile/config/DeclarativeUserProfileModel.java index e3c8ad8f20..0dc74d12b5 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/DeclarativeUserProfileModel.java +++ b/services/src/main/java/org/keycloak/userprofile/config/DeclarativeUserProfileModel.java @@ -17,7 +17,7 @@ * */ -package org.keycloak.testsuite.user.profile.config; +package org.keycloak.userprofile.config; import org.keycloak.component.ComponentModel; import org.keycloak.userprofile.UserProfileProvider; diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/DeclarativeUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/config/DeclarativeUserProfileProvider.java similarity index 74% rename from testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/DeclarativeUserProfileProvider.java rename to services/src/main/java/org/keycloak/userprofile/config/DeclarativeUserProfileProvider.java index 3f23e744d0..916665df44 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/DeclarativeUserProfileProvider.java +++ b/services/src/main/java/org/keycloak/userprofile/config/DeclarativeUserProfileProvider.java @@ -17,10 +17,11 @@ * */ -package org.keycloak.testsuite.user.profile.config; +package org.keycloak.userprofile.config; import static org.keycloak.common.util.ObjectUtil.isBlank; -import static org.keycloak.testsuite.user.profile.config.UPConfigUtils.readConfig; +import static org.keycloak.protocol.oidc.TokenManager.getRequestedClientScopes; +import static org.keycloak.userprofile.config.UPConfigUtils.readConfig; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -31,30 +32,33 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.function.Predicate; -import java.util.stream.Collectors; +import org.keycloak.common.Profile; import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.common.util.StreamUtil; import org.keycloak.component.AmphibianProviderFactory; import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentValidationException; +import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.protocol.oidc.OIDCLoginProtocol; -import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.provider.EnvironmentDependentProviderFactory; import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.userprofile.AttributeContext; import org.keycloak.userprofile.AttributeMetadata; import org.keycloak.userprofile.AttributeValidatorMetadata; +import org.keycloak.userprofile.Attributes; import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.UserProfileMetadata; import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.legacy.AbstractUserProfileProvider; import org.keycloak.userprofile.validator.AttributeRequiredByMetadataValidator; +import org.keycloak.userprofile.validator.ImmutableAttributeValidator; import org.keycloak.validate.AbstractSimpleValidator; import org.keycloak.validate.ValidatorConfig; @@ -65,13 +69,28 @@ import org.keycloak.validate.ValidatorConfig; * @author Pedro Igor * @author Vlastimil Elias */ -public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider implements AmphibianProviderFactory { +public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider + implements AmphibianProviderFactory, EnvironmentDependentProviderFactory { - public static final String ID = "declarative-userprofile-provider"; + public static final String SYSTEM_DEFAULT_CONFIG_RESOURCE = "keycloak-default-user-profile.json"; + public static final String ID = "declarative-user-profile"; public static final String UP_PIECES_COUNT_COMPONENT_CONFIG_KEY = "config-pieces-count"; private static final String PARSED_CONFIG_COMPONENT_KEY = "kc.user.profile.metadata"; private static final String UP_PIECE_COMPONENT_CONFIG_KEY_BASE = "config-piece-"; - private static final String SYSTEM_DEFAULT_CONFIG_RESOURCE = "keycloak-default-user-profile.json"; + + private static boolean createRequiredForScopePredicate(AttributeContext context, List requiredScopes) { + KeycloakSession session = context.getSession(); + AuthenticationSessionModel authenticationSession = session.getContext().getAuthenticationSession(); + + if (authenticationSession == null) { + return false; + } + + String requestedScopesString = authenticationSession.getClientNote(OIDCLoginProtocol.SCOPE_PARAM); + ClientModel client = authenticationSession.getClient(); + + return getRequestedClientScopes(requestedScopesString, client).map((csm) -> csm.getName()).anyMatch(requiredScopes::contains); + } private String defaultRawConfig; @@ -79,8 +98,9 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< // for reflection } - public DeclarativeUserProfileProvider(KeycloakSession session, Map metadataRegistry) { + public DeclarativeUserProfileProvider(KeycloakSession session, Map metadataRegistry, String defaultRawConfig) { super(session, metadataRegistry); + this.defaultRawConfig = defaultRawConfig; } @Override @@ -90,7 +110,13 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< @Override protected DeclarativeUserProfileProvider create(KeycloakSession session, Map metadataRegistry) { - return new DeclarativeUserProfileProvider(session, metadataRegistry); + return new DeclarativeUserProfileProvider(session, metadataRegistry, defaultRawConfig); + } + + @Override + protected Attributes createAttributes(UserProfileContext context, Map attributes, + UserModel user, UserProfileMetadata metadata) { + return new DeclarativeAttributes(context, attributes, user, metadata, session); } @Override @@ -122,10 +148,10 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< List errors = UPConfigUtils.validate(session, upc); if (!errors.isEmpty()) { - throw new ComponentValidationException("UserProfile configuration is invalid: " + errors.toString()); + throw new ComponentValidationException(errors.toString()); } } catch (IOException e) { - throw new ComponentValidationException("UserProfile configuration is invalid due to JSON parsing error: " + e.getMessage(), e); + throw new ComponentValidationException(e.getMessage(), e); } } @@ -202,7 +228,8 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< UserProfileContext context = metadata.getContext(); UPConfig parsedConfig = getParsedConfig(model); - if (parsedConfig == null) { + // do not change config for REGISTRATION_USER_CREATION context, everything important is covered thanks to REGISTRATION_PROFILE + if (parsedConfig == null || context == UserProfileContext.REGISTRATION_USER_CREATION) { return metadata; } @@ -227,46 +254,58 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< if (rc != null && !(UserModel.USERNAME.equals(attributeName) || UserModel.EMAIL.equals(attributeName))) { // do not take requirements from config for username and email as they are // driven by business logic from parent! - if (rc.isAlways() || UPConfigUtils.isRoleForContext(context, rc.getRoles())) { - validators.add(createRequiredValidator(attrConfig)); required = AttributeMetadata.ALWAYS_TRUE; } else if (UPConfigUtils.canBeAuthFlowContext(context) && rc.getScopes() != null && !rc.getScopes().isEmpty()) { // for contexts executed from auth flow and with configured scopes requirement // we have to create required validation with scopes based selector - required = (c) -> attributePredicateAuthFlowRequestedScope(rc.getScopes()); - validators.add(createRequiredValidator(attrConfig)); + required = (c) -> createRequiredForScopePredicate(c, rc.getScopes()); } + + validators.add(new AttributeValidatorMetadata(AttributeRequiredByMetadataValidator.ID)); } - Predicate readOnly = AttributeMetadata.ALWAYS_FALSE; + Predicate writeAllowed = AttributeMetadata.ALWAYS_FALSE; + Predicate readAllowed = AttributeMetadata.ALWAYS_FALSE; UPAttributePermissions permissions = attrConfig.getPermissions(); if (permissions != null) { List editRoles = permissions.getEdit(); - if (editRoles != null && !editRoles.isEmpty()) { - readOnly = ac -> !UPConfigUtils.isRoleForContext(ac.getContext(), editRoles); + if (!editRoles.isEmpty()) { + writeAllowed = ac -> UPConfigUtils.isRoleForContext(ac.getContext(), editRoles); + } + + List viewRoles = permissions.getView(); + + if (viewRoles.isEmpty()) { + readAllowed = writeAllowed; + } else { + readAllowed = createViewAllowedPredicate(writeAllowed, viewRoles); } } Map annotations = attrConfig.getAnnotations(); if (UserModel.USERNAME.equals(attributeName) || UserModel.EMAIL.equals(attributeName)) { - // add format validators for special attributes which may exist from parent - if (!validators.isEmpty()) { - List atts = decoratedMetadata.getAttribute(attributeName); - if (atts.isEmpty()) { - // attribute metadata doesn't exist so we have to add it. We keep it optional as Abstract base - // doesn't require it. - decoratedMetadata.addAttribute(attributeName, validators, readOnly).addAnnotations(annotations); - } else { - // only add configured validators and annotations if attribute metadata exist - atts.stream().forEach(c -> c.addValidator(validators).addAnnotations(annotations)); - } + if (permissions == null) { + writeAllowed = AttributeMetadata.ALWAYS_TRUE; + } + + List atts = decoratedMetadata.getAttribute(attributeName); + + if (atts.isEmpty()) { + // attribute metadata doesn't exist so we have to add it. We keep it optional as Abstract base + // doesn't require it. + decoratedMetadata.addAttribute(attributeName, writeAllowed, validators).addAnnotations(annotations); + } else { + // only add configured validators and annotations if attribute metadata exist + atts.stream().forEach(c -> c.addValidator(validators).addAnnotations(annotations)); } } else { - decoratedMetadata.addAttribute(attributeName, validators, readOnly, required).addAnnotations(annotations); + // always add validation for imuttable/read-only attributes + validators.add(new AttributeValidatorMetadata(ImmutableAttributeValidator.ID)); + decoratedMetadata.addAttribute(attributeName, validators, writeAllowed, required, readAllowed).addAnnotations(annotations); } } @@ -274,6 +313,11 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< } + private Predicate createViewAllowedPredicate(Predicate canEdit, + List viewRoles) { + return ac -> UPConfigUtils.isRoleForContext(ac.getContext(), viewRoles) || canEdit.test(ac); + } + /** * Get parsed config file configured in model. Default one used if not configured. * @@ -302,30 +346,6 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< return null; } - /** - * Predicate to select attributes for Authentication flow cases where requested scopes (including configured Default - * client scopes) are compared to set of scopes from user profile configuration. - *

- * This patches problem with some auth flows (eg. register) where authSession.getClientScopes() doesn't work - * correctly! - * - * @param scopesConfigured to match - * @return true if at least one requested scope matches at least one configured scope - */ - private boolean attributePredicateAuthFlowRequestedScope(List scopesConfigured) { - // never match out of auth flow - if (session.getContext().getAuthenticationSession() == null) { - return false; - } - - return getAuthFlowRequestedScopeNames().stream().anyMatch(scopesConfigured::contains); - } - - private Set getAuthFlowRequestedScopeNames() { - String requestedScopesString = session.getContext().getAuthenticationSession().getClientNote(OIDCLoginProtocol.SCOPE_PARAM); - return TokenManager.getRequestedClientScopes(requestedScopesString, session.getContext().getAuthenticationSession().getClient()).map((csm) -> csm.getName()).collect(Collectors.toSet()); - } - /** * Get componenet to store our "per realm" configuration into. * @@ -337,15 +357,6 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< return realm.getComponentsStream(realm.getId(), UserProfileProvider.class.getName()).findAny().orElseGet(() -> realm.addComponentModel(new DeclarativeUserProfileModel())); } - /** - * Create validator for 'required' validation. - * - * @return validator metadata to run given validation - */ - protected AttributeValidatorMetadata createRequiredValidator(UPAttribute attrConfig) { - return new AttributeValidatorMetadata(AttributeRequiredByMetadataValidator.ID); - } - /** * Create validator for validation configured in the user profile config. * @@ -363,7 +374,7 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< int count = model.get(UP_PIECES_COUNT_COMPONENT_CONFIG_KEY, 0); if (count < 1) { - return null; + return defaultRawConfig; } StringBuilder sb = new StringBuilder(); @@ -390,4 +401,9 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< } model.getConfig().remove(UP_PIECES_COUNT_COMPONENT_CONFIG_KEY); } + + @Override + public boolean isSupported() { + return Profile.isFeatureEnabled(Profile.Feature.DECLARATIVE_USER_PROFILE); + } } diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttribute.java b/services/src/main/java/org/keycloak/userprofile/config/UPAttribute.java similarity index 98% rename from testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttribute.java rename to services/src/main/java/org/keycloak/userprofile/config/UPAttribute.java index eea0742932..f63d3e5609 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttribute.java +++ b/services/src/main/java/org/keycloak/userprofile/config/UPAttribute.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.keycloak.testsuite.user.profile.config; +package org.keycloak.userprofile.config; import java.util.HashMap; import java.util.Map; diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttributePermissions.java b/services/src/main/java/org/keycloak/userprofile/config/UPAttributePermissions.java similarity index 87% rename from testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttributePermissions.java rename to services/src/main/java/org/keycloak/userprofile/config/UPAttributePermissions.java index 7f53481330..ed2d246f8e 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttributePermissions.java +++ b/services/src/main/java/org/keycloak/userprofile/config/UPAttributePermissions.java @@ -14,8 +14,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.keycloak.testsuite.user.profile.config; +package org.keycloak.userprofile.config; +import java.util.Collections; import java.util.List; /** @@ -26,8 +27,8 @@ import java.util.List; */ public class UPAttributePermissions { - private List view; - private List edit; + private List view = Collections.emptyList(); + private List edit = Collections.emptyList(); public List getView() { return view; diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttributeRequired.java b/services/src/main/java/org/keycloak/userprofile/config/UPAttributeRequired.java similarity index 97% rename from testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttributeRequired.java rename to services/src/main/java/org/keycloak/userprofile/config/UPAttributeRequired.java index dc60d57a0f..f8cd78a362 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttributeRequired.java +++ b/services/src/main/java/org/keycloak/userprofile/config/UPAttributeRequired.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.keycloak.testsuite.user.profile.config; +package org.keycloak.userprofile.config; import java.util.List; diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPConfig.java b/services/src/main/java/org/keycloak/userprofile/config/UPConfig.java similarity index 96% rename from testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPConfig.java rename to services/src/main/java/org/keycloak/userprofile/config/UPConfig.java index 7b12b50046..3d1154b6cd 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPConfig.java +++ b/services/src/main/java/org/keycloak/userprofile/config/UPConfig.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.keycloak.testsuite.user.profile.config; +package org.keycloak.userprofile.config; import java.util.ArrayList; import java.util.List; diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPConfigUtils.java b/services/src/main/java/org/keycloak/userprofile/config/UPConfigUtils.java similarity index 97% rename from testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPConfigUtils.java rename to services/src/main/java/org/keycloak/userprofile/config/UPConfigUtils.java index ba8888b140..fdf00faa05 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPConfigUtils.java +++ b/services/src/main/java/org/keycloak/userprofile/config/UPConfigUtils.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.keycloak.testsuite.user.profile.config; +package org.keycloak.userprofile.config; import static org.keycloak.common.util.ObjectUtil.isBlank; @@ -106,7 +106,7 @@ public class UPConfigUtils { errors.add("Attribute configuration without 'name' is not allowed"); } else { if (attNamesCache.contains(attributeName)) { - errors.add("Duplicit attribute configuration with 'name':'" + attributeName + "'"); + errors.add("Attribute configuration already exists with 'name':'" + attributeName + "'"); } else { attNamesCache.add(attributeName); if(!isValidAttributeName(attributeName)) { @@ -134,7 +134,7 @@ public class UPConfigUtils { * @param attributeName to validate * @return */ - static boolean isValidAttributeName(String attributeName) { + public static boolean isValidAttributeName(String attributeName) { return Pattern.matches("[a-zA-Z0-9\\._\\-]+", attributeName); } diff --git a/services/src/main/java/org/keycloak/userprofile/legacy/AbstractUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/legacy/AbstractUserProfileProvider.java index a0bb1405e3..9503614389 100644 --- a/services/src/main/java/org/keycloak/userprofile/legacy/AbstractUserProfileProvider.java +++ b/services/src/main/java/org/keycloak/userprofile/legacy/AbstractUserProfileProvider.java @@ -38,10 +38,14 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; import org.keycloak.Config; +import org.keycloak.models.KeycloakContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.services.messages.Messages; +import org.keycloak.userprofile.AttributeContext; +import org.keycloak.userprofile.AttributeMetadata; import org.keycloak.userprofile.AttributeValidatorMetadata; import org.keycloak.userprofile.Attributes; import org.keycloak.userprofile.DefaultAttributes; @@ -72,6 +76,13 @@ import org.keycloak.validate.validators.EmailValidator; */ public abstract class AbstractUserProfileProvider implements UserProfileProvider, UserProfileProviderFactory { + private static boolean editUsernameCondition(AttributeContext c) { + KeycloakSession session = c.getSession(); + KeycloakContext context = session.getContext(); + RealmModel realm = context.getRealm(); + return realm.isEditUsernameAllowed(); + } + public static Pattern getRegexPatternString(String[] builtinReadOnlyAttributes) { if (builtinReadOnlyAttributes != null) { List readOnlyAttributes = new ArrayList<>(Arrays.asList(builtinReadOnlyAttributes)); @@ -133,6 +144,8 @@ public abstract class AbstractUserProfileProvider @Override public void init(Config.Scope config) { + // make sure registry is clear in case of re-deploy + contextualMetadataRegistry.clear(); Pattern pattern = getRegexPatternString(config.getArray("read-only-attributes")); AttributeValidatorMetadata readOnlyValidator = null; @@ -234,8 +247,13 @@ public abstract class AbstractUserProfileProvider private UserProfile createUserProfile(UserProfileContext context, Map attributes, UserModel user) { UserProfileMetadata metadata = configureUserProfile(contextualMetadataRegistry.get(context), session); - Attributes profileAttributes = new DefaultAttributes(context, attributes, user, metadata, session); - return new DefaultUserProfile(profileAttributes, createUserFactory(), user); + Attributes profileAttributes = createAttributes(context, attributes, user, metadata); + return new DefaultUserProfile(profileAttributes, createUserFactory(), user, session); + } + + protected Attributes createAttributes(UserProfileContext context, Map attributes, UserModel user, + UserProfileMetadata metadata) { + return new DefaultAttributes(context, attributes, user, metadata, session); } private void addContextualProfileMetadata(UserProfileMetadata metadata) { @@ -259,9 +277,11 @@ public abstract class AbstractUserProfileProvider private UserProfileMetadata createDefaultProfile(UserProfileContext context, AttributeValidatorMetadata readOnlyValidator) { UserProfileMetadata metadata = new UserProfileMetadata(context); - metadata.addAttribute(UserModel.USERNAME, new AttributeValidatorMetadata(UsernameHasValueValidator.ID), - new AttributeValidatorMetadata(DuplicateUsernameValidator.ID), - new AttributeValidatorMetadata(UsernameMutationValidator.ID)); + metadata.addAttribute(UserModel.USERNAME, AbstractUserProfileProvider::editUsernameCondition, + AbstractUserProfileProvider::editUsernameCondition, + new AttributeValidatorMetadata(UsernameHasValueValidator.ID), + new AttributeValidatorMetadata(DuplicateUsernameValidator.ID), + new AttributeValidatorMetadata(UsernameMutationValidator.ID)); metadata.addAttribute(UserModel.FIRST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_FIRST_NAME))); diff --git a/services/src/main/java/org/keycloak/userprofile/validator/AttributeRequiredByMetadataValidator.java b/services/src/main/java/org/keycloak/userprofile/validator/AttributeRequiredByMetadataValidator.java index 695445b2a9..2d50e27917 100644 --- a/services/src/main/java/org/keycloak/userprofile/validator/AttributeRequiredByMetadataValidator.java +++ b/services/src/main/java/org/keycloak/userprofile/validator/AttributeRequiredByMetadataValidator.java @@ -20,6 +20,7 @@ import java.util.List; import org.keycloak.services.validation.Validation; import org.keycloak.userprofile.AttributeContext; +import org.keycloak.userprofile.AttributeMetadata; import org.keycloak.userprofile.UserProfileAttributeValidationContext; import org.keycloak.validate.SimpleValidator; import org.keycloak.validate.ValidationContext; @@ -46,10 +47,14 @@ public class AttributeRequiredByMetadataValidator implements SimpleValidator { @Override public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) { - AttributeContext attContext = UserProfileAttributeValidationContext.from(context).getAttributeContext(); + AttributeMetadata metadata = attContext.getMetadata(); - if (!attContext.getMetadata().isRequired(attContext)) { + if (!metadata.isRequired(attContext)) { + return context; + } + + if (metadata.isReadOnly(attContext)) { return context; } @@ -60,7 +65,7 @@ public class AttributeRequiredByMetadataValidator implements SimpleValidator { context.addError(new ValidationError(ID, inputHint, ERROR_USER_ATTRIBUTE_REQUIRED)); } else { for (String value : values) { - if (value == null || Validation.isBlank(value)) { + if (Validation.isBlank(value)) { context.addError(new ValidationError(ID, inputHint, ERROR_USER_ATTRIBUTE_REQUIRED)); return context; } @@ -68,5 +73,4 @@ public class AttributeRequiredByMetadataValidator implements SimpleValidator { } return context; } - } diff --git a/services/src/main/java/org/keycloak/userprofile/validator/DuplicateEmailValidator.java b/services/src/main/java/org/keycloak/userprofile/validator/DuplicateEmailValidator.java index 654bb005d2..63a3da02d8 100644 --- a/services/src/main/java/org/keycloak/userprofile/validator/DuplicateEmailValidator.java +++ b/services/src/main/java/org/keycloak/userprofile/validator/DuplicateEmailValidator.java @@ -16,6 +16,7 @@ */ package org.keycloak.userprofile.validator; +import javax.ws.rs.core.Response; import java.util.List; import org.keycloak.models.KeycloakSession; @@ -67,7 +68,8 @@ public class DuplicateEmailValidator implements SimpleValidator { UserModel user = UserProfileAttributeValidationContext.from(context).getAttributeContext().getUser(); // check for duplicated email if (userByEmail != null && (user == null || !userByEmail.getId().equals(user.getId()))) { - context.addError(new ValidationError(ID, inputHint, Messages.EMAIL_EXISTS)); + context.addError(new ValidationError(ID, inputHint, Messages.EMAIL_EXISTS) + .setStatusCode(Response.Status.CONFLICT)); } } diff --git a/services/src/main/java/org/keycloak/userprofile/validator/DuplicateUsernameValidator.java b/services/src/main/java/org/keycloak/userprofile/validator/DuplicateUsernameValidator.java index e5f0db92dd..fdfbdd09e5 100644 --- a/services/src/main/java/org/keycloak/userprofile/validator/DuplicateUsernameValidator.java +++ b/services/src/main/java/org/keycloak/userprofile/validator/DuplicateUsernameValidator.java @@ -16,6 +16,7 @@ */ package org.keycloak.userprofile.validator; +import javax.ws.rs.core.Response; import java.util.List; import org.keycloak.models.KeycloakSession; @@ -63,7 +64,8 @@ public class DuplicateUsernameValidator implements SimpleValidator { UserModel user = UserProfileAttributeValidationContext.from(context).getAttributeContext().getUser(); if (user != null && !value.equals(user.getFirstAttribute(UserModel.USERNAME)) && (existing != null && !existing.getId().equals(user.getId()))) { - context.addError(new ValidationError(ID, inputHint, Messages.USERNAME_EXISTS)); + context.addError(new ValidationError(ID, inputHint, Messages.USERNAME_EXISTS) + .setStatusCode(Response.Status.CONFLICT)); } return context; diff --git a/services/src/main/java/org/keycloak/userprofile/validator/EmailExistsAsUsernameValidator.java b/services/src/main/java/org/keycloak/userprofile/validator/EmailExistsAsUsernameValidator.java index d2389260b3..a81c6328ff 100644 --- a/services/src/main/java/org/keycloak/userprofile/validator/EmailExistsAsUsernameValidator.java +++ b/services/src/main/java/org/keycloak/userprofile/validator/EmailExistsAsUsernameValidator.java @@ -16,6 +16,7 @@ */ package org.keycloak.userprofile.validator; +import javax.ws.rs.core.Response; import java.util.List; import org.keycloak.models.KeycloakSession; @@ -66,7 +67,8 @@ public class EmailExistsAsUsernameValidator implements SimpleValidator { UserModel user = UserProfileAttributeValidationContext.from(context).getAttributeContext().getUser(); UserModel userByEmail = session.users().getUserByEmail(realm, value); if (userByEmail != null && user != null && !userByEmail.getId().equals(user.getId())) { - context.addError(new ValidationError(ID, inputHint, Messages.USERNAME_EXISTS)); + context.addError(new ValidationError(ID, inputHint, Messages.USERNAME_EXISTS) + .setStatusCode(Response.Status.CONFLICT)); } } diff --git a/services/src/main/java/org/keycloak/userprofile/validator/ImmutableAttributeValidator.java b/services/src/main/java/org/keycloak/userprofile/validator/ImmutableAttributeValidator.java new file mode 100644 index 0000000000..6758f23a17 --- /dev/null +++ b/services/src/main/java/org/keycloak/userprofile/validator/ImmutableAttributeValidator.java @@ -0,0 +1,75 @@ +/* + * Copyright 2021 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.userprofile.validator; + +import java.util.List; +import java.util.stream.Collectors; + +import org.keycloak.models.UserModel; +import org.keycloak.userprofile.AttributeContext; +import org.keycloak.userprofile.UserProfileAttributeValidationContext; +import org.keycloak.validate.SimpleValidator; +import org.keycloak.validate.ValidationContext; +import org.keycloak.validate.ValidationError; +import org.keycloak.validate.ValidatorConfig; + +/** + * A validator that fails when the attribute is marked as read only and its value has changed. + * + * @author Pedro Igor + */ +public class ImmutableAttributeValidator implements SimpleValidator { + + public static final String ID = "up-immutable-attribute"; + + private static final String DEFAULT_ERROR_MESSAGE = "error-user-attribute-read-only"; + + @Override + public String getId() { + return ID; + } + + @Override + public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) { + UserProfileAttributeValidationContext ac = (UserProfileAttributeValidationContext) context; + AttributeContext attributeContext = ac.getAttributeContext(); + + if (!isReadOnly(attributeContext)) { + return context; + } + + UserModel user = attributeContext.getUser(); + + if (user == null) { + return context; + } + + List currentValue = user.getAttributeStream(inputHint).collect(Collectors.toList()); + List values = (List) input; + + if (!(currentValue.containsAll(values) && currentValue.size() == values.size())) { + context.addError(new ValidationError(ID, inputHint, DEFAULT_ERROR_MESSAGE)); + return context; + } + + return context; + } + + private boolean isReadOnly(AttributeContext attributeContext) { + return attributeContext.getMetadata().isReadOnly(attributeContext); + } +} diff --git a/services/src/main/java/org/keycloak/userprofile/validator/RegistrationUsernameExistsValidator.java b/services/src/main/java/org/keycloak/userprofile/validator/RegistrationUsernameExistsValidator.java index 2eb87274ce..3e94f5fea5 100644 --- a/services/src/main/java/org/keycloak/userprofile/validator/RegistrationUsernameExistsValidator.java +++ b/services/src/main/java/org/keycloak/userprofile/validator/RegistrationUsernameExistsValidator.java @@ -16,6 +16,7 @@ */ package org.keycloak.userprofile.validator; +import javax.ws.rs.core.Response; import java.util.List; import org.keycloak.models.KeycloakSession; @@ -64,7 +65,8 @@ public class RegistrationUsernameExistsValidator implements SimpleValidator { UserModel existing = session.users().getUserByUsername(realm, value); if (existing != null) { - context.addError(new ValidationError(ID, inputHint, Messages.USERNAME_EXISTS)); + context.addError(new ValidationError(ID, inputHint, Messages.USERNAME_EXISTS) + .setStatusCode(Response.Status.CONFLICT)); } return context; diff --git a/services/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory b/services/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory index d6400491a3..eb56155543 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory @@ -23,4 +23,5 @@ org.keycloak.authentication.requiredactions.TermsAndConditions org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory org.keycloak.authentication.requiredactions.UpdateUserLocaleAction -org.keycloak.authentication.requiredactions.DeleteAccount \ No newline at end of file +org.keycloak.authentication.requiredactions.DeleteAccount +org.keycloak.authentication.requiredactions.VerifyUserProfile \ No newline at end of file diff --git a/services/src/main/resources/META-INF/services/org.keycloak.userprofile.UserProfileProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.userprofile.UserProfileProviderFactory index c23196f894..d04172afe7 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.userprofile.UserProfileProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.userprofile.UserProfileProviderFactory @@ -1,18 +1,20 @@ # -# Copyright 2016 Red Hat, Inc. and/or its affiliates -# and other contributors as indicated by the @author tags. +# /* +# * Copyright 2021 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. +# */ # -# 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. -# - org.keycloak.userprofile.legacy.DefaultUserProfileProvider +org.keycloak.userprofile.config.DeclarativeUserProfileProvider \ No newline at end of file diff --git a/services/src/main/resources/META-INF/services/org.keycloak.validate.ValidatorFactory b/services/src/main/resources/META-INF/services/org.keycloak.validate.ValidatorFactory index 3ee057b33f..7871fae7e1 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.validate.ValidatorFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.validate.ValidatorFactory @@ -10,3 +10,4 @@ org.keycloak.userprofile.validator.RegistrationEmailAsUsernameUsernameValueValid org.keycloak.userprofile.validator.RegistrationUsernameExistsValidator org.keycloak.userprofile.validator.RegistrationEmailAsUsernameEmailValueValidator org.keycloak.userprofile.validator.BrokeringFederatedUsernameHasValueValidator +org.keycloak.userprofile.validator.ImmutableAttributeValidator diff --git a/services/src/main/resources/org/keycloak/userprofile/config/keycloak-default-user-profile.json b/services/src/main/resources/org/keycloak/userprofile/config/keycloak-default-user-profile.json new file mode 100644 index 0000000000..9e0406f43f --- /dev/null +++ b/services/src/main/resources/org/keycloak/userprofile/config/keycloak-default-user-profile.json @@ -0,0 +1,26 @@ +{ + "attributes": [ + { + "name": "username" + }, + { + "name": "email" + }, + { + "name": "firstName", + "required": {"roles" : ["user"]}, + "permissions": { + "view": ["admin", "user"], + "edit": ["admin", "user"] + } + }, + { + "name": "lastName", + "required": {"roles" : ["user"]}, + "permissions": { + "view": ["admin", "user"], + "edit": ["admin", "user"] + } + } + ] +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/servers/auth-server/jboss/common/jboss-cli/keycloak-server-subsystem.cli b/testsuite/integration-arquillian/servers/auth-server/jboss/common/jboss-cli/keycloak-server-subsystem.cli index ffe7dd7b1f..8432ce8905 100644 --- a/testsuite/integration-arquillian/servers/auth-server/jboss/common/jboss-cli/keycloak-server-subsystem.cli +++ b/testsuite/integration-arquillian/servers/auth-server/jboss/common/jboss-cli/keycloak-server-subsystem.cli @@ -22,6 +22,9 @@ echo ** Adding spi=userProfile with legacy-user-profile configuration of read-on /subsystem=keycloak-server/spi=userProfile/provider=legacy-user-profile/:add(properties={},enabled=true) /subsystem=keycloak-server/spi=userProfile/provider=legacy-user-profile/:map-put(name=properties,key=read-only-attributes,value=[deniedFoo,deniedBar*,deniedSome/thing,deniedsome*thing]) /subsystem=keycloak-server/spi=userProfile/provider=legacy-user-profile/:map-put(name=properties,key=admin-read-only-attributes,value=[deniedSomeAdmin]) +/subsystem=keycloak-server/spi=userProfile/provider=declarative-user-profile/:add(properties={},enabled=true) +/subsystem=keycloak-server/spi=userProfile/provider=declarative-user-profile/:map-put(name=properties,key=read-only-attributes,value=[deniedFoo,deniedBar*,deniedSome/thing,deniedsome*thing]) +/subsystem=keycloak-server/spi=userProfile/provider=declarative-user-profile/:map-put(name=properties,key=admin-read-only-attributes,value=[deniedSomeAdmin]) echo ** Do not reuse connections for HttpClientProvider within testsuite ** /subsystem=keycloak-server/spi=connectionsHttpClient/provider=default/:map-put(name=properties,key=reuse-connections,value=false) diff --git a/testsuite/integration-arquillian/servers/auth-server/quarkus/src/main/content/conf/keycloak.properties b/testsuite/integration-arquillian/servers/auth-server/quarkus/src/main/content/conf/keycloak.properties index 4a1ae1e764..a8cc8ea308 100644 --- a/testsuite/integration-arquillian/servers/auth-server/quarkus/src/main/content/conf/keycloak.properties +++ b/testsuite/integration-arquillian/servers/auth-server/quarkus/src/main/content/conf/keycloak.properties @@ -25,3 +25,6 @@ spi.truststore.file.password=secret # http client connection reuse settings spi.connections-http-client.default.reuse-connections=false + +# user profile provider settings +spi.user-profile.provider=${keycloak.userProfile.provider:legacy-user-profile} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.userprofile.UserProfileProviderFactory b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.userprofile.UserProfileProviderFactory deleted file mode 100644 index 7c61cabce3..0000000000 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.userprofile.UserProfileProviderFactory +++ /dev/null @@ -1,20 +0,0 @@ -# -# /* -# * Copyright 2021 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. -# */ -# - -org.keycloak.testsuite.user.profile.config.DeclarativeUserProfileProvider \ No newline at end of file diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/user/profile/config/keycloak-default-user-profile.json b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/user/profile/config/keycloak-default-user-profile.json deleted file mode 100644 index e614ff4063..0000000000 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/user/profile/config/keycloak-default-user-profile.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "attributes": [ - { - "name": "username" - }, - { - "name": "email" - }, - { - "name": "firstName", - "required": {} - }, - { - "name": "lastName", - "required": {} - } - ] -} \ No newline at end of file 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 1b9425fb78..661a90e771 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 @@ -564,8 +564,12 @@ public class AuthServerTestEnricher { wasUpdated = true; } if (event.getTestClass().isAnnotationPresent(SetDefaultProvider.class)) { - SpiProvidersSwitchingUtils.addProviderDefaultValue(suiteContext, event.getTestClass().getAnnotation(SetDefaultProvider.class)); - wasUpdated = true; + SetDefaultProvider defaultProvider = event.getTestClass().getAnnotation(SetDefaultProvider.class); + + if (defaultProvider.beforeEnableFeature()) { + SpiProvidersSwitchingUtils.addProviderDefaultValue(suiteContext, defaultProvider); + wasUpdated = true; + } } if (wasUpdated) { diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/annotation/SetDefaultProvider.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/annotation/SetDefaultProvider.java index d2d585deb6..29d3f21138 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/annotation/SetDefaultProvider.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/annotation/SetDefaultProvider.java @@ -10,4 +10,27 @@ import java.lang.annotation.Target; public @interface SetDefaultProvider { String spi(); String providerId(); + + /** + *

Defines whether the default provider should be set by updating an existing Spi configuration. + * + *

This flag is useful when running the Wildfly distribution and when the server is already configured + * with a Spi that should only be updated with the default provider. + * + * @return {@code true} if the default provider should update an existing Spi configuration. Otherwise, the Spi + * configuration will be added with the default provider set. + */ + boolean onlyUpdateDefault() default false; + + /** + *

Defines whether the default provider should be set prior to enabling a feature. + * + *

This flag should be used together with {@link EnableFeature} so that the default provider + * is set after enabling a feature. It is useful in case the default provider is not enabled by default, + * thus requiring the feature to be enabled first. + * + * @return {@code true} if the default should be set prior to enabling a feature. Otherwise, + * the default provider is only set after enabling a feature. + */ + boolean beforeEnableFeature() default true; } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/containers/KeycloakContainerFeaturesController.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/containers/KeycloakContainerFeaturesController.java index 3d05a1e921..b2529ac825 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/containers/KeycloakContainerFeaturesController.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/containers/KeycloakContainerFeaturesController.java @@ -18,10 +18,13 @@ import org.keycloak.testsuite.arquillian.annotation.DisableFeature; import org.keycloak.testsuite.arquillian.annotation.DisableFeatures; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.arquillian.annotation.EnableFeatures; +import org.keycloak.testsuite.arquillian.annotation.SetDefaultProvider; import org.keycloak.testsuite.client.KeycloakTestingClient; +import org.keycloak.testsuite.util.SpiProvidersSwitchingUtils; import org.wildfly.extras.creaper.core.online.OnlineManagementClient; import org.wildfly.extras.creaper.core.online.operations.admin.Administration; +import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; import java.util.Arrays; import java.util.HashSet; @@ -74,12 +77,15 @@ public class KeycloakContainerFeaturesController { private boolean skipRestart; private FeatureAction action; private boolean onlyForProduct; + private final AnnotatedElement annotatedElement; - public UpdateFeature(Profile.Feature feature, boolean skipRestart, FeatureAction action, boolean onlyForProduct) { + public UpdateFeature(Profile.Feature feature, boolean skipRestart, FeatureAction action, boolean onlyForProduct + , AnnotatedElement annotatedElement) { this.feature = feature; this.skipRestart = skipRestart; this.action = action; this.onlyForProduct = onlyForProduct; + this.annotatedElement = annotatedElement; } private void assertPerformed() { @@ -94,6 +100,18 @@ public class KeycloakContainerFeaturesController { if ((action == FeatureAction.ENABLE && !ProfileAssume.isFeatureEnabled(feature)) || (action == FeatureAction.DISABLE && ProfileAssume.isFeatureEnabled(feature))) { action.accept(testContextInstance.get().getTestingClient(), feature); + SetDefaultProvider setDefaultProvider = annotatedElement.getAnnotation(SetDefaultProvider.class); + if (setDefaultProvider != null) { + try { + if (action == FeatureAction.ENABLE) { + SpiProvidersSwitchingUtils.addProviderDefaultValue(suiteContextInstance.get(), setDefaultProvider); + } else { + SpiProvidersSwitchingUtils.removeProvider(suiteContextInstance.get(), setDefaultProvider); + } + } catch (Exception cause) { + throw new RuntimeException("Failed to (un)set default provider", cause); + } + } } } @@ -186,12 +204,13 @@ public class KeycloakContainerFeaturesController { ret.addAll(Arrays.stream(annotatedElement.getAnnotationsByType(EnableFeature.class)) .map(annotation -> new UpdateFeature(annotation.value(), annotation.skipRestart(), - state == State.BEFORE ? FeatureAction.ENABLE : FeatureAction.DISABLE, annotation.onlyForProduct())) + state == State.BEFORE ? FeatureAction.ENABLE : FeatureAction.DISABLE, annotation.onlyForProduct(), annotatedElement)) .collect(Collectors.toSet())); ret.addAll(Arrays.stream(annotatedElement.getAnnotationsByType(DisableFeature.class)) .map(annotation -> new UpdateFeature(annotation.value(), annotation.skipRestart(), - state == State.BEFORE ? FeatureAction.DISABLE : FeatureAction.ENABLE, annotation.onlyForProduct())) + state == State.BEFORE ? FeatureAction.DISABLE : FeatureAction.ENABLE, annotation.onlyForProduct(), + annotatedElement)) .collect(Collectors.toSet())); return ret; diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/containers/KeycloakQuarkusServerDeployableContainer.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/containers/KeycloakQuarkusServerDeployableContainer.java index e63a1a3093..0d6eee7159 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/containers/KeycloakQuarkusServerDeployableContainer.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/containers/KeycloakQuarkusServerDeployableContainer.java @@ -16,6 +16,8 @@ import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.security.cert.X509Certificate; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -47,6 +49,9 @@ public class KeycloakQuarkusServerDeployableContainer implements DeployableConta @Inject private Instance suiteContext; + private boolean forceReaugmentation; + private List additionalArgs = Collections.emptyList(); + @Override public Class getConfigurationClass() { return KeycloakQuarkusConfiguration.class; @@ -120,8 +125,12 @@ public class KeycloakQuarkusServerDeployableContainer implements DeployableConta FileUtils.deleteDirectory(configuration.getProvidersPath().resolve("data").toFile()); } - if (configuration.isReaugmentBeforeStart()) { - ProcessBuilder reaugment = new ProcessBuilder("./kc.sh", "config"); + if (isReaugmentBeforeStart()) { + List commands = new ArrayList<>(Arrays.asList("./kc.sh", "config", "-Dquarkus.http.root-path=/auth")); + + addAdditionalCommands(commands); + + ProcessBuilder reaugment = new ProcessBuilder(commands); reaugment.directory(wrkDir).inheritIO(); @@ -136,6 +145,10 @@ public class KeycloakQuarkusServerDeployableContainer implements DeployableConta return builder.start(); } + private boolean isReaugmentBeforeStart() { + return configuration.isReaugmentBeforeStart() || forceReaugmentation; + } + private String[] getProcessCommands() { List commands = new ArrayList<>(); @@ -158,9 +171,15 @@ public class KeycloakQuarkusServerDeployableContainer implements DeployableConta commands.add("--cluster=" + System.getProperty("auth.server.quarkus.cluster.config", "local")); + addAdditionalCommands(commands); + return commands.toArray(new String[commands.size()]); } + private void addAdditionalCommands(List commands) { + commands.addAll(additionalArgs); + } + private void waitForReadiness() throws MalformedURLException, LifecycleException { SuiteContext suiteContext = this.suiteContext.get(); //TODO: not sure if the best endpoint but it makes sure that everything is properly initialized. Once we have @@ -252,4 +271,14 @@ public class KeycloakQuarkusServerDeployableContainer implements DeployableConta private long getStartTimeout() { return TimeUnit.SECONDS.toMillis(configuration.getStartupTimeoutInSeconds()); } + + public void forceReAugmentation(String... args) { + forceReaugmentation = true; + additionalArgs = Arrays.asList(args); + } + + public void resetConfiguration() { + additionalArgs = Collections.emptyList(); + forceReAugmentation(); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/VerifyProfilePage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/VerifyProfilePage.java new file mode 100644 index 0000000000..ef04e3f464 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/VerifyProfilePage.java @@ -0,0 +1,137 @@ +/* + * Copyright 2021 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.jboss.arquillian.graphene.page.Page; +import org.keycloak.testsuite.auth.page.AccountFields; +import org.keycloak.testsuite.util.UIUtils; +import org.openqa.selenium.By; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +/** + * @author Vlastimil Elias + */ +public class VerifyProfilePage extends AbstractPage { + + @Page + private AccountFields.AccountErrors accountErrors; + + @FindBy(id = "firstName") + private WebElement firstNameInput; + + @FindBy(id = "lastName") + private WebElement lastNameInput; + + @FindBy(id = "email") + private WebElement emailInput; + + @FindBy(id = "department") + private WebElement departmentInput; + + + @FindBy(css = "input[type=\"submit\"]") + private WebElement submitButton; + + @FindBy(className = "alert-error") + private WebElement loginAlertErrorMessage; + + + public void update(String firstName, String lastName) { + firstNameInput.clear(); + if (firstName != null) { + firstNameInput.sendKeys(firstName); + } + + lastNameInput.clear(); + if (lastName != null) { + lastNameInput.sendKeys(lastName); + } + + submitButton.click(); + } + + public void update(String firstName, String lastName, String department) { + departmentInput.clear(); + if (department != null) { + departmentInput.sendKeys(department); + } + + update(firstName, lastName); + } + + public String getAlertError() { + try { + return UIUtils.getTextFromElement(loginAlertErrorMessage); + } catch (NoSuchElementException e) { + return null; + } + } + + public String getFirstName() { + return firstNameInput.getAttribute("value"); + } + + public String getLastName() { + return lastNameInput.getAttribute("value"); + } + + public String getDepartment() { + return departmentInput.getAttribute("value"); + } + + public boolean isDepartmentEnabled() { + return departmentInput.isEnabled(); + } + + public boolean isUsernamePresent() { + try { + return driver.findElement(By.id("username")).isDisplayed(); + } catch (NoSuchElementException nse) { + return false; + } + } + + public boolean isDepartmentPresent() { + try { + isDepartmentEnabled(); + return true; + } catch (NoSuchElementException e) { + return false; + } + } + + public String getEmail() { + return emailInput.getAttribute("value"); + } + + public boolean isCurrent() { + return PageUtils.getPageTitle(driver).equals("Update Account Information"); + } + + public AccountFields.AccountErrors getInputAccountErrors(){ + return accountErrors; + } + + @Override + public void open() { + throw new UnsupportedOperationException(); + } + +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SpiProvidersSwitchingUtils.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SpiProvidersSwitchingUtils.java index a1669e696c..b92b2ed84f 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SpiProvidersSwitchingUtils.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SpiProvidersSwitchingUtils.java @@ -1,31 +1,52 @@ package org.keycloak.testsuite.util; import org.keycloak.testsuite.arquillian.AuthServerTestEnricher; +import org.keycloak.testsuite.arquillian.ContainerInfo; import org.keycloak.testsuite.arquillian.SuiteContext; import org.keycloak.testsuite.arquillian.annotation.SetDefaultProvider; +import org.keycloak.testsuite.arquillian.containers.KeycloakQuarkusServerDeployableContainer; import org.wildfly.extras.creaper.core.online.CliException; import org.wildfly.extras.creaper.core.online.OnlineManagementClient; import java.io.IOException; -import java.util.concurrent.TimeoutException; public class SpiProvidersSwitchingUtils { public static void addProviderDefaultValue(SuiteContext suiteContext, SetDefaultProvider annotation) throws IOException, CliException { - if (suiteContext.getAuthServerInfo().isUndertow()) { + ContainerInfo authServerInfo = suiteContext.getAuthServerInfo(); + + if (authServerInfo.isUndertow()) { System.setProperty("keycloak." + annotation.spi() + ".provider", annotation.providerId()); + } else if (authServerInfo.isQuarkus()) { + KeycloakQuarkusServerDeployableContainer container = (KeycloakQuarkusServerDeployableContainer) authServerInfo.getArquillianContainer().getDeployableContainer(); + container.forceReAugmentation("-Dkeycloak." + annotation.spi() + ".provider=" + annotation.providerId()); } else { OnlineManagementClient client = AuthServerTestEnricher.getManagementClient(); - client.execute("/subsystem=keycloak-server/spi=" + annotation.spi() + "/:add(default-provider=\"" + annotation.providerId() + "\")"); + + if (annotation.onlyUpdateDefault()) { + client.execute("/subsystem=keycloak-server/spi=" + annotation.spi() + ":write-attribute(name=default-provider, value=" + annotation.providerId() + ")"); + } else { + client.execute("/subsystem=keycloak-server/spi=" + annotation.spi() + "/:add(default-provider=\"" + annotation.providerId() + "\")"); + } + client.close(); } } public static void removeProvider(SuiteContext suiteContext, SetDefaultProvider annotation) throws IOException, CliException { - if (suiteContext.getAuthServerInfo().isUndertow()) { + ContainerInfo authServerInfo = suiteContext.getAuthServerInfo(); + + if (authServerInfo.isUndertow()) { System.clearProperty("keycloak." + annotation.spi() + ".provider"); + } else if (authServerInfo.isQuarkus()) { + KeycloakQuarkusServerDeployableContainer container = (KeycloakQuarkusServerDeployableContainer) authServerInfo.getArquillianContainer().getDeployableContainer(); + container.resetConfiguration(); } else { OnlineManagementClient client = AuthServerTestEnricher.getManagementClient(); - client.execute("/subsystem=keycloak-server/spi=" + annotation.spi() + "/:remove"); + if (annotation.onlyUpdateDefault()) { + client.execute("/subsystem=keycloak-server/spi=" + annotation.spi() + "/:undefine-attribute(name=default-provider)"); + } else { + client.execute("/subsystem=keycloak-server/spi=" + annotation.spi() + "/:remove"); + } client.close(); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/RequiredActionsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/RequiredActionsTest.java index f6e85e06fc..362dedb519 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/RequiredActionsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/RequiredActionsTest.java @@ -84,7 +84,7 @@ public class RequiredActionsTest extends AbstractAuthenticationTest { // Dummy RequiredAction is not registered in the realm and WebAuthn actions List result = authMgmtResource.getUnregisteredRequiredActions(); - Assert.assertEquals(3, result.size()); + Assert.assertEquals(4, result.size()); RequiredActionProviderSimpleRepresentation action = result.get(0); Assert.assertEquals(DummyRequiredActionFactory.PROVIDER_ID, action.getProviderId()); Assert.assertEquals("Dummy Action", action.getName()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/userprofile/UserProfileAdminTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/userprofile/UserProfileAdminTest.java new file mode 100644 index 0000000000..756c9d9336 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/userprofile/UserProfileAdminTest.java @@ -0,0 +1,76 @@ +/* + * + * * Copyright 2021 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.userprofile; + +import static org.junit.Assert.assertEquals; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; + +import org.junit.Test; +import org.keycloak.admin.client.resource.UserProfileResource; +import org.keycloak.common.Profile; +import org.keycloak.common.util.StreamUtil; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.testsuite.admin.AbstractAdminTest; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.arquillian.annotation.SetDefaultProvider; +import org.keycloak.userprofile.UserProfileSpi; +import org.keycloak.userprofile.config.DeclarativeUserProfileProvider; + +/** + * @author Pedro Igor + */ +@EnableFeature(value = Profile.Feature.DECLARATIVE_USER_PROFILE, skipRestart = false) +@SetDefaultProvider(spi = UserProfileSpi.ID, providerId = DeclarativeUserProfileProvider.ID, + beforeEnableFeature = false, + onlyUpdateDefault = true +) +public class UserProfileAdminTest extends AbstractAdminTest { + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + + } + + @Test + public void testDefaultConfigIfNoneSet() { + String defaultRawConfig; + + try (InputStream is = DeclarativeUserProfileProvider.class.getResourceAsStream(DeclarativeUserProfileProvider.SYSTEM_DEFAULT_CONFIG_RESOURCE)) { + defaultRawConfig = StreamUtil.readString(is, Charset.defaultCharset()); + } catch (IOException cause) { + throw new RuntimeException("Failed to load default user profile config file", cause); + } + + assertEquals(defaultRawConfig, testRealm().users().userProfile().getConfiguration()); + } + + @Test + public void testSetDefaultConfig() throws IOException { + String rawConfig = "{\"attributes\": [{\"name\": \"test\"}]}"; + UserProfileResource userProfile = testRealm().users().userProfile(); + + userProfile.update(rawConfig); + + assertEquals(rawConfig, userProfile.getConfiguration()); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RegisterWithUserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RegisterWithUserProfileTest.java new file mode 100644 index 0000000000..0ffb8b424c --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RegisterWithUserProfileTest.java @@ -0,0 +1,260 @@ +/* + * 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.forms; + +import org.jboss.arquillian.graphene.page.Page; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.common.Profile; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.arquillian.annotation.SetDefaultProvider; +import org.keycloak.testsuite.pages.*; +import org.keycloak.testsuite.pages.AppPage.RequestType; + +import org.keycloak.testsuite.util.*; +import org.keycloak.userprofile.UserProfileSpi; +import org.keycloak.userprofile.config.DeclarativeUserProfileProvider; + +import javax.ws.rs.core.Response; + +import static org.junit.Assert.assertEquals; + +import java.util.Collections; + +/** + * @author Stian Thorgersen + * @author Stan Silvert ssilvert@redhat.com (C) 2016 Red Hat Inc. + * @author Vlastimil Elias + */ +@EnableFeature(value = Profile.Feature.DECLARATIVE_USER_PROFILE, skipRestart = false) +@SetDefaultProvider(spi = UserProfileSpi.ID, providerId = DeclarativeUserProfileProvider.ID, + beforeEnableFeature = false, + onlyUpdateDefault = true +) +public class RegisterWithUserProfileTest extends AbstractTestRealmKeycloakTest { + + @Rule + public AssertEvents events = new AssertEvents(this); + + @Page + protected AppPage appPage; + + @Page + protected LoginPage loginPage; + + @Page + protected RegisterPage registerPage; + + @Page + protected VerifyEmailPage verifyEmailPage; + + @Page + protected AccountUpdateProfilePage accountPage; + + @Rule + public GreenMailRule greenMail = new GreenMailRule(); + + + private static final String SCOPE_LAST_NAME = "lastName"; + + private static ClientRepresentation client_scope_default; + private static ClientRepresentation client_scope_optional; + + public static String UP_CONFIG_BASIC_ATTRIBUTES = "{\"name\": \"username\"," + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"email\"," + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\": {}},"; + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + + testRealm.setClientScopes(Collections.singletonList(ClientScopeBuilder.create().name(SCOPE_LAST_NAME).protocol("openid-connect").build())); + client_scope_default = KeycloakModelUtils.createClient(testRealm, "client-a"); + client_scope_default.setDefaultClientScopes(Collections.singletonList(SCOPE_LAST_NAME)); + client_scope_default.setRedirectUris(Collections.singletonList("https://*")); + client_scope_optional = KeycloakModelUtils.createClient(testRealm, "client-b"); + client_scope_optional.setOptionalClientScopes(Collections.singletonList(SCOPE_LAST_NAME)); + client_scope_optional.setRedirectUris(Collections.singletonList("https://*")); + + } + + @Test + public void registerUserSuccess_lastNameOptional() { + setUserProfileConfiguration("{\"attributes\": [" + + UP_CONFIG_BASIC_ATTRIBUTES + + "{\"name\": \"firstName\"," + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + VerifyProfileTest.PERMISSIONS_ALL + "}" + + "]}"); + + loginPage.open(); + loginPage.clickRegister(); + registerPage.assertCurrent(); + + registerPage.register("firstName", "", "registerUserSuccessLastNameOptional@email", "registerUserSuccessLastNameOptional", "password", "password"); + + appPage.assertCurrent(); + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + + String userId = events.expectRegister("registerUserSuccessLastNameOptional", "registerUserSuccessLastNameOptional@email").assertEvent().getUserId(); + assertUserRegistered(userId, "registerUserSuccessLastNameOptional", "registerusersuccesslastnameoptional@email", "firstName", ""); + } + + @Test + public void registerUserSuccess_lastNameRequiredForScope_notRequested() { + setUserProfileConfiguration("{\"attributes\": [" + + UP_CONFIG_BASIC_ATTRIBUTES + + "{\"name\": \"firstName\"," + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\":{\"scopes\":[\""+SCOPE_LAST_NAME+"\"]}}" + + "]}"); + + loginPage.open(); + loginPage.clickRegister(); + registerPage.assertCurrent(); + + registerPage.register("firstName", "", "registerUserSuccessLastNameRequiredForScope_notRequested@email", "registerUserSuccessLastNameRequiredForScope_notRequested", "password", "password"); + + appPage.assertCurrent(); + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + + String userId = events.expectRegister("registerUserSuccessLastNameRequiredForScope_notRequested", "registerUserSuccessLastNameRequiredForScope_notRequested@email").assertEvent().getUserId(); + assertUserRegistered(userId, "registerUserSuccessLastNameRequiredForScope_notRequested", "registerusersuccesslastnamerequiredforscope_notrequested@email", "firstName", ""); + } + + @Test + public void registerUserSuccess_lastNameRequiredForScope_requested() { + setUserProfileConfiguration("{\"attributes\": [" + + UP_CONFIG_BASIC_ATTRIBUTES + + "{\"name\": \"firstName\"," + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\":{\"scopes\":[\""+SCOPE_LAST_NAME+"\"]}}" + + "]}"); + + oauth.scope(SCOPE_LAST_NAME).clientId(client_scope_optional.getClientId()).openLoginForm(); + + loginPage.clickRegister(); + registerPage.assertCurrent(); + + registerPage.register("firstName", "", "registerUserSuccessLastNameRequiredForScope_requested@email", "registerUserSuccessLastNameRequiredForScope_requested", "password", "password"); + + //error reported + registerPage.assertCurrent(); + assertEquals("Please specify this field.", registerPage.getInputAccountErrors().getLastNameError()); + + //submit correct form + registerPage.register("firstName", "lastName", "registerUserSuccessLastNameRequiredForScope_requested@email", "registerUserSuccessLastNameRequiredForScope_requested", "password", "password"); + + appPage.assertCurrent(); + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + } + + @Test + public void registerUserSuccess_lastNameRequiredForScope_clientDefault() { + setUserProfileConfiguration("{\"attributes\": [" + + UP_CONFIG_BASIC_ATTRIBUTES + + "{\"name\": \"firstName\"," + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\":{\"scopes\":[\""+SCOPE_LAST_NAME+"\"]}}" + + "]}"); + + oauth.clientId(client_scope_default.getClientId()).openLoginForm(); + + loginPage.clickRegister(); + registerPage.assertCurrent(); + + registerPage.register("firstName", "", "registerUserSuccessLastNameRequiredForScope_clientDefault@email", "registerUserSuccessLastNameRequiredForScope_clientDefault", "password", "password"); + + //error reported + registerPage.assertCurrent(); + assertEquals("Please specify this field.", registerPage.getInputAccountErrors().getLastNameError()); + + //submit correct form + registerPage.register("firstName", "lastName", "registerUserSuccessLastNameRequiredForScope_clientDefault@email", "registerUserSuccessLastNameRequiredForScope_clientDefault", "password", "password"); + + appPage.assertCurrent(); + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + } + + @Test + public void registerUserSuccess_lastNameLengthValidation() { + setUserProfileConfiguration("{\"attributes\": [" + + UP_CONFIG_BASIC_ATTRIBUTES + + "{\"name\": \"firstName\"," + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + VerifyProfileTest.PERMISSIONS_ALL + ", " + VerifyProfileTest.VALIDATIONS_LENGTH + "}" + + "]}"); + + loginPage.open(); + loginPage.clickRegister(); + registerPage.assertCurrent(); + + registerPage.register("firstName", "last", "registerUserSuccessLastNameLengthValidation@email", "registerUserSuccessLastNameLengthValidation", "password", "password"); + + appPage.assertCurrent(); + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + + String userId = events.expectRegister("registerUserSuccessLastNameLengthValidation", "registerUserSuccessLastNameLengthValidation@email").assertEvent().getUserId(); + assertUserRegistered(userId, "registerUserSuccessLastNameLengthValidation", "registerusersuccesslastnamelengthvalidation@email", "firstName", "last"); + } + + @Test + public void registerUserInvalidLastNameLength() { + setUserProfileConfiguration("{\"attributes\": [" + + UP_CONFIG_BASIC_ATTRIBUTES + + "{\"name\": \"firstName\"," + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + VerifyProfileTest.PERMISSIONS_ALL + ", " + VerifyProfileTest.VALIDATIONS_LENGTH + "}" + + "]}"); + + loginPage.open(); + loginPage.clickRegister(); + registerPage.assertCurrent(); + + registerPage.register("firstName", "L", "registerUserInvalidLastNameLength@email", "registerUserInvalidLastNameLength", "password", "password"); + + registerPage.assertCurrent(); + assertEquals("Length must be between 3 and 255.", registerPage.getInputAccountErrors().getLastNameError()); + + events.expectRegister("registeruserinvalidlastnamelength", "registerUserInvalidLastNameLength@email") + .error("invalid_registration").assertEvent(); + } + + private void assertUserRegistered(String userId, String username, String email, String firstName, String lastName) { + events.expectLogin().detail("username", username.toLowerCase()).user(userId).assertEvent(); + + UserRepresentation user = getUser(userId); + Assert.assertNotNull(user); + Assert.assertNotNull(user.getCreatedTimestamp()); + // test that timestamp is current with 10s tollerance + Assert.assertTrue((System.currentTimeMillis() - user.getCreatedTimestamp()) < 10000); + // test user info is set from form + assertEquals(username.toLowerCase(), user.getUsername()); + assertEquals(email.toLowerCase(), user.getEmail()); + assertEquals(firstName, user.getFirstName()); + assertEquals(lastName, user.getLastName()); + } + + protected UserRepresentation getUser(String userId) { + return testRealm().users().get(userId).toRepresentation(); + } + + private void setUserProfileConfiguration(String configuration) { + Response r = testRealm().users().userProfile().update(configuration); + if (r.getStatus() != 200) { + Assert.fail("Configuration not set due to error: " + r.readEntity(String.class)); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/VerifyProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/VerifyProfileTest.java new file mode 100644 index 0000000000..1f35af8e04 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/VerifyProfileTest.java @@ -0,0 +1,600 @@ +/* + * Copyright 2021 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.forms; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +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.Rule; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.common.Profile; +import org.keycloak.events.EventType; +import org.keycloak.models.UserModel; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.RequiredActionProviderRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.arquillian.annotation.SetDefaultProvider; +import org.keycloak.testsuite.pages.AppPage; +import org.keycloak.testsuite.pages.AppPage.RequestType; +import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.pages.VerifyProfilePage; +import org.keycloak.testsuite.util.ClientScopeBuilder; +import org.keycloak.testsuite.util.KeycloakModelUtils; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.RealmBuilder; +import org.keycloak.testsuite.util.UserBuilder; +import org.keycloak.userprofile.UserProfileSpi; +import org.keycloak.userprofile.config.DeclarativeUserProfileProvider; + +/** + * @author Vlastimil Elias + */ +@EnableFeature(value = Profile.Feature.DECLARATIVE_USER_PROFILE, skipRestart = false) +@SetDefaultProvider(spi = UserProfileSpi.ID, providerId = DeclarativeUserProfileProvider.ID, + beforeEnableFeature = false, + onlyUpdateDefault = true) +public class VerifyProfileTest extends AbstractTestRealmKeycloakTest { + + private static final String SCOPE_DEPARTMENT = "department"; + private static final String ATTRIBUTE_DEPARTMENT = "department"; + + public static String PERMISSIONS_ALL = "\"permissions\": {\"view\": [\"admin\", \"user\"], \"edit\": [\"admin\", \"user\"]}"; + public static String PERMISSIONS_ADMIN_ONLY = "\"permissions\": {\"view\": [\"admin\"], \"edit\": [\"admin\"]}"; + public static String PERMISSIONS_ADMIN_EDITABLE = "\"permissions\": {\"view\": [\"admin\", \"user\"], \"edit\": [\"admin\"]}"; + + public static String VALIDATIONS_LENGTH = "\"validations\": {\"length\": { \"min\": 3, \"max\": 255 }}"; + + private static final String CONFIGURATION_FOR_USER_EDIT = "{\"attributes\": [" + + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + "}," + + "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "}," + + "{\"name\": \"department\"," + PERMISSIONS_ALL + "}" + + "]}"; + + + private static String userId; + + private static String user2Id; + + private static String user3Id; + + private static String user4Id; + + private static String user5Id; + + private static ClientRepresentation client_scope_default; + private static ClientRepresentation client_scope_optional; + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + UserRepresentation user = UserBuilder.create().id(UUID.randomUUID().toString()).username("login-test").email("login@test.com").enabled(true).password("password").build(); + userId = user.getId(); + + UserRepresentation user2 = UserBuilder.create().id(UUID.randomUUID().toString()).username("login-test2").email("login2@test.com").enabled(true).password("password").build(); + user2Id = user2.getId(); + + UserRepresentation user3 = UserBuilder.create().id(UUID.randomUUID().toString()).username("login-test3").email("login3@test.com").enabled(true).password("password").lastName("ExistingLast").build(); + user3Id = user3.getId(); + + UserRepresentation user4 = UserBuilder.create().id(UUID.randomUUID().toString()).username("login-test4").email("login4@test.com").enabled(true).password("password").lastName("ExistingLast").build(); + user4Id = user4.getId(); + + UserRepresentation user5 = UserBuilder.create().id(UUID.randomUUID().toString()).username("login-test5").email("login5@test.com").enabled(true).password("password").firstName("ExistingFirst").lastName("ExistingLast").build(); + user5Id = user5.getId(); + + RealmBuilder.edit(testRealm).user(user).user(user2).user(user3).user(user4).user(user5); + + RequiredActionProviderRepresentation action = new RequiredActionProviderRepresentation(); + action.setAlias(UserModel.RequiredAction.VERIFY_PROFILE.name()); + action.setProviderId(UserModel.RequiredAction.VERIFY_PROFILE.name()); + action.setEnabled(true); + action.setDefaultAction(false); + action.setPriority(10); + + List actions = new ArrayList<>(); + actions.add(action); + testRealm.setRequiredActions(actions); + + testRealm.setClientScopes(Collections.singletonList(ClientScopeBuilder.create().name(SCOPE_DEPARTMENT).protocol("openid-connect").build())); + client_scope_default = KeycloakModelUtils.createClient(testRealm, "client-a"); + client_scope_default.setDefaultClientScopes(Collections.singletonList(SCOPE_DEPARTMENT)); + client_scope_default.setRedirectUris(Collections.singletonList("https://*")); + client_scope_optional = KeycloakModelUtils.createClient(testRealm, "client-b"); + client_scope_optional.setOptionalClientScopes(Collections.singletonList(SCOPE_DEPARTMENT)); + client_scope_optional.setRedirectUris(Collections.singletonList("https://*")); + } + + @Rule + public AssertEvents events = new AssertEvents(this); + + @Page + protected AppPage appPage; + + @Page + protected LoginPage loginPage; + + @Page + protected VerifyProfilePage verifyProfilePage; + + @ArquillianResource + protected OAuthClient oauth; + + @Test + public void testDefaultProfile() { + setUserProfileConfiguration(null); + + loginPage.open(); + loginPage.login("login-test", "password"); + + //submit with error + verifyProfilePage.assertCurrent(); + Assert.assertFalse(verifyProfilePage.isDepartmentPresent()); + verifyProfilePage.update("First", " "); + + //submit OK + verifyProfilePage.assertCurrent(); + Assert.assertFalse(verifyProfilePage.isDepartmentPresent()); + verifyProfilePage.update("First", "Last"); + + + Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + + events.expectRequiredAction(EventType.VERIFY_PROFILE).user(userId).assertEvent(); + + UserRepresentation user = getUser(userId); + assertEquals("First", user.getFirstName()); + assertEquals("Last", user.getLastName()); + } + + @Test + public void testUsernameOnlyIfEditAllowed() { + RealmRepresentation realm = testRealm().toRepresentation(); + + try { + setUserProfileConfiguration(null); + + realm.setEditUsernameAllowed(false); + testRealm().update(realm); + + loginPage.open(); + loginPage.login("login-test", "password"); + + assertFalse(verifyProfilePage.isUsernamePresent()); + + realm.setEditUsernameAllowed(true); + testRealm().update(realm); + + driver.navigate().refresh(); + assertTrue(verifyProfilePage.isUsernamePresent()); + } finally { + realm.setEditUsernameAllowed(false); + testRealm().update(realm); + } + } + + @Test + public void testOptionalAttribute() { + setUserProfileConfiguration("{\"attributes\": [" + + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "}" + + "]}"); + + loginPage.open(); + loginPage.login("login-test2", "password"); + + verifyProfilePage.assertCurrent(); + verifyProfilePage.update("First", ""); + + Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + + events.expectRequiredAction(EventType.VERIFY_PROFILE).user(user2Id).assertEvent(); + + UserRepresentation user = getUser(user2Id); + assertEquals("First", user.getFirstName()); + assertEquals("", user.getLastName()); + } + + @Test + public void testCustomValidationLastName() { + + setUserProfileConfiguration(CONFIGURATION_FOR_USER_EDIT); + updateUser(user5Id, "ExistingFirst", "La", "Department"); + + setUserProfileConfiguration("{\"attributes\": [" + + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + PERMISSIONS_ALL +","+VALIDATIONS_LENGTH + "}," + + "{\"name\": \"department\"," + PERMISSIONS_ADMIN_ONLY + "}" + + "]}"); + + loginPage.open(); + loginPage.login("login-test5", "password"); + + verifyProfilePage.assertCurrent(); + //submit with error + verifyProfilePage.update("First", "L"); + + verifyProfilePage.assertCurrent(); + //submit OK + verifyProfilePage.update("First", "Last"); + + Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + + events.expectRequiredAction(EventType.VERIFY_PROFILE).user(user5Id).assertEvent(); + + UserRepresentation user = getUser(user5Id); + assertEquals("First", user.getFirstName()); + assertEquals("Last", user.getLastName()); + //check that not configured attribute is unchanged + assertEquals("Department", user.firstAttribute(ATTRIBUTE_DEPARTMENT)); + } + + @Test + public void testNoActionIfNoValidationError() { + + setUserProfileConfiguration(CONFIGURATION_FOR_USER_EDIT); + updateUser(user5Id, "ExistingFirst", "ExistingLast", "Department"); + + setUserProfileConfiguration("{\"attributes\": [" + + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + PERMISSIONS_ALL +","+VALIDATIONS_LENGTH + "}" + + "]}"); + + loginPage.open(); + loginPage.login("login-test5", "password"); + + Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + } + + @Test + public void testRequiredReadOnlyAttribute() { + + setUserProfileConfiguration("{\"attributes\": [" + + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "}," + + "{\"name\": \"department\"," + PERMISSIONS_ADMIN_EDITABLE + ", \"required\":{}}" + + "]}"); + + loginPage.open(); + loginPage.login("login-test3", "password"); + + verifyProfilePage.assertCurrent(); + Assert.assertEquals("ExistingLast", verifyProfilePage.getLastName()); + Assert.assertFalse(verifyProfilePage.isDepartmentEnabled()); + + //update of the other attributes must be successful in this case + verifyProfilePage.update("First", "Last"); + + Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + + events.expectRequiredAction(EventType.VERIFY_PROFILE).user(user3Id).assertEvent(); + + UserRepresentation user = getUser(user3Id); + assertEquals("First", user.getFirstName()); + assertEquals("Last", user.getLastName()); + } + + @Test + public void testAttributeNotVisible() { + + setUserProfileConfiguration("{\"attributes\": [" + + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "}," + + "{\"name\": \"department\"," + PERMISSIONS_ADMIN_ONLY + ", \"required\":{}}" + + "]}"); + + loginPage.open(); + loginPage.login("login-test4", "password"); + + verifyProfilePage.assertCurrent(); + Assert.assertEquals("ExistingLast", verifyProfilePage.getLastName()); + Assert.assertFalse("'department' field is visible" , verifyProfilePage.isDepartmentPresent()); + + //update of the other attributes must be successful in this case + verifyProfilePage.update("First", "Last"); + + Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + + events.expectRequiredAction(EventType.VERIFY_PROFILE).user(user4Id).assertEvent(); + + UserRepresentation user = getUser(user4Id); + assertEquals("First", user.getFirstName()); + assertEquals("Last", user.getLastName()); + } + + @Test + public void testRequiredAttribute() { + + setUserProfileConfiguration(CONFIGURATION_FOR_USER_EDIT); + updateUser(user5Id, "ExistingFirst", "ExistingLast", null); + + setUserProfileConfiguration("{\"attributes\": [" + + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "}," + + "{\"name\": \"department\"," + PERMISSIONS_ALL + ", \"required\":{}}" + + "]}"); + + loginPage.open(); + loginPage.login("login-test5", "password"); + + verifyProfilePage.assertCurrent(); + + //submit with error + verifyProfilePage.update("FirstCC", "LastCC", " "); + verifyProfilePage.assertCurrent(); + + //submit OK + verifyProfilePage.update("FirstCC", "LastCC", "DepartmentCC"); + + + Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + + events.expectRequiredAction(EventType.VERIFY_PROFILE).user(user5Id).assertEvent(); + + UserRepresentation user = getUser(user5Id); + assertEquals("FirstCC", user.getFirstName()); + assertEquals("LastCC", user.getLastName()); + assertEquals("DepartmentCC", user.firstAttribute(ATTRIBUTE_DEPARTMENT)); + } + + @Test + public void testRequiredOnlyIfUser() { + + setUserProfileConfiguration("{\"attributes\": [" + + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "}," + + "{\"name\": \"department\"," + PERMISSIONS_ALL + ", \"required\":{\"roles\":[\"user\"]}}" + + "]}"); + + updateUser(user5Id, "ExistingFirst", "ExistingLast", null); + + + loginPage.open(); + loginPage.login("login-test5", "password"); + + verifyProfilePage.assertCurrent(); + + //submit with error + verifyProfilePage.update("FirstCC", "LastCC", " "); + verifyProfilePage.assertCurrent(); + + //submit OK + verifyProfilePage.update("FirstCC", "LastCC", "DepartmentCC"); + + + Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + + events.expectRequiredAction(EventType.VERIFY_PROFILE).user(user5Id).assertEvent(); + + UserRepresentation user = getUser(user5Id); + assertEquals("FirstCC", user.getFirstName()); + assertEquals("LastCC", user.getLastName()); + assertEquals("DepartmentCC", user.firstAttribute(ATTRIBUTE_DEPARTMENT)); + } + + @Test + public void testAttributeNotRequiredWhenMissingScope() { + + setUserProfileConfiguration("{\"attributes\": [" + + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "}," + + "{\"name\": \"department\"," + PERMISSIONS_ALL + ", \"required\":{\"scopes\":[\"profile\"]}}" + + "]}"); + + updateUser(user5Id, "ExistingFirst", "ExistingLast", null); + + oauth.clientId(client_scope_optional.getClientId()).openLoginForm(); + + loginPage.login("login-test5", "password"); + + Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + + UserRepresentation user = getUser(user5Id); + assertEquals("ExistingFirst", user.getFirstName()); + assertEquals("ExistingLast", user.getLastName()); + } + + @Test + public void testAttributeRequiredForScope() { + + setUserProfileConfiguration("{\"attributes\": [" + + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "}," + + "{\"name\": \"department\"," + PERMISSIONS_ALL + ", \"required\":{\"scopes\":[\""+SCOPE_DEPARTMENT+"\"]}}" + + "]}"); + + updateUser(user5Id, "ExistingFirst", "ExistingLast", null); + + oauth.scope(SCOPE_DEPARTMENT).clientId(client_scope_optional.getClientId()).openLoginForm(); + + loginPage.assertCurrent(); + loginPage.login("login-test5", "password"); + + verifyProfilePage.assertCurrent(); + + verifyProfilePage.update("FirstAA", "LastAA", "DepartmentAA"); + + Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + + events.expectRequiredAction(EventType.VERIFY_PROFILE).client(client_scope_optional).user(user5Id).assertEvent(); + + UserRepresentation user = getUser(user5Id); + assertEquals("FirstAA", user.getFirstName()); + assertEquals("LastAA", user.getLastName()); + assertEquals("DepartmentAA", user.firstAttribute(ATTRIBUTE_DEPARTMENT)); + } + + @Test + public void testAttributeRequiredForDefaultScope() { + + setUserProfileConfiguration("{\"attributes\": [" + + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "}," + + "{\"name\": \"department\"," + PERMISSIONS_ALL + ", \"required\":{\"scopes\":[\""+SCOPE_DEPARTMENT+"\"]}}" + + "]}"); + + updateUser(user5Id, "ExistingFirst", "ExistingLast", null); + + oauth.clientId(client_scope_default.getClientId()).openLoginForm(); + + loginPage.assertCurrent(); + loginPage.login("login-test5", "password"); + + verifyProfilePage.assertCurrent(); + + //submit with error + verifyProfilePage.update("FirstBB", "LastBB", " "); + verifyProfilePage.assertCurrent(); + + //submit OK + verifyProfilePage.update("FirstBB", "LastBB", "DepartmentBB"); + + Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + + events.expectRequiredAction(EventType.VERIFY_PROFILE).client(client_scope_default).user(user5Id).assertEvent(); + + UserRepresentation user = getUser(user5Id); + assertEquals("FirstBB", user.getFirstName()); + assertEquals("LastBB", user.getLastName()); + assertEquals("DepartmentBB", user.firstAttribute(ATTRIBUTE_DEPARTMENT)); + } + + @Test + public void testNoActionIfValidForScope() { + + setUserProfileConfiguration("{\"attributes\": [" + + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "}," + + "{\"name\": \"department\"," + PERMISSIONS_ALL + ", \"required\":{\"scopes\":[\""+SCOPE_DEPARTMENT+"\"]}}" + + "]}"); + + updateUser(user5Id, "ExistingFirst", "ExistingLast", "ExistingDepartment"); + + oauth.clientId(client_scope_default.getClientId()).openLoginForm(); + + loginPage.assertCurrent(); + loginPage.login("login-test5", "password"); + + Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + + UserRepresentation user = getUser(user5Id); + assertEquals("ExistingFirst", user.getFirstName()); + assertEquals("ExistingLast", user.getLastName()); + assertEquals("ExistingDepartment", user.firstAttribute(ATTRIBUTE_DEPARTMENT)); + } + + @Test + public void testCustomValidationInCustomAttribute() { + + setUserProfileConfiguration(CONFIGURATION_FOR_USER_EDIT); + updateUser(user5Id, "ExistingFirst", "ExistingLast", "D"); + + setUserProfileConfiguration("{\"attributes\": [" + + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "}," + + "{\"name\": \"department\"," + PERMISSIONS_ALL + ", "+VALIDATIONS_LENGTH+"}" + + "]}"); + + loginPage.open(); + loginPage.login("login-test5", "password"); + + verifyProfilePage.assertCurrent(); + + //submit with error + verifyProfilePage.update("FirstCC", "LastCC", "De"); + verifyProfilePage.assertCurrent(); + + //submit OK + verifyProfilePage.update("FirstCC", "LastCC", "DepartmentCC"); + + + Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + + events.expectRequiredAction(EventType.VERIFY_PROFILE).user(user5Id).assertEvent(); + + UserRepresentation user = getUser(user5Id); + assertEquals("FirstCC", user.getFirstName()); + assertEquals("LastCC", user.getLastName()); + assertEquals("DepartmentCC", user.firstAttribute(ATTRIBUTE_DEPARTMENT)); + } + + @Test + public void testNoActionIfSuccessfulValidationForCustomAttribute() { + + setUserProfileConfiguration(CONFIGURATION_FOR_USER_EDIT); + updateUser(user5Id, "ExistingFirst", "ExistingLast", "Department"); + + setUserProfileConfiguration("{\"attributes\": [" + + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}}," + + "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "}," + + "{\"name\": \"department\"," + PERMISSIONS_ALL + ", "+VALIDATIONS_LENGTH+"}" + + "]}"); + + loginPage.open(); + loginPage.login("login-test5", "password"); + + Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + } + + protected UserRepresentation getUser(String userId) { + return testRealm().users().get(userId).toRepresentation(); + } + + protected void updateUser(String userId, String firstName, String lastName, String department) { + UserRepresentation ur = testRealm().users().get(userId).toRepresentation(); + ur.setFirstName(firstName); + ur.setLastName(lastName); + ur.singleAttribute(ATTRIBUTE_DEPARTMENT, department); + testRealm().users().get(userId).update(ur); + } + + protected void setUserProfileConfiguration(String configuration) { + Response r = testRealm().users().userProfile().update(configuration); + if (r.getStatus() != 200) { + Assert.fail("Configuration not set due to error: " + r.readEntity(String.class)); + } + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/AbstractUserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/AbstractUserProfileTest.java index 7c7049db70..3fb184d63c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/AbstractUserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/AbstractUserProfileTest.java @@ -30,7 +30,7 @@ import org.keycloak.models.UserModel; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.RootAuthenticationSessionModel; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; -import org.keycloak.testsuite.user.profile.config.DeclarativeUserProfileProvider; +import org.keycloak.userprofile.config.DeclarativeUserProfileProvider; import org.keycloak.userprofile.UserProfileProvider; /** @@ -39,7 +39,6 @@ import org.keycloak.userprofile.UserProfileProvider; public abstract class AbstractUserProfileTest extends AbstractTestRealmKeycloakTest { protected static void configureAuthenticationSession(KeycloakSession session) { - configureSessionRealm(session); Set scopes = new HashSet<>(); scopes.add("customer"); @@ -53,16 +52,12 @@ public abstract class AbstractUserProfileTest extends AbstractTestRealmKeycloakT session.getContext().setAuthenticationSession(createAuthenticationSession(realm.getClientByClientId(clientId), requestedScopes)); } - protected static RealmModel configureSessionRealm(KeycloakSession session) { - RealmModel realm = session.realms().getRealm(TEST_REALM_NAME); - - session.getContext().setRealm(realm); - - return realm; - } - protected static DeclarativeUserProfileProvider getDynamicUserProfileProvider(KeycloakSession session) { - return (DeclarativeUserProfileProvider) session.getProvider(UserProfileProvider.class, DeclarativeUserProfileProvider.ID); + UserProfileProvider provider = session.getProvider(UserProfileProvider.class); + + provider.setConfiguration(null); + + return (DeclarativeUserProfileProvider) provider; } protected static AuthenticationSessionModel createAuthenticationSession(ClientModel client, Set scopes) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileConfigTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileConfigTest.java deleted file mode 100644 index e5c3c3585f..0000000000 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileConfigTest.java +++ /dev/null @@ -1,633 +0,0 @@ -/* - * - * * Copyright 2021 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.user.profile; - -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; -import static org.keycloak.testsuite.user.profile.config.UPConfigUtils.ROLE_USER; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import org.junit.Assert; -import org.junit.Test; -import org.keycloak.component.ComponentModel; -import org.keycloak.component.ComponentValidationException; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.UserModel; -import org.keycloak.representations.idm.RealmRepresentation; -import org.keycloak.testsuite.runonserver.RunOnServer; -import org.keycloak.testsuite.user.profile.config.DeclarativeUserProfileProvider; -import org.keycloak.testsuite.user.profile.config.UPAttribute; -import org.keycloak.testsuite.user.profile.config.UPAttributeRequired; -import org.keycloak.testsuite.user.profile.config.UPConfig; -import org.keycloak.testsuite.user.profile.config.UPConfigUtils; -import org.keycloak.testsuite.util.KeycloakModelUtils; -import org.keycloak.userprofile.UserProfile; -import org.keycloak.userprofile.UserProfileContext; -import org.keycloak.userprofile.ValidationException; -import org.keycloak.util.JsonSerialization; -import org.keycloak.validate.validators.LengthValidator; - -/** - * @author Pedro Igor - */ -public class UserProfileConfigTest extends AbstractUserProfileTest { - - protected static final String ATT_ADDRESS = "address"; - - @Override - public void configureTestRealm(RealmRepresentation testRealm) { - KeycloakModelUtils.createClient(testRealm, "client-a"); - KeycloakModelUtils.createClient(testRealm, "client-b"); - } - - @Test - public void testConfigurationSetInvalid() { - getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testConfigurationSetInvalid); - } - - private static void testConfigurationSetInvalid(KeycloakSession session) { - configureSessionRealm(session); - DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); - - try { - provider.setConfiguration("{\"validateConfigAttribute\": true}"); - fail("Should fail validation"); - } catch (ComponentValidationException ve) { - // OK - } - - } - - @Test - public void testConfigurationGetSet() { - getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testConfigurationGetSet); - } - - private static void testConfigurationGetSet(KeycloakSession session) throws IOException { - configureSessionRealm(session); - DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); - ComponentModel component = provider.getComponentModel(); - - assertNotNull(component); - - // generate big configuration to test slicing in the persistence/component config - UPConfig config = new UPConfig(); - for (int i = 0; i < 80; i++) { - UPAttribute attribute = new UPAttribute(); - attribute.setName(UserModel.USERNAME+i); - Map validatorConfig = new HashMap<>(); - validatorConfig.put("min", 3); - attribute.addValidation("length", validatorConfig); - config.addAttribute(attribute); - } - String newConfig = JsonSerialization.writeValueAsString(config); - - provider.setConfiguration(newConfig); - // assert config is persisted in 2 pieces - Assert.assertEquals("2", component.get(DeclarativeUserProfileProvider.UP_PIECES_COUNT_COMPONENT_CONFIG_KEY)); - // assert config is returned correctly - Assert.assertEquals(newConfig, provider.getConfiguration()); - } - - @Test - public void testConfigurationGetSetDefault() { - getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testConfigurationGetSetDefault); - } - - private static void testConfigurationGetSetDefault(KeycloakSession session) throws IOException { - configureSessionRealm(session); - DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); - - provider.setConfiguration(null); - - Assert.assertNull(provider.getComponentModel().get(DeclarativeUserProfileProvider.UP_PIECES_COUNT_COMPONENT_CONFIG_KEY)); - - ComponentModel component = provider.getComponentModel(); - - assertNotNull(component); - - Assert.assertTrue(component.getConfig().isEmpty()); - } - - @Test - public void testDefaultConfigForUpdateProfile() { - getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testDefaultConfigForUpdateProfile); - } - - private static void testDefaultConfigForUpdateProfile(KeycloakSession session) throws IOException { - configureSessionRealm(session); - DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); - - // reset configuration to default - provider.setConfiguration(null); - - // failed required validations - UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, Collections.emptyMap()); - - try { - profile.validate(); - fail("Should fail validation"); - } catch (ValidationException ve) { - assertTrue(ve.isAttributeOnError(UserModel.USERNAME)); - } - - // failed for blank values also - Map attributes = new HashMap<>(); - - attributes.put(UserModel.FIRST_NAME, ""); - attributes.put(UserModel.LAST_NAME, " "); - attributes.put(UserModel.EMAIL, ""); - - profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - - try { - profile.validate(); - fail("Should fail validation"); - } catch (ValidationException ve) { - assertTrue(ve.isAttributeOnError(UserModel.USERNAME)); - assertTrue(ve.isAttributeOnError(UserModel.FIRST_NAME)); - assertTrue(ve.isAttributeOnError(UserModel.LAST_NAME)); - assertTrue(ve.isAttributeOnError(UserModel.EMAIL)); - } - - // all OK - attributes.put(UserModel.USERNAME, "jdoeusername"); - attributes.put(UserModel.FIRST_NAME, "John"); - attributes.put(UserModel.LAST_NAME, "Doe"); - attributes.put(UserModel.EMAIL, "jdoe@acme.org"); - - profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - profile.validate(); - } - - @Test - public void testAdditionalValidationForUsername() { - getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testAdditionalValidationForUsername); - } - - private static void testAdditionalValidationForUsername(KeycloakSession session) throws IOException { - configureSessionRealm(session); - DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); - ComponentModel component = provider.getComponentModel(); - - assertNotNull(component); - - UPConfig config = new UPConfig(); - UPAttribute attribute = new UPAttribute(); - - attribute.setName(UserModel.USERNAME); - - Map validatorConfig = new HashMap<>(); - - validatorConfig.put("min", 4); - - attribute.addValidation(LengthValidator.ID, validatorConfig); - - config.addAttribute(attribute); - - provider.setConfiguration(JsonSerialization.writeValueAsString(config)); - - Map attributes = new HashMap<>(); - - attributes.put(UserModel.USERNAME, "us"); - - UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - - try { - profile.validate(); - fail("Should fail validation"); - } catch (ValidationException ve) { - assertTrue(ve.isAttributeOnError(UserModel.USERNAME)); - assertTrue(ve.hasError(LengthValidator.MESSAGE_INVALID_LENGTH)); - } - - attributes.put(UserModel.USERNAME, "user"); - - profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - - profile.validate(); - - provider.setConfiguration(null); - - attributes.put(UserModel.USERNAME, "us"); - - profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - - profile.validate(); - } - - @Test - public void testFirstLastNameCanBeOptional() { - getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testFirstLastNameCanBeOptional); - } - - private static void testFirstLastNameCanBeOptional(KeycloakSession session) throws IOException { - - configureSessionRealm(session); - DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); - ComponentModel component = provider.getComponentModel(); - - assertNotNull(component); - - UPConfig config = new UPConfig(); - UPAttribute attribute = new UPAttribute(); - attribute.setName(UserModel.FIRST_NAME); - Map validatorConfig = new HashMap<>(); - validatorConfig.put(LengthValidator.KEY_MAX, 4); - attribute.addValidation(LengthValidator.ID, validatorConfig); - config.addAttribute(attribute); - - attribute = new UPAttribute(); - attribute.setName(UserModel.LAST_NAME); - attribute.addValidation(LengthValidator.ID, validatorConfig); - config.addAttribute(attribute); - - provider.setConfiguration(JsonSerialization.writeValueAsString(config)); - - Map attributes = new HashMap<>(); - - attributes.put(UserModel.USERNAME, "user"); - - // not present attributes are OK - UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - profile.validate(); - - //empty attributes are OK - attributes.put(UserModel.FIRST_NAME, ""); - attributes.put(UserModel.LAST_NAME, ""); - profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - profile.validate(); - - //filled attributes are OK - attributes.put(UserModel.FIRST_NAME, "John"); - attributes.put(UserModel.LAST_NAME, "Doe"); - profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - profile.validate(); - - // fails due to additional length validation so it is executed correctly - attributes.put(UserModel.FIRST_NAME, "JohnTooLong"); - attributes.put(UserModel.LAST_NAME, "DoeTooLong"); - profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - try { - profile.validate(); - fail("Should fail validation"); - } catch (ValidationException ve) { - assertTrue(ve.isAttributeOnError(UserModel.FIRST_NAME)); - assertTrue(ve.isAttributeOnError(UserModel.LAST_NAME)); - } - } - - @Test - public void testCustomAttribute_Required() { - getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testCustomAttribute_Required); - } - - private static void testCustomAttribute_Required(KeycloakSession session) throws IOException { - configureSessionRealm(session); - DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); - ComponentModel component = provider.getComponentModel(); - - assertNotNull(component); - - UPConfig config = new UPConfig(); - UPAttribute attribute = new UPAttribute(); - - attribute.setName(ATT_ADDRESS); - - Map validatorConfig = new HashMap<>(); - - validatorConfig.put(LengthValidator.KEY_MIN, 4); - - attribute.addValidation(LengthValidator.ID, validatorConfig); - - // make it ALWAYS required - UPAttributeRequired requirements = new UPAttributeRequired(); - attribute.setRequired(requirements); - - config.addAttribute(attribute); - - provider.setConfiguration(JsonSerialization.writeValueAsString(config)); - - Map attributes = new HashMap<>(); - - attributes.put(UserModel.USERNAME, "user"); - - UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - - // fails on required validation - try { - profile.validate(); - fail("Should fail validation"); - } catch (ValidationException ve) { - assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); - } - - // fails on length validation - attributes.put(ATT_ADDRESS, "adr"); - profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - try { - profile.validate(); - fail("Should fail validation"); - } catch (ValidationException ve) { - assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); - } - - // all OK - attributes.put(ATT_ADDRESS, "adress ok"); - profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - profile.validate(); - } - - @Test - public void testCustomAttribute_Optional() { - getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testCustomAttribute_Optional); - } - - private static void testCustomAttribute_Optional(KeycloakSession session) throws IOException { - configureSessionRealm(session); - DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); - ComponentModel component = provider.getComponentModel(); - - assertNotNull(component); - - UPConfig config = new UPConfig(); - UPAttribute attribute = new UPAttribute(); - - attribute.setName(ATT_ADDRESS); - - Map validatorConfig = new HashMap<>(); - validatorConfig.put(LengthValidator.KEY_MIN, 4); - attribute.addValidation(LengthValidator.ID, validatorConfig); - - config.addAttribute(attribute); - - provider.setConfiguration(JsonSerialization.writeValueAsString(config)); - - Map attributes = new HashMap<>(); - attributes.put(UserModel.USERNAME, "user"); - - // null is OK as attribute is optional - UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - profile.validate(); - - //blank String have to be OK as it is what UI forms send for not filled in optional attributes - attributes.put(ATT_ADDRESS, ""); - profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - profile.validate(); - - // fails on length validation - attributes.put(ATT_ADDRESS, "adr"); - profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - try { - profile.validate(); - fail("Should fail validation"); - } catch (ValidationException ve) { - assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); - } - - // all OK - attributes.put(ATT_ADDRESS, "adress ok"); - profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - profile.validate(); - - } - - @Test - public void testRequiredByUserRole_USER() { - getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testRequiredByUserRole_USER); - } - - private static void testRequiredByUserRole_USER(KeycloakSession session) throws IOException { - configureSessionRealm(session); - DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); - ComponentModel component = provider.getComponentModel(); - - assertNotNull(component); - - UPConfig config = new UPConfig(); - UPAttribute attribute = new UPAttribute(); - - attribute.setName(ATT_ADDRESS); - - UPAttributeRequired requirements = new UPAttributeRequired(); - - List roles = new ArrayList<>(); - roles.add(ROLE_USER); - requirements.setRoles(roles); - - attribute.setRequired(requirements); - - config.addAttribute(attribute); - - provider.setConfiguration(JsonSerialization.writeValueAsString(config)); - - Map attributes = new HashMap<>(); - - attributes.put(UserModel.USERNAME, "user"); - - // fail on common contexts - UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - try { - profile.validate(); - fail("Should fail validation"); - } catch (ValidationException ve) { - assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); - } - - profile = provider.create(UserProfileContext.ACCOUNT, attributes); - try { - profile.validate(); - fail("Should fail validation"); - } catch (ValidationException ve) { - assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); - } - - profile = provider.create(UserProfileContext.REGISTRATION_PROFILE, attributes); - try { - profile.validate(); - fail("Should fail validation"); - } catch (ValidationException ve) { - assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); - } - - // no fail on User API - profile = provider.create(UserProfileContext.USER_API, attributes); - profile.validate(); - } - - @Test - public void testRequiredByUserRole_ADMIN() { - getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testRequiredByUserRole_ADMIN); - } - - private static void testRequiredByUserRole_ADMIN(KeycloakSession session) throws IOException { - configureSessionRealm(session); - DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); - ComponentModel component = provider.getComponentModel(); - - assertNotNull(component); - - UPConfig config = new UPConfig(); - UPAttribute attribute = new UPAttribute(); - - attribute.setName(ATT_ADDRESS); - - UPAttributeRequired requirements = new UPAttributeRequired(); - - List roles = new ArrayList<>(); - roles.add(UPConfigUtils.ROLE_ADMIN); - requirements.setRoles(roles); - - attribute.setRequired(requirements); - - config.addAttribute(attribute); - - provider.setConfiguration(JsonSerialization.writeValueAsString(config)); - - Map attributes = new HashMap<>(); - - attributes.put(UserModel.USERNAME, "user"); - - // NO fail on common contexts - UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - profile.validate(); - - profile = provider.create(UserProfileContext.ACCOUNT, attributes); - profile.validate(); - - profile = provider.create(UserProfileContext.REGISTRATION_PROFILE, attributes); - profile.validate(); - - // fail on User API - try { - profile = provider.create(UserProfileContext.USER_API, attributes); - profile.validate(); - fail("Should fail validation"); - } catch (ValidationException ve) { - assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); - } - - } - - @Test - public void testRequiredByScope_clientDefaultScope() { - getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testRequiredByScope_clientDefaultScope); - } - - private static void testRequiredByScope_clientDefaultScope(KeycloakSession session) throws IOException { - configureSessionRealm(session); - DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); - ComponentModel component = provider.getComponentModel(); - - assertNotNull(component); - - UPConfig config = new UPConfig(); - UPAttribute attribute = new UPAttribute(); - - attribute.setName(ATT_ADDRESS); - - UPAttributeRequired requirements = new UPAttributeRequired(); - - List scopes = new ArrayList<>(); - scopes.add("client-a"); - requirements.setScopes(scopes); - - attribute.setRequired(requirements); - - config.addAttribute(attribute); - - provider.setConfiguration(JsonSerialization.writeValueAsString(config)); - - Map attributes = new HashMap<>(); - - attributes.put(UserModel.USERNAME, "user"); - - // client with default scopes for which is attribute NOT configured as required - configureAuthenticationSession(session, "client-b", null); - - // no fail on User API nor Account console as they do not have scopes - UserProfile profile = provider.create(UserProfileContext.USER_API, attributes); - profile.validate(); - profile = provider.create(UserProfileContext.ACCOUNT, attributes); - profile.validate(); - profile = provider.create(UserProfileContext.ACCOUNT_OLD, attributes); - profile.validate(); - - // no fail on auth flow scopes when scope is not required - profile = provider.create(UserProfileContext.REGISTRATION_PROFILE, attributes); - profile.validate(); - profile = provider.create(UserProfileContext.REGISTRATION_USER_CREATION, attributes); - profile.validate(); - profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - profile.validate(); - profile = provider.create(UserProfileContext.IDP_REVIEW, attributes); - profile.validate(); - - // client with default scope for which is attribute configured as required - configureAuthenticationSession(session, "client-a", null); - - // no fail on User API nor Account console as they do not have scopes - profile = provider.create(UserProfileContext.USER_API, attributes); - profile.validate(); - profile = provider.create(UserProfileContext.ACCOUNT, attributes); - profile.validate(); - profile = provider.create(UserProfileContext.ACCOUNT_OLD, attributes); - profile.validate(); - - // fail on auth flow scopes when scope is required - try { - profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); - profile.validate(); - fail("Should fail validation"); - } catch (ValidationException ve) { - assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); - } - try { - profile = provider.create(UserProfileContext.REGISTRATION_PROFILE, attributes); - profile.validate(); - fail("Should fail validation"); - } catch (ValidationException ve) { - assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); - } - try { - profile = provider.create(UserProfileContext.REGISTRATION_USER_CREATION, attributes); - profile.validate(); - fail("Should fail validation"); - } catch (ValidationException ve) { - assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); - } - try { - profile = provider.create(UserProfileContext.IDP_REVIEW, attributes); - profile.validate(); - fail("Should fail validation"); - } catch (ValidationException ve) { - assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); - } - - } - -} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java index c017e7b6a5..42ffda6552 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java @@ -26,6 +26,9 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.keycloak.userprofile.config.UPConfigUtils.ROLE_ADMIN; +import static org.keycloak.userprofile.config.UPConfigUtils.ROLE_USER; import java.io.IOException; import java.util.ArrayList; @@ -38,19 +41,26 @@ import java.util.Map; import java.util.Set; import java.util.function.Consumer; -import org.junit.After; import org.junit.Assert; import org.junit.Test; +import org.keycloak.common.Profile; +import org.keycloak.component.ComponentModel; +import org.keycloak.component.ComponentValidationException; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.services.messages.Messages; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.arquillian.annotation.SetDefaultProvider; import org.keycloak.testsuite.runonserver.RunOnServer; -import org.keycloak.testsuite.user.profile.config.UPAttribute; -import org.keycloak.testsuite.user.profile.config.UPAttributeRequired; -import org.keycloak.testsuite.user.profile.config.UPConfig; +import org.keycloak.userprofile.UserProfileSpi; +import org.keycloak.userprofile.config.DeclarativeUserProfileProvider; +import org.keycloak.userprofile.config.UPAttribute; +import org.keycloak.userprofile.config.UPAttributePermissions; +import org.keycloak.userprofile.config.UPAttributeRequired; +import org.keycloak.userprofile.config.UPConfig; import org.keycloak.testsuite.util.ClientScopeBuilder; import org.keycloak.testsuite.util.KeycloakModelUtils; import org.keycloak.userprofile.Attributes; @@ -58,13 +68,19 @@ import org.keycloak.userprofile.UserProfile; import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.ValidationException; +import org.keycloak.userprofile.config.UPConfigUtils; import org.keycloak.util.JsonSerialization; import org.keycloak.validate.ValidationError; import org.keycloak.validate.validators.EmailValidator; +import org.keycloak.validate.validators.LengthValidator; /** * @author Pedro Igor */ +@EnableFeature(Profile.Feature.DECLARATIVE_USER_PROFILE) +@SetDefaultProvider(spi = UserProfileSpi.ID, providerId = DeclarativeUserProfileProvider.ID, + beforeEnableFeature = false, + onlyUpdateDefault = true) public class UserProfileTest extends AbstractUserProfileTest { @Override @@ -72,21 +88,12 @@ public class UserProfileTest extends AbstractUserProfileTest { testRealm.setClientScopes(Collections.singletonList(ClientScopeBuilder.create().name("customer").protocol("openid-connect").build())); ClientRepresentation client = KeycloakModelUtils.createClient(testRealm, "client-a"); client.setDefaultClientScopes(Collections.singletonList("customer")); - } - - @After - public void onAfter() { - getTestingClient().server().run((RunOnServer) UserProfileTest::resetConfiguration); - } - - private static void resetConfiguration(KeycloakSession session) { - configureSessionRealm(session); - getDynamicUserProfileProvider(session).setConfiguration(null); + KeycloakModelUtils.createClient(testRealm, "client-b"); } @Test public void testIdempotentProfile() { - getTestingClient().server().run((RunOnServer) UserProfileTest::testIdempotentProfile); + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testIdempotentProfile); } private static void testIdempotentProfile(KeycloakSession session) { @@ -103,7 +110,7 @@ public class UserProfileTest extends AbstractUserProfileTest { @Test public void testCustomAttributeInAnyContext() { - getTestingClient().server().run((RunOnServer) UserProfileTest::testCustomAttributeInAnyContext); + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testCustomAttributeInAnyContext); } private static void testCustomAttributeInAnyContext(KeycloakSession session) { @@ -113,7 +120,7 @@ public class UserProfileTest extends AbstractUserProfileTest { UserProfileProvider provider = getDynamicUserProfileProvider(session); - provider.setConfiguration("{\"attributes\": [{\"name\": \"address\", \"required\": {}}]}"); + provider.setConfiguration("{\"attributes\": [{\"name\": \"address\", \"required\": {}, \"permissions\": {\"edit\": [\"user\"]}}]}"); UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); @@ -137,7 +144,7 @@ public class UserProfileTest extends AbstractUserProfileTest { @Test public void testResolveProfile() { - getTestingClient().server().run((RunOnServer) UserProfileTest::testResolveProfile); + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testResolveProfile); } private static void testResolveProfile(KeycloakSession session) { @@ -149,7 +156,7 @@ public class UserProfileTest extends AbstractUserProfileTest { UserProfileProvider provider = getDynamicUserProfileProvider(session); - provider.setConfiguration("{\"attributes\": [{\"name\": \"business.address\", \"required\": {\"scopes\": [\"customer\"]}}]}"); + provider.setConfiguration("{\"attributes\": [{\"name\": \"business.address\", \"required\": {\"scopes\": [\"customer\"]}, \"permissions\": {\"edit\": [\"user\"]}}]}"); UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); @@ -173,13 +180,14 @@ public class UserProfileTest extends AbstractUserProfileTest { @Test public void testValidation() { - getTestingClient().server().run((RunOnServer) UserProfileTest::failValidationWhenEmptyAttributes); - getTestingClient().server().run((RunOnServer) UserProfileTest::testAttributeValidation); + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::failValidationWhenEmptyAttributes); + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testAttributeValidation); } private static void failValidationWhenEmptyAttributes(KeycloakSession session) { Map attributes = new HashMap<>(); UserProfileProvider provider = session.getProvider(UserProfileProvider.class); + provider.setConfiguration(null); UserProfile profile; try { @@ -207,6 +215,8 @@ public class UserProfileTest extends AbstractUserProfileTest { try { realm.setRegistrationEmailAsUsername(true); attributes.clear(); + attributes.put(UserModel.FIRST_NAME, "Joe"); + attributes.put(UserModel.LAST_NAME, "Doe"); attributes.put(UserModel.EMAIL, "profile-user@keycloak.org"); profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); profile.validate(); @@ -219,6 +229,8 @@ public class UserProfileTest extends AbstractUserProfileTest { attributes.clear(); attributes.put(UserModel.USERNAME, "profile-user"); + attributes.put(UserModel.FIRST_NAME, "Joe"); + attributes.put(UserModel.LAST_NAME, "Doe"); provider.create(UserProfileContext.UPDATE_PROFILE, attributes).validate(); } @@ -252,11 +264,11 @@ public class UserProfileTest extends AbstractUserProfileTest { @Test public void testValidateComplianceWithUserProfile() { - getTestingClient().server().run((RunOnServer) UserProfileTest::testValidateComplianceWithUserProfile); + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testValidateComplianceWithUserProfile); } private static void testValidateComplianceWithUserProfile(KeycloakSession session) throws IOException { - RealmModel realm = configureSessionRealm(session); + RealmModel realm = session.getContext().getRealm(); UserModel user = session.users().addUser(realm, "profiled-user"); UserProfileProvider provider = getDynamicUserProfileProvider(session); @@ -269,6 +281,10 @@ public class UserProfileTest extends AbstractUserProfileTest { attribute.setRequired(requirements); + UPAttributePermissions permissions = new UPAttributePermissions(); + permissions.setEdit(Collections.singletonList(ROLE_USER)); + attribute.setPermissions(permissions); + config.addAttribute(attribute); provider.setConfiguration(JsonSerialization.writeValueAsString(config)); @@ -292,15 +308,15 @@ public class UserProfileTest extends AbstractUserProfileTest { @Test public void testGetProfileAttributes() { - getTestingClient().server().run((RunOnServer) UserProfileTest::testGetProfileAttributes); + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testGetProfileAttributes); } private static void testGetProfileAttributes(KeycloakSession session) { - RealmModel realm = configureSessionRealm(session); + RealmModel realm = session.getContext().getRealm(); UserModel user = session.users().addUser(realm, org.keycloak.models.utils.KeycloakModelUtils.generateId()); UserProfileProvider provider = getDynamicUserProfileProvider(session); - provider.setConfiguration("{\"attributes\": [{\"name\": \"address\", \"required\": {}}]}"); + provider.setConfiguration("{\"attributes\": [{\"name\": \"address\", \"required\": {}, \"permissions\": {\"edit\": [\"user\"]}}]}"); UserProfile profile = provider.create(UserProfileContext.ACCOUNT, user); Attributes attributes = profile.getAttributes(); @@ -334,7 +350,7 @@ public class UserProfileTest extends AbstractUserProfileTest { @Test public void testCreateAndUpdateUser() { - getTestingClient().server().run((RunOnServer) UserProfileTest::testCreateAndUpdateUser); + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testCreateAndUpdateUser); } private static void testCreateAndUpdateUser(KeycloakSession session) { @@ -343,6 +359,8 @@ public class UserProfileTest extends AbstractUserProfileTest { String userName = org.keycloak.models.utils.KeycloakModelUtils.generateId(); attributes.put(UserModel.USERNAME, userName); + attributes.put(UserModel.FIRST_NAME, "Joe"); + attributes.put(UserModel.LAST_NAME, "Doe"); attributes.put("address", "fixed-address"); UserProfile profile = provider.create(UserProfileContext.ACCOUNT, attributes); @@ -377,12 +395,10 @@ public class UserProfileTest extends AbstractUserProfileTest { @Test public void testReadonlyUpdates() { - getTestingClient().server().run((RunOnServer) UserProfileTest::testReadonlyUpdates); + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testReadonlyUpdates); } private static void testReadonlyUpdates(KeycloakSession session) { - configureSessionRealm(session); - Map attributes = new HashMap<>(); attributes.put(UserModel.USERNAME, org.keycloak.models.utils.KeycloakModelUtils.generateId()); @@ -415,10 +431,684 @@ public class UserProfileTest extends AbstractUserProfileTest { profile = provider.create(UserProfileContext.ACCOUNT, attributes, user); - profile.update(); + try { + profile.update(); + fail("Should fail due to read only attribute"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError("department")); + } assertEquals("sales", user.getFirstAttribute("department")); assertTrue(profile.getAttributes().isReadOnly("department")); } + + protected static final String ATT_ADDRESS = "address"; + + @Test + public void testInvalidConfiguration() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testInvalidConfiguration); + } + + private static void testInvalidConfiguration(KeycloakSession session) { + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + + try { + provider.setConfiguration("{\"validateConfigAttribute\": true}"); + fail("Should fail validation"); + } catch (ComponentValidationException ve) { + // OK + } + + } + + @Test + public void testConfigurationChunks() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testConfigurationChunks); + } + + private static void testConfigurationChunks(KeycloakSession session) throws IOException { + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + // generate big configuration to test slicing in the persistence/component config + UPConfig config = new UPConfig(); + for (int i = 0; i < 80; i++) { + UPAttribute attribute = new UPAttribute(); + attribute.setName(UserModel.USERNAME+i); + Map validatorConfig = new HashMap<>(); + validatorConfig.put("min", 3); + attribute.addValidation("length", validatorConfig); + config.addAttribute(attribute); + } + String newConfig = JsonSerialization.writeValueAsString(config); + + provider.setConfiguration(newConfig); + + component = provider.getComponentModel(); + + // assert config is persisted in 2 pieces + Assert.assertEquals("2", component.get(DeclarativeUserProfileProvider.UP_PIECES_COUNT_COMPONENT_CONFIG_KEY)); + // assert config is returned correctly + Assert.assertEquals(newConfig, provider.getConfiguration()); + } + + @Test + public void testResetConfiguration() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testResetConfiguration); + } + + private static void testResetConfiguration(KeycloakSession session) throws IOException { + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + + provider.setConfiguration(null); + + Assert.assertNull(provider.getComponentModel().get(DeclarativeUserProfileProvider.UP_PIECES_COUNT_COMPONENT_CONFIG_KEY)); + + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + Assert.assertTrue(component.getConfig().isEmpty()); + } + + @Test + public void testDefaultConfig() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testDefaultConfig); + } + + private static void testDefaultConfig(KeycloakSession session) { + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + + // reset configuration to default + provider.setConfiguration(null); + + // failed required validations + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, Collections.emptyMap()); + + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(UserModel.USERNAME)); + } + + // failed for blank values also + Map attributes = new HashMap<>(); + + attributes.put(UserModel.FIRST_NAME, ""); + attributes.put(UserModel.LAST_NAME, " "); + attributes.put(UserModel.EMAIL, ""); + + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(UserModel.USERNAME)); + assertTrue(ve.isAttributeOnError(UserModel.FIRST_NAME)); + assertTrue(ve.isAttributeOnError(UserModel.LAST_NAME)); + assertTrue(ve.isAttributeOnError(UserModel.EMAIL)); + } + + // all OK + attributes.put(UserModel.USERNAME, "jdoeusername"); + attributes.put(UserModel.FIRST_NAME, "John"); + attributes.put(UserModel.LAST_NAME, "Doe"); + attributes.put(UserModel.EMAIL, "jdoe@acme.org"); + + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + } + + @Test + public void testCustomValidationForUsername() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testCustomValidationForUsername); + } + + private static void testCustomValidationForUsername(KeycloakSession session) throws IOException { + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + UPConfig config = new UPConfig(); + UPAttribute attribute = new UPAttribute(); + + attribute.setName(UserModel.USERNAME); + + Map validatorConfig = new HashMap<>(); + + validatorConfig.put("min", 4); + + attribute.addValidation(LengthValidator.ID, validatorConfig); + + config.addAttribute(attribute); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, "us"); + + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(UserModel.USERNAME)); + assertTrue(ve.hasError(LengthValidator.MESSAGE_INVALID_LENGTH)); + } + + attributes.put(UserModel.USERNAME, "user"); + + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + + profile.validate(); + + provider.setConfiguration(null); + + attributes.put(UserModel.USERNAME, "us"); + attributes.put(UserModel.FIRST_NAME, "Joe"); + attributes.put(UserModel.LAST_NAME, "Doe"); + + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + + profile.validate(); + } + + @Test + public void testOptionalAttributes() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testOptionalAttributes); + } + + private static void testOptionalAttributes(KeycloakSession session) throws IOException { + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + UPConfig config = new UPConfig(); + UPAttribute attribute = new UPAttribute(); + attribute.setName(UserModel.FIRST_NAME); + Map validatorConfig = new HashMap<>(); + validatorConfig.put(LengthValidator.KEY_MAX, 4); + attribute.addValidation(LengthValidator.ID, validatorConfig); + config.addAttribute(attribute); + + attribute = new UPAttribute(); + attribute.setName(UserModel.LAST_NAME); + attribute.addValidation(LengthValidator.ID, validatorConfig); + config.addAttribute(attribute); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, "user"); + + // not present attributes are OK + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + + //empty attributes are OK + attributes.put(UserModel.FIRST_NAME, ""); + attributes.put(UserModel.LAST_NAME, ""); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + + //filled attributes are OK + attributes.put(UserModel.FIRST_NAME, "John"); + attributes.put(UserModel.LAST_NAME, "Doe"); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + + // fails due to additional length validation so it is executed correctly + attributes.put(UserModel.FIRST_NAME, "JohnTooLong"); + attributes.put(UserModel.LAST_NAME, "DoeTooLong"); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(UserModel.FIRST_NAME)); + assertTrue(ve.isAttributeOnError(UserModel.LAST_NAME)); + } + } + + @Test + public void testCustomAttributeRequired() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testCustomAttributeRequired); + } + + private static void testCustomAttributeRequired(KeycloakSession session) throws IOException { + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + UPConfig config = new UPConfig(); + UPAttribute attribute = new UPAttribute(); + + attribute.setName(ATT_ADDRESS); + + Map validatorConfig = new HashMap<>(); + + validatorConfig.put(LengthValidator.KEY_MIN, 4); + + attribute.addValidation(LengthValidator.ID, validatorConfig); + + // make it ALWAYS required + UPAttributeRequired requirements = new UPAttributeRequired(); + attribute.setRequired(requirements); + + UPAttributePermissions permissions = new UPAttributePermissions(); + permissions.setEdit(Collections.singletonList(ROLE_USER)); + attribute.setPermissions(permissions); + + config.addAttribute(attribute); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, "user"); + + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + + // fails on required validation + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + // fails on length validation + attributes.put(ATT_ADDRESS, "adr"); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + // all OK + attributes.put(ATT_ADDRESS, "adress ok"); + attributes.put(UserModel.FIRST_NAME, "Joe"); + attributes.put(UserModel.LAST_NAME, "Doe"); + + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + } + + @Test + public void testCustomAttributeOptional() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testCustomAttributeOptional); + } + + private static void testCustomAttributeOptional(KeycloakSession session) throws IOException { + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + UPConfig config = new UPConfig(); + UPAttribute attribute = new UPAttribute(); + + attribute.setName(ATT_ADDRESS); + + Map validatorConfig = new HashMap<>(); + validatorConfig.put(LengthValidator.KEY_MIN, 4); + attribute.addValidation(LengthValidator.ID, validatorConfig); + + config.addAttribute(attribute); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + Map attributes = new HashMap<>(); + attributes.put(UserModel.USERNAME, "user"); + + // null is OK as attribute is optional + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + + //blank String have to be OK as it is what UI forms send for not filled in optional attributes + attributes.put(ATT_ADDRESS, ""); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + + // fails on length validation + attributes.put(ATT_ADDRESS, "adr"); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + // all OK + attributes.put(ATT_ADDRESS, "adress ok"); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + + } + + @Test + public void testRequiredIfUser() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testRequiredIfUser); + } + + private static void testRequiredIfUser(KeycloakSession session) throws IOException { + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + UPConfig config = new UPConfig(); + UPAttribute attribute = new UPAttribute(); + + attribute.setName(ATT_ADDRESS); + + UPAttributeRequired requirements = new UPAttributeRequired(); + + List roles = new ArrayList<>(); + roles.add(ROLE_USER); + requirements.setRoles(roles); + + attribute.setRequired(requirements); + + UPAttributePermissions permissions = new UPAttributePermissions(); + permissions.setEdit(Collections.singletonList(ROLE_USER)); + attribute.setPermissions(permissions); + + config.addAttribute(attribute); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, "user"); + + // fail on common contexts + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + profile = provider.create(UserProfileContext.ACCOUNT, attributes); + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + profile = provider.create(UserProfileContext.REGISTRATION_PROFILE, attributes); + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + attributes.put(UserModel.FIRST_NAME, "Joe"); + attributes.put(UserModel.LAST_NAME, "Doe"); + + // no fail on User API + profile = provider.create(UserProfileContext.USER_API, attributes); + profile.validate(); + } + + @Test + public void testRequiredIfAdmin() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testRequiredIfAdmin); + } + + private static void testRequiredIfAdmin(KeycloakSession session) throws IOException { + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + UPConfig config = new UPConfig(); + UPAttribute attribute = new UPAttribute(); + + attribute.setName(ATT_ADDRESS); + + UPAttributeRequired requirements = new UPAttributeRequired(); + + requirements.setRoles(Collections.singletonList(ROLE_ADMIN)); + + attribute.setRequired(requirements); + + UPAttributePermissions permissions = new UPAttributePermissions(); + permissions.setEdit(Collections.singletonList(UPConfigUtils.ROLE_ADMIN)); + attribute.setPermissions(permissions); + + config.addAttribute(attribute); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, "user"); + + // NO fail on common contexts + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + + profile = provider.create(UserProfileContext.ACCOUNT, attributes); + profile.validate(); + + profile = provider.create(UserProfileContext.REGISTRATION_PROFILE, attributes); + profile.validate(); + + // fail on User API + try { + profile = provider.create(UserProfileContext.USER_API, attributes); + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + } + + @Test + public void testNoValidationsIfUserReadOnly() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testNoValidationsIfUserReadOnly); + } + + private static void testNoValidationsIfUserReadOnly(KeycloakSession session) throws IOException { + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + UPConfig config = new UPConfig(); + UPAttribute attribute = new UPAttribute(); + + attribute.setName(ATT_ADDRESS); + + UPAttributeRequired requirements = new UPAttributeRequired(); + attribute.setRequired(requirements); + + UPAttributePermissions permissions = new UPAttributePermissions(); + permissions.setEdit(Collections.singletonList(UPConfigUtils.ROLE_ADMIN)); + attribute.setPermissions(permissions); + + config.addAttribute(attribute); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, "user"); + attributes.put(UserModel.FIRST_NAME, "user"); + attributes.put(UserModel.LAST_NAME, "user"); + + // NO fail on USER contexts + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + + // Fails on ADMIN context - User REST API + try { + profile = provider.create(UserProfileContext.USER_API, attributes); + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + } + + @Test + public void testNoValidationsIfAdminReadOnly() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testNoValidationsIfAdminReadOnly); + } + + private static void testNoValidationsIfAdminReadOnly(KeycloakSession session) throws IOException { + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + UPConfig config = new UPConfig(); + UPAttribute attribute = new UPAttribute(); + + attribute.setName(ATT_ADDRESS); + + UPAttributeRequired requirements = new UPAttributeRequired(); + attribute.setRequired(requirements); + + UPAttributePermissions permissions = new UPAttributePermissions(); + List roles = new ArrayList<>(); + roles.add(UPConfigUtils.ROLE_USER); + permissions.setEdit(roles); + attribute.setPermissions(permissions); + + config.addAttribute(attribute); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, "user"); + + // Fails on USER context + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + // NO fail on ADMIN context - User REST API + profile = provider.create(UserProfileContext.USER_API, attributes); + profile.validate(); + } + + @Test + public void testRequiredByClientScope() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testRequiredByClientScope); + } + + private static void testRequiredByClientScope(KeycloakSession session) throws IOException { + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + UPConfig config = new UPConfig(); + UPAttribute attribute = new UPAttribute(); + + attribute.setName(ATT_ADDRESS); + + UPAttributeRequired requirements = new UPAttributeRequired(); + + List scopes = new ArrayList<>(); + scopes.add("client-a"); + requirements.setScopes(scopes); + + attribute.setRequired(requirements); + + UPAttributePermissions permissions = new UPAttributePermissions(); + permissions.setEdit(Collections.singletonList("user")); + attribute.setPermissions(permissions); + + config.addAttribute(attribute); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, "user"); + + // client with default scopes for which is attribute NOT configured as required + configureAuthenticationSession(session, "client-b", null); + + // no fail on User API nor Account console as they do not have scopes + UserProfile profile = provider.create(UserProfileContext.USER_API, attributes); + profile.validate(); + profile = provider.create(UserProfileContext.ACCOUNT, attributes); + profile.validate(); + profile = provider.create(UserProfileContext.ACCOUNT_OLD, attributes); + profile.validate(); + + // no fail on auth flow scopes when scope is not required + profile = provider.create(UserProfileContext.REGISTRATION_PROFILE, attributes); + profile.validate(); + profile = provider.create(UserProfileContext.REGISTRATION_USER_CREATION, attributes); + profile.validate(); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + profile = provider.create(UserProfileContext.IDP_REVIEW, attributes); + profile.validate(); + + // client with default scope for which is attribute configured as required + configureAuthenticationSession(session, "client-a", null); + + // no fail on User API nor Account console as they do not have scopes + profile = provider.create(UserProfileContext.USER_API, attributes); + profile.validate(); + profile = provider.create(UserProfileContext.ACCOUNT, attributes); + profile.validate(); + profile = provider.create(UserProfileContext.ACCOUNT_OLD, attributes); + profile.validate(); + + // fail on auth flow scopes when scope is required + try { + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + try { + profile = provider.create(UserProfileContext.REGISTRATION_PROFILE, attributes); + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + try { + profile = provider.create(UserProfileContext.IDP_REVIEW, attributes); + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigParserTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigParserTest.java index b9989ccfde..ec295ec304 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigParserTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigParserTest.java @@ -16,8 +16,8 @@ */ package org.keycloak.testsuite.user.profile.config; -import static org.keycloak.testsuite.user.profile.config.UPConfigUtils.readConfig; -import static org.keycloak.testsuite.user.profile.config.UPConfigUtils.validate; +import static org.keycloak.userprofile.config.UPConfigUtils.readConfig; +import static org.keycloak.userprofile.config.UPConfigUtils.validate; import java.io.IOException; import java.io.InputStream; @@ -35,6 +35,11 @@ import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.runonserver.RunOnServer; import com.fasterxml.jackson.databind.JsonMappingException; +import org.keycloak.userprofile.config.UPAttribute; +import org.keycloak.userprofile.config.UPAttributePermissions; +import org.keycloak.userprofile.config.UPAttributeRequired; +import org.keycloak.userprofile.config.UPConfig; +import org.keycloak.userprofile.config.UPConfigUtils; /** * Unit test for {@link UPConfigParser} functionality diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigUtilsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigUtilsTest.java index 2d8d89d59f..bc8ec1aec6 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigUtilsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigUtilsTest.java @@ -16,8 +16,8 @@ */ package org.keycloak.testsuite.user.profile.config; -import static org.keycloak.testsuite.user.profile.config.UPConfigUtils.ROLE_ADMIN; -import static org.keycloak.testsuite.user.profile.config.UPConfigUtils.ROLE_USER; +import static org.keycloak.userprofile.config.UPConfigUtils.ROLE_ADMIN; +import static org.keycloak.userprofile.config.UPConfigUtils.ROLE_USER; import java.util.ArrayList; import java.util.List; @@ -25,6 +25,7 @@ import java.util.List; import org.junit.Assert; import org.junit.Test; import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.config.UPConfigUtils; /** * Unit test for {@link UPConfigUtils} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/validation/ValidatorTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/validation/ValidatorTest.java new file mode 100644 index 0000000000..b0404723a7 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/validation/ValidatorTest.java @@ -0,0 +1,80 @@ +/* + * + * * Copyright 2021 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.validation; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.Locale; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.runonserver.RunOnServer; +import org.keycloak.validate.ValidationContext; +import org.keycloak.validate.Validators; + +/** + * @author Pedro Igor + */ +public class ValidatorTest extends AbstractTestRealmKeycloakTest { + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + testRealm.user("alice"); + } + + @Test + public void testDateValidator() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) ValidatorTest::testDateValidator); + } + + private static void testDateValidator(KeycloakSession session) { + assertTrue(Validators.dateValidator().validate(null, new ValidationContext(session)).isValid()); + assertTrue(Validators.dateValidator().validate("", new ValidationContext(session)).isValid()); + + // defaults to Locale.ENGLISH as per default locale selector + assertFalse(Validators.dateValidator().validate("13/12/2021", new ValidationContext(session)).isValid()); + assertFalse(Validators.dateValidator().validate("13/12/21", new ValidationContext(session)).isValid()); + assertTrue(Validators.dateValidator().validate("12/13/2021", new ValidationContext(session)).isValid()); + RealmModel realm = session.getContext().getRealm(); + + realm.setInternationalizationEnabled(true); + realm.setDefaultLocale(Locale.FRANCE.getLanguage()); + + assertTrue(Validators.dateValidator().validate("13/12/21", new ValidationContext(session)).isValid()); + assertTrue(Validators.dateValidator().validate("13/12/2021", new ValidationContext(session)).isValid()); + assertFalse(Validators.dateValidator().validate("12/13/2021", new ValidationContext(session)).isValid()); + + UserModel alice = session.users().getUserByUsername(realm, "alice"); + + alice.setAttribute(UserModel.LOCALE, Collections.singletonList(Locale.ENGLISH.getLanguage())); + + ValidationContext context = new ValidationContext(session); + + context.getAttributes().put(UserModel.class.getName(), alice); + + assertFalse(Validators.dateValidator().validate("13/12/2021", context).isValid()); + } +} 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 49e5aded5c..c80de7b1f9 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 @@ -219,9 +219,14 @@ }, "userProfile": { + "provider": "${keycloak.userProfile.provider:}", "legacy-user-profile": { "read-only-attributes": [ "deniedFoo", "deniedBar*", "deniedSome/thing", "deniedsome*thing" ], "admin-read-only-attributes": [ "deniedSomeAdmin" ] + }, + "declarative-user-profile": { + "read-only-attributes": [ "deniedFoo", "deniedBar*", "deniedSome/thing", "deniedsome*thing" ], + "admin-read-only-attributes": [ "deniedSomeAdmin" ] } }, diff --git a/testsuite/utils/src/main/resources/META-INF/keycloak-server.json b/testsuite/utils/src/main/resources/META-INF/keycloak-server.json index 878fddcf2f..6382015b05 100755 --- a/testsuite/utils/src/main/resources/META-INF/keycloak-server.json +++ b/testsuite/utils/src/main/resources/META-INF/keycloak-server.json @@ -139,9 +139,14 @@ }, "userProfile": { + "provider": "${keycloak.userProfile.provider:}", "legacy-user-profile": { "read-only-attributes": [ "deniedFoo", "deniedBar*", "deniedSome/thing", "deniedsome*thing" ], "admin-read-only-attributes": [ "deniedSomeAdmin" ] + }, + "declarative-user-profile": { + "read-only-attributes": [ "deniedFoo", "deniedBar*", "deniedSome/thing", "deniedsome*thing" ], + "admin-read-only-attributes": [ "deniedSomeAdmin" ] } }, diff --git a/themes/src/main/resources/theme/base/account/messages/messages_en.properties b/themes/src/main/resources/theme/base/account/messages/messages_en.properties index c97c181846..af40c073ea 100755 --- a/themes/src/main/resources/theme/base/account/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/account/messages/messages_en.properties @@ -374,3 +374,16 @@ openshift.scope.user_info=User information openshift.scope.user_check-access=User access information openshift.scope.user_full=Full Access openshift.scope.list-projects=List projects + +error-invalid-value=Invalid value. +error-invalid-blank=Please specify value. +error-empty=Please specify value. +error-invalid-length=Attribute {0} must have a length between {1} and {2}. +error-invalid-email=Invalid email address. +error-invalid-number=Invalid number. +error-number-out-of-range=Attribute {0} must be a number between {1} and {2}. +error-pattern-no-match=Invalid value. +error-invalid-uri=Invalid URL. +error-invalid-uri-scheme=Invalid URL scheme. +error-invalid-uri-fragment=Invalid URL fragment. +error-user-attribute-required=Please specify attribute {0}. \ No newline at end of file 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 7b0b26cdcb..e8b8daf1ac 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 @@ -221,6 +221,7 @@ realm-tab-cache=Cache realm-tab-tokens=Tokens realm-tab-client-registration=Client Registration realm-tab-security-defenses=Security Defenses +realm-tab-user-profile=User Profile realm-tab-general=General add-realm=Add realm @@ -1882,3 +1883,23 @@ dialogs.delete.message=Are you sure you want to permanently delete the {{type}} dialogs.delete.confirm=Delete dialogs.cancel=Cancel dialogs.ok=Ok + +user.profile.attribute=Attribute +user.profile.attribute.name=Name +user.profile.attribute.name.tooltip=The name of the attribute. +user.profile.attribute.required=Required +user.profile.attribute.required.tooltip=Set the attribute as required. If enabled, the attribute must be set by users and administrators. Otherwise, the attribute is optional. +user.profile.attribute.permission=Permission +user.profile.attribute.canUserView=Can user view? +user.profile.attribute.canUserView.tooltip=If enabled, users can view the attribute. Otherwise, users don't have access to the attribute. +user.profile.attribute.canUserEdit=Can user edit? +user.profile.attribute.canUserEdit.tooltip=If enabled, users can view and edit the attribute. Otherwise, users don't have access to write to the attribute. +user.profile.attribute.canAdminView=Can admin view? +user.profile.attribute.canAdminView.tooltip=If enabled, administrators can view the attribute. Otherwise, administrators don't have access to the attribute. +user.profile.attribute.canAdminEdit=Can admin edit? +user.profile.attribute.canAdminEdit.tooltip=If enabled, administrators can view and edit the attribute. Otherwise, administrators don't have access to write to the attribute. +user.profile.attribute.validation=Validation +user.profile.attribute.validation.add.validator=Add Validator +user.profile.attribute.validation.add.validator.tooltip=Select a validator to enforce specific constraints to the attribute value. +user.profile.attribute.validation.no.validators=No validators. +user.profile.attribute.annotation=Annotation \ No newline at end of file diff --git a/themes/src/main/resources/theme/base/admin/messages/messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/messages_en.properties index a3e115d78e..94bbb7d82d 100644 --- a/themes/src/main/resources/theme/base/admin/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/messages_en.properties @@ -39,3 +39,19 @@ pairwiseClientRedirectURIsMultipleHosts=Without a configured Sector Identifier U pairwiseMalformedSectorIdentifierURI=Malformed Sector Identifier URI. pairwiseFailedToGetRedirectURIs=Failed to get redirect URIs from the Sector Identifier URI. pairwiseRedirectURIsMismatch=Client redirect URIs does not match redirect URIs fetched from the Sector Identifier URI. + +error-invalid-value=Invalid value. +error-invalid-blank=Please specify value. +error-empty=Please specify value. +error-invalid-length=Attribute {0} must have a length between {1} and {2}. +error-invalid-email=Invalid email address. +error-invalid-number=Invalid number. +error-number-out-of-range=Attribute {0} must be a number between {1} and {2}. +error-pattern-no-match=Invalid value. +error-invalid-uri=Invalid URL. +error-invalid-uri-scheme=Invalid URL scheme. +error-invalid-uri-fragment=Invalid URL fragment. +error-user-attribute-required=Please specify attribute {0}. +error-invalid-date=Invalid date. + +error-user-attribute-read-only=Attribute {0} is read only. \ No newline at end of file diff --git a/themes/src/main/resources/theme/base/admin/resources/js/app.js b/themes/src/main/resources/theme/base/admin/resources/js/app.js index 01d9e50b90..1654e397d0 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/app.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/app.js @@ -260,6 +260,18 @@ module.config([ '$routeProvider', function($routeProvider) { }, controller : 'RealmTokenDetailCtrl' }) + .when('/realms/:realm/user-profile', { + templateUrl : resourceUrl + '/partials/realm-user-profile.html', + resolve : { + serverInfo : function(ServerInfoLoader) { + return ServerInfoLoader(); + }, + realm : function(RealmLoader) { + return RealmLoader(); + } + }, + controller : 'RealmUserProfileCtrl' + }) .when('/realms/:realm/client-registration/client-initial-access', { templateUrl : resourceUrl + '/partials/client-initial-access.html', resolve : { @@ -2433,6 +2445,14 @@ module.factory('errorInterceptor', function($q, $window, $rootScope, $location, } else if (response.status) { if (response.data && response.data.errorMessage) { Notifications.error(response.data.errorMessage); + } else if (response.data && response.data.errors) { + var messages = "Multiple errors found: "; + + for (var i = 0; i < response.data.errors.length; i++) { + messages+=response.data.errors[i].errorMessage + " "; + } + + Notifications.error(messages); } else if (response.data && response.data.error_description) { Notifications.error(response.data.error_description); } else { diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js index 8da6729e0e..13d06e7787 100644 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js @@ -1401,6 +1401,232 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, }; }); +module.controller('RealmUserProfileCtrl', function($scope, Realm, realm, $http, $location, $route, UserProfile, Dialog, Notifications, serverInfo) { + $scope.realm = realm; + $scope.validatorProviders = serverInfo.componentTypes['org.keycloak.validate.Validator']; + + $scope.isShowAttributes = true; + + UserProfile.get({realm: realm.realm}, function(config) { + $scope.config = config; + $scope.rawConfig = angular.toJson(config, true); + }); + + $scope.isShowAttributes = true; + + $scope.showAttributes = function() { + $route.reload(); + } + + $scope.showJsonEditor = function() { + $scope.isShowAttributes = false; + } + + $scope.canViewPermission = { + minimumInputLength: 0, + delay: 500, + allowClear: true, + query: function (query) { + query.callback({results: ['user', 'admin']}); + }, + formatResult: function(object, container, query) { + return object; + }, + formatSelection: function(object, container, query) { + return object; + } + }; + + $scope.canEditPermission = { + minimumInputLength: 0, + delay: 500, + allowClear: true, + query: function (query) { + query.callback({results: ['user', 'admin']}); + }, + formatResult: function(object, container, query) { + return object; + }, + formatSelection: function(object, container, query) { + return object; + } + }; + + $scope.attributeSelected = false; + + $scope.showListing = function() { + return !$scope.attributeSelected && $scope.currentAttribute == null && $scope.isShowAttributes; + } + + $scope.create = function() { + $scope.isCreate = true; + $scope.currentAttribute = { + permissions: { + view: [], + edit: [] + } + }; + }; + + $scope.removeAttribute = function(attribute) { + Dialog.confirmDelete(attribute.name, 'attribute', function() { + let newAttributes = []; + + for (var v of $scope.config.attributes) { + if (v != attribute) { + newAttributes.push(v); + } + } + + $scope.config.attributes = newAttributes; + $scope.save(); + }); + }; + + $scope.addAnnotation = function() { + if (!$scope.currentAttribute.annotations) { + $scope.currentAttribute.annotations = {}; + } + $scope.currentAttribute.annotations[$scope.newAnnotation.key] = $scope.newAnnotation.value; + delete $scope.newAnnotation; + } + + $scope.removeAnnotation = function(key) { + delete $scope.currentAttribute.annotations[key]; + } + + $scope.edit = function(attribute) { + if (attribute.permissions == null) { + attribute.permissions = { + view: [], + edit: [] + }; + } + + $scope.isRequired = attribute.required != null; + $scope.canUserView = attribute.permissions.view.includes('user'); + $scope.canAdminView = attribute.permissions.view.includes('admin'); + $scope.canUserEdit = attribute.permissions.edit.includes('user'); + $scope.canAdminEdit = attribute.permissions.edit.includes('admin'); + $scope.currentAttribute = attribute; + $scope.attributeSelected = true; + }; + + $scope.$watch('isRequired', function() { + if ($scope.isRequired) { + $scope.currentAttribute.required = {}; + } else { + delete $scope.currentAttribute.required; + } + }, true); + + handlePermission = function(permission, role, allowed) { + let attribute = $scope.currentAttribute; + let roles = []; + + for (let r of attribute.permissions[permission]) { + if (r != role) { + roles.push(r); + } + } + + if (allowed) { + roles.push(role); + } + + attribute.permissions[permission] = roles; + } + + $scope.$watch('canUserView', function() { + handlePermission('view', 'user', $scope.canUserView); + }, true); + + $scope.$watch('canAdminView', function() { + handlePermission('view', 'admin', $scope.canAdminView); + }, true); + + $scope.$watch('canUserEdit', function() { + handlePermission('edit', 'user', $scope.canUserEdit); + }, true); + + $scope.$watch('canAdminEdit', function() { + handlePermission('edit', 'admin', $scope.canAdminEdit); + }, true); + + $scope.addValidator = function(validator) { + if ($scope.currentAttribute.validations == null) { + $scope.currentAttribute.validations = {}; + } + + let config = {}; + + for (let key in validator.config) { + let values = validator.config[key]; + + for (let k in values) { + config[key] = values[k]; + } + } + + $scope.currentAttribute.validations[validator.id] = config; + + delete $scope.newValidator; + }; + + $scope.selectValidator = function(validator) { + validator.config = {}; + }; + + $scope.cancelAddValidator = function() { + delete $scope.newValidator; + }; + + $scope.removeValidator = function(id) { + let newValidators = {}; + + for (let v in $scope.currentAttribute.validations) { + if (v != id) { + newValidators[v] = $scope.currentAttribute.validations[v]; + } + } + + if (newValidators.length == 0) { + delete $scope.currentAttribute.validations; + return; + } + + $scope.currentAttribute.validations = newValidators; + }; + + $scope.save = function() { + if (!$scope.isShowAttributes) { + $scope.config = JSON.parse($scope.rawConfig); + } + + if ($scope.isCreate && $scope.currentAttribute) { + $scope.config['attributes'].push($scope.currentAttribute); + } + + UserProfile.update({realm: realm.realm}, + $scope.config, function () { + $scope.attributeSelected = false; + delete $scope.currentAttribute; + delete $scope.isCreate; + delete $scope.isRequired; + delete $scope.canUserView; + delete $scope.canAdminView; + delete $scope.canUserEdit; + delete $scope.canAdminEdit; + $route.reload(); + Notifications.success("The attribute has been added."); + }); + }; + + $scope.reset = function() { + $route.reload(); + }; +}); + module.controller('ViewKeyCtrl', function($scope, key) { $scope.key = key; }); diff --git a/themes/src/main/resources/theme/base/admin/resources/js/services.js b/themes/src/main/resources/theme/base/admin/resources/js/services.js index 34cc1a8a06..95487d1114 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/services.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/services.js @@ -2112,6 +2112,16 @@ module.factory('UserGroupMapping', function($resource) { }); }); +module.factory('UserProfile', function($resource) { + return $resource(authUrl + '/admin/realms/:realm/users/profile', { + realm : '@realm' + }, { + update : { + method : 'PUT' + } + }); +}); + module.factory('DefaultGroups', function($resource) { return $resource(authUrl + '/admin/realms/:realm/default-groups/:groupId', { realm : '@realm', diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/realm-user-profile.html b/themes/src/main/resources/theme/base/admin/resources/partials/realm-user-profile.html new file mode 100755 index 0000000000..fd74383f0b --- /dev/null +++ b/themes/src/main/resources/theme/base/admin/resources/partials/realm-user-profile.html @@ -0,0 +1,209 @@ +

+ + + + +
+ + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+
+ +
+
+
+ +
+
+ + +
+
+
+ +
+ +
+

+ {{:: 'user.profile.attribute' | translate}} {{currentAttribute.name}} {{:: 'configuration' | translate}} + +

+ + {{:: 'user.profile.attribute.name.tooltip' | translate}} +
+ +
+
+
+ + {{:: 'user.profile.attribute.required.tooltip' | translate}} +
+ +
+
+
+ {{:: 'user.profile.attribute.permission' | translate}} +
+ + {{:: 'user.profile.attribute.canUserView.tooltip' | translate}} +
+ +
+
+
+ + {{:: 'user.profile.attribute.canAdminView.tooltip' | translate}} +
+ +
+
+
+ + {{:: 'user.profile.attribute.canUserEdit.tooltip' | translate}} +
+ +
+
+
+ + {{:: 'user.profile.attribute.canAdminEdit.tooltip' | translate}} +
+ +
+
+
+
+ {{:: 'user.profile.attribute.validation' | translate}} +
+ + {{:: 'user.profile.attribute.validation.add.validator.tooltip' | translate}} +
+ +
+
+ +

+

+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + +
+
+
+ {{:: 'user.profile.attribute.annotation' | translate}} +
+ + + + + + + + + + + + + + + + + + + + +
{{:: 'key' | translate}}{{:: 'value' | translate}}{{:: 'actions' | translate}}
{{key}}{{:: 'delete' | translate}}
{{:: 'add' | translate}} +
+
+
+
+

+

+ + +
+
+
+ +
+ + \ No newline at end of file diff --git a/themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-realm.html b/themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-realm.html index 2e449f305e..d9caf3ec6f 100755 --- a/themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-realm.html +++ b/themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-realm.html @@ -19,5 +19,6 @@ {{:: 'realm-tab-client-policies' | translate}}
  • {{:: 'realm-tab-security-defenses' | translate}}
  • +
  • {{:: 'realm-tab-user-profile' | translate}}
  • \ No newline at end of file diff --git a/themes/src/main/resources/theme/base/login/verify-profile.ftl b/themes/src/main/resources/theme/base/login/verify-profile.ftl new file mode 100755 index 0000000000..29b061c0d0 --- /dev/null +++ b/themes/src/main/resources/theme/base/login/verify-profile.ftl @@ -0,0 +1,47 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','email','firstName','lastName'); section> + <#if section = "header"> + ${msg("loginProfileTitle")} + <#elseif section = "form"> +
    + + <#list profile.attributes as attribute> +
    +
    + + <#if attribute.required>* +
    +
    + disabled + /> + + <#if messagesPerField.existsError('${attribute.name}')> + + ${kcSanitize(messagesPerField.get('${attribute.name}'))?no_esc} + + +
    +
    + + +
    +
    +
    +
    +
    + +
    + <#if isAppInitiatedAction??> + + + <#else> + + +
    +
    +
    + + \ No newline at end of file diff --git a/themes/src/main/resources/theme/keycloak.v2/account/messages/messages_en.properties b/themes/src/main/resources/theme/keycloak.v2/account/messages/messages_en.properties index c7087c9076..e05311a9ae 100644 --- a/themes/src/main/resources/theme/keycloak.v2/account/messages/messages_en.properties +++ b/themes/src/main/resources/theme/keycloak.v2/account/messages/messages_en.properties @@ -118,4 +118,17 @@ infoMessage=By clicking 'Remove Access', you will remove granted permissions of doDelete=Delete deleteAccountSummary=Deleting your account will erase all your data and log you out immediately. deleteAccount=Delete Account -deleteAccountWarning=This is irreversible. All your data will be permanently destroyed, and irretrievable. \ No newline at end of file +deleteAccountWarning=This is irreversible. All your data will be permanently destroyed, and irretrievable. + +error-invalid-value=''{0}'' has invalid value. +error-invalid-blank=Please specify value of ''{0}''. +error-empty=Please specify value of ''{0}''. +error-invalid-length=''{0}'' must have a length between {1} and {2}. +error-invalid-email=Invalid email address. +error-invalid-number=''{0}'' is invalid number. +error-number-out-of-range=''{0}'' must be a number between {1} and {2}. +error-pattern-no-match=''{0}'' doesn''t match required format. +error-invalid-uri=''{0}'' is invalid URL. +error-invalid-uri-scheme=''{0}'' has invalid URL scheme. +error-invalid-uri-fragment=''{0}'' is invalid URL fragment. +error-user-attribute-required=Please specify ''{0}''. \ No newline at end of file diff --git a/themes/src/main/resources/theme/keycloak.v2/account/src/app/account-service/account.service.ts b/themes/src/main/resources/theme/keycloak.v2/account/src/app/account-service/account.service.ts index 1cf62fcf4a..8cd17d9f61 100644 --- a/themes/src/main/resources/theme/keycloak.v2/account/src/app/account-service/account.service.ts +++ b/themes/src/main/resources/theme/keycloak.v2/account/src/app/account-service/account.service.ts @@ -105,9 +105,13 @@ export class AccountServiceClient { } if (response !== null && response.data != null) { - ContentAlert.danger( - `${response.statusText}: ${response.data['errorMessage'] ? response.data['errorMessage'] : ''} ${response.data['error'] ? response.data['error'] : ''}` - ); + if (response.data['errors'] != null) { + for(let err of response.data['errors']) + ContentAlert.danger(err['errorMessage'], err['params']); + } else { + ContentAlert.danger( + `${response.statusText}: ${response.data['errorMessage'] ? response.data['errorMessage'] : ''} ${response.data['error'] ? response.data['error'] : ''}`); + }; } else { ContentAlert.danger(response.statusText); }