DeclarativeUserProfileProviderFactory.java
/*
* Copyright 2023 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.userprofile;
import java.io.IOException;
import java.nio.file.Paths;
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.Optional;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.keycloak.Config;
import org.keycloak.Config.Scope;
import org.keycloak.authentication.requiredactions.TermsAndConditions;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.component.AmphibianProviderFactory;
import org.keycloak.component.ComponentModel;
import org.keycloak.component.ComponentValidationException;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RequiredActionProviderModel;
import org.keycloak.models.UserModel;
import org.keycloak.organization.validator.OrganizationMemberValidator;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.services.messages.Messages;
import org.keycloak.userprofile.config.UPConfigUtils;
import org.keycloak.userprofile.validator.BlankAttributeValidator;
import org.keycloak.userprofile.validator.BrokeringFederatedUsernameHasValueValidator;
import org.keycloak.userprofile.validator.DuplicateEmailValidator;
import org.keycloak.userprofile.validator.DuplicateUsernameValidator;
import org.keycloak.userprofile.validator.EmailExistsAsUsernameValidator;
import org.keycloak.userprofile.validator.ImmutableAttributeValidator;
import org.keycloak.userprofile.validator.ReadOnlyAttributeUnchangedValidator;
import org.keycloak.userprofile.validator.RegistrationEmailAsUsernameEmailValueValidator;
import org.keycloak.userprofile.validator.RegistrationEmailAsUsernameUsernameValueValidator;
import org.keycloak.userprofile.validator.RegistrationUsernameExistsValidator;
import org.keycloak.userprofile.validator.UsernameHasValueValidator;
import org.keycloak.userprofile.validator.UsernameMutationValidator;
import org.keycloak.validate.ValidatorConfig;
import org.keycloak.validate.validators.EmailValidator;
import static org.keycloak.common.util.ObjectUtil.isBlank;
import static org.keycloak.userprofile.DefaultAttributes.READ_ONLY_ATTRIBUTE_KEY;
import static org.keycloak.userprofile.UserProfileContext.ACCOUNT;
import static org.keycloak.userprofile.UserProfileContext.IDP_REVIEW;
import static org.keycloak.userprofile.UserProfileContext.REGISTRATION;
import static org.keycloak.userprofile.UserProfileContext.UPDATE_EMAIL;
import static org.keycloak.userprofile.UserProfileContext.UPDATE_PROFILE;
import static org.keycloak.userprofile.UserProfileContext.USER_API;
public class DeclarativeUserProfileProviderFactory implements UserProfileProviderFactory, AmphibianProviderFactory<UserProfileProvider> {
public static final String CONFIG_ADMIN_READ_ONLY_ATTRIBUTES = "admin-read-only-attributes";
public static final String CONFIG_READ_ONLY_ATTRIBUTES = "read-only-attributes";
public static final String MAX_EMAIL_LOCAL_PART_LENGTH = "max-email-local-part-length";
public static final String ID = "declarative-user-profile";
public static final int PROVIDER_PRIORITY = 1;
/**
* There are the declarations for creating the built-in validations for read-only attributes. Regardless of the context where
* user profiles are used. They are related to internal attributes with hard conditions on them in terms of management.
*/
private static final String[] DEFAULT_READ_ONLY_ATTRIBUTES = { "KERBEROS_PRINCIPAL", "LDAP_ID", "LDAP_ENTRY_DN", "CREATED_TIMESTAMP", "createTimestamp", "modifyTimestamp", "userCertificate", "saml.persistent.name.id.for.*", "ENABLED", "EMAIL_VERIFIED", "disabledReason" };
private static final String[] DEFAULT_ADMIN_READ_ONLY_ATTRIBUTES = { "KERBEROS_PRINCIPAL", "LDAP_ID", "LDAP_ENTRY_DN", "CREATED_TIMESTAMP", "createTimestamp", "modifyTimestamp" };
private static final Pattern readOnlyAttributesPattern = getRegexPatternString(DEFAULT_READ_ONLY_ATTRIBUTES);
private static final Pattern adminReadOnlyAttributesPattern = getRegexPatternString(DEFAULT_ADMIN_READ_ONLY_ATTRIBUTES);
private static volatile UPConfig PARSED_DEFAULT_RAW_CONFIG;
private final Map<UserProfileContext, UserProfileMetadata> contextualMetadataRegistry = new HashMap<>();
public static void setDefaultConfig(UPConfig defaultConfig) {
if (PARSED_DEFAULT_RAW_CONFIG == null) {
PARSED_DEFAULT_RAW_CONFIG = defaultConfig;
}
}
private static boolean editUsernameCondition(AttributeContext c) {
KeycloakSession session = c.getSession();
KeycloakContext context = session.getContext();
RealmModel realm = context.getRealm();
if (REGISTRATION.equals(c.getContext())
|| IDP_REVIEW.equals(c.getContext())
|| isNewUser(c)) {
return !realm.isRegistrationEmailAsUsername();
}
if (realm.isRegistrationEmailAsUsername()) {
return false;
}
return realm.isEditUsernameAllowed();
}
private static boolean readUsernameCondition(AttributeContext c) {
KeycloakSession session = c.getSession();
KeycloakContext context = session.getContext();
RealmModel realm = context.getRealm();
switch (c.getContext()) {
case REGISTRATION:
case IDP_REVIEW:
return !realm.isRegistrationEmailAsUsername();
case UPDATE_PROFILE:
if (realm.isRegistrationEmailAsUsername()) {
return false;
}
return realm.isEditUsernameAllowed();
case UPDATE_EMAIL:
return false;
}
return true;
}
private static boolean editEmailCondition(AttributeContext c) {
RealmModel realm = c.getSession().getContext().getRealm();
if (REGISTRATION.equals(c.getContext()) || USER_API.equals(c.getContext())) {
return true;
}
if (Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL)) {
return !(UPDATE_PROFILE.equals(c.getContext()) || ACCOUNT.equals(c.getContext()));
}
if (!isNewUser(c) && realm.isRegistrationEmailAsUsername() && !realm.isEditUsernameAllowed()) {
return false;
}
return true;
}
private static boolean readEmailCondition(AttributeContext c) {
UserProfileContext context = c.getContext();
if (REGISTRATION.equals(context) || USER_API.equals(c.getContext())) {
return true;
}
if (Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL)) {
return !UPDATE_PROFILE.equals(context);
}
if (UPDATE_PROFILE.equals(context)) {
RealmModel realm = c.getSession().getContext().getRealm();
if (realm.isRegistrationEmailAsUsername()) {
return realm.isEditUsernameAllowed();
}
}
return true;
}
private static boolean isInternationalizationEnabled(AttributeContext context) {
RealmModel realm = context.getSession().getContext().getRealm();
return realm.isInternationalizationEnabled();
}
private static boolean isTermAndConditionsEnabled(AttributeContext context) {
RealmModel realm = context.getSession().getContext().getRealm();
RequiredActionProviderModel tacModel = realm.getRequiredActionProviderByAlias(
UserModel.RequiredAction.TERMS_AND_CONDITIONS.name());
return tacModel != null && tacModel.isEnabled();
}
private static boolean isNewUser(AttributeContext c) {
return c.getUser() == null;
}
public static Pattern getRegexPatternString(String[] builtinReadOnlyAttributes) {
if (builtinReadOnlyAttributes != null) {
List<String> readOnlyAttributes = new ArrayList<>(Arrays.asList(builtinReadOnlyAttributes));
String regexStr = readOnlyAttributes.stream()
.map(configAttrName -> configAttrName.endsWith("*")
? "^" + Pattern.quote(configAttrName.substring(0, configAttrName.length() - 1)) + ".*$"
: "^" + Pattern.quote(configAttrName) + "$")
.collect(Collectors.joining("|"));
regexStr = "(?i:" + regexStr + ")";
return Pattern.compile(regexStr);
}
return null;
}
@Override
public void init(Config.Scope config) {
initDefaultConfiguration(config);
// make sure registry is clear in case of re-deploy
contextualMetadataRegistry.clear();
Pattern pattern = getRegexPatternString(config.getArray(CONFIG_READ_ONLY_ATTRIBUTES));
AttributeValidatorMetadata readOnlyValidator = null;
if (pattern != null) {
readOnlyValidator = createReadOnlyAttributeUnchangedValidator(pattern);
}
addContextualProfileMetadata(configureUserProfile(createBrokeringProfile(readOnlyValidator)));
addContextualProfileMetadata(configureUserProfile(createAccountProfile(ACCOUNT, readOnlyValidator)));
addContextualProfileMetadata(configureUserProfile(createDefaultProfile(UPDATE_PROFILE, readOnlyValidator)));
if (Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL)) {
addContextualProfileMetadata(configureUserProfile(createDefaultProfile(UPDATE_EMAIL, readOnlyValidator)));
}
addContextualProfileMetadata(configureUserProfile(createRegistrationUserCreationProfile(readOnlyValidator)));
addContextualProfileMetadata(configureUserProfile(createUserResourceValidation(config)));
}
@Override
public List<ProviderConfigProperty> getConfigMetadata() {
return ProviderConfigurationBuilder.create()
.property()
.name(CONFIG_READ_ONLY_ATTRIBUTES)
.type(ProviderConfigProperty.MULTIVALUED_STRING_TYPE)
.helpText("Array of regular expressions to identify fields that should be treated read-only so users can't change them.")
.add()
.property()
.name(CONFIG_ADMIN_READ_ONLY_ATTRIBUTES)
.type(ProviderConfigProperty.MULTIVALUED_STRING_TYPE)
.helpText("Array of regular expressions to identify fields that should be treated read-only so administrators can't change them.")
.add()
.property()
.name(MAX_EMAIL_LOCAL_PART_LENGTH)
.type(ProviderConfigProperty.STRING_TYPE)
.helpText("To set user profile max email local part length")
.add()
.build();
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return ProviderConfigurationBuilder.create()
.property().name(DeclarativeUserProfileProvider.UP_COMPONENT_CONFIG_KEY)
.type(ProviderConfigProperty.STRING_TYPE)
.add()
.build();
}
@Override
public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException {
String upConfigJson = model == null ? null : model.get(DeclarativeUserProfileProvider.UP_COMPONENT_CONFIG_KEY);
if (!isBlank(upConfigJson)) {
try {
UPConfig upc = UPConfigUtils.parseConfig(upConfigJson);
List<String> errors = UPConfigUtils.validate(session, upc);
if (!errors.isEmpty()) {
throw new ComponentValidationException(errors.toString());
}
} catch (IOException e) {
throw new ComponentValidationException(e.getMessage(), e);
}
}
// delete cache so new config is parsed and applied next time it is required
// throught #configureUserProfile(metadata, session)
if (model != null) {
model.removeNote(DeclarativeUserProfileProvider.PARSED_CONFIG_COMPONENT_KEY);
}
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public String getId() {
return ID;
}
@Override
public int order() {
return PROVIDER_PRIORITY;
}
@Override
public String getHelpText() {
return null;
}
@Override
public void close() {
}
@Override
public DeclarativeUserProfileProvider create(KeycloakSession session) {
return new DeclarativeUserProfileProvider(session, this);
}
/**
* Specifies how contextual profile metadata is configured at init time.
*
* @param metadata the profile metadata
* @return the metadata
*/
protected UserProfileMetadata configureUserProfile(UserProfileMetadata metadata) {
// default metadata for each context is based on the default realm configuration
return new DeclarativeUserProfileProvider(null, this).decorateUserProfileForCache(metadata, PARSED_DEFAULT_RAW_CONFIG);
}
private AttributeValidatorMetadata createReadOnlyAttributeUnchangedValidator(Pattern pattern) {
return new AttributeValidatorMetadata(ReadOnlyAttributeUnchangedValidator.ID,
ValidatorConfig.builder().config(ReadOnlyAttributeUnchangedValidator.CFG_PATTERN, pattern)
.build());
}
private void addContextualProfileMetadata(UserProfileMetadata metadata) {
if (contextualMetadataRegistry.putIfAbsent(metadata.getContext(), metadata) != null) {
throw new IllegalStateException("Multiple profile metadata found for context " + metadata.getContext());
}
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
for (AttributeMetadata attribute : metadata.getAttributes()) {
String name = attribute.getName();
if (UserModel.EMAIL.equals(name)) {
attribute.addValidators(List.of(new AttributeValidatorMetadata(OrganizationMemberValidator.ID)));
}
}
}
}
private UserProfileMetadata createBrokeringProfile(AttributeValidatorMetadata readOnlyValidator) {
UserProfileMetadata metadata = new UserProfileMetadata(IDP_REVIEW);
metadata.addAttribute(UserModel.USERNAME, -2, DeclarativeUserProfileProviderFactory::editUsernameCondition,
DeclarativeUserProfileProviderFactory::readUsernameCondition, new AttributeValidatorMetadata(BrokeringFederatedUsernameHasValueValidator.ID)).setAttributeDisplayName("${username}");
metadata.addAttribute(UserModel.EMAIL, -1,
new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_EMAIL, true)))
.setAttributeDisplayName("${email}");
List<AttributeValidatorMetadata> readonlyValidators = new ArrayList<>();
readonlyValidators.add(createReadOnlyAttributeUnchangedValidator(readOnlyAttributesPattern));
if (readOnlyValidator != null) {
readonlyValidators.add(readOnlyValidator);
}
metadata.addAttribute(READ_ONLY_ATTRIBUTE_KEY, 1000, readonlyValidators);
return metadata;
}
private UserProfileMetadata createRegistrationUserCreationProfile(AttributeValidatorMetadata readOnlyValidator) {
UserProfileMetadata metadata = createDefaultProfile(REGISTRATION, readOnlyValidator);
metadata.getAttribute(UserModel.USERNAME).get(0).addValidators(Arrays.asList(
new AttributeValidatorMetadata(RegistrationEmailAsUsernameUsernameValueValidator.ID), new AttributeValidatorMetadata(RegistrationUsernameExistsValidator.ID), new AttributeValidatorMetadata(UsernameHasValueValidator.ID)));
metadata.getAttribute(UserModel.EMAIL).get(0).addValidators(Collections.singletonList(
new AttributeValidatorMetadata(RegistrationEmailAsUsernameEmailValueValidator.ID)));
return metadata;
}
private UserProfileMetadata createDefaultProfile(UserProfileContext context, AttributeValidatorMetadata readOnlyValidator) {
UserProfileMetadata metadata = new UserProfileMetadata(context);
metadata.addAttribute(UserModel.USERNAME, -2,
DeclarativeUserProfileProviderFactory::editUsernameCondition,
DeclarativeUserProfileProviderFactory::readUsernameCondition,
new AttributeValidatorMetadata(UsernameHasValueValidator.ID),
new AttributeValidatorMetadata(DuplicateUsernameValidator.ID),
new AttributeValidatorMetadata(UsernameMutationValidator.ID)).setAttributeDisplayName("${username}");
metadata.addAttribute(UserModel.EMAIL, -1,
DeclarativeUserProfileProviderFactory::editEmailCondition,
DeclarativeUserProfileProviderFactory::readEmailCondition,
new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_EMAIL, false)),
new AttributeValidatorMetadata(DuplicateEmailValidator.ID),
new AttributeValidatorMetadata(EmailExistsAsUsernameValidator.ID),
new AttributeValidatorMetadata(EmailValidator.ID, ValidatorConfig.builder().config(EmailValidator.IGNORE_EMPTY_VALUE, true).build()))
.setAttributeDisplayName("${email}");
List<AttributeValidatorMetadata> readonlyValidators = new ArrayList<>();
readonlyValidators.add(createReadOnlyAttributeUnchangedValidator(readOnlyAttributesPattern));
if (readOnlyValidator != null) {
readonlyValidators.add(readOnlyValidator);
}
metadata.addAttribute(READ_ONLY_ATTRIBUTE_KEY, 1000, readonlyValidators);
return metadata;
}
private UserProfileMetadata createUserResourceValidation(Config.Scope config) {
Pattern p = getRegexPatternString(config.getArray(CONFIG_ADMIN_READ_ONLY_ATTRIBUTES));
UserProfileMetadata metadata = new UserProfileMetadata(USER_API);
metadata.addAttribute(UserModel.USERNAME, -2,
new AttributeValidatorMetadata(UsernameHasValueValidator.ID),
new AttributeValidatorMetadata(DuplicateUsernameValidator.ID))
.addWriteCondition(DeclarativeUserProfileProviderFactory::editUsernameCondition);
metadata.addAttribute(UserModel.EMAIL, -1,
new AttributeValidatorMetadata(DuplicateEmailValidator.ID),
new AttributeValidatorMetadata(EmailExistsAsUsernameValidator.ID),
new AttributeValidatorMetadata(EmailValidator.ID, ValidatorConfig.builder().config(EmailValidator.IGNORE_EMPTY_VALUE, true).build()))
.addWriteCondition(DeclarativeUserProfileProviderFactory::editEmailCondition);
List<AttributeValidatorMetadata> readonlyValidators = new ArrayList<>();
if (p != null) {
readonlyValidators.add(createReadOnlyAttributeUnchangedValidator(p));
}
readonlyValidators.add(createReadOnlyAttributeUnchangedValidator(adminReadOnlyAttributesPattern));
metadata.addAttribute(READ_ONLY_ATTRIBUTE_KEY, 1000, readonlyValidators);
metadata.addAttribute(UserModel.LOCALE, -1, DeclarativeUserProfileProviderFactory::isInternationalizationEnabled, DeclarativeUserProfileProviderFactory::isInternationalizationEnabled)
.setRequired(AttributeMetadata.ALWAYS_FALSE);
metadata.addAttribute(TermsAndConditions.USER_ATTRIBUTE, -1, AttributeMetadata.ALWAYS_FALSE,
DeclarativeUserProfileProviderFactory::isTermAndConditionsEnabled)
.setAttributeDisplayName("${termsAndConditionsUserAttribute}")
.setRequired(AttributeMetadata.ALWAYS_FALSE);
return metadata;
}
private UserProfileMetadata createAccountProfile(UserProfileContext context, AttributeValidatorMetadata readOnlyValidator) {
UserProfileMetadata defaultProfile = createDefaultProfile(context, readOnlyValidator);
defaultProfile.addAttribute(UserModel.LOCALE, -1, DeclarativeUserProfileProviderFactory::isInternationalizationEnabled, DeclarativeUserProfileProviderFactory::isInternationalizationEnabled)
.setRequired(AttributeMetadata.ALWAYS_FALSE);
return defaultProfile;
}
// GETTER METHODS FOR INTERNAL FIELDS
protected UPConfig getParsedDefaultRawConfig() {
return PARSED_DEFAULT_RAW_CONFIG;
}
protected Map<UserProfileContext, UserProfileMetadata> getContextualMetadataRegistry() {
return contextualMetadataRegistry;
}
private void initDefaultConfiguration(Scope config) {
// The user-defined configuration is always parsed during init and should be avoided as much as possible
// If no user-defined configuration is set, the system default configuration must have been set
// In Quarkus, the system default configuration is set at build time for optimization purposes
UPConfig defaultConfig = Optional.ofNullable(config.get("configFile"))
.map(Paths::get)
.map(UPConfigUtils::parseConfig)
.orElse(PARSED_DEFAULT_RAW_CONFIG);
if (defaultConfig == null) {
// as a fallback parse the system default config
defaultConfig = UPConfigUtils.parseSystemDefaultConfig();
}
PARSED_DEFAULT_RAW_CONFIG = null;
setDefaultConfig(defaultConfig);
}
}