[KEYCLOAK-17399] UserProfile SPI - Validation SPI integration

This commit is contained in:
Vlastimil Elias 2021-05-12 15:54:05 +02:00 committed by Pedro Igor
parent 7c2341f1ed
commit 4ad1687f2b
34 changed files with 1521 additions and 713 deletions

View file

@ -19,28 +19,53 @@
package org.keycloak.userprofile;
import org.keycloak.userprofile.AttributeContext;
import org.keycloak.userprofile.validation.Validator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.Validator;
import org.keycloak.validate.ValidatorConfig;
import org.keycloak.validate.Validators;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
* @author Vlastimil Elias <velias@redhat.com>
*/
public final class AttributeValidatorMetadata implements Validator {
public final class AttributeValidatorMetadata {
private final String message;
private final Validator validator;
private final String validatorId;
private final ValidatorConfig validatorConfig;
public AttributeValidatorMetadata(String message, Validator validator) {
this.message = message;
this.validator = validator;
public AttributeValidatorMetadata(String validatorId) {
this.validatorId = validatorId;
this.validatorConfig = ValidatorConfig.configFromMap(null);
}
public String getMessage() {
return message;
public AttributeValidatorMetadata(String validatorId, ValidatorConfig validatorConfig) {
this.validatorId = validatorId;
this.validatorConfig = validatorConfig;
}
@Override
public boolean validate(AttributeContext context) {
return validator.validate(context);
/**
* Getters so we can collect validation configurations and provide them to GUI for dynamic client side validations.
*
* @return the validatorId
*/
public String getValidatorId() {
return validatorId;
}
/**
* Run validation for given AttributeContext.
*
* @param context to validate
* @return context containing errors if any found
*/
public ValidationContext validate(AttributeContext context) {
Validator validator = Validators.validator(context.getSession(), validatorId);
if (validator == null) {
throw new RuntimeException("No validator with id " + validatorId + " found to validate UserProfile attribute " + context.getMetadata().getName() + " in realm " + context.getSession().getContext().getRealm().getName());
}
return validator.validate(context.getAttribute().getValue(), context.getMetadata().getName(), new UserProfileAttributeValidationContext(context), validatorConfig);
}
}

View file

@ -23,9 +23,10 @@ import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import org.keycloak.validate.ValidationError;
/**
* <p>This interface wraps the attributes associated with a user profile. Different operations are provided to access and
* manage these attributes.
@ -75,31 +76,15 @@ public interface Attributes {
boolean isReadOnly(String key);
/**
* <Validates the attribute with the given {@code name}.
* Validates the attribute with the given {@code name}.
*
* @param name the name of the attribute
* @param listeners the listeners for listening for errors
* @param listeners the listeners for listening for errors. <code>ValidationError.inputHint</code> contains name of the attribute in error.
*
* @return {@code true} if validation is successful. Otherwise, {@code false}. In case there is no attribute with the given {@code name},
* {@code false} is also returned but without triggering listeners
*/
boolean validate(String name, BiConsumer<Map.Entry<String, List<String>>, String>... listeners);
/**
* A simpler variant of {@link #validate(String, BiConsumer[])} for those only interested on error messages.
*
* @param name the name of the attribute
* @param listeners the listeners for listening for errors
* @return {@code true} if validation is successful. Otherwise, {@code false}. In case there is no attribute with the given {@code name},
* {@code false} is also returned but without triggering listeners
*/
default boolean validate(String name, Consumer<String>... listeners) {
return validate(name, (attribute, error) -> {
for (Consumer<String> consumer : listeners) {
consumer.accept(error);
}
});
}
boolean validate(String name, Consumer<ValidationError>... listeners);
/**
* Checks whether an attribute with the given {@code name} is defined.

View file

@ -26,13 +26,15 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
/**
* <p>The default implementation for {@link Attributes}. Should be reused as much as possible by the different implementations
@ -84,7 +86,7 @@ public final class DefaultAttributes extends HashMap<String, List<String>> imple
}
@Override
public boolean validate(String name, BiConsumer<Entry<String, List<String>>, String>... listeners) {
public boolean validate(String name, Consumer<ValidationError>... listeners) {
Entry<String, List<String>> attribute = createAttribute(name);
List<AttributeMetadata> metadatas = new ArrayList<>();
@ -93,23 +95,26 @@ 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<AttributeValidatorMetadata> failingValidators = Collections.emptyList();
List<ValidationContext> failingValidators = Collections.emptyList();
for (AttributeMetadata metadata : metadatas) {
for (AttributeValidatorMetadata validator : metadata.getValidators()) {
if (!validator.validate(createAttributeContext(attribute, metadata))) {
ValidationContext vc = validator.validate(createAttributeContext(attribute, metadata));
if (!vc.isValid()) {
if (failingValidators.equals(Collections.emptyList())) {
failingValidators = new ArrayList<>();
}
failingValidators.add(validator);
failingValidators.add(vc);
}
}
}
if (listeners != null) {
for (AttributeValidatorMetadata failingValidator : failingValidators) {
for (BiConsumer<Entry<String, List<String>>, String> consumer : listeners) {
consumer.accept(attribute, failingValidator.getMessage());
for (ValidationContext failingValidator : failingValidators) {
for (Consumer<ValidationError> consumer : listeners) {
for(ValidationError err: failingValidator.getErrors()) {
consumer.accept(err);
}
}
}
}
@ -296,7 +301,8 @@ public final class DefaultAttributes extends HashMap<String, List<String>> imple
SimpleImmutableEntry<String, List<String>> attribute = createAttribute(attributeName);
for (AttributeValidatorMetadata validator : readonlyMetadata.getValidators()) {
if (!validator.validate(createAttributeContext(attribute, readonlyMetadata))) {
ValidationContext vc = validator.validate(createAttributeContext(attribute, readonlyMetadata));
if (!vc.isValid()) {
return true;
}
}

View file

@ -58,7 +58,7 @@ public final class DefaultUserProfile implements UserProfile {
for (String attributeName : attributes.nameSet()) {
this.attributes.validate(attributeName,
(attribute, message) -> validationException.addError(new ValidationException.Error(attribute, message)));
(error) -> validationException.addError(error));
}
if (validationException.hasError()) {

View file

@ -0,0 +1,49 @@
/*
* 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;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.Validator;
/**
* Extension of the {@link ValidationContext} used when validators are called for {@link UserProfile} attribute validation. Allows
* easy access to UserProfile related bits, like {@link AttributeContext}
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public class UserProfileAttributeValidationContext extends ValidationContext {
/**
* Easy way to cast me from {@link ValidationContext} in {@link Validator} implementation
*/
public static UserProfileAttributeValidationContext from(ValidationContext vc) {
return (UserProfileAttributeValidationContext) vc;
}
private AttributeContext attributeContext;
public UserProfileAttributeValidationContext(AttributeContext attributeContext) {
super(attributeContext.getSession());
this.attributeContext = attributeContext;
}
public AttributeContext getAttributeContext() {
return attributeContext;
}
}

View file

@ -19,6 +19,7 @@
package org.keycloak.userprofile;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@ -26,73 +27,90 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.keycloak.validate.ValidationError;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public final class ValidationException extends RuntimeException {
private final Map<String, List<Error>> errors = new HashMap<>();
private final Map<String, List<Error>> errors = new HashMap<>();
public List<Error> getErrors() {
return errors.values().stream().reduce(new ArrayList<>(),
(l, r) -> {
l.addAll(r);
return l;
}, (l, r) -> l);
}
public boolean hasError(String... types) {
if (types.length == 0) {
return !errors.isEmpty();
}
for (String type : types) {
if (errors.containsKey(type)) {
return true;
}
}
return false;
}
/**
* Checks if there are validation errors related to the attribute with the given {@code name}.
*
* @param name
* @return
*/
public boolean isAttributeOnError(String... name) {
if (name.length == 0) {
return !errors.isEmpty();
}
List<String> names = Arrays.asList(name);
return errors.values().stream().flatMap(Collection::stream)
.anyMatch(error -> names.contains(error.attribute.getKey()));
}
void addError(Error error) {
List<Error> errors = this.errors.computeIfAbsent(error.getMessage(), (k) -> new ArrayList<>());
errors.add(error);
public List<Error> getErrors() {
return errors.values().stream().reduce(new ArrayList<>(), (l, r) -> {
l.addAll(r);
return l;
}, (l, r) -> l);
}
public static class Error {
public boolean hasError(String... types) {
if (types.length == 0) {
return !errors.isEmpty();
}
private final Map.Entry<String, List<String>> attribute;
private final String message;
for (String type : types) {
if (errors.containsKey(type)) {
return true;
}
}
return false;
}
public Error(Map.Entry<String, List<String>> attribute, String message) {
this.attribute = attribute;
this.message = message;
}
/**
* Checks if there are validation errors related to the attribute with the given {@code name}.
*
* @param name
* @return
*/
public boolean isAttributeOnError(String... name) {
if (name.length == 0) {
return !errors.isEmpty();
}
public String getAttribute() {
return attribute.getKey();
}
List<String> names = Arrays.asList(name);
//TODO: support parameters to messsages for formatting purposes. Message key and parameters.
public String getMessage() {
return message;
}
}
return errors.values().stream().flatMap(Collection::stream).anyMatch(error -> names.contains(error.getAttribute()));
}
void addError(ValidationError error) {
List<Error> errors = this.errors.computeIfAbsent(error.getMessage(), (k) -> new ArrayList<>());
errors.add(new Error(error));
}
@Override
public String toString() {
return "ValidationException [errors=" + errors + "]";
}
@Override
public String getMessage() {
return toString();
}
public static class Error implements Serializable {
private final ValidationError error;
public Error(ValidationError error) {
this.error = error;
}
public String getAttribute() {
return error.getInputHint();
}
public String getMessage() {
return error.getMessage();
}
public Object[] getMessageParameters() {
return error.getMessageParameters();
}
@Override
public String toString() {
return "Error [error=" + error + "]";
}
}
}

View file

@ -1,34 +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.userprofile.validation;
import org.keycloak.userprofile.AttributeContext;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public interface Validator {
/**
* @returns true if validation success, false if validation fails
*/
boolean validate(AttributeContext context);
}

View file

@ -18,15 +18,13 @@ package org.keycloak.validate;
import java.util.Collection;
import org.keycloak.validate.validators.NotBlankValidator;
import org.keycloak.validate.validators.NotEmptyValidator;
/**
* Base class for arbitrary value type validators. Functionality covered in this base class:
* <ul>
* <li>accepts supported type, collection of supported type.
* <li>null values are always treated as valid to support optional fields! Use other validators (like
* {@link NotBlankValidator} or {@link NotEmptyValidator} to force field as required.
* <li>behavior around null and empty values is controlled by {@link #IGNORE_EMPTY_VALUE} configuration option which is
* boolean. Error should be produced for them by default, but they should be ignored if that option is
* <code>true</code>. Logic must be implemented in {@link #skipValidation(Object, ValidatorConfig)}.
* </ul>
*
* @author Vlastimil Elias <velias@redhat.com>
@ -34,25 +32,32 @@ import org.keycloak.validate.validators.NotEmptyValidator;
*/
public abstract class AbstractSimpleValidator implements SimpleValidator {
/**
* Config option which allows to switch validator to ignore null, empty string and even blank string value - not to
* produce error for them. Used eg. in UserProfile where we have optional attributes and required concern is checked
* by separate validators.
*/
public static final String IGNORE_EMPTY_VALUE = "ignore.empty.value";
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
if (input instanceof Collection) {
@SuppressWarnings("unchecked")
Collection<Object> values = (Collection<Object>) input;
if (input != null) {
if (input instanceof Collection) {
@SuppressWarnings("unchecked")
Collection<Object> values = (Collection<Object>) input;
if (values.isEmpty()) {
return context;
}
for (Object value : values) {
validate(value, inputHint, context, config);
}
} else {
doValidate(input, inputHint, context, config);
for (Object value : values) {
validate(value, inputHint, context, config);
}
return context;
}
if (skipValidation(input, config)) {
return context;
}
doValidate(input, inputHint, context, config);
return context;
}
@ -64,6 +69,31 @@ public abstract class AbstractSimpleValidator implements SimpleValidator {
* @param inputHint
* @param context for the validation. Add errors into it.
* @param config of the validation if provided
*
* @see #skipValidation(Object, ValidatorConfig)
*/
protected abstract void doValidate(Object value, String inputHint, ValidationContext context, ValidatorConfig config);
/**
* Decide if validation of individual value should be skipped or not. It should be controlled by
* {@link #IGNORE_EMPTY_VALUE} configuration option, see {@link #isIgnoreEmptyValuesConfigured(ValidatorConfig)}.
*
* @param value currently validated we make decision for
* @param config to look for options in
* @return true if validation should be skipped for this value -
* {@link #doValidate(Object, String, ValidationContext, ValidatorConfig)} is not called in this case.
*
* @see #doValidate(Object, String, ValidationContext, ValidatorConfig)
*/
protected abstract boolean skipValidation(Object value, ValidatorConfig config);
/**
* Default implementation only looks for {@link #IGNORE_EMPTY_VALUE} configuration option.
*
* @param config to get option from
* @return
*/
protected boolean isIgnoreEmptyValuesConfigured(ValidatorConfig config) {
return config != null && config.getBooleanOrDefault(IGNORE_EMPTY_VALUE, false);
}
}

View file

@ -16,16 +16,14 @@
*/
package org.keycloak.validate;
import org.keycloak.validate.validators.NotBlankValidator;
import org.keycloak.utils.StringUtil;
/**
* Base class for String value format validators. Functionality covered in this base class:
* <ul>
* <li>accepts plain string and collections of strings as input
* <li>each item is validated for collections of strings, see
* {@link #validateFormat(String, String, ValidationContext, ValidatorConfig)}
* <li>null values are always treated as valid to support optional fields! Use other validators (like
* {@link NotBlankValidator} to force field as required.
* <li>each item is validated for collections of strings by {@link #doValidate(String, String, ValidationContext, ValidatorConfig)}
* <li>null and empty values behavior should follow config, see {@link AbstractSimpleValidator} javadoc.
* </ul>
*
* @author Vlastimil Elias <velias@redhat.com>
@ -43,4 +41,12 @@ public abstract class AbstractStringValidator extends AbstractSimpleValidator {
}
protected abstract void doValidate(String value, String inputHint, ValidationContext context, ValidatorConfig config);
@Override
protected boolean skipValidation(Object value, ValidatorConfig config) {
if (isIgnoreEmptyValuesConfigured(config) && (value == null || value instanceof String)) {
return value == null || StringUtil.isBlank(value.toString());
}
return false;
}
}

View file

@ -59,7 +59,7 @@ public class ValidatorConfig {
}
return new ValidatorConfig(map);
}
public boolean containsKey(String key) {
return config.containsKey(key);
}
@ -226,6 +226,26 @@ public class ValidatorConfig {
config.put(name, value);
return this;
}
/**
* Add all configurations from map
*/
public ValidatorConfigBuilder config(Map<String, Object> values) {
if(values!=null) {
config.putAll(values);
}
return this;
}
/**
* Add all configurations from other config
*/
public ValidatorConfigBuilder config(ValidatorConfig values) {
if(values != null && values.config != null) {
config.putAll(values.config);
}
return this;
}
}
@Override

View file

@ -20,6 +20,7 @@ import java.util.LinkedHashSet;
import java.util.Set;
import org.keycloak.models.KeycloakSession;
import org.keycloak.utils.StringUtil;
import org.keycloak.validate.AbstractSimpleValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
@ -51,17 +52,31 @@ public abstract class AbstractNumberValidator extends AbstractSimpleValidator {
this.defaultConfig = config;
}
@Override
protected boolean skipValidation(Object value, ValidatorConfig config) {
if (isIgnoreEmptyValuesConfigured(config) && (value == null || value instanceof String)) {
return value == null || StringUtil.isBlank(value.toString());
}
return false;
}
@Override
protected void doValidate(Object value, String inputHint, ValidationContext context, ValidatorConfig config) {
if (config == null || config.isEmpty()) {
config = defaultConfig;
}
Number number;
Number number = null;
try {
number = convert(value, config);
} catch (NumberFormatException ignore) {
if (value != null) {
try {
number = convert(value, config);
} catch (NumberFormatException ignore) {
// N/A
}
}
if (number == null) {
context.addError(new ValidationError(getId(), inputHint, MESSAGE_INVALID_NUMBER, value));
return;
}

View file

@ -21,16 +21,23 @@ import com.google.common.collect.ImmutableMap;
public class BuiltinValidatorsTest {
private static final ValidatorConfig valConfigIgnoreEmptyValues = ValidatorConfig.builder().config(AbstractSimpleValidator.IGNORE_EMPTY_VALUE, true).build();
@Test
public void validateLength() {
Validator validator = Validators.lengthValidator();
// null and empty values handling
Assert.assertTrue(validator.validate(null, "name", configFromMap(ImmutableMap.of(LengthValidator.KEY_MIN, 1))).isValid());
Assert.assertFalse(validator.validate(null, "name", configFromMap(ImmutableMap.of(LengthValidator.KEY_MIN, 1))).isValid());
Assert.assertFalse(validator.validate("", "name", configFromMap(ImmutableMap.of(LengthValidator.KEY_MIN, 1))).isValid());
Assert.assertFalse(validator.validate(" ", "name", configFromMap(ImmutableMap.of(LengthValidator.KEY_MIN, 1))).isValid());
Assert.assertTrue(validator.validate(" ", "name", configFromMap(ImmutableMap.of(LengthValidator.KEY_MAX, 10))).isValid());
// empty value ignoration configured
Assert.assertTrue(validator.validate(null, "name", valConfigIgnoreEmptyValues).isValid());
Assert.assertTrue(validator.validate("", "name", valConfigIgnoreEmptyValues).isValid());
Assert.assertTrue(validator.validate(" ", "name", valConfigIgnoreEmptyValues).isValid());
// min validation only
Assert.assertTrue(validator.validate("tester", "name", configFromMap(ImmutableMap.of(LengthValidator.KEY_MIN, 1))).isValid());
@ -97,8 +104,15 @@ public class BuiltinValidatorsTest {
Validator validator = Validators.emailValidator();
Assert.assertTrue(validator.validate(null, "email").isValid());
Assert.assertFalse(validator.validate(null, "email").isValid());
Assert.assertFalse(validator.validate("", "email").isValid());
// empty value ignoration configured
Assert.assertTrue(validator.validate(null, "emptyString", valConfigIgnoreEmptyValues).isValid());
Assert.assertTrue(validator.validate("", "emptyString", valConfigIgnoreEmptyValues).isValid());
Assert.assertTrue(validator.validate(" ", "blankString", valConfigIgnoreEmptyValues).isValid());
Assert.assertTrue(validator.validate("admin@example.org", "email").isValid());
Assert.assertTrue(validator.validate("admin+sds@example.org", "email").isValid());
@ -177,8 +191,14 @@ public class BuiltinValidatorsTest {
Validator validator = Validators.doubleValidator();
// null value and empty String
Assert.assertTrue(validator.validate(null, "null").isValid());
Assert.assertFalse(validator.validate(null, "null").isValid());
Assert.assertFalse(validator.validate("", "emptyString").isValid());
Assert.assertFalse(validator.validate(" ", "blankString").isValid());
// empty value ignoration configured
Assert.assertTrue(validator.validate(null, "emptyString", valConfigIgnoreEmptyValues).isValid());
Assert.assertTrue(validator.validate("", "emptyString", valConfigIgnoreEmptyValues).isValid());
Assert.assertTrue(validator.validate(" ", "blankString", valConfigIgnoreEmptyValues).isValid());
// simple values
Assert.assertTrue(validator.validate(10, "age").isValid());
@ -190,8 +210,9 @@ public class BuiltinValidatorsTest {
Assert.assertFalse(validator.validate(true, "true").isValid());
// collections
Assert.assertTrue(validator.validate(new ArrayList<>(), "age").isValid());
Assert.assertFalse(validator.validate(Arrays.asList(""), "age").isValid());
Assert.assertTrue(validator.validate(Arrays.asList(""), "age",valConfigIgnoreEmptyValues).isValid());
Assert.assertTrue(validator.validate(new ArrayList<>(), "age").isValid());
Assert.assertTrue(validator.validate(Arrays.asList(10), "age").isValid());
Assert.assertTrue(validator.validate(Arrays.asList(" 10 "), "age").isValid());
Assert.assertTrue(validator.validate(Arrays.asList("3.14"), "pi").isValid());
@ -200,6 +221,28 @@ public class BuiltinValidatorsTest {
Assert.assertFalse(validator.validate(Arrays.asList("a"), "notAnumber").isValid());
Assert.assertFalse(validator.validate(Arrays.asList("3.14", "a"), "notANumberPresent").isValid());
Assert.assertFalse(validator.validate(Arrays.asList("3.14", new Object()), "notANumberPresent").isValid());
// min only
Assert.assertTrue(validator.validate("10.1", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 1.4).build()).isValid());
Assert.assertFalse(validator.validate("10.1", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 100.5).build()).isValid());
// min behavior around empty values
Assert.assertFalse(validator.validate(null, "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 1.1).build()).isValid());
Assert.assertFalse(validator.validate("", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 1.1).build()).isValid());
Assert.assertFalse(validator.validate(" ", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 1.1).build()).isValid());
Assert.assertTrue(validator.validate(null, "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 1.1).config(valConfigIgnoreEmptyValues).build()).isValid());
Assert.assertTrue(validator.validate("", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 1.1).config(valConfigIgnoreEmptyValues).build()).isValid());
Assert.assertTrue(validator.validate(" ", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 1.1).config(valConfigIgnoreEmptyValues).build()).isValid());
// max only
Assert.assertFalse(validator.validate("10.5", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MAX, 1.1).build()).isValid());
Assert.assertTrue(validator.validate("10.5", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MAX, 100.1).build()).isValid());
// min and max
Assert.assertFalse(validator.validate("10.09", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 10.1).config(DoubleValidator.KEY_MAX, 100).build()).isValid());
Assert.assertTrue(validator.validate("10.1", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 10.1).config(DoubleValidator.KEY_MAX, 100).build()).isValid());
Assert.assertTrue(validator.validate("100.1", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 10.1).config(DoubleValidator.KEY_MAX, 100.1).build()).isValid());
Assert.assertFalse(validator.validate("100.2", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 10.1).config(DoubleValidator.KEY_MAX, 100.1).build()).isValid());
}
@ -244,9 +287,14 @@ public class BuiltinValidatorsTest {
Validator validator = Validators.integerValidator();
// null value and empty String
Assert.assertTrue(validator.validate(null, "null").isValid());
Assert.assertFalse(validator.validate(null, "null").isValid());
Assert.assertFalse(validator.validate("", "emptyString").isValid());
// empty value ignoration configured
Assert.assertTrue(validator.validate(null, "emptyString", valConfigIgnoreEmptyValues).isValid());
Assert.assertTrue(validator.validate("", "emptyString", valConfigIgnoreEmptyValues).isValid());
Assert.assertTrue(validator.validate(" ", "blankString", valConfigIgnoreEmptyValues).isValid());
// simple values
Assert.assertTrue(validator.validate(10, "age").isValid());
Assert.assertTrue(validator.validate("10", "age").isValid());
@ -259,6 +307,7 @@ public class BuiltinValidatorsTest {
// collections
Assert.assertTrue(validator.validate(new ArrayList<>(), "age").isValid());
Assert.assertFalse(validator.validate(Arrays.asList(""), "age").isValid());
Assert.assertTrue(validator.validate(Arrays.asList(""), "age",valConfigIgnoreEmptyValues).isValid());
Assert.assertTrue(validator.validate(Arrays.asList(10), "age").isValid());
Assert.assertFalse(validator.validate(Arrays.asList(" 10 "), "age").isValid());
@ -271,7 +320,14 @@ public class BuiltinValidatorsTest {
// min only
Assert.assertTrue(validator.validate("10", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 1).build()).isValid());
Assert.assertFalse(validator.validate("10", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 100).build()).isValid());
// min behavior around empty values
Assert.assertFalse(validator.validate(null, "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 1).build()).isValid());
Assert.assertFalse(validator.validate("", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 1).build()).isValid());
Assert.assertFalse(validator.validate(" ", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 1).build()).isValid());
Assert.assertTrue(validator.validate(null, "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 1).config(valConfigIgnoreEmptyValues).build()).isValid());
Assert.assertTrue(validator.validate("", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 1).config(valConfigIgnoreEmptyValues).build()).isValid());
Assert.assertTrue(validator.validate(" ", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 1).config(valConfigIgnoreEmptyValues).build()).isValid());
// max only
Assert.assertFalse(validator.validate("10", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MAX, 1).build()).isValid());
Assert.assertTrue(validator.validate("10", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MAX, 100).build()).isValid());
@ -283,6 +339,7 @@ public class BuiltinValidatorsTest {
Assert.assertFalse(validator.validate("101", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 10).config(DoubleValidator.KEY_MAX, 100).build()).isValid());
Assert.assertTrue(validator.validate(Long.MIN_VALUE, "name").isValid());
Assert.assertTrue(validator.validate(Long.MAX_VALUE, "name").isValid());
}
@Test
@ -337,11 +394,15 @@ public class BuiltinValidatorsTest {
Assert.assertFalse(validator.validate("start___end", "value", config).isValid());
// null and empty values handling
// pattern not applied to null or empty string
Assert.assertTrue(validator.validate(null, "value", config).isValid());
Assert.assertFalse(validator.validate(null, "value", config).isValid());
Assert.assertFalse(validator.validate("", "value", config).isValid());
// pattern is applied to blank string
Assert.assertFalse(validator.validate(" ", "value", config).isValid());
// empty value ignoration configured
Assert.assertTrue(validator.validate(null, "value", valConfigIgnoreEmptyValues).isValid());
Assert.assertTrue(validator.validate("", "value", valConfigIgnoreEmptyValues).isValid());
Assert.assertTrue(validator.validate(" ", "value", valConfigIgnoreEmptyValues).isValid());
}
@Test

View file

@ -18,17 +18,10 @@
package org.keycloak.services.validation;
import org.keycloak.authentication.requiredactions.util.UpdateProfileContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.PasswordPolicy;
import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.policy.PasswordPolicyManagerProvider;
import org.keycloak.policy.PolicyError;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.services.messages.Messages;
import org.keycloak.userprofile.ValidationException;
import javax.ws.rs.core.MultivaluedMap;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
@ -45,8 +38,8 @@ public class Validation {
// 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 static void addError(List<FormMessage> errors, String field, String message){
errors.add(new FormMessage(field, message));
private static void addError(List<FormMessage> errors, String field, String message, Object... parameters){
errors.add(new FormMessage(field, message, parameters));
}
/**
@ -88,7 +81,7 @@ public class Validation {
public static List<FormMessage> getFormErrorsFromValidation(List<ValidationException.Error> errors) {
List<FormMessage> messages = new ArrayList<>();
for (ValidationException.Error error : errors) {
addError(messages, error.getAttribute(), error.getMessage());
addError(messages, error.getAttribute(), error.getMessage(), error.getMessageParameters());
}
return messages;

View file

@ -20,12 +20,13 @@
package org.keycloak.userprofile.legacy;
import static org.keycloak.userprofile.DefaultAttributes.READ_ONLY_ATTRIBUTE_KEY;
import static org.keycloak.userprofile.UserProfileContext.*;
import static org.keycloak.userprofile.UserProfileContext.ACCOUNT;
import static org.keycloak.userprofile.UserProfileContext.ACCOUNT_OLD;
import static org.keycloak.userprofile.UserProfileContext.IDP_REVIEW;
import static org.keycloak.userprofile.UserProfileContext.REGISTRATION_PROFILE;
import static org.keycloak.userprofile.UserProfileContext.REGISTRATION_USER_CREATION;
import static org.keycloak.userprofile.UserProfileContext.UPDATE_PROFILE;
import static org.keycloak.userprofile.UserProfileContext.USER_API;
import java.util.ArrayList;
import java.util.Arrays;
@ -36,15 +37,12 @@ import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.common.util.ObjectUtil;
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.services.validation.Validation;
import org.keycloak.userprofile.AttributeValidatorMetadata;
import org.keycloak.userprofile.Attributes;
import org.keycloak.userprofile.DefaultAttributes;
import org.keycloak.userprofile.DefaultUserProfile;
@ -53,8 +51,19 @@ import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.UserProfileMetadata;
import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.UserProfileProviderFactory;
import org.keycloak.userprofile.AttributeValidatorMetadata;
import org.keycloak.userprofile.validation.Validator;
import org.keycloak.userprofile.validator.BlankAttributeValidator;
import org.keycloak.userprofile.validator.BrokeringFederatedUsernameHasValueValidator;
import org.keycloak.userprofile.validator.DuplicateEmailValidator;
import org.keycloak.userprofile.validator.DuplicateUsernameValidator;
import org.keycloak.userprofile.validator.EmailExistsAsUsernameValidator;
import org.keycloak.userprofile.validator.ReadOnlyAttributeUnchangedValidator;
import org.keycloak.userprofile.validator.RegistrationEmailAsUsernameEmailValueValidator;
import org.keycloak.userprofile.validator.RegistrationEmailAsUsernameUsernameValueValidator;
import org.keycloak.userprofile.validator.RegistrationUsernameExistsValidator;
import org.keycloak.userprofile.validator.UsernameHasValueValidator;
import org.keycloak.userprofile.validator.UsernameMutationValidator;
import org.keycloak.validate.ValidatorConfig;
import org.keycloak.validate.validators.EmailValidator;
/**
* <p>A base class for {@link UserProfileProvider} implementations providing the main hooks for customizations.
@ -63,8 +72,6 @@ import org.keycloak.userprofile.validation.Validator;
*/
public abstract class AbstractUserProfileProvider<U extends UserProfileProvider> implements UserProfileProvider, UserProfileProviderFactory<U> {
private static final Logger logger = Logger.getLogger(DefaultAttributes.class);
public static Pattern getRegexPatternString(String[] builtinReadOnlyAttributes) {
if (builtinReadOnlyAttributes != null) {
List<String> readOnlyAttributes = new ArrayList<>(Arrays.asList(builtinReadOnlyAttributes));
@ -82,55 +89,10 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
return null;
}
public static Validator isReadOnlyAttributeUnchanged(Pattern pattern) {
return (context) -> {
Map.Entry<String, List<String>> attribute = context.getAttribute();
String key = attribute.getKey();
if (!pattern.matcher(key).find()) {
return true;
}
List<String> values = attribute.getValue();
if (values == null) {
return true;
}
UserModel user = context.getUser();
List<String> existingAttrValues = user == null ? null : user.getAttribute(key);
String existingValue = null;
if (existingAttrValues != null && !existingAttrValues.isEmpty()) {
existingValue = existingAttrValues.get(0);
}
if (values.isEmpty() && existingValue != null) {
return false;
}
String value = null;
if (!values.isEmpty()) {
value = values.get(0);
}
boolean result = ObjectUtil.isEqualOrBothNull(value, existingValue);
if (!result) {
logger.warnf("Attempt to edit denied attribute '%s' of user '%s'", pattern, user == null ? "new user" : user.getFirstAttribute(UserModel.USERNAME));
}
return result;
};
}
/**
* There are the declarations for creating the built-in validations for read-only attributes. Regardless of the context where
* user profiles are used. They are related to internal attributes with hard conditions on them in terms of management.
*/
private static String UPDATE_READ_ONLY_ATTRIBUTES_REJECTED = "updateReadOnlyAttributesRejectedMessage";
private static String[] DEFAULT_READ_ONLY_ATTRIBUTES = { "KERBEROS_PRINCIPAL", "LDAP_ID", "LDAP_ENTRY_DN", "CREATED_TIMESTAMP", "createTimestamp", "modifyTimestamp", "userCertificate", "saml.persistent.name.id.for.*", "ENABLED", "EMAIL_VERIFIED", "disabledReason" };
private static String[] DEFAULT_ADMIN_READ_ONLY_ATTRIBUTES = { "KERBEROS_PRINCIPAL", "LDAP_ID", "LDAP_ENTRY_DN", "CREATED_TIMESTAMP", "createTimestamp", "modifyTimestamp" };
private static Pattern readOnlyAttributesPattern = getRegexPatternString(DEFAULT_READ_ONLY_ATTRIBUTES);
@ -175,7 +137,7 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
AttributeValidatorMetadata readOnlyValidator = null;
if (pattern != null) {
readOnlyValidator = Validators.create(Messages.UPDATE_READ_ONLY_ATTRIBUTES_REJECTED, isReadOnlyAttributeUnchanged(pattern));
readOnlyValidator = createReadOnlyAttributeUnchangedValidator(pattern);
}
addContextualProfileMetadata(configureUserProfile(createBrokeringProfile(readOnlyValidator)));
@ -186,6 +148,12 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
addContextualProfileMetadata(configureUserProfile(createRegistrationUserCreationProfile()));
addContextualProfileMetadata(configureUserProfile(createUserResourceValidation(config)));
}
private AttributeValidatorMetadata createReadOnlyAttributeUnchangedValidator(Pattern pattern) {
return new AttributeValidatorMetadata(ReadOnlyAttributeUnchangedValidator.ID,
ValidatorConfig.builder().config(ReadOnlyAttributeUnchangedValidator.CFG_PATTERN, pattern)
.build());
}
@Override
public void postInit(KeycloakSessionFactory factory) {
@ -279,56 +247,11 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
private UserProfileMetadata createRegistrationUserCreationProfile() {
UserProfileMetadata metadata = new UserProfileMetadata(REGISTRATION_USER_CREATION);
metadata.addAttribute(UserModel.USERNAME, Validators.create(Messages.MISSING_USERNAME, (context) -> {
RealmModel realm = context.getSession().getContext().getRealm();
metadata.addAttribute(UserModel.USERNAME, new AttributeValidatorMetadata(RegistrationEmailAsUsernameUsernameValueValidator.ID), new AttributeValidatorMetadata(RegistrationUsernameExistsValidator.ID));
if (!realm.isRegistrationEmailAsUsername()) {
return true;
}
metadata.addAttribute(UserModel.EMAIL, new AttributeValidatorMetadata(RegistrationEmailAsUsernameEmailValueValidator.ID));
return Validators.isBlank().validate(context);
}), Validators.create(Messages.USERNAME_EXISTS,
(context) -> {
KeycloakSession session = context.getSession();
RealmModel realm = session.getContext().getRealm();
if (realm.isRegistrationEmailAsUsername()) {
return true;
}
Map.Entry<String, List<String>> attribute = context.getAttribute();
List<String> values = attribute.getValue();
if (values.isEmpty()) {
return true;
}
String value = values.get(0);
UserModel existing = session.users().getUserByUsername(realm, value);
return existing == null;
}));
metadata.addAttribute(UserModel.EMAIL, Validators.create(Messages.INVALID_EMAIL, (context) -> {
RealmModel realm = context.getSession().getContext().getRealm();
if (!realm.isRegistrationEmailAsUsername()) {
return true;
}
Map.Entry<String, List<String>> attribute = context.getAttribute();
List<String> values = attribute.getValue();
if (values.isEmpty()) {
return true;
}
String value = values.get(0);
return Validation.isBlank(value) || Validation.isEmailValid(value);
}));
metadata.addAttribute(READ_ONLY_ATTRIBUTE_KEY, new AttributeValidatorMetadata(UPDATE_READ_ONLY_ATTRIBUTES_REJECTED, isReadOnlyAttributeUnchanged(readOnlyAttributesPattern)));
metadata.addAttribute(READ_ONLY_ATTRIBUTE_KEY, createReadOnlyAttributeUnchangedValidator(readOnlyAttributesPattern));
return metadata;
}
@ -336,23 +259,23 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
private UserProfileMetadata createDefaultProfile(UserProfileContext context, AttributeValidatorMetadata readOnlyValidator) {
UserProfileMetadata metadata = new UserProfileMetadata(context);
metadata.addAttribute(UserModel.USERNAME, Validators.create(Messages.MISSING_USERNAME, Validators.checkUsernameExists()),
Validators.create(Messages.USERNAME_EXISTS, Validators.userNameExists()),
Validators.create(Messages.READ_ONLY_USERNAME, Validators.isUserMutable()));
metadata.addAttribute(UserModel.USERNAME, new AttributeValidatorMetadata(UsernameHasValueValidator.ID),
new AttributeValidatorMetadata(DuplicateUsernameValidator.ID),
new AttributeValidatorMetadata(UsernameMutationValidator.ID));
metadata.addAttribute(UserModel.FIRST_NAME, Validators.create(Messages.MISSING_FIRST_NAME, Validators.isBlank()));
metadata.addAttribute(UserModel.FIRST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID,
BlankAttributeValidator.createConfig(Messages.MISSING_FIRST_NAME)));
metadata.addAttribute(UserModel.LAST_NAME, Validators.create(Messages.MISSING_LAST_NAME, Validators.isBlank()));
metadata.addAttribute(UserModel.LAST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_LAST_NAME)));
metadata.addAttribute(UserModel.EMAIL, Validators.create(Messages.MISSING_EMAIL, Validators.isBlank()),
Validators.create(Messages.INVALID_EMAIL, Validators.isEmailValid()),
Validators.create(Messages.EMAIL_EXISTS, Validators.isEmailDuplicated()),
Validators.create(Messages.USERNAME_EXISTS, Validators.doesEmailExistAsUsername()));
metadata.addAttribute(UserModel.EMAIL, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_EMAIL)),
new AttributeValidatorMetadata(EmailValidator.ID, ValidatorConfig.builder().config(EmailValidator.IGNORE_EMPTY_VALUE, true).build()),
new AttributeValidatorMetadata(DuplicateEmailValidator.ID),
new AttributeValidatorMetadata(EmailExistsAsUsernameValidator.ID));
List<AttributeValidatorMetadata> readonlyValidators = new ArrayList<>();
readonlyValidators.add(new AttributeValidatorMetadata(UPDATE_READ_ONLY_ATTRIBUTES_REJECTED,
isReadOnlyAttributeUnchanged(readOnlyAttributesPattern)));
readonlyValidators.add(createReadOnlyAttributeUnchangedValidator(readOnlyAttributesPattern));
if (readOnlyValidator != null) {
readonlyValidators.add(readOnlyValidator);
@ -366,22 +289,18 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
private UserProfileMetadata createBrokeringProfile(AttributeValidatorMetadata readOnlyValidator) {
UserProfileMetadata metadata = new UserProfileMetadata(IDP_REVIEW);
metadata.addAttribute(UserModel.USERNAME, Validators
.create(Messages.MISSING_USERNAME, Validators.checkFederatedUsernameExists()));
metadata.addAttribute(UserModel.USERNAME, new AttributeValidatorMetadata(BrokeringFederatedUsernameHasValueValidator.ID));
metadata.addAttribute(UserModel.FIRST_NAME,
Validators.create(Messages.MISSING_FIRST_NAME, Validators.isBlank()));
metadata.addAttribute(UserModel.FIRST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_FIRST_NAME)));
metadata.addAttribute(UserModel.LAST_NAME,
Validators.create(Messages.MISSING_LAST_NAME, Validators.isBlank()));
metadata.addAttribute(UserModel.LAST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_LAST_NAME)));
metadata.addAttribute(UserModel.EMAIL, Validators.create(Messages.MISSING_EMAIL, Validators.isBlank()),
Validators.create(Messages.INVALID_EMAIL, Validators.isEmailValid()));
metadata.addAttribute(UserModel.EMAIL, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_EMAIL)),
new AttributeValidatorMetadata(EmailValidator.ID));
List<AttributeValidatorMetadata> readonlyValidators = new ArrayList<>();
readonlyValidators.add(new AttributeValidatorMetadata(UPDATE_READ_ONLY_ATTRIBUTES_REJECTED,
isReadOnlyAttributeUnchanged(readOnlyAttributesPattern)));
readonlyValidators.add(createReadOnlyAttributeUnchangedValidator(readOnlyAttributesPattern));
if (readOnlyValidator != null) {
readonlyValidators.add(readOnlyValidator);
@ -398,11 +317,10 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
List<AttributeValidatorMetadata> readonlyValidators = new ArrayList<>();
if (p != null) {
readonlyValidators.add(Validators.create(Messages.UPDATE_READ_ONLY_ATTRIBUTES_REJECTED, isReadOnlyAttributeUnchanged(p)));
readonlyValidators.add(createReadOnlyAttributeUnchangedValidator(p));
}
readonlyValidators.add(new AttributeValidatorMetadata(UPDATE_READ_ONLY_ATTRIBUTES_REJECTED,
isReadOnlyAttributeUnchanged(adminReadOnlyAttributesPattern)));
readonlyValidators.add(createReadOnlyAttributeUnchangedValidator(adminReadOnlyAttributesPattern));
metadata.addAttribute(READ_ONLY_ATTRIBUTE_KEY, readonlyValidators);

View file

@ -1,278 +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.userprofile.legacy;
import java.util.List;
import java.util.Map;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.services.validation.Validation;
import org.keycloak.userprofile.AttributeContext;
import org.keycloak.userprofile.AttributeMetadata;
import org.keycloak.userprofile.AttributeValidatorMetadata;
import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.validation.Validator;
/**
* Functions are supposed to return:
* - true if validation success
* - false if validation fails
*
* @author <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
public class Validators {
public static final AttributeValidatorMetadata create(String message, Validator validator) {
return new AttributeValidatorMetadata(message, validator);
}
public static final Validator isBlank() {
return (context) -> {
Map.Entry<String, List<String>> attribute = context.getAttribute();
List<String> values = attribute.getValue();
if (values.isEmpty()) {
return true;
}
String value = values.get(0);
return value == null || !Validation.isBlank(value);
};
}
public static final Validator isEmailValid() {
return (context) -> {
Map.Entry<String, List<String>> attribute = context.getAttribute();
List<String> values = attribute.getValue();
if (values.isEmpty()) {
return true;
}
String value = values.get(0);
return Validation.isBlank(value) || Validation.isEmailValid(value);
};
}
public static final Validator userNameExists() {
return (context) -> {
Map.Entry<String, List<String>> attribute = context.getAttribute();
List<String> values = attribute.getValue();
if (values.isEmpty()) {
return true;
}
String value = values.get(0);
if (Validation.isBlank(value)) return true;
KeycloakSession session = context.getSession();
UserModel existing = session.users().getUserByUsername(session.getContext().getRealm(), value);
UserModel user = context.getUser();
return !(user != null
&& !value.equals(user.getFirstAttribute(UserModel.USERNAME))
&& (existing != null && !existing.getId().equals(user.getId())));
};
}
public static final Validator isUserMutable() {
return (context) -> {
Map.Entry<String, List<String>> attribute = context.getAttribute();
List<String> values = attribute.getValue();
if (values.isEmpty()) {
return true;
}
String value = values.get(0);
if (Validation.isBlank(value)) return true;
UserModel user = context.getUser();
RealmModel realm = context.getSession().getContext().getRealm();
return !(!realm.isEditUsernameAllowed()
&& user != null
&& !value.equals(user.getFirstAttribute(UserModel.USERNAME))
);
};
}
public static final Validator checkFederatedUsernameExists() {
return (context) -> {
Map.Entry<String, List<String>> attribute = context.getAttribute();
List<String> values = attribute.getValue();
String value = null;
if (!values.isEmpty()) {
value = values.get(0);
}
RealmModel realm = context.getSession().getContext().getRealm();
return !(!realm.isRegistrationEmailAsUsername() && Validation.isBlank(value));
};
}
public static final Validator checkUsernameExists() {
return (context) -> {
Map.Entry<String, List<String>> attribute = context.getAttribute();
List<String> values = attribute.getValue();
String value = null;
if (!values.isEmpty()) {
value = values.get(0);
}
return !Validation.isBlank(value);
};
}
public static final Validator doesEmailExistAsUsername() {
return (context) -> {
Map.Entry<String, List<String>> attribute = context.getAttribute();
List<String> values = attribute.getValue();
if (values.isEmpty()) {
return true;
}
String value = values.get(0);
if (Validation.isBlank(value)) return true;
KeycloakSession session = context.getSession();
RealmModel realm = session.getContext().getRealm();
UserModel user = context.getUser();
if (!realm.isDuplicateEmailsAllowed()) {
UserModel userByEmail = session.users().getUserByEmail(realm, value);
return !(realm.isRegistrationEmailAsUsername() && userByEmail != null && user != null && !userByEmail.getId().equals(user.getId()));
}
return true;
};
}
public static final Validator isEmailDuplicated() {
return (context) -> {
Map.Entry<String, List<String>> attribute = context.getAttribute();
List<String> values = attribute.getValue();
if (values.isEmpty()) {
return true;
}
String value = values.get(0);
if (Validation.isBlank(value)) return true;
KeycloakSession session = context.getSession();
RealmModel realm = session.getContext().getRealm();
if (!realm.isDuplicateEmailsAllowed()) {
UserModel userByEmail = session.users().getUserByEmail(realm, value);
UserModel user = context.getUser();
// check for duplicated email
return !(userByEmail != null && (user == null || !userByEmail.getId().equals(user.getId())));
}
return true;
};
}
public static final Validator doesEmailExist(KeycloakSession session) {
return (context) -> {
if (UserProfileContext.REGISTRATION_USER_CREATION.equals(context.getContext())) {
RealmModel realm = context.getSession().getContext().getRealm();
if (!realm.isRegistrationEmailAsUsername()) {
return true;
}
}
Map.Entry<String, List<String>> attribute = context.getAttribute();
List<String> values = attribute.getValue();
String value = values.get(0);
return !(value != null
&& !session.getContext().getRealm().isDuplicateEmailsAllowed()
&& session.users().getUserByEmail(session.getContext().getRealm(), value) != null);
};
}
/**
* Validate String length based on the configuration if string is not blank.
*
* @param config can contain "max" and "min" keys with integer values
* @return true if string is blank or conforms min and max configurations
*/
public static final Validator length(final Map<String, Object> config) {
return (context) -> {
Map.Entry<String, List<String>> attribute = context.getAttribute();
List<String> values = attribute.getValue();
if (values == null || values.isEmpty()) {
return true;
}
String value = values.get(0);
if (Validation.isBlank(value))
return true;
if (config.containsKey("min") && value.length() < (Integer) config.get("min")) {
return false;
}
if (config.containsKey("max") && value.length() > (Integer) config.get("max")) {
return false;
}
return true;
};
}
/**
* Validator for "required" validation based on evaluation of the {@link AttributeMetadata#isRequired(AttributeContext)}.
*
*/
public static final Validator requiredByAttributeMetadata() {
return (context) -> {
if(!context.getMetadata().isRequired(context)) {
return true;
}
Map.Entry<String, List<String>> attribute = context.getAttribute();
List<String> values = attribute.getValue();
if (values == null || values.isEmpty()) {
return false;
}
String value = values.get(0);
return !Validation.isBlank(value);
};
}
}

View file

@ -0,0 +1,72 @@
/*
* 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 org.keycloak.services.validation.Validation;
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;
/**
* Validator to check that User Profile attribute value is not blank (nor null) if the attribute is required based on
* AttributeMetadata predicate. Expects List of Strings as input.
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public class AttributeRequiredByMetadataValidator implements SimpleValidator {
public static final String ERROR_USER_ATTRIBUTE_REQUIRED = "error-user-attribute-required";
public static final String ID = "up-attribute-required-by-metadata-value";
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
AttributeContext attContext = UserProfileAttributeValidationContext.from(context).getAttributeContext();
if (!attContext.getMetadata().isRequired(attContext)) {
return context;
}
@SuppressWarnings("unchecked")
List<String> values = (List<String>) input;
if (values == null || values.isEmpty()) {
context.addError(new ValidationError(ID, inputHint, ERROR_USER_ATTRIBUTE_REQUIRED));
} else {
for (String value : values) {
if (value == null || Validation.isBlank(value)) {
context.addError(new ValidationError(ID, inputHint, ERROR_USER_ATTRIBUTE_REQUIRED));
return context;
}
}
}
return context;
}
}

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.userprofile.validator;
import java.util.List;
import org.keycloak.services.validation.Validation;
import org.keycloak.validate.SimpleValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidatorConfig;
/**
* Validator to check that User Profile attribute value is not blank (null value is OK!). Expects List of Strings as
* input.
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public class BlankAttributeValidator implements SimpleValidator {
public static final String ID = "up-blank-attribute-value";
public static final String CFG_ERROR_MESSAGE = "error-message";
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
@SuppressWarnings("unchecked")
List<String> values = (List<String>) input;
if (values.isEmpty()) {
return context;
}
String value = values.get(0);
if (value != null && Validation.isBlank(value)) {
context.addError(new ValidationError(ID, inputHint, config.getStringOrDefault(CFG_ERROR_MESSAGE, AttributeRequiredByMetadataValidator.ERROR_USER_ATTRIBUTE_REQUIRED)));
}
return context;
}
/**
* Create config for this validator to get customized error message
*
* @param errorMessage to be used if validation fails
* @return config
*/
public static ValidatorConfig createConfig(String errorMessage) {
if (errorMessage != null) {
return ValidatorConfig.builder().config(CFG_ERROR_MESSAGE, errorMessage).build();
}
return ValidatorConfig.EMPTY;
}
}

View file

@ -0,0 +1,64 @@
/*
* 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 org.keycloak.models.RealmModel;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;
import org.keycloak.validate.SimpleValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidatorConfig;
/**
* Validator to check that User Profile username is provided during Brokerin/Federation. Expects List of Strings as
* input.
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public class BrokeringFederatedUsernameHasValueValidator implements SimpleValidator {
public static final String ID = "up-brokering-federated-username-has-value";
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
@SuppressWarnings("unchecked")
List<String> values = (List<String>) input;
String value = null;
if (!values.isEmpty()) {
value = values.get(0);
}
RealmModel realm = context.getSession().getContext().getRealm();
if (!realm.isRegistrationEmailAsUsername() && Validation.isBlank(value)) {
context.addError(new ValidationError(ID, inputHint, Messages.MISSING_USERNAME));
}
return context;
}
}

View file

@ -0,0 +1,77 @@
/*
* 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 org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;
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;
/**
* Validator to check User Profile email duplication conditions based on realm settings like isDuplicateEmailsAllowed.
* Expects List of Strings as input.
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public class DuplicateEmailValidator implements SimpleValidator {
public static final String ID = "up-duplicate-email";
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
@SuppressWarnings("unchecked")
List<String> values = (List<String>) input;
if (values == null || values.isEmpty()) {
return context;
}
String value = values.get(0);
if (Validation.isBlank(value))
return context;
KeycloakSession session = context.getSession();
RealmModel realm = session.getContext().getRealm();
if (!realm.isDuplicateEmailsAllowed()) {
UserModel userByEmail = session.users().getUserByEmail(realm, value);
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));
}
}
return context;
}
}

View file

@ -0,0 +1,72 @@
/*
* 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 org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;
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;
/**
* Validator to check that User Profile username already exists in database for another user in case of it's change, and
* fail in this case. Expects List of Strings as input.
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public class DuplicateUsernameValidator implements SimpleValidator {
public static final String ID = "up-duplicate-username";
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
@SuppressWarnings("unchecked")
List<String> values = (List<String>) input;
if (values.isEmpty()) {
return context;
}
String value = values.get(0);
if (Validation.isBlank(value))
return context;
KeycloakSession session = context.getSession();
UserModel existing = session.users().getUserByUsername(session.getContext().getRealm(), value);
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));
}
return context;
}
}

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.userprofile.validator;
import java.util.List;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;
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;
/**
* Validator to check User Profile email duplication conditions if isDuplicateEmailsAllowed is false but
* isRegistrationEmailAsUsername is true. Expects List of Strings as input.
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public class EmailExistsAsUsernameValidator implements SimpleValidator {
public static final String ID = "up-email-exists-as-username";
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
@SuppressWarnings("unchecked")
List<String> values = (List<String>) input;
if (values == null || values.isEmpty()) {
return context;
}
String value = values.get(0);
if (Validation.isBlank(value))
return context;
KeycloakSession session = context.getSession();
RealmModel realm = session.getContext().getRealm();
if (!realm.isDuplicateEmailsAllowed() && realm.isRegistrationEmailAsUsername()) {
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));
}
}
return context;
}
}

View file

@ -0,0 +1,104 @@
/*
* 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.Map;
import java.util.regex.Pattern;
import org.jboss.logging.Logger;
import org.keycloak.common.util.ObjectUtil;
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;
/**
* Validator to check that User Profile attribute value is not changed if attribute is read-only. Expects List of
* Strings as input.
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public class ReadOnlyAttributeUnchangedValidator implements SimpleValidator {
private static final Logger logger = Logger.getLogger(ReadOnlyAttributeUnchangedValidator.class);
public static final String ID = "up-readonly-attribute-unchanged";
public static final String CFG_PATTERN = "pattern";
public static String UPDATE_READ_ONLY_ATTRIBUTES_REJECTED_MSG = "updateReadOnlyAttributesRejectedMessage";
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
AttributeContext attributeContext = UserProfileAttributeValidationContext.from(context).getAttributeContext();
Map.Entry<String, List<String>> attribute = attributeContext.getAttribute();
String key = attribute.getKey();
Pattern pattern = (Pattern) config.get(CFG_PATTERN);
if (!pattern.matcher(key).find()) {
return context;
}
@SuppressWarnings("unchecked")
List<String> values = (List<String>) input;
if (values == null) {
return context;
}
UserModel user = attributeContext.getUser();
List<String> existingAttrValues = user == null ? null : user.getAttribute(key);
String existingValue = null;
if (existingAttrValues != null && !existingAttrValues.isEmpty()) {
existingValue = existingAttrValues.get(0);
}
if (values.isEmpty() && existingValue != null) {
context.addError(new ValidationError(ID, key, UPDATE_READ_ONLY_ATTRIBUTES_REJECTED_MSG));
return context;
}
String value = null;
if (!values.isEmpty()) {
value = values.get(0);
}
boolean unchanged = ObjectUtil.isEqualOrBothNull(value, existingValue);
if (!unchanged) {
logger.warnf("Attempt to edit denied attribute '%s' of user '%s'", pattern, user == null ? "new user" : user.getFirstAttribute(UserModel.USERNAME));
context.addError(new ValidationError(ID, key, UPDATE_READ_ONLY_ATTRIBUTES_REJECTED_MSG));
}
return context;
}
}

View file

@ -0,0 +1,70 @@
/*
* 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 org.keycloak.models.RealmModel;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;
import org.keycloak.validate.SimpleValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidatorConfig;
/**
* Validator to check User Profile email attribute value during Registration when "RegistrationEmailAsUsername()" is
* enabled. Expects List of Strings as input.
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public class RegistrationEmailAsUsernameEmailValueValidator implements SimpleValidator {
public static final String ID = "up-registration-email-as-username-email-value";
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
RealmModel realm = context.getSession().getContext().getRealm();
if (!realm.isRegistrationEmailAsUsername()) {
return context;
}
@SuppressWarnings("unchecked")
List<String> values = (List<String>) input;
if (values == null || values.isEmpty()) {
return context;
}
String value = values.get(0);
if (!(Validation.isBlank(value) || Validation.isEmailValid(value))) {
context.addError(new ValidationError(ID, inputHint, Messages.INVALID_EMAIL));
}
return context;
}
}

View file

@ -0,0 +1,70 @@
/*
* 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 org.keycloak.models.RealmModel;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;
import org.keycloak.validate.SimpleValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidatorConfig;
/**
* Validator to check User Profile username attribute value during Registration when "RegistrationEmailAsUsername()" is
* enabled. Expects List of Strings as input.
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public class RegistrationEmailAsUsernameUsernameValueValidator implements SimpleValidator {
public static final String ID = "up-registration-email-as-username-username-value";
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
RealmModel realm = context.getSession().getContext().getRealm();
if (!realm.isRegistrationEmailAsUsername()) {
return context;
}
@SuppressWarnings("unchecked")
List<String> values = (List<String>) input;
if (values == null || values.isEmpty()) {
return context;
}
String value = values.get(0);
if (value != null && Validation.isBlank(value)) {
context.addError(new ValidationError(ID, inputHint, Messages.MISSING_USERNAME));
}
return context;
}
}

View file

@ -0,0 +1,73 @@
/*
* 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 org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.services.messages.Messages;
import org.keycloak.validate.SimpleValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidatorConfig;
/**
* Validator to check User Profile username attribute uniqueness during registration (when
* "RegistrationEmailAsUsername()" is NOT enabled). Expects List of Strings as input.
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public class RegistrationUsernameExistsValidator implements SimpleValidator {
public static final String ID = "up-registration-username-exists";
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
KeycloakSession session = context.getSession();
RealmModel realm = session.getContext().getRealm();
if (realm.isRegistrationEmailAsUsername()) {
return context;
}
@SuppressWarnings("unchecked")
List<String> values = (List<String>) input;
if (values == null || values.isEmpty()) {
return context;
}
String value = values.get(0);
UserModel existing = session.users().getUserByUsername(realm, value);
if (existing != null) {
context.addError(new ValidationError(ID, inputHint, Messages.USERNAME_EXISTS));
}
return context;
}
}

View file

@ -0,0 +1,61 @@
/*
* 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 org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;
import org.keycloak.validate.SimpleValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidatorConfig;
/**
* Validator to check that User Profile username is provided. Expects List of Strings as input.
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public class UsernameHasValueValidator implements SimpleValidator {
public static final String ID = "up-username-has-value";
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
@SuppressWarnings("unchecked")
List<String> values = (List<String>) input;
String value = null;
if (values != null && !values.isEmpty()) {
value = values.get(0);
}
if (Validation.isBlank(value)) {
context.addError(new ValidationError(ID, inputHint, Messages.MISSING_USERNAME));
}
return context;
}
}

View file

@ -0,0 +1,71 @@
/*
* 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 org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;
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;
/**
* Validator to check User Profile username change and prevent it if not allowed in realm. Expects List of Strings as
* input.
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public class UsernameMutationValidator implements SimpleValidator {
public static final String ID = "up-username-mutation";
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
@SuppressWarnings("unchecked")
List<String> values = (List<String>) input;
if (values.isEmpty()) {
return context;
}
String value = values.get(0);
if (Validation.isBlank(value)) {
return context;
}
UserModel user = UserProfileAttributeValidationContext.from(context).getAttributeContext().getUser();
RealmModel realm = context.getSession().getContext().getRealm();
if (!realm.isEditUsernameAllowed() && user != null && !value.equals(user.getFirstAttribute(UserModel.USERNAME))) {
context.addError(new ValidationError(ID, inputHint, Messages.READ_ONLY_USERNAME));
}
return context;
}
}

View file

@ -0,0 +1,12 @@
org.keycloak.userprofile.validator.BlankAttributeValidator
org.keycloak.userprofile.validator.AttributeRequiredByMetadataValidator
org.keycloak.userprofile.validator.ReadOnlyAttributeUnchangedValidator
org.keycloak.userprofile.validator.DuplicateUsernameValidator
org.keycloak.userprofile.validator.UsernameHasValueValidator
org.keycloak.userprofile.validator.UsernameMutationValidator
org.keycloak.userprofile.validator.DuplicateEmailValidator
org.keycloak.userprofile.validator.EmailExistsAsUsernameValidator
org.keycloak.userprofile.validator.RegistrationEmailAsUsernameUsernameValueValidator
org.keycloak.userprofile.validator.RegistrationUsernameExistsValidator
org.keycloak.userprofile.validator.RegistrationEmailAsUsernameEmailValueValidator
org.keycloak.userprofile.validator.BrokeringFederatedUsernameHasValueValidator

View file

@ -54,17 +54,18 @@ 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.legacy.Validators;
import org.keycloak.userprofile.validator.AttributeRequiredByMetadataValidator;
import org.keycloak.validate.AbstractSimpleValidator;
import org.keycloak.validate.ValidatorConfig;
/**
* {@link UserProfileProvider} loading configuration from the changeable JSON
* file stored in component config. Parsed configuration is cached.
* {@link UserProfileProvider} loading configuration from the changeable JSON file stored in component config. Parsed
* configuration is cached.
*
* @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> {
public static final String ID = "declarative-userprofile-provider";
public static final String UP_PIECES_COUNT_COMPONENT_CONFIG_KEY = "config-pieces-count";
@ -78,8 +79,7 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
// for reflection
}
public DeclarativeUserProfileProvider(KeycloakSession session,
Map<UserProfileContext, UserProfileMetadata> metadataRegistry) {
public DeclarativeUserProfileProvider(KeycloakSession session, Map<UserProfileContext, UserProfileMetadata> metadataRegistry) {
super(session, metadataRegistry);
}
@ -89,8 +89,7 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
}
@Override
protected DeclarativeUserProfileProvider create(KeycloakSession session,
Map<UserProfileContext, UserProfileMetadata> metadataRegistry) {
protected DeclarativeUserProfileProvider create(KeycloakSession session, Map<UserProfileContext, UserProfileMetadata> metadataRegistry) {
return new DeclarativeUserProfileProvider(session, metadataRegistry);
}
@ -105,8 +104,7 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
model.setNote(PARSED_CONFIG_COMPONENT_KEY, metadataMap);
}
return metadataMap.computeIfAbsent(metadata.getContext(),
(context) -> decorateUserProfileForCache(metadata, model));
return metadataMap.computeIfAbsent(metadata.getContext(), (context) -> decorateUserProfileForCache(metadata, model));
}
@Override
@ -115,22 +113,19 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
}
@Override
public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model)
throws ComponentValidationException {
public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException {
String upConfigJson = getConfigJsonFromComponentModel(model);
if (!isBlank(upConfigJson)) {
try {
UPConfig upc = readConfig(new ByteArrayInputStream(upConfigJson.getBytes("UTF-8")));
List<String> errors = UPConfigUtils.validate(upc);
List<String> errors = UPConfigUtils.validate(session, upc);
if (!errors.isEmpty()) {
throw new ComponentValidationException(
"UserProfile configuration is invalid: " + errors.toString());
throw new ComponentValidationException("UserProfile configuration is invalid: " + errors.toString());
}
} catch (IOException e) {
throw new ComponentValidationException(
"UserProfile configuration is invalid due to JSON parsing error: " + e.getMessage(), e);
throw new ComponentValidationException("UserProfile configuration is invalid due to JSON parsing error: " + e.getMessage(), e);
}
}
@ -177,7 +172,7 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
@Override
public void postInit(KeycloakSessionFactory factory) {
//TODO: We should avoid blocking operations during startup. Need to review this.
// TODO: We should avoid blocking operations during startup. Need to review this.
try (InputStream is = getClass().getResourceAsStream(SYSTEM_DEFAULT_CONFIG_RESOURCE)) {
defaultRawConfig = StreamUtil.readString(is, Charset.defaultCharset());
} catch (IOException cause) {
@ -195,17 +190,15 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
}
/**
* Decorate basic metadata provided from {@link AbstractUserProfileProvider}
* based on 'per realm' configuration. This method is called for each
* {@link UserProfileContext} in each realm, and metadata are cached then and
* this method is called again only if configuration changes.
* Decorate basic metadata provided from {@link AbstractUserProfileProvider} based on 'per realm' configuration.
* This method is called for each {@link UserProfileContext} in each realm, and metadata are cached then and this
* method is called again only if configuration changes.
*
* @param metadata base to be decorated based on configuration loaded from
* component model
* @param model component model to get "per realm" configuration from
* @param metadata base to be decorated based on configuration loaded from component model
* @param model component model to get "per realm" configuration from
* @return decorated metadata
*/
private UserProfileMetadata decorateUserProfileForCache(UserProfileMetadata metadata, ComponentModel model) {
protected UserProfileMetadata decorateUserProfileForCache(UserProfileMetadata metadata, ComponentModel model) {
UserProfileContext context = metadata.getContext();
UPConfig parsedConfig = getParsedConfig(model);
@ -224,7 +217,7 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
if (validationsConfig != null) {
for (Map.Entry<String, Map<String, Object>> vc : validationsConfig.entrySet()) {
validators.add(createConfiguredValidator(attrConfig, vc.getKey(), vc.getValue()));
validators.add(createConfiguredValidator(vc.getKey(), vc.getValue()));
}
}
@ -238,8 +231,7 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
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()) {
} 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());
@ -265,7 +257,8 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
if (!validators.isEmpty()) {
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.
// attribute metadata doesn't exist so we have to add it. We keep it optional as Abstract base
// doesn't require it.
decoratedMetadata.addAttribute(attributeName, validators, readOnly).addAnnotations(annotations);
} else {
// only add configured validators and annotations if attribute metadata exist
@ -282,21 +275,27 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
}
/**
* Get parsed config file configured in model. Default one used if not
* configured.
* Get parsed config file configured in model. Default one used if not configured.
*
* @param model to take config from
* @return parsed configuration
*/
private UPConfig getParsedConfig(ComponentModel model) {
protected UPConfig getParsedConfig(ComponentModel model) {
String rawConfig = getConfigJsonFromComponentModel(model);
if (!isBlank(rawConfig)) {
try {
return readConfig(new ByteArrayInputStream(rawConfig.getBytes("UTF-8")));
UPConfig upc = readConfig(new ByteArrayInputStream(rawConfig.getBytes("UTF-8")));
//validate configuration to catch things like changed/removed validators etc, and warn early and clearly about this problem
List<String> errors = UPConfigUtils.validate(session, upc);
if (!errors.isEmpty()) {
throw new RuntimeException("UserProfile configuration for realm '" + session.getContext().getRealm().getName() + "' is invalid: " + errors.toString());
}
return upc;
} catch (IOException e) {
throw new RuntimeException("UserProfile config for realm " + session.getContext().getRealm().getName()
+ " is invalid:" + e.getMessage(), e);
throw new RuntimeException("UserProfile configuration for realm '" + session.getContext().getRealm().getName() + "' is invalid:" + e.getMessage(), e);
}
}
@ -304,16 +303,14 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
}
/**
* 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.
* 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!
* 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
* @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
@ -325,12 +322,8 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
}
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());
String requestedScopesString = session.getContext().getAuthenticationSession().getClientNote(OIDCLoginProtocol.SCOPE_PARAM);
return TokenManager.getRequestedClientScopes(requestedScopesString, session.getContext().getAuthenticationSession().getClient()).map((csm) -> csm.getName()).collect(Collectors.toSet());
}
/**
@ -341,36 +334,27 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
*/
private ComponentModel getComponentModelOrCreate(KeycloakSession session) {
RealmModel realm = session.getContext().getRealm();
return realm.getComponentsStream(realm.getId(), UserProfileProvider.class.getName()).findAny()
.orElseGet(() -> realm.addComponentModel(new DeclarativeUserProfileModel()));
return realm.getComponentsStream(realm.getId(), UserProfileProvider.class.getName()).findAny().orElseGet(() -> realm.addComponentModel(new DeclarativeUserProfileModel()));
}
/**
* Create validator for 'required' validation.
*
* @return validator
* @return validator metadata to run given validation
*/
private AttributeValidatorMetadata createRequiredValidator(UPAttribute attrConfig) {
String msg = "missing" + UPConfigUtils.capitalizeFirstLetter(attrConfig.getName()) + "Message";
return Validators.create(msg, Validators.requiredByAttributeMetadata());
protected AttributeValidatorMetadata createRequiredValidator(UPAttribute attrConfig) {
return new AttributeValidatorMetadata(AttributeRequiredByMetadataValidator.ID);
}
/**
* Create validator for validation configured in the user profile config.
*
* @param attrConfig to create validator for
* @return validator
* @param validator id to create validator for
* @param validatorConfig of the validator
* @return validator metadata to run given validation
*/
private AttributeValidatorMetadata createConfiguredValidator(UPAttribute attrConfig,
String validator, Map<String, Object> validatorConfig) {
// TODO UserProfile - integrate Validation SPI
if ("length".equals(validator))
return Validators.create("badLenght" + UPConfigUtils.capitalizeFirstLetter(attrConfig.getName()) + "Message",
Validators.length(validatorConfig));
else if ("emailFormat".equals(validator))
return Validators.create("invalidEmailMessage", Validators.isEmailValid());
else
throw new RuntimeException("Unsupported UserProfile validator " + validator);
protected AttributeValidatorMetadata createConfiguredValidator(String validator, Map<String, Object> validatorConfig) {
return new AttributeValidatorMetadata(validator, ValidatorConfig.builder().config(validatorConfig).config(AbstractSimpleValidator.IGNORE_EMPTY_VALUE, true).build());
}
private String getConfigJsonFromComponentModel(ComponentModel model) {

View file

@ -27,8 +27,12 @@ import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.keycloak.models.KeycloakSession;
import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.util.JsonSerialization;
import org.keycloak.validate.ValidationResult;
import org.keycloak.validate.ValidatorConfig;
import org.keycloak.validate.Validators;
/**
* Utility methods to work with User Profile Configurations
@ -71,15 +75,16 @@ public class UPConfigUtils {
* <li>validator (from Validator SPI) exists for validation and it's config is correct
* </ul>
*
* @param session to be used for Validator SPI integration
* @param config to validate
* @return list of errors, empty if no error found
*/
public static List<String> validate(UPConfig config) {
public static List<String> validate(KeycloakSession session, UPConfig config) {
List<String> errors = new ArrayList<>();
if (config.getAttributes() != null) {
Set<String> attNamesCache = new HashSet<>();
config.getAttributes().forEach((attribute) -> validate(attribute, errors, attNamesCache));
config.getAttributes().forEach((attribute) -> validate(session, attribute, errors, attNamesCache));
} else {
errors.add("UserProfile configuration without 'attributes' section is not allowed");
}
@ -90,10 +95,12 @@ public class UPConfigUtils {
/**
* Validate attribute configuration
*
* @param session to be used for Validator SPI integration
* @param attributeConfig config to be validated
* @param errors to add error message in if something is invalid
* @param attNamesCache cache of already existing attribute names so we can check uniqueness
*/
private static void validate(UPAttribute attributeConfig, List<String> errors, Set<String> attNamesCache) {
private static void validate(KeycloakSession session, UPAttribute attributeConfig, List<String> errors, Set<String> attNamesCache) {
String attributeName = attributeConfig.getName();
if (isBlank(attributeName)) {
errors.add("Attribute configuration without 'name' is not allowed");
@ -108,7 +115,7 @@ public class UPConfigUtils {
}
}
if (attributeConfig.getValidations() != null) {
attributeConfig.getValidations().forEach((validator, validatorConfig) -> validateValidationConfig(validator, validatorConfig, attributeName, errors));
attributeConfig.getValidations().forEach((validator, validatorConfig) -> validateValidationConfig(session, validator, validatorConfig, attributeName, errors));
}
if (attributeConfig.getPermissions() != null) {
if (attributeConfig.getPermissions().getView() != null) {
@ -150,23 +157,34 @@ public class UPConfigUtils {
}
/**
* Validate that validation configuration is correct
* Validate that validation configuration is correct.
*
* @param session to be used for Validator SPI integration
* @param validatorConfig config to be checked
* @param errors to add error message in if something is invalid
*/
private static void validateValidationConfig(String validator, Map<String, Object> validatorConfig, String attributeName, List<String> errors) {
private static void validateValidationConfig(KeycloakSession session, String validator, Map<String, Object> validatorConfig, String attributeName, List<String> errors) {
if (isBlank(validator)) {
errors.add("Validation without 'validator' is defined for attribute '" + attributeName + "'");
errors.add("Validation without validator id is defined for attribute '" + attributeName + "'");
} else {
// TODO UserProfile - Validation SPI integration - check that the validator exists using Validation SPI
// TODO UserProfile - Validation SPI integration - check that the validation configuration is correct for given validator using Validation SPI
if(session!=null) {
if(Validators.validator(session, validator) == null) {
errors.add("Validator '" + validator + "' defined for attribute '" + attributeName + "' doesn't exist");
} else {
ValidationResult result = Validators.validateConfig(session, validator, ValidatorConfig.configFromMap(validatorConfig));
if(!result.isValid()) {
final StringBuilder sb = new StringBuilder();
result.forEachError(err -> sb.append(err.toString()+", "));
errors.add("Validator '" + validator + "' defined for attribute '" + attributeName + "' has incorrect configuration: " + sb.toString());
}
}
}
}
}
/**
* Break string to substrings of given length
* Break string to substrings of given length.
*
* @param src to break
* @param partLength

View file

@ -49,6 +49,7 @@ 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>
@ -204,7 +205,7 @@ public class UserProfileConfigTest extends AbstractUserProfileTest {
validatorConfig.put("min", 4);
attribute.addValidation("length", validatorConfig);
attribute.addValidation(LengthValidator.ID, validatorConfig);
config.addAttribute(attribute);
@ -221,7 +222,7 @@ public class UserProfileConfigTest extends AbstractUserProfileTest {
fail("Should fail validation");
} catch (ValidationException ve) {
assertTrue(ve.isAttributeOnError(UserModel.USERNAME));
assertTrue(ve.hasError("badLenghtUsernameMessage"));
assertTrue(ve.hasError(LengthValidator.MESSAGE_INVALID_LENGTH));
}
attributes.put(UserModel.USERNAME, "user");
@ -256,13 +257,13 @@ public class UserProfileConfigTest extends AbstractUserProfileTest {
UPAttribute attribute = new UPAttribute();
attribute.setName(UserModel.FIRST_NAME);
Map<String, Object> validatorConfig = new HashMap<>();
validatorConfig.put("max", 4);
attribute.addValidation("length", validatorConfig);
validatorConfig.put(LengthValidator.KEY_MAX, 4);
attribute.addValidation(LengthValidator.ID, validatorConfig);
config.addAttribute(attribute);
attribute = new UPAttribute();
attribute.setName(UserModel.LAST_NAME);
attribute.addValidation("length", validatorConfig);
attribute.addValidation(LengthValidator.ID, validatorConfig);
config.addAttribute(attribute);
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
@ -301,11 +302,11 @@ public class UserProfileConfigTest extends AbstractUserProfileTest {
}
@Test
public void testCustomAttribute() {
getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testCustomAttribute);
public void testCustomAttribute_Required() {
getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testCustomAttribute_Required);
}
private static void testCustomAttribute(KeycloakSession session) throws IOException {
private static void testCustomAttribute_Required(KeycloakSession session) throws IOException {
configureSessionRealm(session);
DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session);
ComponentModel component = provider.getComponentModel();
@ -319,9 +320,9 @@ public class UserProfileConfigTest extends AbstractUserProfileTest {
Map<String, Object> validatorConfig = new HashMap<>();
validatorConfig.put("min", 4);
validatorConfig.put(LengthValidator.KEY_MIN, 4);
attribute.addValidation("length", validatorConfig);
attribute.addValidation(LengthValidator.ID, validatorConfig);
// make it ALWAYS required
UPAttributeRequired requirements = new UPAttributeRequired();
@ -359,8 +360,61 @@ public class UserProfileConfigTest extends AbstractUserProfileTest {
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() {

View file

@ -41,16 +41,12 @@ import java.util.function.Consumer;
import org.junit.After;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.models.ClientModel;
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.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.RootAuthenticationSessionModel;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.runonserver.RunOnServer;
import org.keycloak.testsuite.user.profile.config.UPAttribute;
import org.keycloak.testsuite.user.profile.config.UPAttributeRequired;
@ -63,6 +59,8 @@ import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.ValidationException;
import org.keycloak.util.JsonSerialization;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.validators.EmailValidator;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
@ -229,18 +227,28 @@ public class UserProfileTest extends AbstractUserProfileTest {
UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes);
List<String> errors = new ArrayList<>();
List<ValidationError> errors = new ArrayList<>();
assertFalse(profile.getAttributes().validate(UserModel.USERNAME, (Consumer<String>) errors::add));
assertTrue(errors.contains(Messages.MISSING_USERNAME));
assertFalse(profile.getAttributes().validate(UserModel.USERNAME, (Consumer<ValidationError>) errors::add));
assertTrue(containsErrorMessage(errors, Messages.MISSING_USERNAME));
errors.clear();
attributes.clear();
attributes.put(UserModel.EMAIL, "invalid");
profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes);
assertFalse(profile.getAttributes().validate(UserModel.EMAIL, (Consumer<String>) errors::add));
assertTrue(errors.contains(Messages.INVALID_EMAIL));
assertFalse(profile.getAttributes().validate(UserModel.EMAIL, (Consumer<ValidationError>) errors::add));
assertTrue(containsErrorMessage(errors, EmailValidator.MESSAGE_INVALID_EMAIL));
}
private static boolean containsErrorMessage(List<ValidationError> errors, String message){
for(ValidationError err : errors) {
if(err.getMessage().equals(message)) {
return true;
}
}
return false;
}
@Test
public void testValidateComplianceWithUserProfile() {

View file

@ -23,11 +23,16 @@ import java.io.IOException;
import java.io.InputStream;
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.models.KeycloakSession;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.runonserver.RunOnServer;
import com.fasterxml.jackson.databind.JsonMappingException;
@ -37,8 +42,12 @@ import com.fasterxml.jackson.databind.JsonMappingException;
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public class UPConfigParserTest {
public class UPConfigParserTest extends AbstractTestRealmKeycloakTest {
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
}
@Test
public void attributeNameIsValid() {
// few invalid cases
@ -111,12 +120,12 @@ public class UPConfigParserTest {
* @return valid config
* @throws IOException
*/
private UPConfig loadValidConfig() throws IOException {
private static UPConfig loadValidConfig() throws IOException {
return readConfig(getValidConfigFileIS());
}
private InputStream getValidConfigFileIS() {
return getClass().getResourceAsStream("test-OK.json");
private static InputStream getValidConfigFileIS() {
return UPConfigParserTest.class.getResourceAsStream("test-OK.json");
}
@Test(expected = JsonMappingException.class)
@ -136,57 +145,59 @@ public class UPConfigParserTest {
@Test
public void validateConfiguration_OK() throws IOException {
List<String> errors = validate(loadValidConfig());
List<String> errors = validate(null, loadValidConfig());
Assert.assertTrue(errors.isEmpty());
}
@Test
public void validateConfiguration_attributeNameErrors() throws IOException {
UPConfig config = loadValidConfig();
//we run this test without KeycloakSession so validator configs are not validated here
UPAttribute attConfig = config.getAttributes().get(1);
attConfig.setName(null);
List<String> errors = validate(config);
List<String> errors = validate(null, config);
Assert.assertEquals(1, errors.size());
attConfig.setName(" ");
errors = validate(config);
errors = validate(null, config);
Assert.assertEquals(1, errors.size());
// duplicate attribute name
attConfig.setName("firstName");
errors = validate(config);
errors = validate(null, config);
Assert.assertEquals(1, errors.size());
// attribute name format error - unallowed character
attConfig.setName("ema il");
errors = validate(config);
errors = validate(null, config);
Assert.assertEquals(1, errors.size());
}
@Test
public void validateConfiguration_attributePermissionsErrors() throws IOException {
UPConfig config = loadValidConfig();
//we run this test without KeycloakSession so validator configs are not validated here
UPAttribute attConfig = config.getAttributes().get(1);
// no permissions configures at all
attConfig.setPermissions(null);
List<String> errors = validate(config);
List<String> errors = validate(null, config);
Assert.assertEquals(0, errors.size());
// no permissions structure fields configured
UPAttributePermissions permsConfig = new UPAttributePermissions();
attConfig.setPermissions(permsConfig);
errors = validate(config);
errors = validate(null, config);
Assert.assertTrue(errors.isEmpty());
// valid if both are present, even empty
permsConfig.setEdit(Collections.emptyList());
permsConfig.setView(Collections.emptyList());
attConfig.setPermissions(permsConfig);
errors = validate(config);
errors = validate(null, config);
Assert.assertEquals(0, errors.size());
List<String> withInvRole = new ArrayList<>();
@ -194,30 +205,31 @@ public class UPConfigParserTest {
// invalid role used for view
permsConfig.setView(withInvRole);
errors = validate(config);
errors = validate(null, config);
Assert.assertEquals(1, errors.size());
// invalid role used for edit also
permsConfig.setEdit(withInvRole);
errors = validate(config);
errors = validate(null, config);
Assert.assertEquals(2, errors.size());
}
@Test
public void validateConfiguration_attributeRequirementsErrors() throws IOException {
UPConfig config = loadValidConfig();
//we run this test without KeycloakSession so validator configs are not validated here
UPAttribute attConfig = config.getAttributes().get(1);
// it is OK without requirements configures at all
attConfig.setRequired(null);
List<String> errors = validate(config);
List<String> errors = validate(null, config);
Assert.assertEquals(0, errors.size());
// it is OK with empty config as it means ALWAYS required
UPAttributeRequired reqConfig = new UPAttributeRequired();
attConfig.setRequired(reqConfig);
errors = validate(config);
errors = validate(null, config);
Assert.assertEquals(0, errors.size());
Assert.assertTrue(reqConfig.isAlways());
@ -226,25 +238,45 @@ public class UPConfigParserTest {
// invalid role used
reqConfig.setRoles(withInvRole);;
errors = validate(config);
errors = validate(null, config);
Assert.assertEquals(1, errors.size());
Assert.assertFalse(reqConfig.isAlways());
}
@Test
public void validateConfiguration_attributeValidationsErrors() throws IOException {
public void validateConfiguration_attributeValidationsErrors() {
getTestingClient().server().run((RunOnServer) UPConfigParserTest::validateConfiguration_attributeValidationsErrors);
}
private static void validateConfiguration_attributeValidationsErrors(KeycloakSession session) throws IOException {
UPConfig config = loadValidConfig();
Map<String, Map<String, Object>> validationConfig = config.getAttributes().get(1).getValidations();
//reset all validations not to affect our test as they may be invalid
for(UPAttribute att: config.getAttributes()) {
att.setValidations(null);
}
//add validation config for one attribute for testing purposes
Map<String, Map<String, Object>> validationConfig = new HashMap<>();
config.getAttributes().get(1).setValidations(validationConfig);
// empty validator name
validationConfig.put(" ",null);
List<String> errors = validate(config);
List<String> errors = validate(session, config);
Assert.assertEquals(1, errors.size());
// TODO Validation SPI integration - test validation of the validator existence and validator config
// validationConfig.setValidator("unknownValidator");
// errors = UPConfigUtils.validateConfiguration(config);
// Assert.assertEquals(1, errors.size());
// wrong configuration for "length" validator
validationConfig.clear();
Map<String, Object> vc = new HashMap<>();
vc.put("min", "aaa");
validationConfig.put("length", vc );
errors = validate(session, config);
Assert.assertEquals(1, errors.size());
}
}

View file

@ -218,7 +218,7 @@ 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 this field.
invalidPasswordExistingMessage=Invalid existing password.
invalidPasswordBlacklistedMessage=Invalid password: password is blacklisted.