KEYCLOAK-2045 Simple Validation SPI for UserProfile SPI (#8053)

* KEYCLOAK-2045 Simple Validation API

Co-authored-by: Thomas Darimont <thomas.darimont@googlemail.com>
This commit is contained in:
Vlastimil Eliáš 2021-05-19 18:57:34 +02:00 committed by GitHub
parent e609949264
commit 0913a22c30
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 2873 additions and 0 deletions

View file

@ -0,0 +1,69 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate;
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.
* </ul>
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public abstract class AbstractSimpleValidator implements SimpleValidator {
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
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);
}
}
return context;
}
/**
* Validate type, format, range of the value etc. Always use {@link ValidationContext#addError(ValidationError)} to
* report error to the user! Can be called multiple time for one validation if input is Collection.
*
* @param value to be validated, never null
* @param inputHint
* @param context for the validation. Add errors into it.
* @param config of the validation if provided
*/
protected abstract void doValidate(Object value, String inputHint, ValidationContext context, ValidatorConfig config);
}

View file

@ -0,0 +1,46 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate;
import org.keycloak.validate.validators.NotBlankValidator;
/**
* 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.
* </ul>
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public abstract class AbstractStringValidator extends AbstractSimpleValidator {
@Override
protected void doValidate(Object value, String inputHint, ValidationContext context, ValidatorConfig config) {
if (value instanceof String) {
doValidate(value.toString(), inputHint, context, config);
} else {
context.addError(new ValidationError(getId(), inputHint, ValidationError.MESSAGE_INVALID_VALUE, value));
}
}
protected abstract void doValidate(String value, String inputHint, ValidationContext context, ValidatorConfig config);
}

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.validate;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
/**
* Convenience interface to ease implementation of small {@link Validator} implementations.
*
* {@link SimpleValidator SimpleValidator's} should be implemented as singletons.
*/
public interface SimpleValidator extends Validator, ValidatorFactory {
@Override
default Validator create(KeycloakSession session) {
return this;
}
@Override
default void init(Config.Scope config) {
// NOOP
}
@Override
default void postInit(KeycloakSessionFactory factory) {
// NOOP
}
@Override
default void close() {
// NOOP
}
}

View file

@ -0,0 +1,133 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import org.keycloak.models.KeycloakSession;
/**
* Holds information about the validation state.
*/
public class ValidationContext {
/**
* Holds the {@link KeycloakSession} in which the validation is performed.
*/
private final KeycloakSession session;
/**
* Holds the {@link ValidationError} found during validation.
*/
private Set<ValidationError> errors;
/**
* Holds optional attributes that should be available to {@link Validator} implementations.
*/
private final Map<String, Object> attributes;
/**
* Creates a new {@link ValidationContext} without a {@link KeycloakSession}.
*/
public ValidationContext() {
this(null, null);
}
/**
* Creates a new {@link ValidationContext} with a {@link KeycloakSession}.
*
* @param session
*/
public ValidationContext(KeycloakSession session) {
// we deliberately use a LinkedHashSet here to retain the order of errors.
this(session, null);
}
/**
* Creates a new {@link ValidationContext}.
*
* @param session
* @param errors
*/
protected ValidationContext(KeycloakSession session, Set<ValidationError> errors) {
this.session = session;
this.errors = errors;
this.attributes = new HashMap<>();
}
/**
* Eases access to {@link Validator Validator's} for nested validation.
*
* @param validatorId
* @return
*/
public Validator validator(String validatorId) {
return Validators.validator(session, validatorId);
}
/**
* Adds an {@link ValidationError}.
*
* @param error
*/
public void addError(ValidationError error) {
if (errors == null)
errors = new LinkedHashSet<>();
errors.add(error);
}
/**
* Convenience method for checking the validation status of the current {@link ValidationContext}.
* <p>
* This is an alternative to {@code toResult().isValid()} for brief validations.
*
* @return
*/
public boolean isValid() {
return errors == null || errors.isEmpty();
}
public Map<String, Object> getAttributes() {
return attributes;
}
public KeycloakSession getSession() {
return session;
}
public Set<ValidationError> getErrors() {
return errors != null ? errors : Collections.emptySet();
}
/**
* Creates a {@link ValidationResult} based on the current errors;
*
* @return
*/
public ValidationResult toResult() {
return new ValidationResult(getErrors());
}
@Override
public String toString() {
return "ValidationContext{" + "valid=" + isValid() + ", errors=" + errors + ", attributes=" + attributes + '}';
}
}

View file

@ -0,0 +1,148 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Objects;
import java.util.function.BiFunction;
/**
* Denotes an error found during validation.
*/
public class ValidationError implements Serializable {
private static final long serialVersionUID = 4950708316675951914L;
/**
* A generic invalid value message.
*/
public static final String MESSAGE_INVALID_VALUE = "error-invalid-value";
/**
* Empty message parameters fly-weight.
*/
private static final Object[] EMPTY_PARAMETERS = {};
/**
* Holds the name of the validator that reported the {@link ValidationError}.
*/
private final String validatorId;
/**
* Holds an inputHint.
* <p>
* This could be a attribute name, a nested field path or a logical key.
*/
private final String inputHint;
/**
* Holds the message key for translation.
*/
private final String message;
/**
* Optional parameters for the message translation.
*/
private final Object[] messageParameters;
public ValidationError(String validatorId, String inputHint, String message) {
this(validatorId, inputHint, message, EMPTY_PARAMETERS);
}
public ValidationError(String validatorId, String inputHint, String message, Object... messageParameters) {
this.validatorId = validatorId;
this.inputHint = inputHint;
this.message = message;
this.messageParameters = messageParameters == null ? EMPTY_PARAMETERS : messageParameters.clone();
}
public String getValidatorId() {
return validatorId;
}
public String getInputHint() {
return inputHint;
}
public String getMessage() {
return message;
}
/**
* Returns the raw message parameters, e.g. the actual input that was given for validation.
*
* @return
* @see #getInputHintWithMessageParameters()
*/
public Object[] getMessageParameters() {
return messageParameters;
}
/**
* Formats the current {@link ValidationError} with the given formatter {@link java.util.function.Function}.
* <p>
* The formatter {@link java.util.function.Function} will be called with the {@link #message} and
* {@link #getInputHintWithMessageParameters()} to render the error message.
*
* @param formatter
* @return
*/
public String formatMessage(BiFunction<String, Object[], String> formatter) {
Objects.requireNonNull(formatter, "formatter must not be null");
return formatter.apply(message, getInputHintWithMessageParameters());
}
/**
* Returns an array where the first element is the {@link #inputHint} follwed by the {@link #messageParameters}.
*
* @return
*/
public Object[] getInputHintWithMessageParameters() {
// insert to current input hint into the message
Object[] args = new Object[messageParameters.length + 1];
args[0] = getInputHint();
System.arraycopy(messageParameters, 0, args, 1, messageParameters.length);
return args;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof ValidationError)) {
return false;
}
ValidationError that = (ValidationError) o;
return Objects.equals(validatorId, that.validatorId) && Objects.equals(inputHint, that.inputHint) && Objects.equals(message, that.message) && Arrays.equals(messageParameters, that.messageParameters);
}
@Override
public int hashCode() {
int result = Objects.hash(validatorId, inputHint, message);
result = 31 * result + Arrays.hashCode(messageParameters);
return result;
}
@Override
public String toString() {
return "ValidationError{" + "validatorId='" + validatorId + '\'' + ", inputHint='" + inputHint + '\'' + ", message='" + message + '\'' + ", messageParameters=" + Arrays.toString(messageParameters) + '}';
}
}

View file

@ -0,0 +1,123 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate;
import java.util.Collections;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* Denotes the result of a validation.
*/
public class ValidationResult {
/**
* An empty ValidationResult that's valid by default.
*/
public static final ValidationResult OK = new ValidationResult(Collections.emptySet());
/**
* Holds the {@link ValidationError ValidationError's} that occurred during validation.
*/
private final Set<ValidationError> errors;
/**
* Creates a new {@link ValidationResult} from the given errors.
* <p>
* The created {@link ValidationResult} is considered valid if the given {@code errors} are empty.
*
* @param errors
*/
public ValidationResult(Set<ValidationError> errors) {
this.errors = errors == null ? Collections.emptySet() : errors;
}
/**
* Convenience method that accepts a {@link Consumer<ValidationResult>} if the result is not valid.
*
* @param consumer
*/
public void ifNotValidAccept(Consumer<ValidationResult> consumer) {
if (!isValid()) {
consumer.accept(this);
}
}
/**
* Convenience method that accepts a {@link Consumer<ValidationError>}.
*
* @param consumer
*/
public void forEachError(Consumer<ValidationError> consumer) {
for (ValidationError error : getErrors()) {
consumer.accept(error);
}
}
public boolean isValid() {
return errors.isEmpty();
}
public Set<ValidationError> getErrors() {
return errors;
}
/**
* Checks if this {@link ValidationResult} contains {@link ValidationError ValidationError's} from the {@link Validator} with the given {@code id}.
*
* @param id
* @return
*/
public boolean hasErrorsForValidatorId(String id) {
return getErrors().stream().anyMatch(e -> e.getValidatorId().equals(id));
}
/**
* Returns a {@link Set} of {@link ValidationError ValidationError's} from the {@link Validator} with the given {@code id} if present, otherwise an empty {@link Set} is returned.
* <p>
*
* @param id
* @return
*/
public Set<ValidationError> getErrorsForValidatorId(String id) {
return getErrors().stream().filter(e -> e.getValidatorId().equals(id)).collect(Collectors.toSet());
}
/**
* Checks if this {@link ValidationResult} contains {@link ValidationError ValidationError's} with the given {@code inputHint}.
* <p>
* This can be used to test if there are {@link ValidationError ValidationError's} for a specified attribute or attribute path.
*
* @param inputHint
* @return
*/
public boolean hasErrorsForInputHint(String inputHint) {
return getErrors().stream().anyMatch(e -> e.getInputHint().equals(inputHint));
}
/**
* Returns a {@link Set} of {@link ValidationError ValidationError's} with the given {@code inputHint} if present, otherwise an empty {@link Set} is returned.
* <p>
*
* @param inputHint
* @return
*/
public Set<ValidationError> getErrorsForInputHint(String inputHint) {
return getErrors().stream().filter(e -> e.getInputHint().equals(inputHint)).collect(Collectors.toSet());
}
}

View file

@ -0,0 +1,114 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate;
import org.keycloak.provider.Provider;
import java.util.Map;
/**
* Validates given input in a {@link ValidationContext}.
* <p>
* Validations can be supported with an optional {@code inputHint}, which could denote a reference to a potentially
* nested attribute of an object to validate.
* <p>
* Validations can be configured with an optional {@code config} {@link Map}.
*/
public interface Validator extends Provider {
/**
* Validates the given {@code input}.
*
* @param input the value to validate
* @return the validation context with the outcome of the validation
*/
default ValidationContext validate(Object input) {
return validate(input, "input", new ValidationContext(), ValidatorConfig.EMPTY);
}
/**
* Validates the given {@code input} with an additional {@code config}.
*
* @param input the value to validate
* @param config parameterization for the current validation
* @return the validation context with the outcome of the validation
*/
default ValidationContext validate(Object input, ValidatorConfig config) {
return validate(input, "input", new ValidationContext(), config);
}
/**
* Validates the given {@code input}.
*
* @param input the value to validate
* @param context the validation context
* @return the validation context with the outcome of the validation
*/
default ValidationContext validate(Object input, ValidationContext context) {
return validate(input, "input", context, ValidatorConfig.EMPTY);
}
/**
* Validates the given {@code input} with an additional {@code inputHint}.
*
* @param input the value to validate
* @param inputHint an optional input hint to guide the validation
* @return the validation context with the outcome of the validation
*/
default ValidationContext validate(Object input, String inputHint) {
return validate(input, inputHint, new ValidationContext(), ValidatorConfig.EMPTY);
}
/**
* Validates the given {@code input} with an additional {@code inputHint}.
*
* @param input the value to validate
* @param inputHint an optional input hint to guide the validation
* @param config parameterization for the current validation
* @return the validation context with the outcome of the validation
*/
default ValidationContext validate(Object input, String inputHint, ValidatorConfig config) {
return validate(input, inputHint, new ValidationContext(), config);
}
/**
* Validates the given {@code input} with an additional {@code inputHint}.
*
* @param input the value to validate
* @param inputHint an optional input hint to guide the validation
* @param context the validation context
* @return the validation context with the outcome of the validation
*/
default ValidationContext validate(Object input, String inputHint, ValidationContext context) {
return validate(input, inputHint, context, ValidatorConfig.EMPTY);
}
/**
* Validates the given {@code input} with an additional {@code inputHint} and {@code config}.
*
* @param input the value to validate
* @param inputHint an optional input hint to guide the validation
* @param context the validation context
* @param config parameterization for the current validation
* @return the validation context with the outcome of the validation
*/
ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config);
default void close() {
// NOOP
}
}

View file

@ -0,0 +1,235 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* A typed wrapper around a {@link Map} based {@link Validator} configuration.
*/
public class ValidatorConfig {
/**
* An empty {@link ValidatorConfig}.
*/
public static final ValidatorConfig EMPTY = new ValidatorConfig(Collections.emptyMap());
/**
* Holds the backing map for the {@link Validator} config.
*/
private final Map<String, Object> config;
/**
* Creates a new {@link ValidatorConfig} from the given {@code map}.
*
* @param config
*/
public ValidatorConfig(Map<String, Object> config) {
this.config = config;
}
/**
* Static helper to create a {@link ValidatorConfig} from the given {@code map}.
*
* @param map
* @return
*/
public static ValidatorConfig configFromMap(Map<String, Object> map) {
if (map == null || map.isEmpty()) {
return EMPTY;
}
return new ValidatorConfig(map);
}
public boolean containsKey(String key) {
return config.containsKey(key);
}
public int size() {
return config.size();
}
public boolean isEmpty() {
return config.isEmpty();
}
public Object get(String key) {
return config.get(key);
}
public Object getOrDefault(String key, Object defaultValue) {
return config.getOrDefault(key, defaultValue);
}
public String getString(String key) {
return getStringOrDefault(key, null);
}
public String getStringOrDefault(String key, String defaultValue) {
Object value = config.get(key);
if (value instanceof String) {
return (String) value;
}
return defaultValue;
}
public Integer getInt(String key) {
return getIntOrDefault(key, null);
}
public Integer getIntOrDefault(String key, Integer defaultValue) {
Object value = config.get(key);
if (value instanceof Integer) {
return (Integer) value;
} else if (value instanceof Number) {
return ((Number) value).intValue();
} else if (value instanceof String) {
try {
return new Integer((String) value);
} catch (NumberFormatException e) {
return null;
}
}
return defaultValue;
}
public Long getLong(String key) {
return getLongOrDefault(key, null);
}
public Long getLongOrDefault(String key, Long defaultValue) {
Object value = config.get(key);
if (value instanceof Long) {
return (Long) value;
} else if (value instanceof Number) {
return ((Number) value).longValue();
} else if (value instanceof String) {
try {
return new Long((String) value);
} catch (NumberFormatException e) {
return null;
}
}
return defaultValue;
}
public Double getDouble(String key) {
return getDoubleOrDefault(key, null);
}
public Double getDoubleOrDefault(String key, Double defaultValue) {
Object value = config.get(key);
if (value instanceof Double) {
return (Double) value;
} else if (value instanceof Number) {
return ((Number) value).doubleValue();
} else if (value instanceof String) {
try {
return Double.parseDouble((String) value);
} catch (NumberFormatException e) {
return null;
}
}
return defaultValue;
}
public Boolean getBoolean(String key) {
return getBooleanOrDefault(key, null);
}
public Boolean getBooleanOrDefault(String key, Boolean defaultValue) {
Object value = config.get(key);
if (value instanceof Boolean) {
return (Boolean) value;
} else if (value instanceof String) {
return Boolean.parseBoolean((String) value);
}
return defaultValue;
}
public Set<String> getStringSet(String key) {
return getStringSetOrDefault(key, null);
}
public Set<String> getStringSetOrDefault(String key, Set<String> defaultValue) {
Object value = config.get(key);
if (value instanceof Set) {
return (Set<String>) value;
}
return defaultValue;
}
public List<String> getStringListOrDefault(String key) {
return getStringListOrDefault(key, null);
}
public List<String> getStringListOrDefault(String key, List<String> defaultValue) {
Object value = config.get(key);
if (value instanceof List) {
return (List<String>) value;
}
return defaultValue;
}
/**
* Get regex Pattern from the configuration. String can be used and it is compiled into Pattern.
*
* @param key to get
* @return Pattern or null
*/
public Pattern getPattern(String key) {
return getPatternOrDefault(key, null);
}
public Pattern getPatternOrDefault(String key, Pattern defaultValue) {
Object value = config.get(key);
if (value instanceof Pattern) {
return (Pattern) value;
} else if (value instanceof String) {
return Pattern.compile((String) value);
}
return defaultValue;
}
public static ValidatorConfigBuilder builder() {
return new ValidatorConfigBuilder();
}
public static class ValidatorConfigBuilder {
private Map<String, Object> config = new HashMap<>();
public ValidatorConfig build() {
return ValidatorConfig.configFromMap(this.config);
}
public ValidatorConfigBuilder config(String name, Object value) {
config.put(name, value);
return this;
}
}
@Override
public String toString() {
return "ValidatorConfig{" + "config=" + config + '}';
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.ProviderFactory;
/**
* A factory for custom {@link Validator} implementations plugged-in through this SPI.
*/
public interface ValidatorFactory extends ProviderFactory<Validator> {
/**
* Validates the given validation config.
* <p>
* Implementations can use the {@link KeycloakSession} to validate the given {@link ValidatorConfig}.
*
* @param session the {@link KeycloakSession}
* @param config the config to be validated
* @return the validation result
*/
default ValidationResult validateConfig(KeycloakSession session, ValidatorConfig config) {
return ValidationResult.OK;
}
/**
* This is called when the server shuts down.
*/
@Override
default void close() {
// NOOP
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
/**
* An {@link Spi} for custom {@link Validator} implementations.
*/
public class ValidatorSPI implements Spi {
@Override
public boolean isInternal() {
// this API is internal for now, but is intended to be public later.
return true;
}
@Override
public String getName() {
return "validator";
}
@Override
public Class<? extends Provider> getProviderClass() {
return Validator.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return ValidatorFactory.class;
}
}

View file

@ -0,0 +1,229 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.validate.validators.EmailValidator;
import org.keycloak.validate.validators.IntegerValidator;
import org.keycloak.validate.validators.LengthValidator;
import org.keycloak.validate.validators.NotBlankValidator;
import org.keycloak.validate.validators.NotEmptyValidator;
import org.keycloak.validate.validators.DoubleValidator;
import org.keycloak.validate.validators.PatternValidator;
import org.keycloak.validate.validators.UriValidator;
import org.keycloak.validate.validators.ValidatorConfigValidator;
/**
* Facade for Validation functions with support for {@link Validator} implementation lookup by id.
*/
public class Validators {
/**
* Holds a mapping of internal {@link SimpleValidator} to allow look-up via provider id.
*/
private static final Map<String, SimpleValidator> INTERNAL_VALIDATORS;
static {
List<SimpleValidator> list = Arrays.asList(
LengthValidator.INSTANCE,
NotEmptyValidator.INSTANCE,
UriValidator.INSTANCE,
EmailValidator.INSTANCE,
NotBlankValidator.INSTANCE,
PatternValidator.INSTANCE,
DoubleValidator.INSTANCE,
IntegerValidator.INSTANCE,
ValidatorConfigValidator.INSTANCE
);
INTERNAL_VALIDATORS = list.stream().collect(Collectors.toMap(SimpleValidator::getId, v -> v));
}
/**
* Holds the {@link KeycloakSession}.
*/
private final KeycloakSession session;
/**
* Creates a new {@link Validators} instance with the given {@link KeycloakSession}.
*
* @param session
*/
public Validators(KeycloakSession session) {
this.session = session;
}
/**
* Look-up for a built-in or registered {@link Validator} with the given provider {@code id}.
*
* @param id
* @return
* @see #validator(KeycloakSession, String)
*/
public Validator validator(String id) {
return validator(session, id);
}
/**
* Look-up for a built-in or registered {@link ValidatorFactory} with the given provider {@code id}.
*
* @param id
* @return
* @see #validatorFactory(KeycloakSession, String)
*/
public ValidatorFactory validatorFactory(String id) {
return validatorFactory(session, id);
}
/**
* Validates the {@link ValidatorConfig} of {@link Validator} referenced by the given provider {@code id}.
*
* @param id
* @param config
* @return
* @see #validateConfig(KeycloakSession, String, ValidatorConfig)
*/
public ValidationResult validateConfig(String id, ValidatorConfig config) {
return validateConfig(session, id, config);
}
/* static import friendly accessor methods for built-in validators */
public static Validator getInternalValidatorById(String id) {
return INTERNAL_VALIDATORS.get(id);
}
public static ValidatorFactory getInternalValidatorFactoryById(String id) {
return INTERNAL_VALIDATORS.get(id);
}
public static Map<String, Validator> getInternalValidators() {
return Collections.unmodifiableMap(INTERNAL_VALIDATORS);
}
public static NotBlankValidator notBlankValidator() {
return NotBlankValidator.INSTANCE;
}
public static NotEmptyValidator notEmptyValidator() {
return NotEmptyValidator.INSTANCE;
}
public static LengthValidator lengthValidator() {
return LengthValidator.INSTANCE;
}
public static UriValidator uriValidator() {
return UriValidator.INSTANCE;
}
public static EmailValidator emailValidator() {
return EmailValidator.INSTANCE;
}
public static PatternValidator patternValidator() {
return PatternValidator.INSTANCE;
}
public static DoubleValidator doubleValidator() {
return DoubleValidator.INSTANCE;
}
public static IntegerValidator integerValidator() {
return IntegerValidator.INSTANCE;
}
public static ValidatorConfigValidator validatorConfigValidator() {
return ValidatorConfigValidator.INSTANCE;
}
/**
* Look-up up for a built-in or registered {@link Validator} with the given validatorId.
*
* @param session the {@link KeycloakSession}
* @param id the id of the validator
* @return the {@link Validator} or {@literal null}
*/
public static Validator validator(KeycloakSession session, String id) {
// Fast-path for internal Validators
Validator validator = getInternalValidatorById(id);
if (validator != null) {
return validator;
}
if (session == null) {
return null;
}
// Lookup validator in registry
return session.getProvider(Validator.class, id);
}
/**
* Look-up for a built-in or registered {@link ValidatorFactory} with the given validatorId.
* <p>
* This is intended for users who want to dynamically create new {@link Validator} instances, validate
* {@link ValidatorConfig} configurations or create default configurations for a {@link Validator}.
*
* @param session the {@link KeycloakSession}
* @param id the id of the validator
* @return the {@link Validator} or {@literal null}
*/
public static ValidatorFactory validatorFactory(KeycloakSession session, String id) {
// Fast-path for internal Validators
ValidatorFactory factory = getInternalValidatorFactoryById(id);
if (factory != null) {
return factory;
}
if (session == null) {
return null;
}
// Lookup factory in registry
KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory();
return (ValidatorFactory) sessionFactory.getProviderFactory(Validator.class, id);
}
/**
* Validates the {@link ValidatorConfig} of {@link Validator} referenced by the given provider {@code id}.
*
* @param session
* @param id of the validator
* @param config to be validated
* @return
*/
public static ValidationResult validateConfig(KeycloakSession session, String id, ValidatorConfig config) {
ValidatorFactory validatorFactory = validatorFactory(session, id);
if (validatorFactory != null) {
return validatorFactory.validateConfig(session, config);
}
// We could not find a ValidationFactory to validate that config, so we assume the config is valid.
return ValidationResult.OK;
}
}

View file

@ -0,0 +1,148 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate.validators;
import java.util.LinkedHashSet;
import java.util.Set;
import org.keycloak.models.KeycloakSession;
import org.keycloak.validate.AbstractSimpleValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidationResult;
import org.keycloak.validate.ValidatorConfig;
/**
* Abstract class for number validator. Supports min and max value validations using {@link #KEY_MIN} and
* {@link #KEY_MAX} config options.
*
* @author Vlastimil Elias <velias@redhat.com>
*/
public abstract class AbstractNumberValidator extends AbstractSimpleValidator {
public static final String MESSAGE_INVALID_NUMBER = "error-invalid-number";
public static final String MESSAGE_NUMBER_OUT_OF_RANGE = "error-number-out-of-range";
public static final String KEY_MIN = "min";
public static final String KEY_MAX = "max";
private final ValidatorConfig defaultConfig;
public AbstractNumberValidator() {
// for reflection
this(ValidatorConfig.EMPTY);
}
public AbstractNumberValidator(ValidatorConfig config) {
this.defaultConfig = config;
}
@Override
protected void doValidate(Object value, String inputHint, ValidationContext context, ValidatorConfig config) {
if (config == null || config.isEmpty()) {
config = defaultConfig;
}
Number number;
try {
number = convert(value, config);
} catch (NumberFormatException ignore) {
context.addError(new ValidationError(getId(), inputHint, MESSAGE_INVALID_NUMBER, value));
return;
}
Number min = getMinMaxConfig(config, KEY_MIN);
Number max = getMinMaxConfig(config, KEY_MAX);
if (min != null && isFirstGreaterThanToSecond(min, number)) {
context.addError(new ValidationError(getId(), inputHint, MESSAGE_NUMBER_OUT_OF_RANGE, value, min, max));
return;
}
if (max != null && isFirstGreaterThanToSecond(number, max)) {
context.addError(new ValidationError(getId(), inputHint, MESSAGE_NUMBER_OUT_OF_RANGE, value, min, max));
return;
}
return;
}
@Override
public ValidationResult validateConfig(KeycloakSession session, ValidatorConfig config) {
Set<ValidationError> errors = new LinkedHashSet<>();
if (config != null) {
boolean containsMin = config.containsKey(KEY_MIN);
boolean containsMax = config.containsKey(KEY_MAX);
Number min = getMinMaxConfig(config, KEY_MIN);
Number max = getMinMaxConfig(config, KEY_MAX);
if (containsMin && min == null) {
errors.add(new ValidationError(getId(), KEY_MIN, ValidatorConfigValidator.MESSAGE_CONFIG_INVALID_NUMBER_VALUE, config.get(KEY_MIN)));
}
if (containsMax && max == null) {
errors.add(new ValidationError(getId(), KEY_MAX, ValidatorConfigValidator.MESSAGE_CONFIG_INVALID_NUMBER_VALUE, config.get(KEY_MAX)));
}
if (errors.isEmpty() && containsMin && containsMax && (!isFirstGreaterThanToSecond(max, min))) {
errors.add(new ValidationError(getId(), KEY_MAX, ValidatorConfigValidator.MESSAGE_CONFIG_INVALID_VALUE));
}
}
ValidationResult s = super.validateConfig(session, config);
if (!s.isValid()) {
errors.addAll(s.getErrors());
}
return new ValidationResult(errors);
}
/**
* Convert input value to instance of Number supported by this validator.
*
* @param value to convert
* @param config
* @return value converted to supported Number instance
* @throws NumberFormatException if value is not convertible to supported Number instance so
* {@link #MESSAGE_INVALID_NUMBER} error is reported.
*/
protected abstract Number convert(Object value, ValidatorConfig config);
/**
* Get config value for min and max validation bound as a Number supported by this validator
*
* @param config to get from
* @param key of the config value
* @return bound value or null
*/
protected abstract Number getMinMaxConfig(ValidatorConfig config, String key);
/**
* Compare two numbers of supported type (fed by {@link #convert(Object, ValidatorConfig)} and
* {@link #getMinMaxConfig(ValidatorConfig, String)} )
*
* @param n1
* @param n2
* @return true if first number is greater than second
*/
protected abstract boolean isFirstGreaterThanToSecond(Number n1, Number n2);
}

View file

@ -0,0 +1,63 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate.validators;
import org.keycloak.validate.ValidatorConfig;
/**
* Validate input being any kind of {@link Number}. Accepts String also if convertible to {@link Double} by common
* {@link Double#parseDouble(String)}. Min and Max validation is based on {@link Double} precision also.
*
* @author Vlastimil Elias <velias@redhat.com>
*/
public class DoubleValidator extends AbstractNumberValidator {
public static final String ID = "double";
public static final DoubleValidator INSTANCE = new DoubleValidator();
public DoubleValidator() {
super();
}
public DoubleValidator(ValidatorConfig config) {
super(config);
}
@Override
public String getId() {
return ID;
}
@Override
protected Number convert(Object value, ValidatorConfig config) {
if (value instanceof Number) {
return (Number) value;
}
return new Double(value.toString());
}
@Override
protected Number getMinMaxConfig(ValidatorConfig config, String key) {
return config != null ? config.getDouble(key) : null;
}
@Override
protected boolean isFirstGreaterThanToSecond(Number n1, Number n2) {
return n1.doubleValue() > n2.doubleValue();
}
}

View file

@ -0,0 +1,55 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate.validators;
import java.util.regex.Pattern;
import org.keycloak.validate.AbstractStringValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidatorConfig;
/**
* Email format validation - accepts plain string and collection of strings, for basic behavior like null/blank values
* handling and collections support see {@link AbstractStringValidator}.
*/
public class EmailValidator extends AbstractStringValidator {
public static final String ID = "email";
public static final EmailValidator INSTANCE = new EmailValidator();
public static final String MESSAGE_INVALID_EMAIL = "error-invalid-email";
// Actually allow same emails like angular. See ValidationTest.testEmailValidation()
private static final Pattern EMAIL_PATTERN = Pattern.compile("[a-zA-Z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-zA-Z0-9-]+(\\.[a-zA-Z0-9-]+)*");
private EmailValidator() {
}
@Override
public String getId() {
return ID;
}
@Override
protected void doValidate(String value, String inputHint, ValidationContext context, ValidatorConfig config) {
if (!EMAIL_PATTERN.matcher(value).matches()) {
context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_EMAIL, value));
}
}
}

View file

@ -0,0 +1,63 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate.validators;
import org.keycloak.validate.ValidatorConfig;
/**
*
* Validate input being integer number {@link Integer} or {@link Long}. Accepts String also if convertible to
* {@link Long} by common {@link Long#parseLong(String)} operation.
*
* @author Vlastimil Elias <velias@redhat.com>
*/
public class IntegerValidator extends AbstractNumberValidator {
public static final String ID = "integer";
public static final IntegerValidator INSTANCE = new IntegerValidator();
public IntegerValidator() {
super();
}
public IntegerValidator(ValidatorConfig config) {
super(config);
}
@Override
protected Number convert(Object value, ValidatorConfig config) {
if (value instanceof Integer || value instanceof Long) {
return (Number) value;
}
return new Long(value.toString());
}
@Override
public String getId() {
return ID;
}
@Override
protected Number getMinMaxConfig(ValidatorConfig config, String key) {
return config != null ? config.getLong(key) : null;
}
@Override
protected boolean isFirstGreaterThanToSecond(Number n1, Number n2) {
return n1.longValue() > n2.longValue();
}
}

View file

@ -0,0 +1,116 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate.validators;
import java.util.LinkedHashSet;
import java.util.Set;
import org.keycloak.models.KeycloakSession;
import org.keycloak.validate.AbstractStringValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidationResult;
import org.keycloak.validate.ValidatorConfig;
/**
* String value length validation - accepts plain string and collection of strings, for basic behavior like null/blank
* values handling and collections support see {@link AbstractStringValidator}. Validator trims String value before the
* length validation, can be disabled by {@link #KEY_TRIM_DISABLED} boolean configuration entry set to
* <code>true</code>.
* <p>
* Configuration have to be always provided, with at least one of {@link #KEY_MIN} and {@link #KEY_MAX}.
*/
public class LengthValidator extends AbstractStringValidator {
public static final LengthValidator INSTANCE = new LengthValidator();
public static final String ID = "length";
public static final String MESSAGE_INVALID_LENGTH = "error-invalid-length";
public static final String KEY_MIN = "min";
public static final String KEY_MAX = "max";
public static final String KEY_TRIM_DISABLED = "trim-disabled";
private LengthValidator() {
}
@Override
public String getId() {
return ID;
}
@Override
protected void doValidate(String value, String inputHint, ValidationContext context, ValidatorConfig config) {
Integer min = config.getInt(KEY_MIN);
Integer max = config.getInt(KEY_MAX);
if (!config.getBooleanOrDefault(KEY_TRIM_DISABLED, Boolean.FALSE)) {
value = value.trim();
}
int length = value.length();
if (config.containsKey(KEY_MIN) && length < min.intValue()) {
context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_LENGTH, value, min, max));
return;
}
if (config.containsKey(KEY_MAX) && length > max.intValue()) {
context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_LENGTH, value, min, max));
return;
}
}
@Override
public ValidationResult validateConfig(KeycloakSession session, ValidatorConfig config) {
Set<ValidationError> errors = new LinkedHashSet<>();
if (config == null || config == ValidatorConfig.EMPTY) {
errors.add(new ValidationError(ID, KEY_MIN, ValidatorConfigValidator.MESSAGE_CONFIG_MISSING_VALUE));
errors.add(new ValidationError(ID, KEY_MAX, ValidatorConfigValidator.MESSAGE_CONFIG_MISSING_VALUE));
} else {
if (config.containsKey(KEY_TRIM_DISABLED) && (config.getBoolean(KEY_TRIM_DISABLED) == null)) {
errors.add(new ValidationError(ID, KEY_TRIM_DISABLED, ValidatorConfigValidator.MESSAGE_CONFIG_INVALID_BOOLEAN_VALUE, config.get(KEY_TRIM_DISABLED)));
}
boolean containsMin = config.containsKey(KEY_MIN);
boolean containsMax = config.containsKey(KEY_MAX);
if (!(containsMin || containsMax)) {
errors.add(new ValidationError(ID, KEY_MIN, ValidatorConfigValidator.MESSAGE_CONFIG_MISSING_VALUE));
errors.add(new ValidationError(ID, KEY_MAX, ValidatorConfigValidator.MESSAGE_CONFIG_MISSING_VALUE));
} else {
if (containsMin && config.getInt(KEY_MIN) == null) {
errors.add(new ValidationError(ID, KEY_MIN, ValidatorConfigValidator.MESSAGE_CONFIG_INVALID_NUMBER_VALUE, config.get(KEY_MIN)));
}
if (containsMax && config.getInt(KEY_MAX) == null) {
errors.add(new ValidationError(ID, KEY_MAX, ValidatorConfigValidator.MESSAGE_CONFIG_INVALID_NUMBER_VALUE, config.get(KEY_MAX)));
}
if (errors.isEmpty() && containsMin && containsMax && (config.getInt(KEY_MIN) > config.getInt(KEY_MAX))) {
errors.add(new ValidationError(ID, KEY_MAX, ValidatorConfigValidator.MESSAGE_CONFIG_INVALID_VALUE));
}
}
}
return new ValidationResult(errors);
}
}

View file

@ -0,0 +1,86 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate.validators;
import java.util.Collection;
import org.keycloak.validate.SimpleValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidatorConfig;
/**
* Validate that value exists and is not empty nor blank. Supports String and collection of Strings as input. For
* collection of Strings input has to contain at least one element and it have to be non-blank to satisfy this
* validation. If collection contains something else than String, or if even one String in it is blank, then this
* validation fails.
*
* @see NotEmptyValidator
*/
public class NotBlankValidator implements SimpleValidator {
public static final String ID = "blank";
public static final String MESSAGE_BLANK = "error-invalid-blank";
public static final NotBlankValidator INSTANCE = new NotBlankValidator();
private NotBlankValidator() {
}
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
if (input == null) {
context.addError(new ValidationError(ID, inputHint, MESSAGE_BLANK, input));
} else if (input instanceof String) {
validateStringValue((String) input, inputHint, context, config);
} else if (input instanceof Collection) {
@SuppressWarnings("unchecked")
Collection<Object> values = (Collection<Object>) input;
if (!values.isEmpty()) {
for (Object value : values) {
if (!(value instanceof String)) {
context.addError(new ValidationError(getId(), inputHint, ValidationError.MESSAGE_INVALID_VALUE, input));
return context;
} else if (!validateStringValue((String) value, inputHint, context, config)) {
return context;
}
}
} else {
context.addError(new ValidationError(ID, inputHint, MESSAGE_BLANK, input));
}
} else {
context.addError(new ValidationError(ID, inputHint, ValidationError.MESSAGE_INVALID_VALUE, input));
}
return context;
}
protected boolean validateStringValue(String value, String inputHint, ValidationContext context, ValidatorConfig config) {
if (value == null || value.trim().length() == 0) {
context.addError(new ValidationError(ID, inputHint, MESSAGE_BLANK, value));
return false;
}
return true;
}
}

View file

@ -0,0 +1,81 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate.validators;
import org.keycloak.validate.SimpleValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidatorConfig;
import java.util.Collection;
import java.util.Map;
/**
* Check that input value is not empty. It means it is not null for all data types. For String it also have to be
* non-empty string (no trim() performed). For {@link Collection} and {@link Map} it also means it is not empty.
*
* @see NotBlankValidator
*/
public class NotEmptyValidator implements SimpleValidator {
public static final NotEmptyValidator INSTANCE = new NotEmptyValidator();
public static final String ID = "not-empty";
public static final String MESSAGE_ERROR_EMPTY = "error-empty";
private NotEmptyValidator() {
}
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
if (input == null) {
context.addError(new ValidationError(ID, inputHint, MESSAGE_ERROR_EMPTY, input));
return context;
}
if (input instanceof String) {
if (((String) input).length() == 0) {
context.addError(new ValidationError(ID, inputHint, MESSAGE_ERROR_EMPTY, input));
}
return context;
}
if (input instanceof Collection) {
if (((Collection<?>) input).isEmpty()) {
context.addError(new ValidationError(ID, inputHint, MESSAGE_ERROR_EMPTY, input));
}
return context;
}
if (input instanceof Map) {
if (((Map<?, ?>) input).isEmpty()) {
context.addError(new ValidationError(ID, inputHint, MESSAGE_ERROR_EMPTY, input));
}
return context;
}
context.addError(new ValidationError(ID, inputHint, ValidationError.MESSAGE_INVALID_VALUE, input));
return context;
}
}

View file

@ -0,0 +1,82 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate.validators;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.validate.AbstractStringValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidationResult;
import org.keycloak.validate.ValidatorConfig;
/**
* Validate String against configured RegEx pattern - accepts plain string and collection of strings, for basic behavior
* like null/blank values handling and collections support see {@link AbstractStringValidator}.
*/
public class PatternValidator extends AbstractStringValidator {
public static final String ID = "pattern";
public static final PatternValidator INSTANCE = new PatternValidator();
public static final String KEY_PATTERN = "pattern";
public static final String MESSAGE_NO_MATCH = "error-pattern-no-match";
private PatternValidator() {
}
@Override
public String getId() {
return ID;
}
@Override
protected void doValidate(String value, String inputHint, ValidationContext context, ValidatorConfig config) {
Pattern pattern = config.getPattern(KEY_PATTERN);
if (!pattern.matcher(value).matches()) {
context.addError(new ValidationError(ID, inputHint, MESSAGE_NO_MATCH, value, config.getString(KEY_PATTERN)));
}
}
@Override
public ValidationResult validateConfig(KeycloakSession session, ValidatorConfig config) {
Set<ValidationError> errors = new LinkedHashSet<>();
if (config == null || config == ValidatorConfig.EMPTY || !config.containsKey(KEY_PATTERN)) {
errors.add(new ValidationError(ID, KEY_PATTERN, ValidatorConfigValidator.MESSAGE_CONFIG_MISSING_VALUE));
} else {
Object maybePattern = config.get(KEY_PATTERN);
try {
Pattern pattern = config.getPattern(KEY_PATTERN);
if (pattern == null) {
errors.add(new ValidationError(ID, KEY_PATTERN, ValidatorConfigValidator.MESSAGE_CONFIG_INVALID_VALUE, maybePattern));
}
} catch (PatternSyntaxException pse) {
errors.add(new ValidationError(ID, KEY_PATTERN, ValidatorConfigValidator.MESSAGE_CONFIG_INVALID_VALUE, maybePattern));
}
}
return new ValidationResult(errors);
}
}

View file

@ -0,0 +1,139 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate.validators;
import org.keycloak.validate.SimpleValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidatorConfig;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
/**
* URI validation - accepts {@link URI}, {@link URL} and single String. Null input is valid, use other validators (like
* {@link NotBlankValidator} or {@link NotEmptyValidator} to force field as required.
*/
public class UriValidator implements SimpleValidator {
public static final UriValidator INSTANCE = new UriValidator();
public static final String KEY_ALLOWED_SCHEMES = "allowedSchemes";
public static final String KEY_ALLOW_FRAGMENT = "allowFragment";
public static final String KEY_REQUIRE_VALID_URL = "requireValidUrl";
public static final Set<String> DEFAULT_ALLOWED_SCHEMES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
"http",
"https"
)));
public static final String MESSAGE_INVALID_URI = "error-invalid-uri";
public static final String MESSAGE_INVALID_SCHEME = "error-invalid-uri-scheme";
public static final String MESSAGE_INVALID_FRAGMENT = "error-invalid-uri-fragment";
public static boolean DEFAULT_ALLOW_FRAGMENT = true;
public static boolean DEFAULT_REQUIRE_VALID_URL = true;
public static final String ID = "uri";
private UriValidator() {
}
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
if(input == null || (input instanceof String && ((String) input).isEmpty())) {
return context;
}
try {
URI uri = toUri(input);
if (uri == null) {
context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_URI, input));
} else {
Set<String> allowedSchemes = config.getStringSetOrDefault(KEY_ALLOWED_SCHEMES, DEFAULT_ALLOWED_SCHEMES);
boolean allowFragment = config.getBooleanOrDefault(KEY_ALLOW_FRAGMENT, DEFAULT_ALLOW_FRAGMENT);
boolean requireValidUrl = config.getBooleanOrDefault(KEY_REQUIRE_VALID_URL, DEFAULT_REQUIRE_VALID_URL);
validateUri(uri, inputHint, context, allowedSchemes, allowFragment, requireValidUrl);
}
} catch (MalformedURLException | IllegalArgumentException | URISyntaxException e) {
context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_URI, input, e.getMessage()));
}
return context;
}
private URI toUri(Object input) throws URISyntaxException {
if (input instanceof String) {
String uriString = (String) input;
return new URI(uriString);
} else if (input instanceof URI) {
return (URI) input;
} else if (input instanceof URL) {
return ((URL) input).toURI();
}
return null;
}
public boolean validateUri(URI uri, Set<String> allowedSchemes, boolean allowFragment, boolean requireValidUrl) {
try {
return validateUri(uri, "url", new ValidationContext(), allowedSchemes, allowFragment, requireValidUrl);
} catch (MalformedURLException mue) {
return false;
}
}
public boolean validateUri(URI uri, String inputHint, ValidationContext context,
Set<String> allowedSchemes, boolean allowFragment, boolean requireValidUrl)
throws MalformedURLException {
boolean valid = true;
if (uri.getScheme() != null && !allowedSchemes.contains(uri.getScheme())) {
context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_SCHEME, uri, uri.getScheme()));
valid = false;
}
if (!allowFragment && uri.getFragment() != null) {
context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_FRAGMENT, uri, uri.getFragment()));
valid = false;
}
// Don't check if URL is valid if there are other problems with it; otherwise it could lead to duplicate errors.
// This cannot be moved higher because it acts on differently based on environment (e.g. sometimes it checks
// scheme, sometimes it doesn't).
if (requireValidUrl && valid) {
URL ignored = uri.toURL(); // throws an exception
}
return valid;
}
}

View file

@ -0,0 +1,78 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.validate.validators;
import org.keycloak.validate.SimpleValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidatorConfig;
import org.keycloak.validate.Validators;
/**
* Validate that input value is {@link ValidatorConfig} and it is correct for validator (<code>inputHint</code> must be
* ID of the validator config is for) by
* {@link Validators#validateConfig(org.keycloak.models.KeycloakSession, String, ValidatorConfig)}. .
*/
public class ValidatorConfigValidator implements SimpleValidator {
/**
* Generic error messages for config validations - missing config value
*/
public static final String MESSAGE_CONFIG_MISSING_VALUE = "error-validator-config-missing-value";
/**
* Generic error messages for config validations - invalid config value
*/
public static final String MESSAGE_CONFIG_INVALID_VALUE = "error-validator-config-invalid-value";
/**
* Generic error messages for config validations - invalid config value - number expected
*/
public static final String MESSAGE_CONFIG_INVALID_NUMBER_VALUE = "error-validator-config-invalid-number-value";
/**
* Generic error messages for config validations - invalid config value - boolean expected
*/
public static final String MESSAGE_CONFIG_INVALID_BOOLEAN_VALUE = "error-validator-config-invalid-boolean-value";
/**
* Generic error messages for config validations - invalid config value - string expected
*/
public static final String MESSAGE_CONFIG_INVALID_STRING_VALUE = "error-validator-config-invalid-string-value";
public static final String ID = "validatorConfig";
public static final ValidatorConfigValidator INSTANCE = new ValidatorConfigValidator();
private ValidatorConfigValidator() {
}
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
if (input == null || input instanceof ValidatorConfig) {
Validators.validateConfig(context.getSession(), inputHint, (ValidatorConfig) input).forEachError(context::addError);
} else {
context.addError(new ValidationError(ID, inputHint, ValidationError.MESSAGE_INVALID_VALUE));
}
return context;
}
}

View file

@ -94,6 +94,7 @@ org.keycloak.vault.VaultSpi
org.keycloak.crypto.CekManagementSpi
org.keycloak.crypto.ContentEncryptionSpi
org.keycloak.validation.ClientValidationSPI
org.keycloak.validate.ValidatorSPI
org.keycloak.headers.SecurityHeadersSpi
org.keycloak.services.clientpolicy.condition.ClientPolicyConditionSpi
org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorSpi

View file

@ -0,0 +1,371 @@
package org.keycloak.validate;
import static org.keycloak.validate.ValidatorConfig.configFromMap;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.regex.Pattern;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.validate.validators.DoubleValidator;
import org.keycloak.validate.validators.IntegerValidator;
import org.keycloak.validate.validators.LengthValidator;
import org.keycloak.validate.validators.PatternValidator;
import org.keycloak.validate.validators.UriValidator;
import com.google.common.collect.ImmutableMap;
public class BuiltinValidatorsTest {
@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("", "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());
// min validation only
Assert.assertTrue(validator.validate("tester", "name", configFromMap(ImmutableMap.of(LengthValidator.KEY_MIN, 1))).isValid());
Assert.assertFalse(validator.validate("tester", "name", configFromMap(ImmutableMap.of(LengthValidator.KEY_MIN, 7))).isValid());
// max validation only
Assert.assertTrue(validator.validate("tester", "name", configFromMap(ImmutableMap.of(LengthValidator.KEY_MAX, 8))).isValid());
Assert.assertFalse(validator.validate("tester", "name", configFromMap(ImmutableMap.of(LengthValidator.KEY_MAX, 4))).isValid());
// both validations together
ValidatorConfig config1 = configFromMap(ImmutableMap.of(LengthValidator.KEY_MIN, 3, LengthValidator.KEY_MAX, 4));
Assert.assertFalse(validator.validate("te", "name", config1).isValid());
Assert.assertTrue(validator.validate("tes", "name", config1).isValid());
Assert.assertTrue(validator.validate("test", "name", config1).isValid());
Assert.assertFalse(validator.validate("testr", "name", config1).isValid());
// test value trimming performed by default
Assert.assertFalse("trim not performed", validator.validate("t ", "name", configFromMap(ImmutableMap.of(LengthValidator.KEY_MIN, 2))).isValid());
Assert.assertFalse("trim not performed", validator.validate(" t", "name", configFromMap(ImmutableMap.of(LengthValidator.KEY_MIN, 2))).isValid());
// test value trimming disabled in config
Assert.assertTrue("trim disabled but performed", validator.validate("t ", "name", configFromMap(ImmutableMap.of(LengthValidator.KEY_MIN, 2, LengthValidator.KEY_TRIM_DISABLED, true))).isValid());
}
@Test
public void validateLength_ConfigValidation() {
// invalid min and max config values
ValidatorConfig config = new ValidatorConfig(ImmutableMap.of(LengthValidator.KEY_MIN, new Object(), LengthValidator.KEY_MAX, "invalid"));
ValidationResult result = Validators.validatorConfigValidator().validate(config, LengthValidator.ID).toResult();
Assert.assertFalse(result.isValid());
ValidationError[] errors = result.getErrors().toArray(new ValidationError[0]);
ValidationError error0 = errors[0];
Assert.assertNotNull(error0);
Assert.assertEquals(LengthValidator.ID, error0.getValidatorId());
Assert.assertEquals(LengthValidator.KEY_MIN, error0.getInputHint());
ValidationError error1 = errors[1];
Assert.assertNotNull(error1);
Assert.assertEquals(LengthValidator.ID, error1.getValidatorId());
Assert.assertEquals(LengthValidator.KEY_MAX, error1.getInputHint());
// empty config
result = Validators.validatorConfigValidator().validate(null, LengthValidator.ID).toResult();
Assert.assertEquals(2, result.getErrors().size());
result = Validators.validatorConfigValidator().validate(ValidatorConfig.EMPTY, LengthValidator.ID).toResult();
Assert.assertEquals(2, result.getErrors().size());
// correct config
Assert.assertTrue(Validators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(LengthValidator.KEY_MIN, "10")), LengthValidator.ID).toResult().isValid());
Assert.assertTrue(Validators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(LengthValidator.KEY_MAX, "10")), LengthValidator.ID).toResult().isValid());
Assert.assertTrue(Validators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(LengthValidator.KEY_MIN, "10", LengthValidator.KEY_MAX, "10")), LengthValidator.ID).toResult().isValid());
// max is smaller than min
Assert.assertFalse(Validators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(LengthValidator.KEY_MIN, "10", LengthValidator.KEY_MAX, "9")), LengthValidator.ID).toResult().isValid());
}
@Test
public void validateEmail() {
// this also validates StringFormatValidatorBase for simple values
Validator validator = Validators.emailValidator();
Assert.assertTrue(validator.validate(null, "email").isValid());
Assert.assertFalse(validator.validate("", "email").isValid());
Assert.assertTrue(validator.validate("admin@example.org", "email").isValid());
Assert.assertTrue(validator.validate("admin+sds@example.org", "email").isValid());
Assert.assertFalse(validator.validate(" ", "email").isValid());
Assert.assertFalse(validator.validate("adminATexample.org", "email").isValid());
}
@Test
public void validateStringFormatValidatorBaseForCollections() {
Validator validator = Validators.emailValidator();
List<String> valuesCollection = new ArrayList<>();
Assert.assertTrue(validator.validate(valuesCollection, "email").isValid());
valuesCollection.add("");
Assert.assertFalse(validator.validate(valuesCollection, "email").isValid());
valuesCollection.add("admin@example.org");
Assert.assertTrue(validator.validate("admin@example.org", "email").isValid());
// wrong value fails validation even it is not at first position
valuesCollection.add(" ");
Assert.assertFalse(validator.validate(valuesCollection, "email").isValid());
valuesCollection.remove(valuesCollection.size() - 1);
valuesCollection.add("adminATexample.org");
Assert.assertFalse(validator.validate(valuesCollection, "email").isValid());
}
@Test
public void validateNotBlank() {
Validator validator = Validators.notBlankValidator();
// simple String value
Assert.assertTrue(validator.validate("tester", "username").isValid());
Assert.assertFalse(validator.validate("", "username").isValid());
Assert.assertFalse(validator.validate(" ", "username").isValid());
Assert.assertFalse(validator.validate(null, "username").isValid());
// collection as input
Assert.assertTrue(validator.validate(Arrays.asList("a", "b"), "username").isValid());
Assert.assertFalse(validator.validate(new ArrayList<>(), "username").isValid());
Assert.assertFalse(validator.validate(Arrays.asList(""), "username").isValid());
Assert.assertFalse(validator.validate(Arrays.asList(" "), "username").isValid());
Assert.assertFalse(validator.validate(Arrays.asList("a", " "), "username").isValid());
Assert.assertFalse(validator.validate(Arrays.asList("a", new Object()), "username").isValid());
// unsupported input type
Assert.assertFalse(validator.validate(new Object(), "username").isValid());
}
@Test
public void validateNotEmpty() {
Validator validator = Validators.notEmptyValidator();
Assert.assertTrue(validator.validate("tester", "username").isValid());
Assert.assertTrue(validator.validate(" ", "username").isValid());
Assert.assertTrue(validator.validate(Arrays.asList(1, 2, 3), "numberList").isValid());
Assert.assertTrue(validator.validate(Collections.singleton("key"), "stringSet").isValid());
Assert.assertTrue(validator.validate(Collections.singletonMap("key", "value"), "stringMap").isValid());
Assert.assertFalse(validator.validate(null, "username").isValid());
Assert.assertFalse(validator.validate("", "username").isValid());
Assert.assertFalse(validator.validate(Collections.emptyList(), "emptyList").isValid());
Assert.assertFalse(validator.validate(Collections.emptySet(), "emptySet").isValid());
Assert.assertFalse(validator.validate(Collections.emptyMap(), "emptyMap").isValid());
}
@Test
public void validateDoubleNumber() {
Validator validator = Validators.doubleValidator();
// null value and empty String
Assert.assertTrue(validator.validate(null, "null").isValid());
Assert.assertFalse(validator.validate("", "emptyString").isValid());
// simple values
Assert.assertTrue(validator.validate(10, "age").isValid());
Assert.assertTrue(validator.validate("10", "age").isValid());
Assert.assertTrue(validator.validate("3.14", "pi").isValid());
Assert.assertTrue(validator.validate(" 3.14 ", "piWithBlank").isValid());
Assert.assertFalse(validator.validate("a", "notAnumber").isValid());
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(10), "age").isValid());
Assert.assertTrue(validator.validate(Arrays.asList(" 10 "), "age").isValid());
Assert.assertTrue(validator.validate(Arrays.asList("3.14"), "pi").isValid());
Assert.assertTrue(validator.validate(Arrays.asList("3.14", 10), "pi").isValid());
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());
}
@Test
public void validateDoubleNumber_ConfigValidation() {
// invalid min and max config values
ValidatorConfig config = new ValidatorConfig(ImmutableMap.of(DoubleValidator.KEY_MIN, new Object(), DoubleValidator.KEY_MAX, "invalid"));
ValidationResult result = Validators.validatorConfigValidator().validate(config, DoubleValidator.ID).toResult();
Assert.assertFalse(result.isValid());
ValidationError[] errors = result.getErrors().toArray(new ValidationError[0]);
ValidationError error0 = errors[0];
Assert.assertNotNull(error0);
Assert.assertEquals(DoubleValidator.ID, error0.getValidatorId());
Assert.assertEquals(DoubleValidator.KEY_MIN, error0.getInputHint());
ValidationError error1 = errors[1];
Assert.assertNotNull(error1);
Assert.assertEquals(DoubleValidator.ID, error1.getValidatorId());
Assert.assertEquals(DoubleValidator.KEY_MAX, error1.getInputHint());
// empty config
result = Validators.validatorConfigValidator().validate(null, DoubleValidator.ID).toResult();
Assert.assertEquals(0, result.getErrors().size());
result = Validators.validatorConfigValidator().validate(ValidatorConfig.EMPTY, DoubleValidator.ID).toResult();
Assert.assertEquals(0, result.getErrors().size());
// correct config
Assert.assertTrue(Validators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(DoubleValidator.KEY_MIN, "10.1")), DoubleValidator.ID).toResult().isValid());
Assert.assertTrue(Validators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(DoubleValidator.KEY_MAX, "10.1")), DoubleValidator.ID).toResult().isValid());
Assert.assertTrue(Validators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(DoubleValidator.KEY_MIN, "10.1", DoubleValidator.KEY_MAX, "11")), DoubleValidator.ID).toResult().isValid());
// max is smaller than min
Assert.assertFalse(Validators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(DoubleValidator.KEY_MIN, "10.1", DoubleValidator.KEY_MAX, "10.1")), DoubleValidator.ID).toResult().isValid());
}
@Test
public void validateIntegerNumber() {
Validator validator = Validators.integerValidator();
// null value and empty String
Assert.assertTrue(validator.validate(null, "null").isValid());
Assert.assertFalse(validator.validate("", "emptyString").isValid());
// simple values
Assert.assertTrue(validator.validate(10, "age").isValid());
Assert.assertTrue(validator.validate("10", "age").isValid());
Assert.assertFalse(validator.validate("3.14", "pi").isValid());
Assert.assertFalse(validator.validate(" 3.14 ", "piWithBlank").isValid());
Assert.assertFalse(validator.validate("a", "notAnumber").isValid());
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(10), "age").isValid());
Assert.assertFalse(validator.validate(Arrays.asList(" 10 "), "age").isValid());
Assert.assertFalse(validator.validate(Arrays.asList("3.14"), "pi").isValid());
Assert.assertFalse(validator.validate(Arrays.asList("3.14", 10), "pi").isValid());
Assert.assertFalse(validator.validate(Arrays.asList("a"), "notAnumber").isValid());
Assert.assertFalse(validator.validate(Arrays.asList("10", "a"), "notANumberPresent").isValid());
Assert.assertFalse(validator.validate(Arrays.asList("10", new Object()), "notANumberPresent").isValid());
// 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());
// 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());
// min and max
Assert.assertFalse(validator.validate("9", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 10).config(DoubleValidator.KEY_MAX, 100).build()).isValid());
Assert.assertTrue(validator.validate("10", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 10).config(DoubleValidator.KEY_MAX, 100).build()).isValid());
Assert.assertTrue(validator.validate("100", "name", ValidatorConfig.builder().config(DoubleValidator.KEY_MIN, 10).config(DoubleValidator.KEY_MAX, 100).build()).isValid());
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());
}
@Test
public void validateIntegerNumber_ConfigValidation() {
// invalid min and max config values
ValidatorConfig config = new ValidatorConfig(ImmutableMap.of(IntegerValidator.KEY_MIN, new Object(), IntegerValidator.KEY_MAX, "invalid"));
ValidationResult result = Validators.validatorConfigValidator().validate(config, IntegerValidator.ID).toResult();
Assert.assertFalse(result.isValid());
ValidationError[] errors = result.getErrors().toArray(new ValidationError[0]);
ValidationError error0 = errors[0];
Assert.assertNotNull(error0);
Assert.assertEquals(IntegerValidator.ID, error0.getValidatorId());
Assert.assertEquals(IntegerValidator.KEY_MIN, error0.getInputHint());
ValidationError error1 = errors[1];
Assert.assertNotNull(error1);
Assert.assertEquals(IntegerValidator.ID, error1.getValidatorId());
Assert.assertEquals(IntegerValidator.KEY_MAX, error1.getInputHint());
// empty config
result = Validators.validatorConfigValidator().validate(null, IntegerValidator.ID).toResult();
Assert.assertEquals(0, result.getErrors().size());
result = Validators.validatorConfigValidator().validate(ValidatorConfig.EMPTY, IntegerValidator.ID).toResult();
Assert.assertEquals(0, result.getErrors().size());
// correct config
Assert.assertTrue(Validators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(IntegerValidator.KEY_MIN, "10")), IntegerValidator.ID).toResult().isValid());
Assert.assertTrue(Validators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(IntegerValidator.KEY_MAX, "10")), IntegerValidator.ID).toResult().isValid());
Assert.assertTrue(Validators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(IntegerValidator.KEY_MIN, "10", IntegerValidator.KEY_MAX, "11")), IntegerValidator.ID).toResult().isValid());
// max is smaller than min
Assert.assertFalse(Validators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(IntegerValidator.KEY_MIN, "10", IntegerValidator.KEY_MAX, "10")), IntegerValidator.ID).toResult().isValid());
}
@Test
public void validatePattern() {
Validator validator = Validators.patternValidator();
// Pattern object in the configuration
ValidatorConfig config = configFromMap(Collections.singletonMap(PatternValidator.KEY_PATTERN, Pattern.compile("^start-.*-end$")));
Assert.assertTrue(validator.validate("start-1234-end", "value", config).isValid());
Assert.assertFalse(validator.validate("start___end", "value", config).isValid());
// String in the configuration
config = configFromMap(Collections.singletonMap(PatternValidator.KEY_PATTERN, "^start-.*-end$"));
Assert.assertTrue(validator.validate("start-1234-end", "value", config).isValid());
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("", "value", config).isValid());
// pattern is applied to blank string
Assert.assertFalse(validator.validate(" ", "value", config).isValid());
}
@Test
public void validateUri() throws Exception {
Validator validator = Validators.uriValidator();
Assert.assertTrue(validator.validate(null, "baseUrl").isValid());
Assert.assertTrue(validator.validate("", "baseUrl").isValid());
Assert.assertTrue(validator.validate("http://localhost:3000/", "baseUrl").isValid());
Assert.assertTrue(validator.validate("https://localhost:3000/", "baseUrl").isValid());
Assert.assertTrue(validator.validate("https://localhost:3000/#someFragment", "baseUrl").isValid());
Assert.assertFalse(validator.validate(" ", "baseUrl").isValid());
Assert.assertFalse(validator.validate("file:///somefile.txt", "baseUrl").isValid());
Assert.assertFalse(validator.validate("invalidUrl++@23", "invalidUri").isValid());
ValidatorConfig config = configFromMap(ImmutableMap.of(UriValidator.KEY_ALLOW_FRAGMENT, false));
Assert.assertFalse(validator.validate("https://localhost:3000/#someFragment", "baseUrl", config).isValid());
// it is also possible to call dedicated validation methods on a built-in validator
Assert.assertTrue(Validators.uriValidator().validateUri(new URI("https://customurl"), Collections.singleton("https"), true, true));
Assert.assertFalse(Validators.uriValidator().validateUri(new URI("http://customurl"), Collections.singleton("https"), true, true));
}
}

View file

@ -0,0 +1,336 @@
package org.keycloak.validate;
import static org.keycloak.validate.ValidatorConfig.configFromMap;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.models.KeycloakSession;
import org.keycloak.validate.validators.LengthValidator;
import org.keycloak.validate.validators.NotBlankValidator;
import org.keycloak.validate.validators.ValidatorConfigValidator;
public class ValidatorTest {
KeycloakSession session = null;
@Test
public void simpleValidation() {
Validator validator = Validators.notEmptyValidator();
Assert.assertTrue(validator.validate("a").isValid());
Assert.assertFalse(validator.validate("").isValid());
}
@Test
public void simpleValidationWithContext() {
Validator validator = Validators.lengthValidator();
ValidationContext context = new ValidationContext(session);
validator.validate("a", "username", context);
ValidationResult result = context.toResult();
Assert.assertTrue(result.isValid());
}
@Test
public void simpleValidationFluent() {
ValidationContext context = new ValidationContext(session);
ValidationResult result = Validators.lengthValidator().validate("a", "username", context).toResult();
Assert.assertTrue(result.isValid());
}
@Test
public void simpleValidationLookup() {
// later: session.validators().validator(LengthValidator.ID);
Validator validator = Validators.validator(session, LengthValidator.ID);
ValidationContext context = new ValidationContext(session);
validator.validate("a", "username", context);
ValidationResult result = context.toResult();
Assert.assertTrue(result.isValid());
}
@Test
public void simpleValidationError() {
Validator validator = LengthValidator.INSTANCE;
String input = "a";
String inputHint = "username";
ValidationContext context = new ValidationContext(session);
validator.validate(input, inputHint, context, configFromMap(Collections.singletonMap("min", "2")));
ValidationResult result = context.toResult();
Assert.assertFalse(result.isValid());
Assert.assertEquals(1, result.getErrors().size());
ValidationError[] errors = result.getErrors().toArray(new ValidationError[0]);
ValidationError error = errors[0];
Assert.assertNotNull(error);
Assert.assertEquals(LengthValidator.ID, error.getValidatorId());
Assert.assertEquals(inputHint, error.getInputHint());
Assert.assertEquals(LengthValidator.MESSAGE_INVALID_LENGTH, error.getMessage());
Assert.assertEquals(input, error.getMessageParameters()[0]);
Assert.assertTrue(result.hasErrorsForValidatorId(LengthValidator.ID));
Assert.assertFalse(result.hasErrorsForValidatorId("unknown"));
Assert.assertEquals(result.getErrors(), result.getErrorsForValidatorId(LengthValidator.ID));
Assert.assertEquals(result.getErrors(), result.getErrorsForInputHint(inputHint));
Assert.assertTrue(result.hasErrorsForInputHint(inputHint));
Assert.assertFalse(result.hasErrorsForInputHint("email"));
}
@Test
public void acceptOnError() {
AtomicBoolean bool1 = new AtomicBoolean();
Validators.notEmptyValidator().validate("a").toResult().ifNotValidAccept(r -> bool1.set(true));
Assert.assertFalse(bool1.get());
AtomicBoolean bool2 = new AtomicBoolean();
Validators.notEmptyValidator().validate("").toResult().ifNotValidAccept(r -> bool2.set(true));
Assert.assertTrue(bool2.get());
}
@Test
public void forEachError() {
List<String> errors = new ArrayList<>();
MockAddress faultyAddress = new MockAddress("", "Saint-Maur-des-Fossés", null, "Germany");
MockAddressValidator.INSTANCE.validate(faultyAddress, "address").toResult().forEachError(e -> {
errors.add(e.getMessage());
});
Assert.assertEquals(Arrays.asList(NotBlankValidator.MESSAGE_BLANK, NotBlankValidator.MESSAGE_BLANK), errors);
}
@Test
public void formatError() {
Map<String, String> miniResourceBundle = new HashMap<>();
miniResourceBundle.put("error-invalid-blank", "{0} is blank: <{1}>");
miniResourceBundle.put("error-invalid-value", "{0} is invalid: <{1}>");
List<String> errors = new ArrayList<>();
MockAddress faultyAddress = new MockAddress("", "Saint-Maur-des-Fossés", null, "Germany");
MockAddressValidator.INSTANCE.validate(faultyAddress, "address").toResult().forEachError(e -> {
errors.add(e.formatMessage((message, args) -> MessageFormat.format(miniResourceBundle.getOrDefault(message, message), args)));
});
Assert.assertEquals(Arrays.asList("address.street is blank: <>", "address.zip is blank: <null>"), errors);
}
@Test
public void multipleValidations() {
ValidationContext context = new ValidationContext(session);
String input = "aaa";
String inputHint = "username";
Validators.lengthValidator().validate(input, inputHint, context);
Validators.notEmptyValidator().validate(input, inputHint, context);
ValidationResult result = context.toResult();
Assert.assertTrue(result.isValid());
}
@Test
public void multipleValidationsError() {
ValidationContext context = new ValidationContext(session);
String input = " ";
String inputHint = "username";
Validators.lengthValidator().validate(input, inputHint, context, configFromMap(Collections.singletonMap(LengthValidator.KEY_MIN, 1)));
Validators.notBlankValidator().validate(input, inputHint, context);
ValidationResult result = context.toResult();
Assert.assertFalse(result.isValid());
Assert.assertEquals(2, result.getErrors().size());
ValidationError[] errors = result.getErrors().toArray(new ValidationError[0]);
ValidationError error1 = errors[1];
Assert.assertNotNull(error1);
Assert.assertEquals(NotBlankValidator.ID, error1.getValidatorId());
Assert.assertEquals(inputHint, error1.getInputHint());
Assert.assertEquals(NotBlankValidator.MESSAGE_BLANK, error1.getMessage());
Assert.assertEquals(input, error1.getMessageParameters()[0]);
}
@Test
public void validateValidatorConfigSimple() {
SimpleValidator validator = LengthValidator.INSTANCE;
Assert.assertFalse(validator.validateConfig(session, null).isValid());
Assert.assertTrue(validator.validateConfig(session, configFromMap(Collections.singletonMap("min", 1))).isValid());
Assert.assertTrue(validator.validateConfig(session, configFromMap(Collections.singletonMap("max", 100))).isValid());
Assert.assertFalse(validator.validateConfig(session, configFromMap(Collections.singletonMap("min", null))).isValid());
Assert.assertFalse(validator.validateConfig(session, configFromMap(Collections.singletonMap("min", "a"))).isValid());
Assert.assertTrue(validator.validateConfig(session, configFromMap(Collections.singletonMap("min", "123"))).isValid());
}
@Test
public void validateValidatorConfigMultipleOptions() {
SimpleValidator validator = LengthValidator.INSTANCE;
Map<String, Object> config = new HashMap<>();
config.put("min", 1);
config.put("max", 10);
ValidatorConfig validatorConfig = configFromMap(config);
Assert.assertTrue(validator.validateConfig(session, validatorConfig).isValid());
}
@Test
public void validateValidatorConfigMultipleOptionsInvalidValues() {
SimpleValidator validator = LengthValidator.INSTANCE;
Map<String, Object> config = new HashMap<>();
config.put("min", "a");
config.put("max", new ArrayList<>());
ValidationResult result = validator.validateConfig(session, configFromMap(config));
Assert.assertFalse(result.isValid());
Assert.assertEquals(2, result.getErrors().size());
ValidationError[] errors = result.getErrors().toArray(new ValidationError[0]);
ValidationError error1 = errors[1];
Assert.assertNotNull(error1);
Assert.assertEquals(LengthValidator.ID, error1.getValidatorId());
Assert.assertEquals("max", error1.getInputHint());
Assert.assertEquals(ValidatorConfigValidator.MESSAGE_CONFIG_INVALID_NUMBER_VALUE, error1.getMessage());
Assert.assertEquals(new ArrayList<>(), error1.getMessageParameters()[0]);
}
@Test
public void validateValidatorConfigViaValidatorFactory() {
Map<String, Object> config = new HashMap<>();
config.put("min", "a");
config.put("max", new ArrayList<>());
ValidatorConfig validatorConfig = configFromMap(config);
ValidationResult result = Validators.validateConfig(session, LengthValidator.ID, validatorConfig);
Assert.assertEquals(2, result.getErrors().size());
ValidationError[] errors = result.getErrors().toArray(new ValidationError[0]);
ValidationError error1 = errors[1];
Assert.assertNotNull(error1);
Assert.assertEquals(LengthValidator.ID, error1.getValidatorId());
Assert.assertEquals("max", error1.getInputHint());
Assert.assertEquals(ValidatorConfigValidator.MESSAGE_CONFIG_INVALID_NUMBER_VALUE, error1.getMessage());
Assert.assertEquals(new ArrayList<>(), error1.getMessageParameters()[0]);
}
@Test
public void nestedValidation() {
Assert.assertTrue(MockAddressValidator.INSTANCE.validate(
new MockAddress("4848 Arcu St.", "Saint-Maur-des-Fossés", "02206", "Germany")
, "address").isValid());
ValidationResult result = MockAddressValidator.INSTANCE.validate(
new MockAddress("", "Saint-Maur-des-Fossés", null, "Germany")
, "address").toResult();
Assert.assertFalse(result.isValid());
Assert.assertEquals(2, result.getErrors().size());
ValidationError[] errors = result.getErrors().toArray(new ValidationError[0]);
ValidationError error0 = errors[0];
Assert.assertNotNull(error0);
Assert.assertEquals(NotBlankValidator.ID, error0.getValidatorId());
Assert.assertEquals("address.street", error0.getInputHint());
Assert.assertEquals(NotBlankValidator.MESSAGE_BLANK, error0.getMessage());
Assert.assertEquals("", error0.getMessageParameters()[0]);
ValidationError error1 = errors[1];
Assert.assertNotNull(error1);
Assert.assertEquals(NotBlankValidator.ID, error1.getValidatorId());
Assert.assertEquals("address.zip", error1.getInputHint());
Assert.assertEquals(NotBlankValidator.MESSAGE_BLANK, error1.getMessage());
}
static class MockAddress {
private final String street;
private final String city;
private final String zip;
private final String country;
public MockAddress(String street, String city, String zip, String country) {
this.street = street;
this.city = city;
this.zip = zip;
this.country = country;
}
}
static class MockAddressValidator implements SimpleValidator {
public static MockAddressValidator INSTANCE = new MockAddressValidator();
public static final String ID = "address";
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
if (!(input instanceof MockAddress)) {
context.addError(new ValidationError(ID, inputHint, ValidationError.MESSAGE_INVALID_VALUE, input));
return context;
}
MockAddress address = (MockAddress) input;
// Access validator statically
NotBlankValidator.INSTANCE.validate(address.street, inputHint + ".street", context);
NotBlankValidator.INSTANCE.validate(address.city, inputHint + ".city", context);
NotBlankValidator.INSTANCE.validate(address.country, inputHint + ".country", context);
// Access validator via lookup (could be built-in or user-provided Validator)
context.validator(NotBlankValidator.ID).validate(address.zip, inputHint + ".zip", context);
return context;
}
}
}

View file

@ -207,6 +207,19 @@ missingTotpMessage=Please specify authenticator code.
missingTotpDeviceNameMessage=Please specify device name.
notMatchPasswordMessage=Passwords don''t match.
error-invalid-value=Invalid value.
error-invalid-blank=Please specify value.
error-empty=Please specify value.
error-invalid-length=Length must be between {1} and {2}.
error-invalid-email=Invalid email address.
error-invalid-number=Invalid number.
error-number-out-of-range=Number must be between {1} and {2}.
error-pattern-no-match=Invalid value.
error-invalid-uri=Invalid URL.
error-invalid-uri-scheme=Invalid URL scheme.
error-invalid-uri-fragment=Invalid URL fragment.
invalidPasswordExistingMessage=Invalid existing password.
invalidPasswordBlacklistedMessage=Invalid password: password is blacklisted.
invalidPasswordConfirmMessage=Password confirmation doesn''t match.