UPConfigUtils.java
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.userprofile.config;
import static org.keycloak.common.util.ObjectUtil.isBlank;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.keycloak.common.util.StreamUtil;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.util.JsonSerialization;
import org.keycloak.validate.ValidationResult;
import org.keycloak.validate.ValidatorConfig;
import org.keycloak.validate.Validators;
/**
* Utility methods to work with User Profile Configurations
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public class UPConfigUtils {
private static final String SYSTEM_DEFAULT_CONFIG_RESOURCE = "keycloak-default-user-profile.json";
public static final String ROLE_USER = "user";
public static final String ROLE_ADMIN = "admin";
private static final Set<String> PSEUDOROLES = new HashSet<>();
static {
PSEUDOROLES.add(ROLE_ADMIN);
PSEUDOROLES.add(ROLE_USER);
}
/**
* Load configuration from JSON file.
* <p>
* Configuration is not validated, use {@link #validate(KeycloakSession, UPConfig)} to validate it and get list of errors.
*
* @param is JSON file to be loaded
* @return object representation of the configuration
* @throws IOException if JSON configuration can't be loaded (eg due to JSON format errors etc)
*/
public static UPConfig readConfig(InputStream is) throws IOException {
return JsonSerialization.readValue(is, UPConfig.class);
}
/**
* Validate object representation of the configuration. Validations:
* <ul>
* <li>defaultProfile is defined and exists in profiles</li>
* <li>parent exists for type</li>
* <li>type exists for attribute</li>
* <li>validator (from Validator SPI) exists for validation and it's config is correct</li>
* <li>if an attribute group is configured it is verified that this group exists</li>
* <li>all groups have a name != null</li>
* </ul>
*
* @param session to be used for Validator SPI integration
* @param config to validate
* @return list of errors, empty if no error found
*/
public static List<String> validate(KeycloakSession session, UPConfig config) {
List<String> errors = validateAttributes(session, config);
errors.addAll(validateAttributeGroups(config));
return errors;
}
private static List<String> validateAttributeGroups(UPConfig config) {
long groupsWithoutName = config.getGroups().stream().filter(g -> g.getName() == null).collect(Collectors.counting());
if (groupsWithoutName > 0) {
String errorMessage = "Name is mandatory for groups, found " + groupsWithoutName + " group(s) without name.";
return Collections.singletonList(errorMessage);
}
return Collections.emptyList();
}
private static List<String> validateAttributes(KeycloakSession session, UPConfig config) {
List<String> errors = new ArrayList<>();
Set<String> groups = config.getGroups().stream().map(g -> g.getName()).collect(Collectors.toSet());
if (config.getAttributes() != null) {
Set<String> attNamesCache = new HashSet<>();
config.getAttributes().forEach((attribute) -> validateAttribute(session, attribute, groups, errors, attNamesCache));
} else {
errors.add("UserProfile configuration without 'attributes' section is not allowed");
}
return errors;
}
/**
* Validate attribute configuration
*
* @param session to be used for Validator SPI integration
* @param attributeConfig config to be validated
* @param groups set of groups that are configured
* @param errors to add error message in if something is invalid
* @param attNamesCache cache of already existing attribute names so we can check uniqueness
*/
private static void validateAttribute(KeycloakSession session, UPAttribute attributeConfig, Set<String> groups, List<String> errors, Set<String> attNamesCache) {
String attributeName = attributeConfig.getName();
if (isBlank(attributeName)) {
errors.add("Attribute configuration without 'name' is not allowed");
} else {
if (attNamesCache.contains(attributeName)) {
errors.add("Attribute configuration already exists with 'name':'" + attributeName + "'");
} else {
attNamesCache.add(attributeName);
if(!isValidAttributeName(attributeName)) {
errors.add("Invalid attribute name (only letters, numbers and '.' '_' '-' special characters allowed): " + attributeName + "'");
}
}
}
if (attributeConfig.getValidations() != null) {
attributeConfig.getValidations().forEach((validator, validatorConfig) -> validateValidationConfig(session, validator, validatorConfig, attributeName, errors));
}
if (attributeConfig.getPermissions() != null) {
if (attributeConfig.getPermissions().getView() != null) {
validateRoles(attributeConfig.getPermissions().getView(), "permissions.view", errors, attributeName);
}
if (attributeConfig.getPermissions().getEdit() != null) {
validateRoles(attributeConfig.getPermissions().getEdit(), "permissions.edit", errors, attributeName);
}
}
if (attributeConfig.getRequired() != null) {
validateRoles(attributeConfig.getRequired().getRoles(), "required.roles", errors, attributeName);
validateScopes(attributeConfig.getRequired().getScopes(), "required.scopes", attributeName, errors, session);
}
if (attributeConfig.getSelector() != null) {
validateScopes(attributeConfig.getSelector().getScopes(), "selector.scopes", attributeName, errors, session);
}
if (attributeConfig.getGroup() != null) {
if (!groups.contains(attributeConfig.getGroup())) {
errors.add("Attribute '" + attributeName + "' references unknown group '" + attributeConfig.getGroup() + "'");
}
}
if (attributeConfig.getAnnotations()!=null) {
validateAnnotations(attributeConfig.getAnnotations(), errors, attributeName);
}
}
private static void validateAnnotations(Map<String, Object> annotations, List<String> errors, String attributeName) {
if (annotations.containsKey("inputOptions") && !(annotations.get("inputOptions") instanceof List)) {
errors.add(new StringBuilder("Annotation 'inputOptions' configured for attribute '").append(attributeName).append("' must be an array of values!'").toString());
}
if (annotations.containsKey("inputOptionLabels") && !(annotations.get("inputOptionLabels") instanceof Map)) {
errors.add(new StringBuilder("Annotation 'inputOptionLabels' configured for attribute '").append(attributeName).append("' must be an object!'").toString());
}
}
private static void validateScopes(Set<String> scopes, String propertyName, String attributeName, List<String> errors, KeycloakSession session) {
if (scopes == null) {
return;
}
for (String scope : scopes) {
RealmModel realm = session.getContext().getRealm();
Stream<ClientScopeModel> realmScopes = realm.getClientScopesStream();
if (!realmScopes.anyMatch(cs -> cs.getName().equals(scope))) {
errors.add(new StringBuilder("'").append(propertyName).append("' configuration for attribute '").append(attributeName).append("' contains unsupported scope '").append(scope).append("'").toString());
}
}
}
/**
* @param attributeName to validate
* @return
*/
public static boolean isValidAttributeName(String attributeName) {
return Pattern.matches("[a-zA-Z0-9\\._\\-]+", attributeName);
}
/**
* Validate list of configured roles - must contain only supported {@link #PSEUDOROLES} for now.
*
* @param roles to validate
* @param fieldName we are validating for use in error messages
* @param errors to ass error message into
* @param attributeName we are validating for use in error messages
*/
private static void validateRoles(Set<String> roles, String fieldName, List<String> errors, String attributeName) {
if (roles != null) {
for (String role : roles) {
if (!PSEUDOROLES.contains(role)) {
errors.add("'" + fieldName + "' configuration for attribute '" + attributeName + "' contains unsupported role '" + role + "'");
}
}
}
}
/**
* Validate that validation configuration is correct.
*
* @param session to be used for Validator SPI integration
* @param validatorConfig config to be checked
* @param errors to add error message in if something is invalid
*/
private static void validateValidationConfig(KeycloakSession session, String validator, Map<String, Object> validatorConfig, String attributeName, List<String> errors) {
if (isBlank(validator)) {
errors.add("Validation without validator id is defined for attribute '" + attributeName + "'");
} else {
if(session!=null) {
if(Validators.validator(session, validator) == null) {
errors.add("Validator '" + validator + "' defined for attribute '" + attributeName + "' doesn't exist");
} else {
ValidationResult result = Validators.validateConfig(session, validator, ValidatorConfig.configFromMap(validatorConfig));
if(!result.isValid()) {
final StringBuilder sb = new StringBuilder();
result.forEachError(err -> sb.append(err.toString()+", "));
errors.add("Validator '" + validator + "' defined for attribute '" + attributeName + "' has incorrect configuration: " + sb.toString());
}
}
}
}
}
/**
* Break string to substrings of given length.
*
* @param src to break
* @param partLength
* @return list of string parts, never null (but can be empty if src is null)
*/
public static List<String> getChunks(String src, int partLength) {
List<String> ret = new ArrayList<>();
if (src != null) {
int pieces = (src.length() / partLength) + 1;
for (int i = 0; i < pieces; i++) {
if ((i + 1) < pieces)
ret.add(src.substring(i * partLength, (i + 1) * partLength));
else if (i == 0 || (i * partLength) < src.length())
ret.add(src.substring(i * partLength));
}
}
return ret;
}
/**
* Check if context CAN BE part of the AuthenticationFlow.
*
* @param context to check
* @return true if context CAN BE part of the auth flow
*/
public static boolean canBeAuthFlowContext(UserProfileContext context) {
return context != UserProfileContext.USER_API && context != UserProfileContext.ACCOUNT
&& context != UserProfileContext.ACCOUNT_OLD;
}
/**
* Check if roles configuration contains role given current context.
*
* @param context to be checked
* @param roles to be inspected
* @return true if roles list contains role representing checked context
*/
public static boolean isRoleForContext(UserProfileContext context, Set<String> roles) {
if (roles == null)
return false;
if (context == UserProfileContext.USER_API)
return roles.contains(ROLE_ADMIN);
else
return roles.contains(ROLE_USER);
}
public static String capitalizeFirstLetter(String str) {
if (str == null || str.isEmpty())
return str;
return str.substring(0, 1).toUpperCase() + str.substring(1);
}
public static String readDefaultConfig() {
try (InputStream is = UPConfigUtils.class.getResourceAsStream(SYSTEM_DEFAULT_CONFIG_RESOURCE)) {
return StreamUtil.readString(is, Charset.defaultCharset());
} catch (IOException cause) {
throw new RuntimeException("Failed to load default user profile config file", cause);
}
}
}