diff --git a/engine/src/main/java/com/agiletec/aps/system/common/entity/AbstractEntitySearcherDAO.java b/engine/src/main/java/com/agiletec/aps/system/common/entity/AbstractEntitySearcherDAO.java index cbb25efb0c..4353296769 100644 --- a/engine/src/main/java/com/agiletec/aps/system/common/entity/AbstractEntitySearcherDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/common/entity/AbstractEntitySearcherDAO.java @@ -136,6 +136,7 @@ protected EntitySearchFilter[] addFilter(EntitySearchFilter[] filters, EntitySea private PreparedStatement buildStatement(EntitySearchFilter[] filters, boolean isCount, boolean selectAll, Connection conn) { String query = this.createQueryString(filters, isCount, selectAll); + PreparedStatement stat = null; try { stat = conn.prepareStatement(query); @@ -567,4 +568,4 @@ protected String getMasterTableName() { return this.getEntityMasterTableName(); } -} \ No newline at end of file +} diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationManager.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationManager.java index 2fa04470e5..cf868a58bc 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationManager.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationManager.java @@ -40,7 +40,7 @@ /** * Servizio di autorizzazione. Il servizio espone tutti i metodi necessari per - * la verifica verifica delle autorizzazioni utente, qualsiasi sia la sua + * la verifica delle autorizzazioni utente, qualsiasi sia la sua * provenienza e definizione. * @author E.Santoboni */ @@ -749,4 +749,4 @@ public void setGroupManager(IGroupManager groupManager) { private IRoleManager _roleManager; private IGroupManager _groupManager; -} \ No newline at end of file +} diff --git a/keycloak-plugin/README.md b/keycloak-plugin/README.md index b43b7c72a4..52676521c9 100644 --- a/keycloak-plugin/README.md +++ b/keycloak-plugin/README.md @@ -10,6 +10,7 @@ Information below is for building from source or running locally as a contributo ### What this plugin does * Enables SSO capabilities to an Entando Instance by using Keycloak. * Moves User Management to Keycloak. +* Assigns on-the-fly authorizations to the logging-in users; such authorizations might come from keycloak itself or external sources ### What this plugin does not This plugin doesn't come with Role and Group management, because Entando Core roles/groups model isn't compatible with Keycloak. That means that even with the same users across multiple Entando Instances, the role and group mappings have to be configured on each instance. @@ -24,6 +25,10 @@ This plugin doesn't come with Role and Group management, because Entando Core ro >- `keycloak.secure.uris`: **[OPTIONAL]** Use if you want to secure an endpoint. Works with wildcards, comma separated. >- `keycloak.authenticated.user.default.authorizations`: **[OPTIONAL]** Use if you want to automatically assign `group:role` to any user that logs in, comma separated. Example: `administrators:admin,readers` +## Environment variables +>- `KC_MAPPING_UPDATE`: specifies the refresh period -Chron style!- of the dynamic configuration used to assign authorizations to the loggin-in users. The default is `0 * * * * *` + + ## Installing ### Installing on your project diff --git a/keycloak-plugin/pom.xml b/keycloak-plugin/pom.xml index 53a50910c8..69fb56e03c 100644 --- a/keycloak-plugin/pom.xml +++ b/keycloak-plugin/pom.xml @@ -13,6 +13,37 @@ Entando Plugin: Keycloak http://www.entando.com/ + + + src/main/resources + + src/main/resources/component + + + + src/main/resources + + **/*component.xml + + true + + + src/main/java + + **/*.properties + **/*.xml + **/*.xsd + **/*.txt + + + + src/test/java + + **/*.sql + **/*.json + + + org.apache.maven.plugins diff --git a/keycloak-plugin/src/main/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilter.java b/keycloak-plugin/src/main/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilter.java index 16261165a5..4b1a7f4c36 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilter.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilter.java @@ -87,6 +87,7 @@ public Authentication attemptAuthentication(final HttpServletRequest request, fi } final String bearerToken = authorization.substring("Bearer ".length()); + final ResponseEntity resp = oidcService.validateToken(bearerToken); final AccessToken accessToken = resp.getBody(); @@ -111,7 +112,7 @@ public Authentication attemptAuthentication(final HttpServletRequest request, fi setUserOnContext(request, user, userAuthentication); // TODO optimise to not check on every request - keycloakGroupManager.processNewUser(user); + keycloakGroupManager.processNewUser(user, bearerToken, true); return userAuthentication; } catch (EntException e) { @@ -174,4 +175,4 @@ public void onAuthenticationFailure(HttpServletRequest request, response.addHeader("Content-Type", "application/json"); response.getOutputStream().println(objectMapper.writeValueAsString(restResponse)); } -} \ No newline at end of file +} diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/filter/KeycloakFilter.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/filter/KeycloakFilter.java index bda6cff8b4..ccc7e824fd 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/filter/KeycloakFilter.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/filter/KeycloakFilter.java @@ -256,7 +256,7 @@ private void doLogin(final HttpServletRequest request, final HttpServletResponse session.setAttribute(SESSION_PARAM_ID_TOKEN, responseEntity.getBody().getIdToken()); session.setAttribute(SESSION_PARAM_REFRESH_TOKEN, responseEntity.getBody().getRefreshToken()); - keycloakGroupManager.processNewUser(user); + keycloakGroupManager.processNewUser(user, responseEntity.getBody().getAccessToken(), true); saveUserOnSession(request, user); log.info("Successfully authenticated user {}", user.getUsername()); } catch (HttpClientErrorException e) { diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManager.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManager.java index 417c6db9ba..57d277d6ef 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManager.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManager.java @@ -1,60 +1,234 @@ package org.entando.entando.keycloak.services; +import static java.util.Collections.emptyList; +import static java.util.Optional.ofNullable; +import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.CLIENTROLE; +import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.GROUP; +import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.GROUPROLE; +import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.ROLE; + +import com.agiletec.aps.system.common.AbstractService; import com.agiletec.aps.system.services.authorization.Authorization; import com.agiletec.aps.system.services.authorization.AuthorizationManager; +import com.agiletec.aps.system.services.baseconfig.BaseConfigManager; import com.agiletec.aps.system.services.group.Group; import com.agiletec.aps.system.services.group.GroupManager; import com.agiletec.aps.system.services.role.Role; import com.agiletec.aps.system.services.role.RoleManager; import com.agiletec.aps.system.services.user.UserDetails; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; import com.google.common.collect.Sets; -import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.stream.Collectors; - -import static java.util.Optional.ofNullable; - +import java.util.stream.StreamSupport; +import org.apache.commons.lang3.StringUtils; import org.entando.entando.ent.exception.EntException; +import org.entando.entando.ent.util.EntLogging.EntLogFactory; +import org.entando.entando.ent.util.EntLogging.EntLogger; +import org.entando.entando.keycloak.services.mapping.DynamicMapping; +import org.entando.entando.keycloak.services.mapping.DynamicMappingElement; +import org.entando.entando.keycloak.services.oidc.model.KeycloakUser; +import org.springframework.beans.factory.annotation.Autowired; -@Service -public class KeycloakAuthorizationManager { +public class KeycloakAuthorizationManager extends AbstractService { + + private static final EntLogger log = EntLogFactory.getSanitizedLogger(KeycloakAuthorizationManager.class); + private static final String DEFAULT_SEPARATOR = "_"; private final KeycloakConfiguration configuration; private final AuthorizationManager authorizationManager; private final GroupManager groupManager; private final RoleManager roleManager; + private final BaseConfigManager configManager; private static final int GROUP_POSITION = 0; private static final int ROLE_POSITION = 1; + private final ObjectMapper mapper = new ObjectMapper(); + private final XmlMapper xmlMapper = new XmlMapper(); + + private final transient ReadWriteLock configUpdateLock = new ReentrantReadWriteLock(); + private final transient Lock readLock = configUpdateLock.readLock(); + private final transient Lock writeLock = configUpdateLock.writeLock(); + @Autowired public KeycloakAuthorizationManager(final KeycloakConfiguration configuration, - final AuthorizationManager authorizationManager, - final GroupManager groupManager, - final RoleManager roleManager) { + final AuthorizationManager authorizationManager, + final GroupManager groupManager, + final RoleManager roleManager, + final BaseConfigManager configManager1) { this.configuration = configuration; this.authorizationManager = authorizationManager; this.groupManager = groupManager; this.roleManager = roleManager; + this.configManager = configManager1; } - public void processNewUser(final UserDetails user) { - if (StringUtils.isEmpty(configuration.getDefaultAuthorizations())) { - return; + /** + * Immutable list of mapping elements that are currently active. The configuration is constantly updated + * either by reloading the global configuration or after a certain amount of time (by default, + * one minute) + */ + private transient List profileMappings; + private transient List jwtMappings; + + @Override + public void init() throws Exception { + writeLock.lock(); + profileMappings = new ArrayList<>(); + jwtMappings = new ArrayList<>(); + try { + String xml = configManager.getConfigItem("dynamicAuthMapping"); + if (StringUtils.isNotBlank(xml)) { + DynamicMapping dynConf = xmlMapper.readValue(xml, DynamicMapping.class); + if (dynConf != null && dynConf.mapping != null) { + + Map> partitioned = + dynConf.mapping.stream() + .filter(this::isValid) + .collect(Collectors.partitioningBy( + item -> item.kind.isJwtMapping() + )); + profileMappings = List.copyOf(partitioned.get(false)); + jwtMappings = List.copyOf(partitioned.get(true)); + log.debug("{} dynamic auth mapping found, {} profileMappings", + dynConf.mapping.size(), profileMappings.size()); + } + } + if (profileMappings != null) { + profileMappings.forEach(m -> log.debug("mapping active: {}", m.toString())); + } + } catch (Exception e) { + log.error("Error initializing KeycloakAuthorizationManager", e); + throw e; + } finally { + writeLock.unlock(); + } + } + + /** + * This is invoked after a configured time to update the configuration + */ + public void refreshConfiguration() { + try { + init(); + } catch (Exception e) { + log.error("Error refreshing dynamic mapping configuration"); + } + } + + /** + * Check whether the dynamic configuration element provided is valid + * @param elem the single dynamic mapping element to validate + * @return true if the element is valid, false otherwise + */ + private boolean isValid(DynamicMappingElement elem) { + if (elem.kind == null) { + log.error("invalid dynamic mapping element, 'kind' is blank"); + return false; + } + if (StringUtils.isBlank(elem.attribute) && elem.kind != CLIENTROLE) { + log.error("invalid dynamic mapping element, 'attribute' is blank"); + return false; + } + if (StringUtils.isBlank(elem.client) && elem.kind == CLIENTROLE) { + log.error("invalid dynamic mapping element, 'client' is blank for CLIENTROLE kind"); + return false; + } + if (StringUtils.isBlank(elem.separator) && elem.kind == GROUPROLE) { + log.error("invalid dynamic mapping element, 'separator' is blank for GROUPROLE kind"); + return false; + } + return true; + } + + public void processNewUser(final UserDetails user, final String token, final boolean decode) { + processNewUser(user); + readLock.lock(); + try { + // TODO for the future: handle also groups ~ "claim to group import" type + final List jwtRoleMapper = ofNullable(jwtMappings) + .orElse(emptyList()) + .stream() + .filter(m -> m.kind == CLIENTROLE) + .collect(Collectors.toList()); + // process client role claims, if any... + if (StringUtils.isNotBlank(token) && !jwtRoleMapper.isEmpty()) { + for (DynamicMappingElement cur: jwtRoleMapper) { + processRoleClaimAttributes(user, token, decode, cur); + } + } + // ...then process attributes coming from the user profile, if needed + if (user instanceof KeycloakUser + && profileMappings != null + && !profileMappings.isEmpty()) { + processProfileAttributes((KeycloakUser) user); + } + } finally { + readLock.unlock(); + } + } + + /** + * Analyze the JWT looking for known mappings to translate into Entando roles + * @param user logged in user + * @param token access token + * @param decode is true the access token is decoded from the base64 form + * @param tokenMapper the mapping configuration + */ + private void processRoleClaimAttributes(UserDetails user, String token, boolean decode, DynamicMappingElement tokenMapper) { + final String payload = decode ? token.split("\\.")[1] : token; + final String json = decode ? new String(Base64.getUrlDecoder().decode(payload)) : payload; + + try { + final JsonNode root = mapper.readTree(json); + + JsonNode roleNode = root + .path("resource_access") + .path(tokenMapper.client) + .path("roles"); + + if (roleNode == null) { + return ; + } + + List roles = StreamSupport.stream(roleNode.spliterator(), false) + .map(JsonNode::asText) + .collect(Collectors.toList()); + if (user instanceof KeycloakUser) { + finalizeRoleAssociation((KeycloakUser) user, tokenMapper, roles); + } + + } catch (Exception e) { + log.error("error importing client role into Entando roles", e); } - final Set defaultAuthorizations = Sets.newHashSet(configuration.getDefaultAuthorizations().split(",")); - final Set userAuthorizations = user.getAuthorizations().stream().map(authorization -> { - final String group = ofNullable(authorization.getGroup()).map(Group::getName).orElse(""); - final String role = ofNullable(authorization.getRole()).map(Role::getName).orElse(""); - return StringUtils.isEmpty(role) ? group : group + ":" + role; - }).collect(Collectors.toSet()); + } - defaultAuthorizations.stream() - .filter(defaultGroup -> !userAuthorizations.contains(defaultGroup)) - .forEach(authorization -> this.assignGroupToUser(authorization, user)); + private void processNewUser(final UserDetails user) { + if (StringUtils.isNotEmpty(configuration.getDefaultAuthorizations())) { + // process group and role coming from the configuration + final Set defaultAuthorizations = Sets.newHashSet(configuration.getDefaultAuthorizations().split(",")); + final Set userAuthorizations = user.getAuthorizations().stream().map(authorization -> { + final String group = ofNullable(authorization.getGroup()).map(Group::getName).orElse(""); + final String role = ofNullable(authorization.getRole()).map(Role::getName).orElse(""); + return StringUtils.isEmpty(role) ? group : group + ":" + role; + }).collect(Collectors.toSet()); + + defaultAuthorizations.stream() + .filter(defaultGroup -> !userAuthorizations.contains(defaultGroup)) + .forEach(authorization -> this.assignGroupToUser(authorization, user)); + } } private void assignGroupToUser(final String authorization, final UserDetails user) { @@ -66,43 +240,294 @@ private void assignGroupToUser(final String authorization, final UserDetails use .map(this::findOrCreateGroup).orElse(null); final Role role = ofNullable(roleName).filter(StringUtils::isNotEmpty) .map(this::findOrCreateRole).orElse(null); - groupName = ofNullable(group).map(Group::getName).orElse(null); // null or "" ? - roleName = ofNullable(role).map(Role::getName).orElse(null); // null or "" ? + groupName = ofNullable(group).map(Group::getName).orElse(null); // null or ""? + roleName = ofNullable(role).map(Role::getName).orElse(null); // null or ""? authorizationManager.addUserAuthorization(user.getUsername(), groupName, roleName); - user.addAuthorization(new Authorization(group, role)); } catch (EntException e) { throw new RuntimeException(e); } } - private Group findOrCreateGroup(final String groupName) { + private synchronized Group findOrCreateGroup(final String groupName) { + Group group = groupManager.getGroup(groupName); + if (group == null) { + group = new Group(); + group.setName(groupName); + group.setDescription(groupName); + try { + groupManager.addGroup(group); + } catch (EntException e) { + log.error("Failed to create group: {}", groupName, e); + } + } + return group; + } + + private synchronized Role findOrCreateRole(final String roleName) { + Role role = roleManager.getRole(roleName); + if (role == null) { + role = new Role(); + role.setName(roleName); + role.setDescription(roleName); + try { + roleManager.addRole(role); + } catch (EntException e) { + log.error("Failed to create role: {}", roleName, e); + } + } + return role; + } + + /** + * Map dynamically, optionally persisting, authorization coming from the user profile in + * keycloak + * @param user the currently logged user + */ + private synchronized void processProfileAttributes(final KeycloakUser user) { + profileMappings.forEach(m -> { + if (m.kind == ROLE) { + doProcessRole(user, m); + } + if (m.kind == GROUP) { + doProcessGroup(user, m); + } + if (m.kind == GROUPROLE) { + doProcessGroupRole(user, m); + } + }); + } + + private void doProcessGroupRole(KeycloakUser user, DynamicMappingElement elem) { + final String separator = StringUtils.isBlank(elem.separator) ? + DEFAULT_SEPARATOR : elem.separator; + try { - Group group = groupManager.getGroup(groupName); - if (group == null) { + final List authorizations = processUserProfileAttribute(user, elem); + + if (authorizations == null) { + return; + } + for (String groupRoleToken : authorizations) { + parseAuthForGroupRole(user, elem, groupRoleToken, separator); + } + } catch (Exception e) { + log.error("error processing dynamic GRUOPROLE association", e); + } + } + + private void parseAuthForGroupRole(KeycloakUser user, DynamicMappingElement elem, String groupRoleToken, String separator) + throws EntException { + final String[] tokens = groupRoleToken.split(separator); + + if (tokens.length != 2 + || StringUtils.isBlank(tokens[0]) + || StringUtils.isBlank(tokens[1])) { + log.error("invalid dynamic config configuration detected"); + return; + } + + final String groupName = tokens[0]; + final String roleName = tokens[1]; + + Authorization authorization; + Group group = null; + Role role = null; + + if (elem.persist) { + if (StringUtils.isNotBlank(groupName)) { + group = findOrCreateGroup(groupName); + } + + if (StringUtils.isNotBlank(roleName)) { + role = findOrCreateRole(roleName); + } + authorization = new Authorization(group, role); + + persistAuthIfMissing(user, authorization); + } else { + if (StringUtils.isNotBlank(groupName)) { group = new Group(); group.setName(groupName); - group.setDescription(groupName); - groupManager.addGroup(group); + group.setDescription("sys:" + groupName); } - return group; - } catch (EntException e) { - throw new RuntimeException(e); + if (StringUtils.isNotBlank(roleName)) { + // make sure all the permissions are assigned to the current role + role = roleManager.getRole(roleName); + } + authorization = new Authorization(group, role); } + user.addAuthorization(authorization); } - private Role findOrCreateRole(final String roleName) { - try { - Role role = roleManager.getRole(roleName); - if (role == null) { - role = new Role(); - role.setName(roleName); - role.setDescription(roleName); - roleManager.addRole(role); + /** + * Process the dynamic Role authorization for the given user + * @param user the currently logging-in user + * @param elem a single dynamic configuration + */ + private void doProcessRole(KeycloakUser user, DynamicMappingElement elem) { + final List authorizations = processUserProfileAttribute(user, elem); + finalizeRoleAssociation(user, elem, authorizations); + } + + private void finalizeRoleAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) { + if (authorizations == null) { + return; + } + for (String kca: authorizations) { + try { + // skip if the group is already mapped + if (user.getAuthorizations() + .stream() + .anyMatch(a -> a.getRole() != null + && a.getRole().getName().equals(kca))) { + log.debug("Role {} already assigned to user {}", kca, user.getUsername()); + return; + } + final Authorization auth = elem.persist + ? createPersistedRoleAuthorization(user, kca) + : createTransientRoleAuthorization(kca); + + user.addAuthorization(auth); + log.info("Successfully assigned role {} to user {}", kca, user.getUsername()); + } catch (Exception e) { + log.error("Error processing dynamic role '{}' for user {}", kca , user.getUsername(), e); } - return role; - } catch (EntException e) { - throw new RuntimeException(e); } } + private Authorization createPersistedRoleAuthorization(KeycloakUser user, String roleName) throws EntException { + Role role = findOrCreateRole(roleName); + Authorization auth = new Authorization(null, role); + persistAuthIfMissing(user, auth); + return auth; + } + + private Authorization createTransientRoleAuthorization(String roleName) { + Role role = roleManager.getRole(roleName); + if (role == null) { + role = new Role(); + role.setName(roleName); + } + return new Authorization(null, role); + } + + /** + * Process the dynamic Group authorization for the given user + * @param user the currently logging-in user + * @param elem a single dynamic configuration + */ + private void doProcessGroup(KeycloakUser user, DynamicMappingElement elem) { + final List authorizations = processUserProfileAttribute(user, elem); + if (authorizations == null) { + return; + } + for (String kca: authorizations) { + try { + // skip if the role is already mapped + if (user.getAuthorizations() + .stream() + .anyMatch(a -> a.getGroup() != null + && a.getGroup().getName().equals(kca))) { + log.debug("Group {} already assigned to user {}", kca, user.getUsername()); + return; + } + final Authorization auth = elem.persist + ? createPersistedGroupAuthorization(user, kca) + : createTransientGroupAuthorization(kca); + + user.addAuthorization(auth); + log.info("Successfully assigned group {} to user {}", kca, user.getUsername()); + } catch (Exception e) { + log.error("Error processing dynamic group for user {}", user.getUsername(), e); + } + } + } + + private Authorization createPersistedGroupAuthorization(KeycloakUser user, String groupName) throws EntException { + final Group group = findOrCreateGroup(groupName); + final Authorization auth = new Authorization(group, null); + persistAuthIfMissing(user, auth); + return auth; + } + + private Authorization createTransientGroupAuthorization(String groupName) { + Group group = new Group(); + group.setName(groupName); + return new Authorization(group, null); + } + + /** + * Process dynamic configuration element for a user. If the attribute is missing, it skips processing. + * @param user the Keycloak user + * @param elem the dynamic mapping element + * @return the list of processed attribute tokens or null if the attribute is missing + */ + private static List processUserProfileAttribute(KeycloakUser user, DynamicMappingElement elem) { + if (user.getUserRepresentation() == null + || user.getUserRepresentation().getAttributes() == null + || !user.getUserRepresentation().getAttributes().containsKey(elem.attribute)) { + log.info("skipping dynamic processing for user {}", user.getUsername()); + return Collections.emptyList(); + } + final Object kcProfileAttr = user.getUserRepresentation() + .getAttributes() + .get(elem.attribute); + return handleKeycloakAttribute(kcProfileAttr); + } + + /** + * To avoid creating duplicate records, we are forced to check if the authorization already exists. + * @param user the user being processed + * @param auth the authorization to persist + * @throws EntException in case of errors + */ + private synchronized void persistAuthIfMissing(KeycloakUser user, Authorization auth) throws EntException { + final List existing = authorizationManager.getUserAuthorizations(user.getUsername()); + if (existing.stream() + .noneMatch(a -> (a.getGroup() != null && a.getRole() == null + && auth.getGroup() != null + && a.getGroup().getName().equals(auth.getGroup().getName())) + || + (a.getRole() != null && a.getGroup() == null + && auth.getRole() != null + && a.getRole().getName().equals(auth.getRole().getName())) + + || + (a.getRole() != null && a.getGroup() != null + && auth.getRole() != null + && auth.getGroup() != null + && a.getRole().getName().equals(auth.getRole().getName()) + && a.getGroup().getName().equals(auth.getGroup().getName())) + ) + ) { + log.debug("dynamically persisting authorization for user '{}' : group {}, role {}", user.getUsername(), + auth.getGroup() != null ? auth.getGroup().getName() : "N/A", + auth.getRole() != null ? auth.getRole().getName() : "N/A"); + authorizationManager.addUserAuthorization(user.getUsername(), auth); + } + } + + /** + * Process user attribute of the Keycloak profile. If it's a list, it will be flattened and split by whitespace. + * If it's a string, it will be split by whitespace. + * @param attribute the attribute data + * @return the list of the processed attribute tokens + */ + protected static List handleKeycloakAttribute(Object attribute) { + if (attribute instanceof List) { + List list = (List) attribute; + return list.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .flatMap(s -> Arrays.stream(s.split("\\s+"))) + .filter(token -> !token.isBlank()) + .collect(Collectors.toUnmodifiableList()); + } else if (attribute instanceof String) { + return Arrays.stream(((String) attribute).split("\\s+")) + .filter(token -> !token.isBlank()) + .collect(Collectors.toUnmodifiableList()); + } + return new ArrayList<>(); + } + } diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakMapper.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakMapper.java index d86f437ac4..574ee54dcd 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakMapper.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakMapper.java @@ -1,23 +1,25 @@ package org.entando.entando.keycloak.services; +import static java.util.Optional.ofNullable; + import com.agiletec.aps.system.services.user.User; +import org.entando.entando.keycloak.services.oidc.model.KeycloakUser; import org.entando.entando.keycloak.services.oidc.model.UserRepresentation; -import static java.util.Optional.ofNullable; - class KeycloakMapper { static User convertUserDetails(final UserRepresentation userRepresentation) { final boolean credentialsExpired = ofNullable(userRepresentation.getRequiredActions()) .filter(actions -> actions.contains("UPDATE_PASSWORD")).isPresent(); - final User user = credentialsExpired ? newUserCredentialsExpired() : new User(); + final KeycloakUser user = credentialsExpired ? newUserCredentialsExpired() : new KeycloakUser(); user.setDisabled(!userRepresentation.isEnabled()); user.setUsername(userRepresentation.getUsername()); + user.setUserRepresentation(userRepresentation); return user; } - private static User newUserCredentialsExpired() { - return new User() { + private static KeycloakUser newUserCredentialsExpired() { + return new KeycloakUser() { { setMaxMonthsSinceLastAccess(-1); setMaxMonthsSinceLastPasswordChange(-1); diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakService.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakService.java index b0d3bd9e7e..43d84e0652 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakService.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakService.java @@ -1,13 +1,10 @@ package org.entando.entando.keycloak.services; -import static org.entando.entando.KeycloakWiki.wiki; - -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.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.entando.entando.KeycloakWiki; import org.entando.entando.aps.system.exception.RestServerError; @@ -27,6 +24,16 @@ import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; +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 static org.entando.entando.KeycloakWiki.wiki; + +@Slf4j @Service public class KeycloakService { @@ -34,6 +41,7 @@ public class KeycloakService { private final KeycloakConfiguration configuration; private final RestTemplate restTemplate; + @Autowired public KeycloakService(final KeycloakConfiguration configuration, final OpenIDConnectService oidcService, @Qualifier("keycloakRestTemplate")final RestTemplate rest) { this.configuration = configuration; @@ -45,17 +53,58 @@ public List listUsers() { return listUsers(null); } + // Handle invocations such as http://localhost:8081/auth/admin/realms/entando-development/users?briefRepresentation=true&first=0&max=20&search=testutentemariorossi%2B4375@gmail.com public List listUsers(final String text) { final String url = String.format("%s/admin/realms/%s/users", configuration.getAuthUrl(), configuration.getRealm()); - final Map params = StringUtils.isEmpty(text) - ? Collections.emptyMap() - : Collections.singletonMap("username", text); - String token = this.extractToken(); - final ResponseEntity response = this.executeRequest(token, url, - HttpMethod.GET, createEntity(token), UserRepresentation[].class, params); - return Optional.ofNullable(response.getBody()) + final String searchString = StringUtils.isNotBlank(text) ? encodeForKeycloakSearchAPI(text) : text; + final boolean isExact = StringUtils.isBlank(text) || (StringUtils.isNotBlank(text) && searchString.equals(text)); + final Map params = new HashMap<>(); + + if (StringUtils.isNotBlank(text)) { + params.put("username", searchString); + } + + List retval; + final String token = this.extractToken(); + + if (!isExact && !params.isEmpty()) { + params.put("briefRepresentation", "true"); + params.put("search", searchString); + // reference for paged request: + // params.put("first", "0"); + // params.put("max", "20"); + params.remove("username"); + } + final ResponseEntity response = this.executeEscapedRequest(token, url, + HttpMethod.GET, createEntity(token, null), UserRepresentation[].class, params, 0); + retval = Optional.ofNullable(response.getBody()) .map(Arrays::asList) .orElse(Collections.emptyList()); + if (retval.size() > 1) { + // must match the exact element by USERNAME + Optional userOpt = retval.stream() + .filter(e -> (e.getUsername() != null && e.getUsername().equals(text)) + || (e.getEmail() != null && e.getEmail().equals(text))) + .findFirst(); + return userOpt.stream().collect(Collectors.toList()); + } + return retval; + } + + private String encodeForKeycloakSearchAPI(String text) { + final List specialCharacters = List.of( + '#', '+', '&', '%', '\'', '/', '=', '?', '^', '{', '|', '}', '`', '"' + ); + final StringBuilder encoded = new StringBuilder(); + + for (char c : text.toCharArray()) { + if (specialCharacters.contains(c)) { + encoded.append(URLEncoder.encode(String.valueOf(c), StandardCharsets.UTF_8)); + } else { + encoded.append(c); + } + } + return encoded.toString(); } public void removeUser(final String uuid) { @@ -107,12 +156,12 @@ private ResponseEntity executeRequest(String token, final String url, } private ResponseEntity executeRequest(String token, final String url, final HttpMethod method, final HttpEntity entity, - final Class result, final Map params) { + final Class result, final Map params) { return executeRequest(token, url, method, entity, result, params, 0); } private ResponseEntity executeRequest(String token, final String url, final HttpMethod method, final HttpEntity entity, - final Class result, final Map params, int retryCount) { + final Class result, final Map params, int retryCount) { try { final UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(url); params.forEach(builder::queryParam); @@ -132,6 +181,48 @@ private ResponseEntity executeRequest(String token, final String url, } } + private ResponseEntity executeEscapedRequest(String token, final String url, final HttpMethod method, final HttpEntity entity, + final Class result, final Map params, int retryCount) { + try { + final UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(url); + final Optional isAlreadyEscaped = params.values() + .stream().filter(v -> StringUtils.isNotBlank(v) && v.contains("%")) + .findFirst(); + if (isAlreadyEscaped.isEmpty()) { + // default behaviour, query string is escaped automatically + params.forEach(builder::queryParam); + return restTemplate.exchange(builder.build().toUri(), method, + createEntity(token, entity.getBody()), result); + } else { + /* + * We don't want to automatically escape character since they come + * already escaped, so we construct URI directly from the string + */ + StringBuilder queryBuilder = new StringBuilder(); + params.forEach((key, value) -> { + if (queryBuilder.length() > 0) { + queryBuilder.append("&"); + } + queryBuilder.append(key).append("=").append(value); + }); + String escapedUrl = builder.build().toUri() + "?" + queryBuilder; + return restTemplate.exchange( + URI.create(escapedUrl), method, createEntity(token, entity.getBody()), + result); + } + } catch (HttpClientErrorException e) { + if (HttpStatus.FORBIDDEN.equals(e.getStatusCode()) || (HttpStatus.UNAUTHORIZED.equals(e.getStatusCode()) && retryCount > 10)) { + throw new RestServerError("There was an error while trying to load user because the " + + "client on Keycloak doesn't have permission to do that. " + + "The client needs to have Service Accounts enabled and the permission 'realm-admin' on client 'realm-management'. ", e); + } + if (HttpStatus.UNAUTHORIZED.equals(e.getStatusCode())) { + return this.executeEscapedRequest(null, url, method, entity, result, params, retryCount + 1); + } + throw e; + } + } + private String extractToken() { try { final AuthResponse authResponse = oidcService.authenticateAPI(); diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakUserManager.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakUserManager.java index 59d08f7ff0..39146d8bc5 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakUserManager.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakUserManager.java @@ -162,7 +162,11 @@ private void updateUserPassword(final UserRepresentation user, final String pass } private Optional getUserRepresentation(final String username) { - return keycloakService.listUsers(username).stream().filter(f->f.getUsername().equals(username)).findFirst(); + return ofNullable(keycloakService.listUsers(username)) + .orElse(emptyList()) + .stream() + .filter(f->f.getUsername().equals(username)) + .findFirst(); } @Override diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java new file mode 100644 index 0000000000..b3b30ecdd5 --- /dev/null +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMapping.java @@ -0,0 +1,15 @@ +package org.entando.entando.keycloak.services.mapping; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; +import java.util.List; + +@JacksonXmlRootElement(localName = "dynamicmapping") +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class DynamicMapping { + + @JacksonXmlElementWrapper(useWrapping = false) + public List mapping; + +} diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java new file mode 100644 index 0000000000..bddf07f618 --- /dev/null +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingElement.java @@ -0,0 +1,19 @@ +package org.entando.entando.keycloak.services.mapping; + + +public class DynamicMappingElement { + + public boolean enabled; + public String attribute; + public DynamicMappingKind kind; + public String injectTo; + public boolean persist; + public String separator; // FOR GROUPROLE ONLY + public String client; // FOR CLIENTROLE ONLY + + public String toString() { + return "DynamicMappingElement(enabled=" + this.enabled + ", attribute=" + this.attribute + ", kind=" + + this.kind + ", injectTo=" + this.injectTo + + ", persist=" + this.persist + ", separator=\" + this.separator + \")"; + } +} diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java new file mode 100644 index 0000000000..3cce689f39 --- /dev/null +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/mapping/DynamicMappingKind.java @@ -0,0 +1,37 @@ +package org.entando.entando.keycloak.services.mapping; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.Arrays; +import lombok.Getter; + +public enum DynamicMappingKind { + + GROUP("group", false), + ROLE("role", false), + GROUPROLE("grouprole", false), + CLIENTROLE("clientrole", true); + + private final String kind; + @Getter + private final boolean jwtMapping; + + DynamicMappingKind(String kind, boolean jwtmapping) { + this.kind = kind; + this.jwtMapping = jwtmapping; + } + + @JsonValue + public String getXmlValue() { + return kind; + } + + @JsonCreator + public static DynamicMappingKind fromValue(String value) { + return Arrays.stream(values()) + .filter(k -> k.kind.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> + new IllegalArgumentException("Unknown DynamicMappingKind: " + value)); + } +} diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/model/KeycloakUser.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/model/KeycloakUser.java new file mode 100644 index 0000000000..acc92afccc --- /dev/null +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/model/KeycloakUser.java @@ -0,0 +1,37 @@ +package org.entando.entando.keycloak.services.oidc.model; + +import com.agiletec.aps.system.services.user.User; + +public class KeycloakUser extends User { + + private UserRepresentation userRepresentation; + + @Override + public boolean isEntandoUser() { + return false; + } + + @Override + @Deprecated + public boolean isJapsUser() { + return this.isEntandoUser(); + } + + @Override + public Object clone() { + KeycloakUser cl = new KeycloakUser(); + cl.setUsername(this.getUsername()); + cl.setPassword(""); + cl.setAuthorizations(this.getAuthorizations()); + cl.setUserRepresentation(this.getUserRepresentation()); + return cl; + } + + public UserRepresentation getUserRepresentation() { + return userRepresentation; + } + public void setUserRepresentation(UserRepresentation userRepresentation) { + this.userRepresentation = userRepresentation; + } + +} diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/model/UserRepresentation.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/model/UserRepresentation.java index 47778d8990..346a827f75 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/model/UserRepresentation.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/oidc/model/UserRepresentation.java @@ -2,6 +2,7 @@ import java.io.Serializable; import java.util.List; +import java.util.Map; public class UserRepresentation implements Serializable { @@ -15,6 +16,16 @@ public class UserRepresentation implements Serializable { private String lastName; private String email; private List requiredActions; + // ESB-890 + private Map attributes; + + public Map getAttributes() { + return attributes; + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } public String getId() { return id; diff --git a/keycloak-plugin/src/main/resources/component/entando-keycloak-auth/component.xml b/keycloak-plugin/src/main/resources/component/entando-keycloak-auth/component.xml new file mode 100644 index 0000000000..2134d388b9 --- /dev/null +++ b/keycloak-plugin/src/main/resources/component/entando-keycloak-auth/component.xml @@ -0,0 +1,20 @@ + + + entando-keycloak-auth + Entando CMS + + ${project.artifactId} + ${project.groupId} + ${project.version} + + + + + sysconfig
+
+
+ + + +
+
diff --git a/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/changeSetPort.xml b/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/changeSetPort.xml new file mode 100644 index 0000000000..02b9537b7b --- /dev/null +++ b/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/changeSetPort.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/00000000000003_dataPort_production.xml b/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/00000000000003_dataPort_production.xml new file mode 100644 index 0000000000..8d2677a621 --- /dev/null +++ b/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/00000000000003_dataPort_production.xml @@ -0,0 +1,27 @@ + + + + + + SELECT COUNT(*) FROM DATABASECHANGELOG WHERE id = '00000000000003_dataPort_production' + + SELECT COUNT(*) FROM DATABASECHANGELOG WHERE id = '00000000000003_dataPort_production' AND filename = 'liquibase/changeSetPort.xml' + + + + + + + + + + + + + diff --git a/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/clob/production/sysconfig_kc.xml b/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/clob/production/sysconfig_kc.xml new file mode 100644 index 0000000000..f631f4c253 --- /dev/null +++ b/keycloak-plugin/src/main/resources/liquibase/entando-keycloak-auth/port/clob/production/sysconfig_kc.xml @@ -0,0 +1,28 @@ + + + + false + sim730 + CLIENTROLE + false + + + false + AD_ROLE + ROLE + false + + + false + AD_GROUP + GROUP + false + + + false + AD_GROUPROLE + GROUPROLE + _r_ + false + + diff --git a/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml b/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml index f4b00a5d58..dc9f3a1a57 100644 --- a/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml +++ b/keycloak-plugin/src/main/resources/spring/plugins/keycloak/aps/keycloak.xml @@ -1,12 +1,30 @@ + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xmlns:context="http://www.springframework.org/schema/context" + xmlns:task="http://www.springframework.org/schema/task" + xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd + http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/task http://www.springframework.org/schema/task/spring-task.xsd"> + + + + + + + + + + + + + diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/filter/KeycloakFilterTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/filter/KeycloakFilterTest.java index 7c162bde02..99b0d5c03c 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/filter/KeycloakFilterTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/filter/KeycloakFilterTest.java @@ -28,7 +28,6 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; -import org.apache.commons.lang3.StringUtils; import org.assertj.core.api.AbstractBooleanAssert; import org.assertj.core.api.AbstractCharSequenceAssert; import org.entando.entando.aps.system.services.tenants.ITenantManager; @@ -160,7 +159,7 @@ void testAuthenticationFlow() throws IOException, ServletException, EntException verify(oidcService, times(1)).requestToken(eq(authorizationCode), eq(loginEndpoint)); verify(oidcService, times(1)).validateToken(eq("access-token-over-here")); - verify(keycloakGroupManager, times(1)).processNewUser(same(userDetails)); + verify(keycloakGroupManager, times(1)).processNewUser(same(userDetails), null, false); verify(session, times(1)).setAttribute(eq("user"), same(userDetails)); verify(session, times(1)).setAttribute(eq(SystemConstants.SESSIONPARAM_CURRENT_USER), same(userDetails)); @@ -169,7 +168,7 @@ void testAuthenticationFlow() throws IOException, ServletException, EntException } @Test - void testAuthenticationFlowWithError() throws IOException, ServletException { + void testAuthenticationFlowWithError() { final String loginEndpoint = "https://dev.entando.org/entando-app/do/login"; final String state = "0ca97afd-f0b0-4860-820a-b7cd1414f69c"; final String authorizationCode = "the-authorization-code-from-keycloak"; diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index 0dfecfc77c..c9ccce4b16 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -1,46 +1,53 @@ package org.entando.entando.keycloak.services; -import com.agiletec.aps.system.services.authorization.Authorization; -import com.agiletec.aps.system.services.authorization.AuthorizationManager; -import com.agiletec.aps.system.services.group.Group; -import com.agiletec.aps.system.services.group.GroupManager; -import com.agiletec.aps.system.services.role.Role; -import com.agiletec.aps.system.services.role.RoleManager; -import com.agiletec.aps.system.services.user.UserDetails; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; - -import java.util.ArrayList; -import java.util.Arrays; - import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.agiletec.aps.system.services.authorization.Authorization; +import com.agiletec.aps.system.services.authorization.AuthorizationManager; +import com.agiletec.aps.system.services.baseconfig.BaseConfigManager; +import com.agiletec.aps.system.services.group.Group; +import com.agiletec.aps.system.services.group.GroupManager; +import com.agiletec.aps.system.services.role.Role; +import com.agiletec.aps.system.services.role.RoleManager; +import com.fasterxml.jackson.core.JsonParseException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; import org.entando.entando.ent.exception.EntException; +import org.entando.entando.keycloak.services.oidc.model.KeycloakUser; +import org.entando.entando.keycloak.services.oidc.model.UserRepresentation; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class KeycloakAuthorizationManagerTest { - @Mock private UserDetails userDetails; + @Mock private KeycloakUser userDetails; @Mock private KeycloakConfiguration configuration; @Mock private AuthorizationManager authorizationManager; @Mock private GroupManager groupManager; @Mock private RoleManager roleManager; + @Mock private BaseConfigManager configManager; private KeycloakAuthorizationManager manager; @BeforeEach public void setUp() { - manager = new KeycloakAuthorizationManager(configuration, authorizationManager, groupManager, roleManager); + manager = new KeycloakAuthorizationManager(configuration, authorizationManager, groupManager, roleManager, configManager); } @Test @@ -49,20 +56,26 @@ void testGroupCreation() throws EntException { when(groupManager.getGroup(anyString())).thenReturn(null); when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); - manager.processNewUser(userDetails); + manager.processNewUser(userDetails, null, false); final ArgumentCaptor groupCaptor = ArgumentCaptor.forClass(Group.class); - final ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(Authorization.class); + final ArgumentCaptor auth_man_usernameCaptor = ArgumentCaptor.forClass(String.class); + final ArgumentCaptor auth_man_groupCaptor = ArgumentCaptor.forClass(String.class); + final ArgumentCaptor auth_man_roleCaptor = ArgumentCaptor.forClass(String.class); + verify(roleManager, times(0)).getRole(anyString()); verify(groupManager, times(1)).getGroup(eq("readers")); verify(groupManager, times(1)).addGroup(groupCaptor.capture()); - verify(userDetails, times(1)).addAuthorization(authorizationCaptor.capture()); + verify(authorizationManager).addUserAuthorization(auth_man_usernameCaptor.capture(), + auth_man_groupCaptor.capture(),auth_man_roleCaptor.capture()); + + assertThat(groupCaptor.getValue().getName()).isEqualTo("readers"); assertThat(groupCaptor.getValue().getName()).isEqualTo("readers"); assertThat(groupCaptor.getValue().getDescription()).isEqualTo("readers"); - assertThat(authorizationCaptor.getValue().getGroup().getName()).isEqualTo("readers"); - assertThat(authorizationCaptor.getValue().getRole()).isNull(); + assertThat(auth_man_groupCaptor.getValue()).isEqualTo("readers"); + assertThat(auth_man_roleCaptor.getValue()).isNull(); } @Test @@ -72,16 +85,20 @@ void testGroupAndRoleCreation() throws EntException { when(roleManager.getRole(anyString())).thenReturn(null); when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); - manager.processNewUser(userDetails); + manager.processNewUser(userDetails, null, false); final ArgumentCaptor groupCaptor = ArgumentCaptor.forClass(Group.class); final ArgumentCaptor roleCaptor = ArgumentCaptor.forClass(Role.class); - final ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(Authorization.class); + final ArgumentCaptor auth_man_usernameCaptor = ArgumentCaptor.forClass(String.class); + final ArgumentCaptor auth_man_groupCaptor = ArgumentCaptor.forClass(String.class); + final ArgumentCaptor auth_man_roleCaptor = ArgumentCaptor.forClass(String.class); + verify(roleManager, times(1)).getRole(eq("read-all")); verify(roleManager, times(1)).addRole(roleCaptor.capture()); verify(groupManager, times(1)).getGroup(eq("readers")); verify(groupManager, times(1)).addGroup(groupCaptor.capture()); - verify(userDetails, times(1)).addAuthorization(authorizationCaptor.capture()); + verify(authorizationManager).addUserAuthorization(auth_man_usernameCaptor.capture(), + auth_man_groupCaptor.capture(), auth_man_roleCaptor.capture()); assertThat(groupCaptor.getValue().getName()).isEqualTo("readers"); assertThat(groupCaptor.getValue().getDescription()).isEqualTo("readers"); @@ -89,8 +106,8 @@ void testGroupAndRoleCreation() throws EntException { assertThat(roleCaptor.getValue().getName()).isEqualTo("read-all"); assertThat(roleCaptor.getValue().getDescription()).isEqualTo("read-all"); - assertThat(authorizationCaptor.getValue().getGroup().getName()).isEqualTo("readers"); - assertThat(authorizationCaptor.getValue().getRole().getName()).isEqualTo("read-all"); + assertThat(auth_man_groupCaptor.getValue()).isEqualTo("readers"); + assertThat(auth_man_roleCaptor.getValue()).isEqualTo("read-all"); } @Test @@ -101,13 +118,264 @@ void testVerification() { when(configuration.getDefaultAuthorizations()).thenReturn("readers:read-all,writers:write-all"); when(userDetails.getAuthorizations()).thenReturn(Arrays.asList(readers, writers)); - manager.processNewUser(userDetails); + manager.processNewUser(userDetails, null, false); verify(roleManager, times(0)).getRole(anyString()); verify(groupManager, times(0)).getGroup(anyString()); verify(userDetails, times(0)).addAuthorization(any()); } + @Test + void testDynamicConfigurationRoleOnLogin() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(roleManager.getRole(anyString())).thenReturn(null); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(userDetails.getUsername()).thenReturn("testuser"); + when(configManager.getConfigItem(anyString())).thenReturn(XML_ROLE_CONF); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_ROLE", List.of("ruolo"))); + + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + manager.init(); + + final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), authCaptor.capture()); + + assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("ruolo"); + assertThat(authCaptor.getValue().getGroup()).isNull(); + } + + @Test + void testDynamicConfigurationRoleOnLoginFromJwt() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(roleManager.getRole(anyString())).thenReturn(null); + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + when(userDetails.getUsername()).thenReturn("testuser"); + when(configManager.getConfigItem(anyString())).thenReturn(XML_CLIENT_ROLE); + + manager.init(); + + final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), authCaptor.capture()); + + assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("generico"); + assertThat(authCaptor.getValue().getGroup()).isNull(); + } + + @Test + void testDynamicConfigurationRoleOnLoginWithWrongJwt() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_CLIENT_ROLE); + + manager.init(); + + final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + + manager.processNewUser(userDetails, JWT_NO_ROLE, false); + + verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), authCaptor.capture()); + } + + @Test + void testDynamicConfigurationGroupOnLogin() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_CONF); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_GROUP", List.of("group"))); + + when(userDetails.getUsername()).thenReturn("testuser"); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + manager.init(); + + final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), authCaptor.capture()); + + assertThat(authCaptor.getValue().getGroup().getName()).isEqualTo("group"); + assertThat(authCaptor.getValue().getRole()).isNull(); + } + + @Test + void testDynamicConfigurationGroupOnLoginNoPersist() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_CONF_NO_PERSIST); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_GROUP", List.of("group"))); + + when(userDetails.getUsername()).thenReturn("testuser"); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + + manager.init(); + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); + verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); + + assertThat(authCaptor.getValue().getGroup().getName()).isEqualTo("group"); + assertThat(authCaptor.getValue().getRole()).isNull(); + } + + @Test + void testDynamicConfigurationGroupRoleOnLogin() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("agroup_r_arole"))); + + when(userDetails.getUsername()).thenReturn("testuser"); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + manager.init(); + + final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, times(1)).addUserAuthorization(eq("testuser"), authCaptor.capture()); + + assertThat(authCaptor.getValue().getGroup().getName()).isEqualTo("agroup"); + assertThat(authCaptor.getValue().getRole().getName()).isEqualTo("arole"); + } + + @Test + void testDynamicConfigurationGroupRoleOnLoginNoPersist() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF_NO_PERSIST); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("agroup_r_arole"))); + + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + manager.init(); + + final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), any()); + verify(userDetails, times(1)).addAuthorization(authCaptor.capture()); + + assertThat(authCaptor.getValue().getGroup().getName()).isEqualTo("agroup"); + // role doesn't exist, so it's not associated + assertNull(authCaptor.getValue().getRole()); + } + + @Test + void testDynamicConfigurationGroupRoleOnLoginAlreadyPresent() throws Exception { + Group group = new Group(); + Role role = new Role(); + Authorization auth = new Authorization(group, role); + + group.setName("agroup"); + group.setDescription("agroup"); + role.setName("arole"); + role.setDescription("arole"); + + when(authorizationManager.getUserAuthorizations(anyString())).thenReturn(List.of(auth)); + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("agroup_r_arole"))); + + when(userDetails.getUsername()).thenReturn("testuser"); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + manager.init(); + + final ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authorization.class); + + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).addUserAuthorization(eq("testuser"), authCaptor.capture()); + } + + @Test + void testDynamicConfigurationNoGroupOnlyRoleOnLogin() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("_r_arole"))); + + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + manager.init(); + + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); + } + + @Test + void testDynamicConfigurationOnlyGroupNoRoleOnLogin() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_GROUP_ROLE_CONF); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_GROUPROLE", List.of("group_r_"))); + + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + manager.init(); + + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); + } + + @Test + void testDynamicConfigurationNoMapping() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_NO_MAPPING); + + manager.init(); + + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); + } + + @Test + void testDynamicConfigurationMalformedMapping() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_MALFORMED_MAPPING); + + assertThrows(JsonParseException.class, () -> manager.init()); + + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); + } + + @Test + void testDynamicConfigurationWrongMapping() throws Exception { + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(XML_WRONG_CONF); + + manager.init(); + manager.processNewUser(userDetails, JWT, false); + + verify(authorizationManager, never()).addUserAuthorization(anyString(), any()); + } + private Authorization authorization(final String groupName, final String roleName) { final Group group = new Group(); group.setName(groupName); @@ -116,4 +384,246 @@ private Authorization authorization(final String groupName, final String roleNam return new Authorization(group, role); } + private static final String XML_ROLE_CONF = "" + + " " + + " true" + + " AD_ROLE" + + " ROLE" + + " true" + + " " + + " " + + " false" + + " AD_GROUP" + + " GROUP" + + " true" + + " " + + " " + + " false" + + " AD_GROUPROLE" + + " GROUPROLE" + + " _r_" + + " true" + + " " + + ""; + + private static final String XML_GROUP_CONF = "" + + " " + + " false" + + " AD_ROLE" + + " ROLE" + + " true" + + " " + + " " + + " true" + + " AD_GROUP" + + " GROUP" + + " true" + + " " + + " " + + " false" + + " AD_GROUPROLE" + + " GROUPROLE" + + " _r_" + + " true" + + " " + + ""; + + private static final String XML_GROUP_CONF_NO_PERSIST = "" + + " " + + " false" + + " AD_ROLE" + + " ROLE" + + " true" + + " " + + " " + + " true" + + " AD_GROUP" + + " GROUP" + + " false" + + " " + + " " + + " false" + + " AD_GROUPROLE" + + " GROUPROLE" + + " _r_" + + " true" + + " " + + ""; + + private static final String XML_GROUP_ROLE_CONF = "" + + " " + + " false" + + " AD_ROLE" + + " ROLE" + + " true" + + " " + + " " + + " false" + + " AD_GROUP" + + " GROUP" + + " true" + + " " + + " " + + " true" + + " AD_GROUPROLE" + + " GROUPROLE" + + " _r_" + + " true" + + " " + + ""; + + private static final String XML_GROUP_ROLE_CONF_NO_PERSIST = "" + + " " + + " false" + + " AD_ROLE" + + " ROLE" + + " true" + + " " + + " " + + " false" + + " AD_GROUP" + + " GROUP" + + " true" + + " " + + " " + + " true" + + " AD_GROUPROLE" + + " GROUPROLE" + + " _r_" + + " false" + + " " + + ""; + + private static final String XML_CLIENT_ROLE = "" + + " " + + " true" + + " sim730" + + " CLIENTROLE" + + " true" + + " " + + ""; + + private static final String XML_NO_MAPPING = "" + + ""; + + private static final String XML_MALFORMED_MAPPING = "" + + "" + + " false" + + " AD_ROLE" +// + " ROLE" // kind null + + " true" + + " " + + " " + + " true" + + " AD_GROUP" + + " GROUP" // unknown + + " true" + + " " + + " " + + " false" +// + " AD_GROUPROLE" // attribute null + + " GROUPROLE" + + " _r_" + + " true" + + " " + + + " " + + " false" + + " AD_ROLE" + + " CLIENTROLE" // no client + + " true" + + " " + + + " " + + " false" + + " AD_GROUPROLE" + + " GROUPROLE" +// + " _r_" // separator null + + " true" + + " " + + + ""; + + private static final String JWT_NO_ROLE = "{\n" + + " \"header\" : {\n" + + " \"alg\" : \"RS256\",\n" + + " \"typ\" : \"JWT\",\n" + + " \"kid\" : \"l09Wlf_NY_dmMORYBjkr7deFVGVJ5TRLHW1p7DIT1ds\"\n" + + " },\n" + + " \"payload\" : {\n" + + " \"exp\" : 1768319443,\n" + + " \"iat\" : 1768319143,\n" + + " \"auth_time\" : 1768319142,\n" + + " \"jti\" : \"e64ed1da-aa8c-488f-be10-09e0c2f580c3\",\n" + + " \"iss\" : \"https://localhost:8080/auth/realms/entando\",\n" + + " \"aud\" : [ \"sim730\", \"account\" ],\n" + + " \"sub\" : \"5e7213c6-ad81-4094-bb24-fead709b05af\",\n" + + " \"typ\" : \"Bearer\",\n" + + " \"azp\" : \"entando-web\",\n" + + " \"nonce\" : \"6a9f89c2-c904-4e9e-80cb-e8c1ccddd1e0\",\n" + + " \"session_state\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\",\n" + + " \"acr\" : \"1\",\n" + + " \"allowed-origins\" : [ \"https://localhost:8080\", \"*\" ],\n" + + " \"realm_access\" : {\n" + + " \"roles\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]\n" + + " },\n" + + " \"resource_access\" : {\n" + + " \"aclient\" : {\n" + + " \"roles\" : [ \"generico\" ]\n" + + " },\n" + + " \"account\" : {\n" + + " \"roles\" : [ \"manage-account\", \"manage-account-links\", \"view-profile\" ]\n" + + " }\n" + + " },\n" + + " \"scope\" : \"openid profile email\",\n" + + " \"sid\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\",\n" + + " \"email_verified\" : false,\n" + + " \"name\" : \"User lastname\",\n" + + " \"preferred_username\" : \"user@email.it\",\n" + + " \"given_name\" : \"User\",\n" + + " \"family_name\" : \"lastname\",\n" + + " \"email\" : \"user@email.it\",\n" + + " \"miei_ruoli_custom\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]\n" + + " },\n" + + " \"signature\" : \"dLENSPEPw\"\n" + + "}"; + + private static final String JWT = "{\n" + + " \"exp\" : 1768319443,\n" + + " \"iat\" : 1768319143,\n" + + " \"auth_time\" : 1768319142,\n" + + " \"jti\" : \"e64ed1da-aa8c-488f-be10-09e0c2f580c3\",\n" + + " \"iss\" : \"https://localhost:8080/auth/realms/entando\",\n" + + " \"aud\" : [ \"sim730\", \"account\" ],\n" + + " \"sub\" : \"5e7213c6-ad81-4094-bb24-fead709b05af\",\n" + + " \"typ\" : \"Bearer\",\n" + + " \"azp\" : \"entando-web\",\n" + + " \"nonce\" : \"6a9f89c2-c904-4e9e-80cb-e8c1ccddd1e0\",\n" + + " \"session_state\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\",\n" + + " \"acr\" : \"1\",\n" + + " \"allowed-origins\" : [ \"https://localhost:8080\", \"*\" ],\n" + + " \"realm_access\" : {\n" + + " \"roles\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]\n" + + " },\n" + + " \"resource_access\" : {\n" + + " \"sim730\" : {\n" + + " \"roles\" : [ \"generico\" ]\n" + + " },\n" + + " \"account\" : {\n" + + " \"roles\" : [ \"manage-account\", \"manage-account-links\", \"view-profile\" ]\n" + + " }\n" + + " },\n" + + " \"scope\" : \"openid profile email\",\n" + + " \"sid\" : \"0503e261-d522-41b6-8096-1debdd2c86e7\",\n" + + " \"email_verified\" : false,\n" + + " \"name\" : \"User lastname\",\n" + + " \"preferred_username\" : \"user@email.it\",\n" + + " \"given_name\" : \"User\",\n" + + " \"family_name\" : \"lastname\",\n" + + " \"email\" : \"user@email.it\",\n" + + " \"miei_ruoli_custom\" : [ \"offline_access\", \"uma_authorization\", \"default-roles-entando\" ]\n" + + " }"; }