diff --git a/services/src/main/java/org/keycloak/broker/saml/mappers/UserAttributeStatementMapper.java b/services/src/main/java/org/keycloak/broker/saml/mappers/UserAttributeStatementMapper.java new file mode 100644 index 0000000000..4856fb6473 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/saml/mappers/UserAttributeStatementMapper.java @@ -0,0 +1,220 @@ +/* + * Copyright (c) eHealth + */ +package org.keycloak.broker.saml.mappers; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.keycloak.broker.provider.AbstractIdentityProviderMapper; +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.broker.saml.SAMLEndpoint; +import org.keycloak.broker.saml.SAMLIdentityProviderFactory; +import org.keycloak.common.util.CollectionUtil; +import org.keycloak.dom.saml.v2.assertion.AssertionType; +import org.keycloak.dom.saml.v2.assertion.AttributeStatementType.ASTChoiceType; +import org.keycloak.dom.saml.v2.assertion.AttributeType; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.provider.ProviderConfigProperty; + +/** + * @author Frederik Libert + * + */ +public class UserAttributeStatementMapper extends AbstractIdentityProviderMapper { + + private static final String USER_ATTR_LOCALE = "locale"; + + private static final String[] COMPATIBLE_PROVIDERS = {SAMLIdentityProviderFactory.PROVIDER_ID}; + + private static final List CONFIG_PROPERTIES = new ArrayList<>(); + + public static final String ATTRIBUTE_NAME_PATTERN = "attribute.name.pattern"; + + public static final String USER_ATTRIBUTE_FIRST_NAME = "user.attribute.firstName"; + + public static final String USER_ATTRIBUTE_LAST_NAME = "user.attribute.lastName"; + + public static final String USER_ATTRIBUTE_EMAIL = "user.attribute.email"; + + public static final String USER_ATTRIBUTE_LANGUAGE = "user.attribute.language"; + + private static final String USE_FRIENDLY_NAMES = "use.friendly.names"; + + static { + ProviderConfigProperty property; + property = new ProviderConfigProperty(); + property.setName(ATTRIBUTE_NAME_PATTERN); + property.setLabel("Attribute Name Pattern"); + property.setHelpText("Pattern of attribute names in assertion that must be mapped. Leave blank to map all attributes."); + property.setType(ProviderConfigProperty.STRING_TYPE); + CONFIG_PROPERTIES.add(property); + property = new ProviderConfigProperty(); + property.setName(USER_ATTRIBUTE_FIRST_NAME); + property.setLabel("User Attribute FirstName"); + property.setHelpText("Define which saml Attribute must be mapped to the User property firstName."); + property.setType(ProviderConfigProperty.STRING_TYPE); + CONFIG_PROPERTIES.add(property); + property = new ProviderConfigProperty(); + property.setName(USER_ATTRIBUTE_LAST_NAME); + property.setLabel("User Attribute LastName"); + property.setHelpText("Define which saml Attribute must be mapped to the User property lastName."); + property.setType(ProviderConfigProperty.STRING_TYPE); + CONFIG_PROPERTIES.add(property); + property = new ProviderConfigProperty(); + property.setName(USER_ATTRIBUTE_EMAIL); + property.setLabel("User Attribute Email"); + property.setHelpText("Define which saml Attribute must be mapped to the User property email."); + property.setType(ProviderConfigProperty.STRING_TYPE); + CONFIG_PROPERTIES.add(property); + property = new ProviderConfigProperty(); + property.setName(USER_ATTRIBUTE_LANGUAGE); + property.setLabel("User Attribute Language"); + property.setHelpText("Define which saml Attribute must be mapped to the User attribute locale."); + property.setType(ProviderConfigProperty.STRING_TYPE); + CONFIG_PROPERTIES.add(property); + property = new ProviderConfigProperty(); + property.setName(USE_FRIENDLY_NAMES); + property.setLabel("Use Attribute Friendly Name"); + property.setHelpText("Define which name to give to each mapped user attribute: name or friendlyName."); + property.setType(ProviderConfigProperty.BOOLEAN_TYPE); + CONFIG_PROPERTIES.add(property); + } + + public static final String PROVIDER_ID = "saml-user-attributestatement-idp-mapper"; + + @Override + public List getConfigProperties() { + return CONFIG_PROPERTIES; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String[] getCompatibleProviders() { + return COMPATIBLE_PROVIDERS.clone(); + } + + @Override + public String getDisplayCategory() { + return "AttributeStatement Importer"; + } + + @Override + public String getDisplayType() { + return "AttributeStatement Importer"; + } + + @Override + public void preprocessFederatedIdentity(KeycloakSession session, RealmModel realm, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { + String firstNameAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_FIRST_NAME); + String lastNameAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_LAST_NAME); + String emailAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_EMAIL); + String langAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_LANGUAGE); + Boolean useFriendlyNames = Boolean.valueOf(mapperModel.getConfig().get(USE_FRIENDLY_NAMES)); + List attributesInContext = findAttributesInContext(context, getAttributePattern(mapperModel)); + for (AttributeType a : attributesInContext) { + String attribute = useFriendlyNames ? a.getFriendlyName() : a.getName(); + List attributeValuesInContext = a.getAttributeValue().stream().filter(Objects::nonNull).map(Object::toString).collect(Collectors.toList()); + if (!attributeValuesInContext.isEmpty()) { + // set as attribute anyway + context.setUserAttribute(attribute, attributeValuesInContext); + // set as special field ? + if (Objects.equals(attribute, emailAttribute)) { + setIfNotEmpty(context::setEmail, attributeValuesInContext); + } else if (Objects.equals(attribute, firstNameAttribute)) { + setIfNotEmpty(context::setFirstName, attributeValuesInContext); + } else if (Objects.equals(attribute, lastNameAttribute)) { + setIfNotEmpty(context::setLastName, attributeValuesInContext); + } else if (Objects.equals(attribute, langAttribute)) { + context.setUserAttribute(USER_ATTR_LOCALE, attributeValuesInContext); + } + } + } + } + + @Override + public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { + String firstNameAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_FIRST_NAME); + String lastNameAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_LAST_NAME); + String emailAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_EMAIL); + String langAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_LANGUAGE); + Boolean useFriendlyNames = Boolean.valueOf(mapperModel.getConfig().get(USE_FRIENDLY_NAMES)); + List attributesInContext = findAttributesInContext(context, getAttributePattern(mapperModel)); + + Set assertedUserAttributes = new HashSet(); + for (AttributeType a : attributesInContext) { + String attribute = useFriendlyNames ? a.getFriendlyName() : a.getName(); + List attributeValuesInContext = a.getAttributeValue().stream().filter(Objects::nonNull).map(Object::toString).collect(Collectors.toList()); + List currentAttributeValues = user.getAttributes().get(attribute); + if (attributeValuesInContext == null) { + // attribute no longer sent by brokered idp, remove it + user.removeAttribute(attribute); + } else if (currentAttributeValues == null) { + // new attribute sent by brokered idp, add it + user.setAttribute(attribute, attributeValuesInContext); + } else if (!CollectionUtil.collectionEquals(attributeValuesInContext, currentAttributeValues)) { + // attribute sent by brokered idp has different values as before, update it + user.setAttribute(attribute, attributeValuesInContext); + } + if (Objects.equals(attribute, emailAttribute)) { + setIfNotEmpty(context::setEmail, attributeValuesInContext); + } else if (Objects.equals(attribute, firstNameAttribute)) { + setIfNotEmpty(context::setFirstName, attributeValuesInContext); + } else if (Objects.equals(attribute, lastNameAttribute)) { + setIfNotEmpty(context::setLastName, attributeValuesInContext); + } else if (Objects.equals(attribute, langAttribute)) { + if(attributeValuesInContext == null) { + user.removeAttribute(USER_ATTR_LOCALE); + } else { + user.setAttribute(USER_ATTR_LOCALE, attributeValuesInContext); + } + assertedUserAttributes.add(USER_ATTR_LOCALE); + } + // Mark attribute as handled + assertedUserAttributes.add(attribute); + } + // Remove user attributes that were not referenced in assertion. + user.getAttributes().keySet().stream().filter(a -> !assertedUserAttributes.contains(a)).forEach(a -> user.removeAttribute(a)); + } + + @Override + public String getHelpText() { + return "Import all saml attributes found in attributestatements in assertion into user properties or attributes."; + } + + private Optional getAttributePattern(IdentityProviderMapperModel mapperModel) { + String attributePatternConfig = mapperModel.getConfig().get(ATTRIBUTE_NAME_PATTERN); + return Optional.ofNullable(attributePatternConfig != null ? Pattern.compile(attributePatternConfig) : null); + } + + private List findAttributesInContext(BrokeredIdentityContext context, Optional attributePattern) { + AssertionType assertion = (AssertionType) context.getContextData().get(SAMLEndpoint.SAML_ASSERTION); + + return assertion.getAttributeStatements().stream()// + .flatMap(statement -> statement.getAttributes().stream())// + .filter(item -> !attributePattern.isPresent() || attributePattern.get().matcher(item.getAttribute().getName()).matches())// + .map(ASTChoiceType::getAttribute)// + .collect(Collectors.toList()); + } + + private void setIfNotEmpty(Consumer consumer, List values) { + if (values != null && !values.isEmpty()) { + consumer.accept(values.get(0)); + } + } + +}