[KEYCLOAK-17399] - Declarative User Profile and UI

Co-authored-by: Vlastimil Elias <velias@redhat.com>
This commit is contained in:
Pedro Igor 2021-06-01 11:45:35 -03:00 committed by Stian Thorgersen
parent d2a8a95d79
commit ef3a0ee06c
91 changed files with 3884 additions and 998 deletions

View file

@ -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;

View file

@ -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());

View file

@ -17,16 +17,35 @@
package org.keycloak.representations.idm;
import java.util.List;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public class ErrorRepresentation {
private String field;
private String errorMessage;
private Object[] params;
private List<ErrorRepresentation> 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<ErrorRepresentation> errors) {
this.errors = errors;
}
public List<ErrorRepresentation> getErrors() {
return errors;
}
}

View file

@ -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 <velias@redhat.com>
*/
@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);
}

View file

@ -247,5 +247,7 @@ public interface UsersResource {
@DELETE
Response delete(@PathParam("id") String id);
@Path("profile")
UserProfileResource userProfile();
}

View file

@ -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),

View file

@ -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;
}

View file

@ -43,25 +43,26 @@ public final class AttributeMetadata {
private final String attributeName;
private final Predicate<AttributeContext> selector;
private final Predicate<AttributeContext> readOnly;
private final Predicate<AttributeContext> writeAllowed;
/** Predicate to decide if attribute is required, it is handled as required if predicate is null */
private final Predicate<AttributeContext> required;
private final Predicate<AttributeContext> readAllowed;
private List<AttributeValidatorMetadata> validators;
private Map<String, Object> 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<AttributeContext> readOnly, Predicate<AttributeContext> required) {
this(attributeName, ALWAYS_TRUE, readOnly, required);
AttributeMetadata(String attributeName, Predicate<AttributeContext> writeAllowed, Predicate<AttributeContext> required) {
this(attributeName, ALWAYS_TRUE, writeAllowed, required, ALWAYS_TRUE);
}
AttributeMetadata(String attributeName, Predicate<AttributeContext> selector) {
this(attributeName, selector, ALWAYS_FALSE, ALWAYS_TRUE);
this(attributeName, selector, ALWAYS_FALSE, ALWAYS_TRUE, ALWAYS_TRUE);
}
AttributeMetadata(String attributeName, List<String> scopes, Predicate<AttributeContext> readOnly, Predicate<AttributeContext> required) {
AttributeMetadata(String attributeName, List<String> scopes, Predicate<AttributeContext> writeAllowed, Predicate<AttributeContext> 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<AttributeContext> selector, Predicate<AttributeContext> readOnly, Predicate<AttributeContext> required) {
AttributeMetadata(String attributeName, Predicate<AttributeContext> selector, Predicate<AttributeContext> writeAllowed,
Predicate<AttributeContext> required,
Predicate<AttributeContext> readAllowed) {
this.attributeName = attributeName;
this.selector = selector;
this.readOnly = readOnly;
this.writeAllowed = writeAllowed;
this.required = required;
this.readAllowed = readAllowed;
}
public String getName() {
@ -100,7 +104,11 @@ 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);
}
/**
@ -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();
}
}

View file

@ -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<Map.Entry<String, List<String>>> attributeSet();
/**
* <p>Returns the metadata associated with the attribute with the given {@code name}.
*
* <p>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<String, List<String>> 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<String, List<String>> 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<String, List<String>> toMap();
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public final class DefaultAttributes extends HashMap<String, List<String>> implements Attributes {
public class DefaultAttributes extends HashMap<String, List<String>> 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<String, List<String>> imple
private final UserProfileContext context;
private final KeycloakSession session;
private final Map<String, AttributeMetadata> metadataByAttribute;
private final UserModel user;
protected final UserModel user;
public DefaultAttributes(UserProfileContext context, Map<String, ?> attributes, UserModel user,
UserProfileMetadata profileMetadata,
@ -79,12 +78,24 @@ public final class DefaultAttributes extends HashMap<String, List<String>> 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 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
public boolean validate(String name, Consumer<ValidationError>... listeners) {
Entry<String, List<String>> attribute = createAttribute(name);
@ -95,31 +106,33 @@ public final class DefaultAttributes extends HashMap<String, List<String>> imple
metadatas.addAll(Optional.ofNullable(this.metadataByAttribute.get(READ_ONLY_ATTRIBUTE_KEY))
.map(Collections::singletonList).orElse(Collections.emptyList()));
List<ValidationContext> 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 (vc.isValid()) {
continue;
}
if (result == null) {
result = false;
}
if (listeners != null) {
for (ValidationContext failingValidator : failingValidators) {
for (ValidationError error : vc.getErrors()) {
for (Consumer<ValidationError> consumer : listeners) {
for(ValidationError err: failingValidator.getErrors()) {
consumer.accept(err);
consumer.accept(error);
}
}
}
}
}
return failingValidators.isEmpty();
return result == null;
}
@Override
@ -142,12 +155,43 @@ public final class DefaultAttributes extends HashMap<String, List<String>> 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<String, List<String>> getReadable() {
Map<String, List<String>> attributes = new HashMap<>(user.getAttributes());
if (attributes.isEmpty()) {
return null;
}
return attributes;
}
@Override
public Map<String, List<String>> toMap() {
return this;
}
private AttributeContext createAttributeContext(Entry<String, List<String>> 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<String, AttributeMetadata> configureMetadata(List<AttributeMetadata> attributes) {
@ -155,7 +199,7 @@ public final class DefaultAttributes extends HashMap<String, List<String>> 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<String, List<String>> imple
Map<String, List<String>> newAttributes = new HashMap<>();
RealmModel realm = session.getContext().getRealm();
if (attributes != null && !attributes.isEmpty()) {
if (attributes != null) {
for (Map.Entry<String, ?> entry : attributes.entrySet()) {
Object value = entry.getValue();
String key = entry.getKey();
if (!isSupportedAttribute(key)) {
@ -204,6 +247,7 @@ public final class DefaultAttributes extends HashMap<String, List<String>> imple
}
List<String> values;
Object value = entry.getValue();
if (value instanceof String) {
values = Collections.singletonList((String) value);
@ -215,26 +259,27 @@ public final class DefaultAttributes extends HashMap<String, List<String>> 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<String> 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<String, List<String>> 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<String, List<String>> imple
return false;
}
SimpleImmutableEntry<String, List<String>> 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;
}

View file

@ -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<Attributes, UserModel> userSupplier;
private final Attributes attributes;
private final KeycloakSession session;
private boolean validated;
private UserModel user;
public DefaultUserProfile(Attributes attributes, Function<Attributes, UserModel> userCreator, UserModel user) {
public DefaultUserProfile(Attributes attributes, Function<Attributes, UserModel> 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<String> attrsToRemove = new HashSet<>(user.getAttributes().keySet());
attrsToRemove.removeAll(attributes.nameSet());
for (String attr : attrsToRemove) {

View file

@ -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<String, Object> getAttributes() {
Map<String, Object> attributes = super.getAttributes();
attributes.put(UserModel.class.getName(), getAttributeContext().getUser());
return attributes;
}
}

View file

@ -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<AttributeMetadata> 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<AttributeContext> writeAllowed, Predicate<AttributeContext> readAllowed, AttributeValidatorMetadata... validator) {
return addAttribute(new AttributeMetadata(name, ALWAYS_TRUE, writeAllowed, ALWAYS_TRUE, readAllowed).addValidator(Arrays.asList(validator)));
}
public AttributeMetadata addAttribute(String name, Predicate<AttributeContext> writeAllowed, List<AttributeValidatorMetadata> validators) {
return addAttribute(new AttributeMetadata(name, ALWAYS_TRUE, writeAllowed, ALWAYS_TRUE, ALWAYS_TRUE).addValidator(validators));
}
public AttributeMetadata addAttribute(String name, List<AttributeValidatorMetadata> validators) {
return addAttribute(new AttributeMetadata(name).addValidator(validators));
}
public AttributeMetadata addAttribute(String name, List<AttributeValidatorMetadata> validator, Predicate<AttributeContext> required) {
return addAttribute(new AttributeMetadata(name, AttributeMetadata.ALWAYS_FALSE, required).addValidator(validator));
}
public AttributeMetadata addAttribute(String name, List<AttributeValidatorMetadata> validator, Predicate<AttributeContext> readOnly, Predicate<AttributeContext> required) {
return addAttribute(new AttributeMetadata(name, readOnly, required).addValidator(validator));
public AttributeMetadata addAttribute(String name, List<AttributeValidatorMetadata> validator, Predicate<AttributeContext> writeAllowed, Predicate<AttributeContext> required, Predicate<AttributeContext> 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;

View file

@ -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

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public final class ValidationException extends RuntimeException {
public final class ValidationException extends RuntimeException implements Consumer<ValidationError> {
private final Map<String, List<Error>> errors = new HashMap<>();
private final BiFunction<String, Object[], String> messageFormatter;
public ValidationException(KeycloakSession session, UserModel user) {
this.messageFormatter = new MessageFormatter(session, user);
}
public List<Error> getErrors() {
return errors.values().stream().reduce(new ArrayList<>(), (l, r) -> {
@ -72,9 +89,14 @@ 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<Error> errors = this.errors.computeIfAbsent(error.getMessage(), (k) -> new ArrayList<>());
errors.add(new Error(error));
errors.add(new Error(error, messageFormatter));
}
@Override
@ -87,12 +109,25 @@ public final class ValidationException extends RuntimeException {
return toString();
}
public Response.Status getStatusCode() {
for (Map.Entry<String, List<Error>> 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<String, Object[], String> messageFormatter;
public Error(ValidationError error) {
public Error(ValidationError error, BiFunction<String, Object[], String> messageFormatter) {
this.error = error;
this.messageFormatter = messageFormatter;
}
public String getAttribute() {
@ -104,7 +139,7 @@ public final class ValidationException extends RuntimeException {
}
public Object[] getMessageParameters() {
return error.getMessageParameters();
return error.getInputHintWithMessageParameters();
}
@Override
@ -112,5 +147,40 @@ public final class ValidationException extends RuntimeException {
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<String, Object[], String> {
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<String, String> 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);
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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 <velias@redhat.com>
*/
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";
@ -43,6 +47,24 @@ public abstract class AbstractNumberValidator extends AbstractSimpleValidator {
private final ValidatorConfig defaultConfig;
protected static final List<ProviderConfigProperty> 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
this(ValidatorConfig.EMPTY);
@ -52,6 +74,10 @@ public abstract class AbstractNumberValidator extends AbstractSimpleValidator {
this.defaultConfig = config;
}
public List<ProviderConfigProperty> getConfigProperties() {
return configProperties;
}
@Override
protected boolean skipValidation(Object value, ValidatorConfig config) {
if (isIgnoreEmptyValuesConfigured(config) && (value == null || value instanceof String)) {
@ -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;
}

View file

@ -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 <velias@redhat.com>
*/
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";
}
}

View file

@ -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<ProviderConfigProperty> getConfigProperties() {
return Collections.emptyList();
}
}

View file

@ -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 <velias@redhat.com>
*/
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";
}
}

View file

@ -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;
* <p>
* 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<ProviderConfigProperty> 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<ProviderConfigProperty> getConfigProperties() {
return configProperties;
}
}

View file

@ -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<ProviderConfigProperty> getConfigProperties() {
return Collections.emptyList();
}
@Override
protected boolean isIgnoreEmptyValuesConfigured(ValidatorConfig config) {
return true;
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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";
@ -42,7 +46,16 @@ public class PatternValidator extends AbstractStringValidator {
public static final String MESSAGE_NO_MATCH = "error-pattern-no-match";
private PatternValidator() {
private static final List<ProviderConfigProperty> configProperties = new ArrayList<>();
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)));
}
}
@ -79,4 +92,14 @@ public class PatternValidator extends AbstractStringValidator {
return new ValidationResult(errors);
}
@Override
public String getHelpText() {
return "RegExp Pattern validator";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return configProperties;
}
}

View file

@ -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<ProviderConfigProperty> getConfigProperties() {
return Collections.emptyList();
}
}

View file

@ -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

View file

@ -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"));

View file

@ -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
}
/**

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
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<FormMessage> errors = Validation.getFormErrorsFromValidation(ve.getErrors());
MultivaluedMap<String, String> 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<String, String> 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<FormMessage> 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<String, String> formData, List<FormMessage> errors) {
LoginFormsProvider form = context.form();
if (!errors.isEmpty()) {
form.setErrors(errors);
}
return form.setFormData(formData)
.createResponse(UserModel.RequiredAction.VERIFY_PROFILE);
}
}

View file

@ -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);

View file

@ -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();
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class VerifyProfileBean {
private final UserModel user;
private final MultivaluedMap<String, String> formData;
private final List<Attribute> attributes;
private final UserProfile profile;
public VerifyProfileBean(UserModel user, MultivaluedMap<String, String> 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<Attribute> getAttributes() {
return attributes;
}
public List<Attribute> getAllAttributes() {
return toAttributes(profile.getAttributes().toMap());
}
private List<Attribute> toAttributes(Map<String, List<String>> readable) {
return readable.keySet().stream()
.map(name -> profile.getAttributes().getMetadata(name)).map(Attribute::new)
.sorted()
.collect(Collectors.toList());
}
public class Attribute implements Comparable<Attribute> {
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<String, Object> getAnnotations() {
Map<String, Object> annotations = metadata.getAnnotations();
if (annotations == null) {
return Collections.emptyMap();
}
return annotations;
}
@Override
public int compareTo(Attribute o) {
return getName().compareTo(o.getName());
}
}
}

View file

@ -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 <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -42,4 +43,12 @@ public class ErrorResponse {
return Response.status(status).entity(error).type(MediaType.APPLICATION_JSON).build();
}
public static Response errors(List<ErrorRepresentation> 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();
}
}

View file

@ -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<String, List<String>> attributes = user.getAttributes();
Map<String, List<String>> 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<ErrorRepresentation> 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.
*

View file

@ -16,28 +16,23 @@
*/
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 <velias@redhat.com>
*
*/
public class UserProfileResource {
@ -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();

View file

@ -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<ErrorRepresentation> 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<String, List<String>> attributes = profile.getAttributes().getReadable(false);
if (!attributes.isEmpty()) {
rep.setAttributes(attributes);
}
return rep;
}

View file

@ -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<UserRepresentation> searchForUser(Map<String, String> attributes, RealmModel realm, UserPermissionEvaluator usersEvaluator, Boolean briefRepresentation, Integer firstResult, Integer maxResults, Boolean includeServiceAccounts) {
session.setAttribute(UserModel.INCLUDE_SERVICE_ACCOUNT, includeServiceAccounts);

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class DeclarativeAttributes extends DefaultAttributes {
public DeclarativeAttributes(UserProfileContext context, Map<String, ?> attributes,
UserModel user, UserProfileMetadata profileMetadata,
KeycloakSession session) {
super(context, attributes, user, profileMetadata, session);
}
@Override
public Map<String, List<String>> getReadable() {
Map<String, List<String>> attributes = new HashMap<>(this);
for (String name : nameSet()) {
AttributeMetadata metadata = getMetadata(name);
if (metadata == null || !metadata.canView(createAttributeContext(metadata))) {
attributes.remove(name);
}
}
return attributes;
}
}

View file

@ -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;

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
* @author Vlastimil Elias <velias@redhat.com>
*/
public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<DeclarativeUserProfileProvider> implements AmphibianProviderFactory<DeclarativeUserProfileProvider> {
public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<DeclarativeUserProfileProvider>
implements AmphibianProviderFactory<DeclarativeUserProfileProvider>, 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<String> 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<UserProfileContext, UserProfileMetadata> metadataRegistry) {
public DeclarativeUserProfileProvider(KeycloakSession session, Map<UserProfileContext, UserProfileMetadata> 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<UserProfileContext, UserProfileMetadata> metadataRegistry) {
return new DeclarativeUserProfileProvider(session, metadataRegistry);
return new DeclarativeUserProfileProvider(session, metadataRegistry, defaultRawConfig);
}
@Override
protected Attributes createAttributes(UserProfileContext context, Map<String, ?> attributes,
UserModel user, UserProfileMetadata metadata) {
return new DeclarativeAttributes(context, attributes, user, metadata, session);
}
@Override
@ -122,10 +148,10 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
List<String> 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());
}
Predicate<AttributeContext> readOnly = AttributeMetadata.ALWAYS_FALSE;
validators.add(new AttributeValidatorMetadata(AttributeRequiredByMetadataValidator.ID));
}
Predicate<AttributeContext> writeAllowed = AttributeMetadata.ALWAYS_FALSE;
Predicate<AttributeContext> readAllowed = AttributeMetadata.ALWAYS_FALSE;
UPAttributePermissions permissions = attrConfig.getPermissions();
if (permissions != null) {
List<String> 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<String> viewRoles = permissions.getView();
if (viewRoles.isEmpty()) {
readAllowed = writeAllowed;
} else {
readAllowed = createViewAllowedPredicate(writeAllowed, viewRoles);
}
}
Map<String, Object> 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()) {
if (permissions == null) {
writeAllowed = AttributeMetadata.ALWAYS_TRUE;
}
List<AttributeMetadata> 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);
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<AttributeContext> createViewAllowedPredicate(Predicate<AttributeContext> canEdit,
List<String> 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.
* <p>
* 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<String> scopesConfigured) {
// never match out of auth flow
if (session.getContext().getAuthenticationSession() == null) {
return false;
}
return getAuthFlowRequestedScopeNames().stream().anyMatch(scopesConfigured::contains);
}
private Set<String> 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);
}
}

View file

@ -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;

View file

@ -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<String> view;
private List<String> edit;
private List<String> view = Collections.emptyList();
private List<String> edit = Collections.emptyList();
public List<String> getView() {
return view;

View file

@ -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;

View file

@ -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;

View file

@ -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);
}

View file

@ -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<U extends UserProfileProvider> implements UserProfileProvider, UserProfileProviderFactory<U> {
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<String> readOnlyAttributes = new ArrayList<>(Arrays.asList(builtinReadOnlyAttributes));
@ -133,6 +144,8 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
@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<U extends UserProfileProvider>
private UserProfile createUserProfile(UserProfileContext context, Map<String, ?> 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<String, ?> attributes, UserModel user,
UserProfileMetadata metadata) {
return new DefaultAttributes(context, attributes, user, metadata, session);
}
private void addContextualProfileMetadata(UserProfileMetadata metadata) {
@ -259,7 +277,9 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
private UserProfileMetadata createDefaultProfile(UserProfileContext context, AttributeValidatorMetadata readOnlyValidator) {
UserProfileMetadata metadata = new UserProfileMetadata(context);
metadata.addAttribute(UserModel.USERNAME, new AttributeValidatorMetadata(UsernameHasValueValidator.ID),
metadata.addAttribute(UserModel.USERNAME, AbstractUserProfileProvider::editUsernameCondition,
AbstractUserProfileProvider::editUsernameCondition,
new AttributeValidatorMetadata(UsernameHasValueValidator.ID),
new AttributeValidatorMetadata(DuplicateUsernameValidator.ID),
new AttributeValidatorMetadata(UsernameMutationValidator.ID));

View file

@ -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;
}
}

View file

@ -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));
}
}

View file

@ -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;

View file

@ -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));
}
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
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<String> currentValue = user.getAttributeStream(inputHint).collect(Collectors.toList());
List<String> values = (List<String>) 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);
}
}

View file

@ -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;

View file

@ -24,3 +24,4 @@ org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory
org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory
org.keycloak.authentication.requiredactions.UpdateUserLocaleAction
org.keycloak.authentication.requiredactions.DeleteAccount
org.keycloak.authentication.requiredactions.VerifyUserProfile

View file

@ -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

View file

@ -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

View file

@ -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"]
}
}
]
}

View file

@ -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)

View file

@ -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}

View file

@ -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

View file

@ -1,18 +0,0 @@
{
"attributes": [
{
"name": "username"
},
{
"name": "email"
},
{
"name": "firstName",
"required": {}
},
{
"name": "lastName",
"required": {}
}
]
}

View file

@ -564,9 +564,13 @@ public class AuthServerTestEnricher {
wasUpdated = true;
}
if (event.getTestClass().isAnnotationPresent(SetDefaultProvider.class)) {
SpiProvidersSwitchingUtils.addProviderDefaultValue(suiteContext, event.getTestClass().getAnnotation(SetDefaultProvider.class));
SetDefaultProvider defaultProvider = event.getTestClass().getAnnotation(SetDefaultProvider.class);
if (defaultProvider.beforeEnableFeature()) {
SpiProvidersSwitchingUtils.addProviderDefaultValue(suiteContext, defaultProvider);
wasUpdated = true;
}
}
if (wasUpdated) {
restartAuthServer();

View file

@ -10,4 +10,27 @@ import java.lang.annotation.Target;
public @interface SetDefaultProvider {
String spi();
String providerId();
/**
* <p>Defines whether the default provider should be set by updating an existing Spi configuration.
*
* <p>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;
/**
* <p>Defines whether the default provider should be set prior to enabling a feature.
*
* <p>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;
}

View file

@ -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;

View file

@ -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> suiteContext;
private boolean forceReaugmentation;
private List<String> additionalArgs = Collections.emptyList();
@Override
public Class<KeycloakQuarkusConfiguration> 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<String> 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<String> 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<String> 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();
}
}

View file

@ -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 <velias@redhat.com>
*/
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();
}
}

View file

@ -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();
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();
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();
}
}

View file

@ -84,7 +84,7 @@ public class RequiredActionsTest extends AbstractAuthenticationTest {
// Dummy RequiredAction is not registered in the realm and WebAuthn actions
List<RequiredActionProviderSimpleRepresentation> 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());

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
@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());
}
}

View file

@ -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 <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
* @author Stan Silvert ssilvert@redhat.com (C) 2016 Red Hat Inc.
* @author Vlastimil Elias <velias@redhat.com>
*/
@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));
}
}
}

View file

@ -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 <velias@redhat.com>
*/
@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<RequiredActionProviderRepresentation> 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));
}
}
}

View file

@ -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<String> 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<String> scopes) {

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
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<String, Object> 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<String, Object> 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<String, Object> validatorConfig = new HashMap<>();
validatorConfig.put("min", 4);
attribute.addValidation(LengthValidator.ID, validatorConfig);
config.addAttribute(attribute);
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> validatorConfig = new HashMap<>();
validatorConfig.put(LengthValidator.KEY_MIN, 4);
attribute.addValidation(LengthValidator.ID, validatorConfig);
config.addAttribute(attribute);
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
Map<String, Object> 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<String> roles = new ArrayList<>();
roles.add(ROLE_USER);
requirements.setRoles(roles);
attribute.setRequired(requirements);
config.addAttribute(attribute);
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
Map<String, Object> 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<String> roles = new ArrayList<>();
roles.add(UPConfigUtils.ROLE_ADMIN);
requirements.setRoles(roles);
attribute.setRequired(requirements);
config.addAttribute(attribute);
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
Map<String, Object> 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<String> scopes = new ArrayList<>();
scopes.add("client-a");
requirements.setScopes(scopes);
attribute.setRequired(requirements);
config.addAttribute(attribute);
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
Map<String, Object> 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));
}
}
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
@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<String, Object> 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<String, Object> 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);
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<String, Object> 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<String, Object> 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<String, Object> validatorConfig = new HashMap<>();
validatorConfig.put("min", 4);
attribute.addValidation(LengthValidator.ID, validatorConfig);
config.addAttribute(attribute);
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> validatorConfig = new HashMap<>();
validatorConfig.put(LengthValidator.KEY_MIN, 4);
attribute.addValidation(LengthValidator.ID, validatorConfig);
config.addAttribute(attribute);
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
Map<String, Object> 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<String> 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<String, Object> 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<String, Object> 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<String, Object> 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<String> roles = new ArrayList<>();
roles.add(UPConfigUtils.ROLE_USER);
permissions.setEdit(roles);
attribute.setPermissions(permissions);
config.addAttribute(attribute);
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
Map<String, Object> 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<String> 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<String, Object> 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));
}
}
}

View file

@ -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

View file

@ -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}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
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());
}
}

View file

@ -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" ]
}
},

View file

@ -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" ]
}
},

View file

@ -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}.

View file

@ -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

View file

@ -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.

View file

@ -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 {

View file

@ -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;
});

View file

@ -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',

View file

@ -0,0 +1,209 @@
<div class="col-sm-9 col-md-10 col-sm-push-3 col-md-push-2">
<kc-tabs-realm></kc-tabs-realm>
<ul class="nav nav-tabs nav-tabs-pf">
<li ng-class="{active: isShowAttributes}"><a href="" data-ng-click="showAttributes()">{{:: 'attributes' | translate}}</a></li>
<li ng-class="{active: !isShowAttributes}"><a href=""
data-ng-click="showJsonEditor()">{{:: 'client-profiles-json-editor' | translate}}</a>
</li>
</ul>
<div data-ng-show="showListing()">
<table class="datatable table table-striped table-bordered dataTable no-footer">
<thead>
<tr>
<th class="kc-table-actions" colspan="3">
<div class="form-inline">
<div class="pull-right" data-ng-show="access.manageClients">
<button class="btn btn-default" data-ng-click="create()">
{{:: 'create' | translate}}
</button>
</div>
</div>
</th>
</tr>
<tr>
<th width="90%">{{:: 'name' | translate}}</th>
<th colspan="2">{{:: 'actions' | translate}}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="attribute in config.attributes">
<td><a href="" data-ng-click="edit(attribute)">{{attribute.name}}</a></td>
<td class="kc-action-cell" data-ng-click="edit(attribute)">{{:: 'edit' | translate}}</td>
<td class="kc-action-cell" data-ng-click="removeAttribute(attribute)">{{:: 'delete' | translate}}</td>
</tr>
</tbody>
</table>
</div>
<form class="form-horizontal" name="realmForm" novalidate kc-read-only="!access.manageRealm"
data-ng-show="!isShowAttributes">
<filedset>
<div class="form-group">
<div class="col-md-10">
<div>
<textarea id="userProfileConfig" name="userProfileConfig" data-ng-model="rawConfig"
class="form-control ng-binding" rows="20"></textarea>
</div>
</div>
</div>
<div class="form-group">
<div class="col-md-10">
<button class="btn btn-primary" data-ng-click="save()">{{:: 'save' | translate}}</button>
<button class="btn btn-default" data-ng-click="reset()">{{:: 'cancel' | translate}}</button>
</div>
</div>
</filedset>
</form>
<form class="form-horizontal" name="realmForm" novalidate kc-read-only="!access.manageRealm"
data-ng-show="currentAttribute != null">
<p/>
<legend expanded><span class="text">{{:: 'user.profile.attribute' | translate}} <b
data-ng-show="!isCreate">{{currentAttribute.name}}</b> {{:: 'configuration' | translate}}</span>
</legend>
<div class="form-group">
<label class="col-md-2 control-label" for="currentAttribute.name">{{:: 'user.profile.attribute.name' | translate}}</label>
<kc-tooltip>{{:: 'user.profile.attribute.name.tooltip' | translate}}</kc-tooltip>
<div class="col-md-4">
<input name="currentAttribute.name" data-ng-model="currentAttribute.name" id="currentAttribute.name"
type="text" class="form-control"
data-ng-readonly="!isCreate"
required/>
</div>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="isRequired">{{:: 'user.profile.attribute.required' | translate}}</label>
<kc-tooltip>{{:: 'user.profile.attribute.required.tooltip' | translate}}</kc-tooltip>
<div class="col-md-6">
<input name="isRequired" data-ng-model="isRequired" id="isRequired" onoffswitch
on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
</div>
</div>
<fieldset class="border-top">
<legend collapsed><span class="text">{{:: 'user.profile.attribute.permission' | translate}}</span></legend>
<div class="form-group">
<label class="col-md-2 control-label" for="canUserView">{{:: 'user.profile.attribute.canUserView' | translate}}</label>
<kc-tooltip>{{:: 'user.profile.attribute.canUserView.tooltip' | translate}}</kc-tooltip>
<div class="col-md-2">
<input name="canUserView" data-ng-model="canUserView" id="canUserView" onoffswitch
on-text="{{:: 'onText' | translate}}"
off-text="{{:: 'offText' | translate}}"/>
</div>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="canAdminView">{{:: 'user.profile.attribute.canAdminView' | translate}}</label>
<kc-tooltip>{{:: 'user.profile.attribute.canAdminView.tooltip' | translate}}</kc-tooltip>
<div class="col-md-6">
<input name="canAdminView" data-ng-model="canAdminView" id="canAdminView" onoffswitch
on-text="{{:: 'onText' | translate}}"
off-text="{{:: 'offText' | translate}}"/>
</div>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="canUserEdit">{{:: 'user.profile.attribute.canUserEdit' | translate}}</label>
<kc-tooltip>{{:: 'user.profile.attribute.canUserEdit.tooltip' | translate}}</kc-tooltip>
<div class="col-md-6">
<input name="canUserEdit" data-ng-model="canUserEdit" id="canUserEdit" onoffswitch
on-text="{{:: 'onText' | translate}}"
off-text="{{:: 'offText' | translate}}"/>
</div>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="canAdminEdit">{{:: 'user.profile.attribute.canAdminEdit' | translate}}</label>
<kc-tooltip>{{:: 'user.profile.attribute.canAdminEdit.tooltip' | translate}}</kc-tooltip>
<div class="col-md-6">
<input name="canAdminEdit" data-ng-model="canAdminEdit" id="canAdminEdit" onoffswitch
on-text="{{:: 'onText' | translate}}"
off-text="{{:: 'offText' | translate}}"/>
</div>
</div>
</fieldset>
<fieldset>
<legend collapsed><span class="text">{{:: 'user.profile.attribute.validation' | translate}}</span></legend>
<div class="form-group">
<label class="col-md-2 control-label" for="create-validator">{{:: 'user.profile.attribute.validation.add.validator' | translate}}</label>
<kc-tooltip>{{:: 'user.profile.attribute.validation.add.validator.tooltip' | translate}}</kc-tooltip>
<div class="col-sm-4">
<select id="create-validator" class="form-control" ng-model="newValidator"
ng-options="p.id for p in validatorProviders"
data-ng-change="selectValidator(newValidator)">
<option value="" disabled selected>{{:: 'user.profile.attribute.validation.add.validator' | translate}}...</option>
</select>
</div>
</div>
<kc-component-config realm="realm" config="newValidator.config"
properties="newValidator.properties"></kc-component-config>
<p/>
<div class="form-group" data-ng-show="newValidator != null">
<div class="col-md-10 col-md-offset-2" data-ng-show="access.manageRealm">
<button class="btn btn-primary" data-ng-click="addValidator(newValidator)">{{:: 'add' | translate}}</button>
<button class="btn btn-primary" data-ng-click="cancelAddValidator()">{{:: 'cancel' | translate}}</button>
</div>
</div>
<div class="form-group col-sm-10">
<table class="datatable table table-striped table-bordered dataTable no-footer">
<thead>
<tr>
<th width="20%">{{:: 'name' | translate}}</th>
<th>{{:: 'config' | translate}}</th>
<th colspan="2">{{:: 'actions' | translate}}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="(key, value) in currentAttribute.validations">
<td>{{key}}</td>
<td>{{value}}</td>
<td class="kc-action-cell" data-ng-click="removeValidator(key)">{{:: 'delete' | translate}}</td>
</tr>
<tr data-ng-show="!currentAttribute.validations || currentAttribute.validations.length == 0">
<td class="text-muted" colspan="3">{{:: 'user.profile.attribute.validation.no.validators' | translate}}</td>
</tr>
</tbody>
</table>
</div>
</fieldset>
<fieldset>
<legend collapsed><span class="text">{{:: 'user.profile.attribute.annotation' | translate}}</span></legend>
<div class="form-group col-sm-10">
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>{{:: 'key' | translate}}</th>
<th>{{:: 'value' | translate}}</th>
<th>{{:: 'actions' | translate}}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="(key, value) in currentAttribute.annotations | toOrderedMapSortedByKey">
<td>{{key}}</td>
<td><input ng-model="currentAttribute.annotations[key]" class="form-control" type="text" name="{{key}}"
id="attribute-{{key}}"/></td>
<td class="kc-action-cell" data-ng-click="removeAnnotation(key)">{{:: 'delete' | translate}}</td>
</tr>
<tr>
<td><input ng-model="newAnnotation.key" class="form-control" type="text" id="newAnnotationKey"/></td>
<td><input ng-model="newAnnotation.value" class="form-control" type="text" id="newAnnotationValue"/></td>
<td class="kc-action-cell" data-ng-click="addAnnotation()"
data-ng-disabled="!newAnnotation.key.length || !newAnnotation.value.length">{{:: 'add' | translate}}
</td>
</tr>
</tbody>
</table>
</div>
</fieldset>
<div class="form-group">
<p/>
<div class="col-md-10 col-md-offset-2" data-ng-show="access.manageRealm">
<button kc-save>{{:: 'save' | translate}}</button>
<button kc-reset>{{:: 'cancel' | translate}}</button>
</div>
</div>
</form>
</div>
<kc-menu></kc-menu>

View file

@ -19,5 +19,6 @@
<a href="#/realms/{{realm.realm}}/client-policies/profiles">{{:: 'realm-tab-client-policies' | translate}}</a>
</li>
<li ng-class="{active: path[2] == 'defense'}" data-ng-show="access.viewRealm"><a href="#/realms/{{realm.realm}}/defense/headers">{{:: 'realm-tab-security-defenses' | translate}}</a></li>
<li ng-class="{active: path[2] == 'user-profile'}" data-ng-show="access.viewRealm && serverInfo.featureEnabled('DECLARATIVE_USER_PROFILE')"><a href="#/realms/{{realm.realm}}/user-profile">{{:: 'realm-tab-user-profile' | translate}}</a></li>
</ul>
</div>

View file

@ -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">
<form id="kc-update-profile-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<#list profile.attributes as attribute>
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="${attribute.name}" class="${properties.kcLabelClass!}">${msg("${attribute.name}")}</label>
<#if attribute.required>*</#if>
</div>
<div class="${properties.kcInputWrapperClass!}">
<input type="text" id="${attribute.name}" name="${attribute.name}" value="${(attribute.value!'')}"
class="${properties.kcInputClass!}"
aria-invalid="<#if messagesPerField.existsError('${attribute.name}')>true</#if>"
<#if attribute.readOnly>disabled</#if>
/>
<#if messagesPerField.existsError('${attribute.name}')>
<span id="input-error-${attribute.name}" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('${attribute.name}'))?no_esc}
</span>
</#if>
</div>
</div>
</#list>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
<div class="${properties.kcFormOptionsWrapperClass!}">
</div>
</div>
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<#if isAppInitiatedAction??>
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doSubmit")}" />
<button class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!}" type="submit" name="cancel-aia" value="true" />${msg("doCancel")}</button>
<#else>
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doSubmit")}" />
</#if>
</div>
</div>
</form>
</#if>
</@layout.registrationLayout>

View file

@ -119,3 +119,16 @@ 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.
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}''.

View file

@ -105,9 +105,13 @@ export class AccountServiceClient {
}
if (response !== null && response.data != null) {
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'] : ''}`
);
`${response.statusText}: ${response.data['errorMessage'] ? response.data['errorMessage'] : ''} ${response.data['error'] ? response.data['error'] : ''}`);
};
} else {
ContentAlert.danger(response.statusText);
}