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 @@
+
\ 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">
+
+ #if>
+@layout.registrationLayout>
\ 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);
}