From 199c54bf2d340f6afaf04045918c4b4d842bcb8e Mon Sep 17 00:00:00 2001 From: "david.blasby" Date: Mon, 25 Mar 2024 15:52:48 -0700 Subject: [PATCH 1/5] JWT Headers security module --- core/pom.xml | 12 + .../jwtheaders/JwtHeadersAuthFilter.java | 123 ++++++ .../jwtheaders/JwtHeadersConfiguration.java | 114 ++++++ .../jwtheaders/JwtHeadersTrivialUser.java | 234 +++++++++++ .../jwtheaders/JwtHeadersUserUtil.java | 246 ++++++++++++ ...rsUsernamePasswordAuthenticationToken.java | 40 ++ .../JwtHeadersConfigurationTest.java | 63 +++ .../jwtheaders/JwtHeadersIntegrationTest.java | 281 ++++++++++++++ .../jwtheaders/JwtHeadersTrivialUserTest.java | 220 +++++++++++ .../jwtheaders/JwtHeadersUserUtilTest.java | 365 ++++++++++++++++++ .../authentication-mode.md | 228 +++++++++++ ...-security-jwt-headers-overrides.properties | 50 +++ .../config-security-jwt-headers.xml | 75 ++++ .../config-security/config-security.xml | 2 + 14 files changed, 2053 insertions(+) create mode 100644 core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersAuthFilter.java create mode 100644 core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersConfiguration.java create mode 100644 core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUser.java create mode 100644 core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUserUtil.java create mode 100644 core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUsernamePasswordAuthenticationToken.java create mode 100644 core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersConfigurationTest.java create mode 100644 core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersIntegrationTest.java create mode 100644 core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUserTest.java create mode 100644 core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUserUtilTest.java create mode 100644 web/src/main/webapp/WEB-INF/config-security/config-security-jwt-headers-overrides.properties create mode 100644 web/src/main/webapp/WEB-INF/config-security/config-security-jwt-headers.xml diff --git a/core/pom.xml b/core/pom.xml index f9a6f76215e..167b88ab8e9 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -35,6 +35,13 @@ GeoNetwork core + + + org.geoserver.community.jwt-headers + jwt-headers-util + 2.25-SNAPSHOT + + net.objecthunter exp4j @@ -304,6 +311,11 @@ org.geotools gt-geojson + + com.jayway.jsonpath + json-path + 2.4.0 + org.locationtech.jts jts-core diff --git a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersAuthFilter.java b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersAuthFilter.java new file mode 100644 index 00000000000..249a18e351f --- /dev/null +++ b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersAuthFilter.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.kernel.security.jwtheaders; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.GenericFilterBean; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; + + +/** + * This handles the JWT-Headers authentication filter. It's based on the Shibboleth filter. + * + */ +public class JwtHeadersAuthFilter extends GenericFilterBean { + + @Autowired + public JwtHeadersUserUtil jwtHeadersUserUtil; + + JwtHeadersConfiguration jwtHeadersConfiguration; + + + public JwtHeadersAuthFilter(JwtHeadersConfiguration jwtHeadersConfiguration) { + this.jwtHeadersConfiguration = jwtHeadersConfiguration; + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + var existingAuth = SecurityContextHolder.getContext().getAuthentication(); + HttpServletRequest request = (HttpServletRequest) servletRequest; + + + var config = jwtHeadersConfiguration.getJwtConfiguration(); + + var user = JwtHeadersTrivialUser.create(config, request); + + if (user == null && existingAuth != null) { + if (existingAuth instanceof JwtHeadersUsernamePasswordAuthenticationToken) { + //at this point, there isn't a JWT header, but there's an existing auth that was made by us (JWT header) + // in this case, we need to log-off. They have a JSESSION auth that is no longer valid. + logout(request); + filterChain.doFilter(servletRequest, servletResponse); + return; + } + } + + + if (user == null) { + filterChain.doFilter(servletRequest, servletResponse); + return; // no valid user in header + } + + //we have a valid user in the headers + + //existing user is the same user as the request + if (existingAuth != null && existingAuth.getName().equals(user.getUsername())) { + filterChain.doFilter(servletRequest, servletResponse); + return; // abort early - no need to do an expensive login. Use the existing one. + } + + //existing user isnt the same user as the request + if (existingAuth != null && !existingAuth.getName().equals(user.getUsername())) { + //in this case there are two auth's - the existing one (likely from JSESSION) + //and one coming in from the JWT headers. In this case, we kill the other login + //and make a new one. + logout(request); + } + + var userDetails = jwtHeadersUserUtil.getUser(user, jwtHeadersConfiguration); + if (userDetails != null) { + UsernamePasswordAuthenticationToken auth = new JwtHeadersUsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + auth.setDetails(userDetails); + SecurityContextHolder.getContext().setAuthentication(auth); + + } + + filterChain.doFilter(servletRequest, servletResponse); + } + + /** + * handle a logout - clear out the security context, and invalidate the session + * @param request + * @throws ServletException + */ + public void logout(HttpServletRequest request) throws ServletException { + request.logout();//dont think this does anything in GN + SecurityContextHolder.getContext().setAuthentication(null); + request.getSession().invalidate(); + } + +} + + diff --git a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersConfiguration.java b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersConfiguration.java new file mode 100644 index 00000000000..c7642c57b3a --- /dev/null +++ b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersConfiguration.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.kernel.security.jwtheaders; + +import org.fao.geonet.kernel.security.SecurityProviderConfiguration; +import org.geoserver.security.jwtheaders.JwtConfiguration; + +/** + * configuration for the JWT Headers security filter. + * See GN documentation. + * This is based on GeoServer's JWT-Headers Module, so you can see there as well. + * + * This class handles the GN filter configuration details, and hands the actual configuration + * for the filter to the JwtConfiguration class. This class is also used in Geoserver. + * + */ +public class JwtHeadersConfiguration implements SecurityProviderConfiguration { + + + public LoginType loginType = LoginType.AUTOLOGIN; + /** + * true -> update the DB with the information from OIDC (don't allow user to edit profile in the UI) + * false -> don't update the DB (user must edit profile in UI). + */ + public boolean updateProfile = true; + /** + * true -> update the DB (user's group) with the information from OIDC (don't allow admin to edit user's groups in the UI) + * false -> don't update the DB (admin must edit groups in UI). + */ + public boolean updateGroup = true; + protected JwtConfiguration jwtConfiguration; + + + // getters/setters + + + public JwtHeadersConfiguration() { + jwtConfiguration = new JwtConfiguration(); + } + + public boolean isUpdateProfile() { + return updateProfile; + } + + public void setUpdateProfile(boolean updateProfile) { + this.updateProfile = updateProfile; + } + + public boolean isUpdateGroup() { + return updateGroup; + } + + + //---- abstract class methods + + public void setUpdateGroup(boolean updateGroup) { + this.updateGroup = updateGroup; + } + + @Override + public String getLoginType() { + return loginType.toString(); + } + + @Override + public String getSecurityProvider() { + return "JWT-HEADERS"; + } + + @Override + public boolean isUserProfileUpdateEnabled() { + // If updating profile from the security provider then disable the profile updates in the interface + return !updateProfile; + } + + //======================================================================== + + @Override + public boolean isUserGroupUpdateEnabled() { + // If updating group from the security provider then disable the group updates in the interface + return !updateGroup; + } + + public org.geoserver.security.jwtheaders.JwtConfiguration getJwtConfiguration() { + return jwtConfiguration; + } + + public void setJwtConfiguration( + org.geoserver.security.jwtheaders.JwtConfiguration jwtConfiguration) { + this.jwtConfiguration = jwtConfiguration; + } + +} diff --git a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUser.java b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUser.java new file mode 100644 index 00000000000..a0e71c4c2ae --- /dev/null +++ b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUser.java @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.kernel.security.jwtheaders; + +import org.fao.geonet.constants.Geonet; +import org.fao.geonet.domain.Profile; +import org.fao.geonet.utils.Log; +import org.geoserver.security.jwtheaders.JwtConfiguration; +import org.geoserver.security.jwtheaders.roles.JwtHeadersRolesExtractor; +import org.geoserver.security.jwtheaders.username.JwtHeaderUserNameExtractor; +import org.springframework.util.StringUtils; + +import javax.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * uses the GS library to process the headers. + * This returns a GN-compliant "user" (JwtHeadersTrivialUser) that + * has the header-derived username and roles (profile and profileGroups). + *

+ * Most of the code, here is for processing profileGroups (Map>). + */ +public class JwtHeadersTrivialUser { + + static String ROLE_GROUP_SEPARATOR = ":"; + static Profile MIN_PROFILE = Profile.RegisteredUser; + Map> profileGroups; + private String username; + + //---------------------- + private Profile profile; + + public JwtHeadersTrivialUser(String userName) { + setUsername(userName); + profileGroups = new HashMap<>(); + } + + public static JwtHeadersTrivialUser create(JwtConfiguration config, HttpServletRequest request) { + if (request == null || config == null || config.getUserNameHeaderAttributeName() == null) { + Log.debug(Geonet.SECURITY, "JwtHeadersUser.create called with null args!"); + return null; // nothing to do + } + + var userNameHeader = request.getHeader(config.getUserNameHeaderAttributeName()); + if (userNameHeader == null) { + return null; // no username in request! + } + + //get the username from the headers (pay attention to config) + JwtHeaderUserNameExtractor userNameExtractor = new JwtHeaderUserNameExtractor(config); + var userName = userNameExtractor.extractUserName(userNameHeader); + + if (userName == null) { + return null; // no username + } + + //get roles from the headers (pay attention to config) + var result = new JwtHeadersTrivialUser(userName); + handleRoles(result, config, request); + + return result; + } + + /** + * @param user user to be modified + * @param config configuration (i.e. where to get the roles from and how to convert them) + * @param request header to get the roles from + */ + public static void handleRoles(JwtHeadersTrivialUser user, JwtConfiguration config, HttpServletRequest request) { + if (!config.getJwtHeaderRoleSource().equals("JSON") && !config.getJwtHeaderRoleSource().equals("JWT")) + return; // nothing to do - we aren't configured to handle roles extraction (get from GN DB). + + if (config.getRolesHeaderName() == null) + return; //misconfigured + + //get the header value and extract the set of roles in it (processed by the RoleConverter) + var rolesHeader = request.getHeader(config.getRolesHeaderName()); + JwtHeadersRolesExtractor rolesExtractor = new JwtHeadersRolesExtractor(config); + var roles = rolesExtractor.getRoles(rolesHeader); + + + updateUserWithRoles(user, roles); + } + + public static void updateUserWithRoles(JwtHeadersTrivialUser user, Collection roles) { + //need to convert the simple roles into profileGroups + // i.e. group1:Reviewer means user has "Reviewer" Profile for group "group1" + Map> profileGroups = extractProfileRoles(roles); + + //get the "max" profile (for User#Profile) + if (profileGroups != null && profileGroups.size() > 0) { + String profile = getMaxProfile(profileGroups).name(); + if (profile != null) { + user.profile = Profile.valueOf(profile); + } + } + + //set the profileGroups + user.profileGroups = profileGroups; + } + + /** + * Get the profiles, and the list of groups for that profile, from the access token. + *

+ * i.e. ["Administrator","g2:Editor"] -> {"Administrator":[], "Editor":["g2"]} + * + * @param rolesInToken list of roles for the user (from headers + gone through the JWT Headers RoleConverter) + * @return map object with the profile and related groups. + */ + //from GN keycloak plugin + public static Map> extractProfileRoles(Collection rolesInToken) { + Map> profileGroups = new HashMap<>(); + + Set roleGroupList = new HashSet<>(); + + // Get role that are in the format of group:role format access + // Todo Reevaluate to see if this is how we want to get role groups. It may not be a good idea to place separator in group name and parse it this way. + for (String role : rolesInToken) { + if (role.contains(ROLE_GROUP_SEPARATOR)) { + Log.debug(Geonet.SECURITY, "Identified group:profile (" + role + ") from user token."); + roleGroupList.add(role); + } else { + // Only use the profiles we know of and don't add duplicates. + Profile p = Profile.findProfileIgnoreCase(role); + if (p != null && !profileGroups.containsKey(p)) { + profileGroups.put(p, new ArrayList<>()); + } + } + } + + + for (String rg : roleGroupList) { + String[] rg_role_groups = rg.split(ROLE_GROUP_SEPARATOR); + + if (rg_role_groups.length == 0 || StringUtils.isEmpty(rg_role_groups[0])) { + continue; + } + + Profile p = null; + if (rg_role_groups.length >= 1) { + p = Profile.findProfileIgnoreCase(rg_role_groups[1]); + } + // If we cannot find the profile then lets ignore this entry. + if (p == null) { + continue; + } + + List groups; + if (profileGroups.containsKey(p)) { + groups = profileGroups.get(p); + } else { + groups = new ArrayList<>(); + } + if (rg_role_groups.length > 1) { + groups.add(rg_role_groups[0]); + } + profileGroups.put(p, groups); + } + + return profileGroups; + } + + //---------------------- + + public static Profile getMaxProfile(Map> profileGroups) { + Profile maxProfile = null; + + for (Profile p : profileGroups.keySet()) { + if (maxProfile == null) { + maxProfile = p; + } else if (maxProfile.compareTo(p) >= 0) { + maxProfile = p; + } + } + + // Fallback if no profile + if (maxProfile == null) { + maxProfile = MIN_PROFILE; + } + return maxProfile; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Map> getProfileGroups() { + return profileGroups; + } + + public void setProfileGroups(Map> profileGroups) { + this.profileGroups = profileGroups; + } + + public Profile getProfile() { + return profile; + } + + public void setProfile(Profile profile) { + this.profile = profile; + } + +} diff --git a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUserUtil.java b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUserUtil.java new file mode 100644 index 00000000000..40519830723 --- /dev/null +++ b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUserUtil.java @@ -0,0 +1,246 @@ +/* + * Copyright (C) 2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.kernel.security.jwtheaders; + +import org.fao.geonet.constants.Geonet; +import org.fao.geonet.domain.Group; +import org.fao.geonet.domain.Language; +import org.fao.geonet.domain.Profile; +import org.fao.geonet.domain.User; +import org.fao.geonet.domain.UserGroup; +import org.fao.geonet.kernel.security.GeonetworkAuthenticationProvider; +import org.fao.geonet.repository.GroupRepository; +import org.fao.geonet.repository.LanguageRepository; +import org.fao.geonet.repository.UserGroupRepository; +import org.fao.geonet.repository.UserRepository; +import org.fao.geonet.utils.Log; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * This class handles GeoNetwork related User (and Group/UserGroup) activities. + * + */ +public class JwtHeadersUserUtil { + + @Autowired + UserRepository userRepository; + + @Autowired + GroupRepository groupRepository; + + @Autowired + UserGroupRepository userGroupRepository; + + @Autowired + GeonetworkAuthenticationProvider authProvider; + + @Autowired + LanguageRepository languageRepository; + + /** + * Gets a user. + * 1. if the user currently existing in the GN DB: + * - user is retrieved from the GN DB + * - if the profile/profileGroup update is true then the DB is updated with info from `userFromHeaders` + * - otherwise, the header roles are ignored and profile/profileGroups are taken from the GN DB + * + * 2. if the user doesn't existing in the DB: + * - user is created and saved to the DB + * - if the profile/profileGroup update is true then the DB is updated with info from `userFromHeaders` + * - otherwise, the header roles are ignored and profile/profileGroups are taken from the GN DB + * - NOTE: in this case, the user will not have any profile/profileGraoup - + * an admin will have to manually set them in GN GUI + * + * @param userFromHeaders This is user info supplied in the request headers + * @param configuration Configuration of the JWT Headers filter + * @return + */ + public User getUser(JwtHeadersTrivialUser userFromHeaders, JwtHeadersConfiguration configuration) { + try { + User userFromDb = (User) authProvider.loadUserByUsername(userFromHeaders.getUsername()); + injectRoles(userFromDb, userFromHeaders, configuration); + return userFromDb; + } catch (UsernameNotFoundException e) { + return createUser(userFromHeaders, configuration); + } + } + + /** + * given an existing user (both from GN DB and from the Request Headers), + * update roles (profile/profileGroups). + * + * isUpdateProfile/isUpdateGroup control if the DB is updated from the request Headers + * + * @param userFromDb + * @param userFromHeaders + * @param configuration + */ + public void injectRoles(User userFromDb, JwtHeadersTrivialUser userFromHeaders, JwtHeadersConfiguration configuration) { + if (configuration.isUpdateProfile()) { + userFromDb.setProfile(userFromHeaders.getProfile()); + userRepository.save(userFromDb); + Log.trace(Geonet.SECURITY, String.format("JwtHeaders: existing user (%s) with profile: '%s'", userFromDb.getUsername(), userFromHeaders.getProfile())); + } + if (configuration.isUpdateGroup()) { + var profileGroups = userFromHeaders.getProfileGroups(); + if (profileGroups != null) { + updateGroups(profileGroups, userFromDb); + if (!profileGroups.isEmpty()) { + Log.trace(Geonet.SECURITY, "JwtHeaders: existing user profile groups: "); + for (var group : profileGroups.entrySet()) { + Log.debug(Geonet.SECURITY, + String.format(" + Profile '%s' has groups: '%s'", + group.getKey(), + String.join(",", group.getValue()) + )); + } + } + } + } + + } + + /** + * creates a new user based on what was in the request headers. + * + * profile updating (in GN DB) is controlled by isUpdateGroup + * profileGroup updating (in GN DB) is controlled by isUpdateGroup + * + * cf. updateGroups for how the profile/profileGroups are updated + * @param userFromHeaders + * @param configuration + * @return + */ + public User createUser(JwtHeadersTrivialUser userFromHeaders, JwtHeadersConfiguration configuration) { + //create user + User user = new User(); + user.setUsername(userFromHeaders.getUsername()); + + // Add email + if (userFromHeaders.getUsername().contains("@")) { + user.getEmailAddresses().add(userFromHeaders.getUsername()); + // dave@example.com --> dave + user.setName(user.getUsername().substring(0, user.getUsername().indexOf("@"))); + } + + Log.debug(Geonet.SECURITY, "JwtHeaders: Creating new User in GN DB: " + user); + + if (configuration.isUpdateProfile()) { + user.setProfile(userFromHeaders.getProfile()); + Log.debug(Geonet.SECURITY, String.format("JwtHeaders: new user profile: '%s'", userFromHeaders.getProfile())); + } else { + user.setProfile(Profile.RegisteredUser);//default to registered user + } + + userRepository.save(user); + + + if (configuration.isUpdateGroup()) { + var profileGroups = userFromHeaders.getProfileGroups(); + if (profileGroups != null) { + updateGroups(profileGroups, user); + if (!profileGroups.isEmpty()) { + Log.debug(Geonet.SECURITY, "JwtHeaders: new user profile groups: "); + for (var group : profileGroups.entrySet()) { + Log.debug(Geonet.SECURITY, + String.format(" + Profile '%s' has groups: '%s'", + group.getKey(), + String.join(",", group.getValue()) + )); + } + } + } + } + + return user; + } + + + /** + * Update users group information in the database. + * + * @param profileGroups object containing the profile and related groups. + * @param user to apply the changes to. + */ + //from keycloak + public void updateGroups(Map> profileGroups, User user) { + Set userGroups = new HashSet<>(); + + // Now we add the groups + for (Profile p : profileGroups.keySet()) { + List groups = profileGroups.get(p); + for (String rgGroup : groups) { + + Group group = groupRepository.findByName(rgGroup); + + if (group == null) { + group = new Group(); + group.setName(rgGroup); + + // Populate languages for the group + for (Language l : languageRepository.findAll()) { + group.getLabelTranslations().put(l.getId(), group.getName()); + } + + groupRepository.save(group); + } + + UserGroup usergroup = new UserGroup(); + usergroup.setGroup(group); + usergroup.setUser(user); + + Profile profile = p; + if (profile.equals(Profile.Administrator)) { + // As we are assigning to a group, it is UserAdmin instead + profile = Profile.UserAdmin; + } + usergroup.setProfile(profile); + + //Todo - It does not seem necessary to add the user to the editor profile + // since the reviewer is the parent of the editor + // Seems like the permission checks should be smart enough to know that if a user + // is a reviewer then they are also an editor. Need to test and fix if necessary + if (profile.equals(Profile.Reviewer)) { + UserGroup ug = new UserGroup(); + ug.setGroup(group); + ug.setUser(user); + ug.setProfile(Profile.Editor); + userGroups.add(ug); + } + + userGroups.add(usergroup); + } + } + + userGroupRepository.updateUserGroups(user.getId(), userGroups); + } + +} diff --git a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUsernamePasswordAuthenticationToken.java b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUsernamePasswordAuthenticationToken.java new file mode 100644 index 00000000000..dc48e815bf9 --- /dev/null +++ b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUsernamePasswordAuthenticationToken.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.kernel.security.jwtheaders; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +/** + * this class just allows us to tag an authentication as coming from JWT Headers (for detecting logout) + */ +public class JwtHeadersUsernamePasswordAuthenticationToken extends UsernamePasswordAuthenticationToken { + + + public JwtHeadersUsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection authorities) { + super(principal, credentials, authorities); + } +} diff --git a/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersConfigurationTest.java b/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersConfigurationTest.java new file mode 100644 index 00000000000..56068bad3a3 --- /dev/null +++ b/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersConfigurationTest.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.kernel.security.jwtheaders; + +import org.junit.Assert; +import org.junit.Test; + +/** + * very simple tests for JwtHeadersConfiguration for the GN-only portions. + */ +public class JwtHeadersConfigurationTest { + + //Very very simple test to ensure that setters/getters are working correctly + @Test + public void testGetSet() { + var config = JwtHeadersIntegrationTest.getBasicConfig(); + + //CONST + Assert.assertEquals("autologin", config.getLoginType()); + Assert.assertEquals("JWT-HEADERS", config.getSecurityProvider()); + + config.setUpdateGroup(false); + Assert.assertEquals(false, config.isUpdateGroup()); + Assert.assertEquals(false, !config.isUserGroupUpdateEnabled()); + config.setUpdateGroup(true); + Assert.assertEquals(true, config.isUpdateGroup()); + Assert.assertEquals(true, !config.isUserGroupUpdateEnabled()); + + + config.setUpdateProfile(false); + Assert.assertEquals(false, config.isUpdateProfile()); + Assert.assertEquals(false, !config.isUserProfileUpdateEnabled()); + config.setUpdateProfile(true); + Assert.assertEquals(true, config.isUpdateProfile()); + Assert.assertEquals(true, !config.isUserProfileUpdateEnabled()); + + + Assert.assertEquals(config.jwtConfiguration, config.getJwtConfiguration()); + config.setJwtConfiguration(null); + Assert.assertNull(config.getJwtConfiguration()); + } +} diff --git a/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersIntegrationTest.java b/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersIntegrationTest.java new file mode 100644 index 00000000000..32755d5e946 --- /dev/null +++ b/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersIntegrationTest.java @@ -0,0 +1,281 @@ +/* + * Copyright (C) 2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.kernel.security.jwtheaders; + +import org.fao.geonet.domain.User; +import org.geoserver.security.jwtheaders.JwtConfiguration; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletResponse; +import java.io.IOException; + +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +/** + * Basic integration tests for the filter. + *

+ * We are mocking all the other interactions and directly calling JwtHeadersAuthFilter#doFilter + * and validating the results. + */ +public class JwtHeadersIntegrationTest { + + + //JWT example + public static String JWT = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICItWEdld190TnFwaWRrYTl2QXNJel82WEQtdnJmZDVyMlNWTWkwcWMyR1lNIn0.eyJleHAiOjE3MDcxNTMxNDYsImlhdCI6MTcwNzE1Mjg0NiwiYXV0aF90aW1lIjoxNzA3MTUyNjQ1LCJqdGkiOiJlMzhjY2ZmYy0zMWNjLTQ0NmEtYmU1Yy04MjliNDE0NTkyZmQiLCJpc3MiOiJodHRwczovL2xvZ2luLWxpdmUtZGV2Lmdlb2NhdC5saXZlL3JlYWxtcy9kYXZlLXRlc3QyIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImVhMzNlM2NjLWYwZTEtNDIxOC04OWNiLThkNDhjMjdlZWUzZCIsInR5cCI6IkJlYXJlciIsImF6cCI6ImxpdmUta2V5MiIsIm5vbmNlIjoiQldzc2M3cTBKZ0tHZC1OdFc1QlFhVlROMkhSa25LQmVIY0ZMTHZ5OXpYSSIsInNlc3Npb25fc3RhdGUiOiIxY2FiZmU1NC1lOWU0LTRjMmMtODQwNy03NTZiMjczZmFmZmIiLCJhY3IiOiIwIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtZGF2ZS10ZXN0MiIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJsaXZlLWtleTIiOnsicm9sZXMiOlsiR2Vvc2VydmVyQWRtaW5pc3RyYXRvciJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgcGhvbmUgb2ZmbGluZV9hY2Nlc3MgbWljcm9wcm9maWxlLWp3dCBwcm9maWxlIGFkZHJlc3MgZW1haWwiLCJzaWQiOiIxY2FiZmU1NC1lOWU0LTRjMmMtODQwNy03NTZiMjczZmFmZmIiLCJ1cG4iOiJkYXZpZC5ibGFzYnlAZ2VvY2F0Lm5ldCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiYWRkcmVzcyI6e30sIm5hbWUiOiJkYXZpZCBibGFzYnkiLCJncm91cHMiOlsiZGVmYXVsdC1yb2xlcy1kYXZlLXRlc3QyIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJkYXZpZC5ibGFzYnlAZ2VvY2F0Lm5ldCIsImdpdmVuX25hbWUiOiJkYXZpZCIsImZhbWlseV9uYW1lIjoiYmxhc2J5IiwiZW1haWwiOiJkYXZpZC5ibGFzYnlAZ2VvY2F0Lm5ldCJ9.fHzXd7oISnqWb09ah9wikfP2UOBeiOA3vd_aDg3Bw-xcfv9aD3CWhAK5FUDPYSPyj4whAcknZbUgUzcm0qkaI8V_aS65F3Fug4jt4nC9YPL4zMSJ5an4Dp6jlQ3OQhrKFn4FwaoW61ndMmScsZZWEQyj6gzHnn5cknqySB26tVydT6q57iTO7KQFcXRdbXd6GWIoFGS-ud9XzxQMUdNfYmsDD7e6hoWhe9PJD9Zq4KT6JN13hUU4Dos-Z5SBHjRa6ieHoOe9gqkjKyA1jT1NU42Nqr-mTV-ql22nAoXuplpvOYc5-09-KDDzSDuVKFwLCNMN3ZyRF1wWuydJeU-gOQ"; + JwtHeadersConfiguration config; + FilterChain filterChain; + ServletResponse response; + JwtHeadersUserUtil jwtHeadersUserUtil; + User user; + User user2; + + /** + * standard configuration for testing JSON + */ + public static JwtHeadersConfiguration getBasicConfig() { + JwtHeadersConfiguration config = new JwtHeadersConfiguration(); + var jwtheadersConfiguration = config.getJwtConfiguration(); + jwtheadersConfiguration.setUserNameHeaderAttributeName("OIDC_id_token_payload"); + + jwtheadersConfiguration.setUserNameFormatChoice(JwtConfiguration.UserNameHeaderFormat.JSON); + jwtheadersConfiguration.setUserNameJsonPath("preferred_username"); + + + jwtheadersConfiguration.setRolesJsonPath("resource_access.live-key2.roles"); + jwtheadersConfiguration.setRolesHeaderName("OIDC_id_token_payload"); + jwtheadersConfiguration.setJwtHeaderRoleSource("JSON"); + + jwtheadersConfiguration.setRoleConverterString("GeonetworkAdministrator=ADMINISTRATOR"); + jwtheadersConfiguration.setOnlyExternalListedRoles(false); + + jwtheadersConfiguration.setValidateToken(false); + + jwtheadersConfiguration.setValidateTokenAgainstURL(true); + jwtheadersConfiguration.setValidateTokenAgainstURLEndpoint(""); + jwtheadersConfiguration.setValidateSubjectWithEndpoint(true); + + jwtheadersConfiguration.setValidateTokenAudience(true); + jwtheadersConfiguration.setValidateTokenAudienceClaimName(""); + jwtheadersConfiguration.setValidateTokenAudienceClaimValue(""); + + jwtheadersConfiguration.setValidateTokenSignature(true); + jwtheadersConfiguration.setValidateTokenSignatureURL(""); + + return config; + } + + /** + * standard configuration for testing JWT + */ + public static JwtHeadersConfiguration getBasicConfigJWT() { + JwtHeadersConfiguration config = new JwtHeadersConfiguration(); + var jwtheadersConfiguration = config.getJwtConfiguration(); + jwtheadersConfiguration.setUserNameHeaderAttributeName("TOKEN"); + + jwtheadersConfiguration.setUserNameFormatChoice(JwtConfiguration.UserNameHeaderFormat.JWT); + jwtheadersConfiguration.setUserNameJsonPath("preferred_username"); + + + jwtheadersConfiguration.setRolesJsonPath("resource_access.live-key2.roles"); + jwtheadersConfiguration.setRolesHeaderName("TOKEN"); + jwtheadersConfiguration.setJwtHeaderRoleSource("JWT"); + + jwtheadersConfiguration.setRoleConverterString("GeoserverAdministrator=ADMINISTRATOR"); + jwtheadersConfiguration.setOnlyExternalListedRoles(false); + + jwtheadersConfiguration.setValidateToken(false); + + jwtheadersConfiguration.setValidateTokenAgainstURL(true); + jwtheadersConfiguration.setValidateTokenAgainstURLEndpoint(""); + jwtheadersConfiguration.setValidateSubjectWithEndpoint(true); + + jwtheadersConfiguration.setValidateTokenAudience(true); + jwtheadersConfiguration.setValidateTokenAudienceClaimName(""); + jwtheadersConfiguration.setValidateTokenAudienceClaimValue(""); + + jwtheadersConfiguration.setValidateTokenSignature(true); + jwtheadersConfiguration.setValidateTokenSignatureURL(""); + + return config; + } + + @Before + public void setUp() throws Exception { + + SecurityContextHolder.getContext().setAuthentication(null); + + + config = getBasicConfig(); + + filterChain = Mockito.mock(FilterChain.class); + response = Mockito.mock(ServletResponse.class); + + jwtHeadersUserUtil = Mockito.mock(JwtHeadersUserUtil.class); + + user = new User(); + user.setUsername("testcase-user@geocat.net"); + + user2 = new User(); + user2.setUsername("testcase-user2222@geocat.net"); + } + + /** + * trivial integration test - user arrives at site with header (gets access). + */ + @Test + public void testTrivialLogin() throws ServletException, IOException { + doReturn(user) + .when(jwtHeadersUserUtil).getUser(any(), any()); + + var request = new MockHttpServletRequest(); + + request.addHeader("oidc_id_token_payload", "{\"preferred_username\":\"david.blasby2@geocat.net\",\"resource_access\":{\"live-key2\":{\"roles\":[\"GeonetworkAdministrator\",\"group1:Reviewer\"]}}}"); + + JwtHeadersAuthFilter filter = new JwtHeadersAuthFilter(config); + filter.jwtHeadersUserUtil = jwtHeadersUserUtil; + filter = spy(filter); + + //this should login the user + filter.doFilter(request, response, filterChain); + + //this validate login + var auth = SecurityContextHolder.getContext().getAuthentication(); + Assert.assertNotNull(auth); + Assert.assertTrue(auth instanceof JwtHeadersUsernamePasswordAuthenticationToken); + var principle = (User) auth.getPrincipal(); + Assert.assertEquals(user.getUsername(), principle.getUsername()); + + //logout() should not have been called + verify(filter, never()).logout(any()); + } + + /** + * integration test - + * 1. user arrives at site with header (gets access). + * 2. user then makes request (without headers) - should get logged out (i.e. not auth + logout() called) + */ + @Test + public void testLoginLogout() throws ServletException, IOException { + doReturn(user) + .when(jwtHeadersUserUtil).getUser(any(), any()); + + var request = new MockHttpServletRequest(); + + + JwtHeadersAuthFilter filter = new JwtHeadersAuthFilter(config); + filter = spy(filter); + filter.jwtHeadersUserUtil = jwtHeadersUserUtil; + + //logged in + request.addHeader("oidc_id_token_payload", "{\"preferred_username\":\"david.blasby2@geocat.net\",\"resource_access\":{\"live-key2\":{\"roles\":[\"GeonetworkAdministrator\",\"group1:Reviewer\"]}}}"); + + //user should be logged in + filter.doFilter(request, response, filterChain); + + //validate login + var auth = SecurityContextHolder.getContext().getAuthentication(); + Assert.assertNotNull(auth); + Assert.assertTrue(auth instanceof JwtHeadersUsernamePasswordAuthenticationToken); + var principle = (User) auth.getPrincipal(); + Assert.assertEquals(user.getUsername(), principle.getUsername()); + verify(filter, never()).logout(any()); //logout() should not have been called + + //logout + request = new MockHttpServletRequest(); + filter.doFilter(request, response, filterChain); + + //no longer an auth + auth = SecurityContextHolder.getContext().getAuthentication(); + Assert.assertNull(auth); + verify(filter).logout(any()); //logout was called + } + + /** + * integration test - + * 1. user1 arrives at site with header (gets access). + * 2. switch to user2 then makes request (with headers) + * - user1 should get logged out (i.e. not auth + logout() called) + * - user2 gets logged in + *

+ * In general, this shouldn't happen, but could happen: + * 1. logon as low-rights user + * 2. -- do stuff --- + * 3. need high privileges, so change to higher-rights user + * 4. -- do stuff --- + */ + @Test + public void testLoginDifferentLogin() throws ServletException, IOException { + doReturn(user) + .when(jwtHeadersUserUtil).getUser(any(), any()); + + var request = new MockHttpServletRequest(); + + + JwtHeadersAuthFilter filter = new JwtHeadersAuthFilter(config); + filter = spy(filter); + filter.jwtHeadersUserUtil = jwtHeadersUserUtil; + + + //logged in + request.addHeader("oidc_id_token_payload", "{\"preferred_username\":\"david.blasby2@geocat.net\",\"resource_access\":{\"live-key2\":{\"roles\":[\"GeonetworkAdministrator\",\"group1:Reviewer\"]}}}"); + + filter.doFilter(request, response, filterChain); + + //validate user logged in + var auth = SecurityContextHolder.getContext().getAuthentication(); + Assert.assertNotNull(auth); + Assert.assertTrue(auth instanceof JwtHeadersUsernamePasswordAuthenticationToken); + var principle = (User) auth.getPrincipal(); + Assert.assertEquals(user.getUsername(), principle.getUsername()); + verify(filter, never()).logout(any()); //logout() should not have been called + + //login new user (user2) + request = new MockHttpServletRequest(); + request.addHeader("oidc_id_token_payload", "{\"preferred_username\":\"david.blasby2@geocat.net\",\"resource_access\":{\"live-key2\":{\"roles\":[\"GeonetworkAdministrator\",\"group1:Reviewer\"]}}}"); + doReturn(user2) + .when(jwtHeadersUserUtil).getUser(any(), any()); + + filter.doFilter(request, response, filterChain); + + //validate that the correct user is logged in + auth = SecurityContextHolder.getContext().getAuthentication(); + Assert.assertNotNull(auth); + Assert.assertTrue(auth instanceof JwtHeadersUsernamePasswordAuthenticationToken); + principle = (User) auth.getPrincipal(); + Assert.assertEquals(user2.getUsername(), principle.getUsername()); + verify(filter).logout(any()); //logout must be called + } + +} diff --git a/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUserTest.java b/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUserTest.java new file mode 100644 index 00000000000..15f220d05fc --- /dev/null +++ b/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUserTest.java @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.kernel.security.jwtheaders; + +import org.fao.geonet.domain.Profile; +import org.junit.Assert; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Tests that the JwtHeadersTrivialUser is working. + */ +public class JwtHeadersTrivialUserTest { + + + /** + * test #maxProfile + * Should give the highest profile in the profileGroups + */ + @Test + public void testMaxProfile() { + Map> profileGroups = new HashMap<>(); + + //no profileGroups -> JwtHeadersTrivialUser.MIN_PROFILE + var maxProfile = JwtHeadersTrivialUser.getMaxProfile(profileGroups); + Assert.assertEquals(JwtHeadersTrivialUser.MIN_PROFILE, maxProfile); + + + //admin -> admin + profileGroups = new HashMap<>(); + profileGroups.put(Profile.Administrator, new ArrayList<>()); + maxProfile = JwtHeadersTrivialUser.getMaxProfile(profileGroups); + Assert.assertEquals(Profile.Administrator, maxProfile); + + //Reviewer -> Reviewer + profileGroups = new HashMap<>(); + profileGroups.put(Profile.Reviewer, new ArrayList<>()); + maxProfile = JwtHeadersTrivialUser.getMaxProfile(profileGroups); + Assert.assertEquals(Profile.Reviewer, maxProfile); + + //Editor -> Editor + profileGroups = new HashMap<>(); + profileGroups.put(Profile.Editor, new ArrayList<>()); + maxProfile = JwtHeadersTrivialUser.getMaxProfile(profileGroups); + Assert.assertEquals(Profile.Editor, maxProfile); + + + //Editor,Reviewer -> Reviewer + profileGroups = new HashMap<>(); + profileGroups.put(Profile.Editor, new ArrayList<>()); + profileGroups.put(Profile.Reviewer, new ArrayList<>()); + maxProfile = JwtHeadersTrivialUser.getMaxProfile(profileGroups); + Assert.assertEquals(Profile.Reviewer, maxProfile); + } + + + /** + * tests that the extraction of ProfileRoles is correct + */ + @Test + public void testExtractProfileRoles() { + + //no roles -> no profileGroups + List processedRolesFromHeaders = Arrays.asList(); + var profileGroups = JwtHeadersTrivialUser.extractProfileRoles(processedRolesFromHeaders); + Assert.assertEquals(0, profileGroups.size()); + + // "Administrator" -> "Administrator":[] + processedRolesFromHeaders = Arrays.asList("Administrator"); + profileGroups = JwtHeadersTrivialUser.extractProfileRoles(processedRolesFromHeaders); + Assert.assertEquals(1, profileGroups.size()); + Assert.assertTrue(profileGroups.containsKey(Profile.Administrator)); + Assert.assertEquals(0, profileGroups.get(Profile.Administrator).size()); + + // "g1:Reviewer" -> "Reviewer":["g1"] + processedRolesFromHeaders = Arrays.asList("g1:Reviewer"); + profileGroups = JwtHeadersTrivialUser.extractProfileRoles(processedRolesFromHeaders); + Assert.assertEquals(1, profileGroups.size()); + Assert.assertTrue(profileGroups.containsKey(Profile.Reviewer)); + Assert.assertEquals(1, profileGroups.get(Profile.Reviewer).size()); + Assert.assertEquals("g1", profileGroups.get(Profile.Reviewer).get(0)); + + // "g1:Reviewer","g2:Reviewer" -> "Reviewer":["g1",g2] + processedRolesFromHeaders = Arrays.asList("g1:Reviewer", "g2:Reviewer"); + profileGroups = JwtHeadersTrivialUser.extractProfileRoles(processedRolesFromHeaders); + Assert.assertEquals(1, profileGroups.size()); + Assert.assertTrue(profileGroups.containsKey(Profile.Reviewer)); + Assert.assertEquals(2, profileGroups.get(Profile.Reviewer).size()); + Assert.assertTrue(profileGroups.get(Profile.Reviewer).contains("g1")); + Assert.assertTrue(profileGroups.get(Profile.Reviewer).contains("g2")); + + // "g1:Reviewer","g2:Editor" -> "Reviewer":["g1"], "Editor":["g2"] + processedRolesFromHeaders = Arrays.asList("g1:Reviewer", "g2:Editor"); + profileGroups = JwtHeadersTrivialUser.extractProfileRoles(processedRolesFromHeaders); + Assert.assertEquals(2, profileGroups.size()); + Assert.assertTrue(profileGroups.containsKey(Profile.Reviewer)); + Assert.assertTrue(profileGroups.containsKey(Profile.Editor)); + Assert.assertEquals(1, profileGroups.get(Profile.Reviewer).size()); + Assert.assertEquals(1, profileGroups.get(Profile.Editor).size()); + Assert.assertTrue(profileGroups.get(Profile.Reviewer).contains("g1")); + Assert.assertTrue(profileGroups.get(Profile.Editor).contains("g2")); + + // "Administrator","g2:Editor" -> "Administrator":[], "Editor":["g2"] + processedRolesFromHeaders = Arrays.asList("Administrator", "g2:Editor"); + profileGroups = JwtHeadersTrivialUser.extractProfileRoles(processedRolesFromHeaders); + Assert.assertEquals(2, profileGroups.size()); + Assert.assertTrue(profileGroups.containsKey(Profile.Administrator)); + Assert.assertTrue(profileGroups.containsKey(Profile.Editor)); + Assert.assertEquals(0, profileGroups.get(Profile.Administrator).size()); + Assert.assertEquals(1, profileGroups.get(Profile.Editor).size()); + Assert.assertTrue(profileGroups.get(Profile.Editor).contains("g2")); + } + + /** + * Method #UpdateUserWithRoles relies on the above methods, so we don't test this too much + * The method just updates the user (Profile & ProfileGroups), so we test that here. + */ + @Test + public void testUpdateUserWithRoles() { + // "Administrator","g2:Editor" -> "Administrator":[], "Editor":["g2"] AND Profile=Administrator + var processedRolesFromHeaders = Arrays.asList("Administrator", "g2:Editor"); + var user = new JwtHeadersTrivialUser("testcaseUser"); + JwtHeadersTrivialUser.updateUserWithRoles(user, processedRolesFromHeaders); + + Assert.assertEquals(Profile.Administrator, user.getProfile()); + var profileGroups = user.getProfileGroups(); + Assert.assertEquals(2, profileGroups.size()); + Assert.assertTrue(profileGroups.containsKey(Profile.Administrator)); + Assert.assertTrue(profileGroups.containsKey(Profile.Editor)); + Assert.assertEquals(0, profileGroups.get(Profile.Administrator).size()); + Assert.assertEquals(1, profileGroups.get(Profile.Editor).size()); + Assert.assertTrue(profileGroups.get(Profile.Editor).contains("g2")); + } + + + /*** + * Method #handleRoles mostly relies on methods tested above and is mostly about extracting the correct headers from the request + */ + @Test + public void testHandleRolesJson() { + var config = JwtHeadersIntegrationTest.getBasicConfig(); + var user = new JwtHeadersTrivialUser("testCaseUser"); + var request = new MockHttpServletRequest(); + request.addHeader("oidc_id_token_payload", "{\"preferred_username\":\"david.blasby2@geocat.net\",\"resource_access\":{\"live-key2\":{\"roles\":[\"GeonetworkAdministrator\",\"group1:Reviewer\"]}}}"); + + JwtHeadersTrivialUser.handleRoles(user, config.getJwtConfiguration(), request); + + Assert.assertEquals(Profile.Administrator, user.getProfile()); + var profileGroups = user.getProfileGroups(); + Assert.assertEquals(2, profileGroups.size()); + Assert.assertTrue(profileGroups.containsKey(Profile.Administrator)); + Assert.assertTrue(profileGroups.containsKey(Profile.Reviewer)); + Assert.assertEquals(0, profileGroups.get(Profile.Administrator).size()); + Assert.assertEquals(1, profileGroups.get(Profile.Reviewer).size()); + Assert.assertTrue(profileGroups.get(Profile.Reviewer).contains("group1")); + } + + @Test + public void testHandleRolesJWT() { + var config = JwtHeadersIntegrationTest.getBasicConfigJWT(); + var user = new JwtHeadersTrivialUser("testCaseUser"); + var request = new MockHttpServletRequest(); + request.addHeader("TOKEN", JwtHeadersIntegrationTest.JWT); + + JwtHeadersTrivialUser.handleRoles(user, config.getJwtConfiguration(), request); + + Assert.assertEquals(Profile.Administrator, user.getProfile()); + var profileGroups = user.getProfileGroups(); + Assert.assertEquals(1, profileGroups.size()); + Assert.assertTrue(profileGroups.containsKey(Profile.Administrator)); + Assert.assertEquals(0, profileGroups.get(Profile.Administrator).size()); + } + + /** + * this is dependent on the above methods, so this is just a quick test + */ + @Test + public void testCreate() { + var config = JwtHeadersIntegrationTest.getBasicConfigJWT(); + var request = new MockHttpServletRequest(); + request.addHeader("TOKEN", JwtHeadersIntegrationTest.JWT); + + var user = JwtHeadersTrivialUser.create(config.getJwtConfiguration(), request); + + Assert.assertEquals("david.blasby@geocat.net", user.getUsername()); + + Assert.assertEquals(Profile.Administrator, user.getProfile()); + var profileGroups = user.getProfileGroups(); + Assert.assertEquals(1, profileGroups.size()); + Assert.assertTrue(profileGroups.containsKey(Profile.Administrator)); + Assert.assertEquals(0, profileGroups.get(Profile.Administrator).size()); + } +} diff --git a/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUserUtilTest.java b/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUserUtilTest.java new file mode 100644 index 00000000000..482ef5549f6 --- /dev/null +++ b/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUserUtilTest.java @@ -0,0 +1,365 @@ +/* + * Copyright (C) 2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.kernel.security.jwtheaders; + +import org.fao.geonet.domain.Group; +import org.fao.geonet.domain.Profile; +import org.fao.geonet.domain.User; +import org.fao.geonet.domain.UserGroup; +import org.fao.geonet.kernel.security.GeonetworkAuthenticationProvider; +import org.fao.geonet.repository.GroupRepository; +import org.fao.geonet.repository.LanguageRepository; +import org.fao.geonet.repository.UserGroupRepository; +import org.fao.geonet.repository.UserRepository; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * tests that JwtHeadersUserUtil works. + * + * Because JwtHeadersUserUtil uses the Group/User/UserGroup repositories, this uses a lot + * of Mockito to setup different scenarios. + * + * The main scenarios we are testing; + * 1. user is correctly saved (if new) or loaded (if existing) + * 2. user's profile and profileGroups are correctly updated (or not updated), depending on the + * filter's configuration. + */ +public class JwtHeadersUserUtilTest { + + JwtHeadersUserUtil jwtHeadersUserUtil; //spy()-ed + + @Before + public void setUp() throws Exception { + jwtHeadersUserUtil = new JwtHeadersUserUtil(); + jwtHeadersUserUtil = spy(jwtHeadersUserUtil); + + jwtHeadersUserUtil.userRepository = Mockito.mock(UserRepository.class); + jwtHeadersUserUtil.groupRepository = Mockito.mock(GroupRepository.class); + jwtHeadersUserUtil.userGroupRepository = Mockito.mock(UserGroupRepository.class); + jwtHeadersUserUtil.authProvider = Mockito.mock(GeonetworkAuthenticationProvider.class); + jwtHeadersUserUtil.languageRepository = Mockito.mock(LanguageRepository.class); + } + + + /** + * we have the config setup so it doesn't get any write access from the database + * + no user in DB + * + new user created + */ + @Test + public void testSimplestCase() { + doThrow(new UsernameNotFoundException("")) + .when(jwtHeadersUserUtil.authProvider).loadUserByUsername(any()); + + JwtHeadersConfiguration basicConfig = JwtHeadersIntegrationTest.getBasicConfig(); + basicConfig.setUpdateGroup(false); + basicConfig.setUpdateProfile(false); + + var trivialUser = new JwtHeadersTrivialUser("testcaseUser@example.com"); + trivialUser = spy(trivialUser); + + User userDetails = (User) jwtHeadersUserUtil.getUser(trivialUser, basicConfig); + + Assert.assertEquals("testcaseUser@example.com", userDetails.getUsername()); + Assert.assertEquals("testcaseUser", userDetails.getName()); + + //verify helper methods called + verify(jwtHeadersUserUtil.authProvider).loadUserByUsername("testcaseUser@example.com"); + verify(jwtHeadersUserUtil).createUser(trivialUser, basicConfig); + + // these shouldn't ever be looked at + verify(jwtHeadersUserUtil, never()).updateGroups(any(), any()); + verify(trivialUser, never()).getProfile(); + verify(trivialUser, never()).getProfileGroups(); + + //db should not have been saved to + verify(jwtHeadersUserUtil.groupRepository, never()).save(any()); + verify(jwtHeadersUserUtil.userGroupRepository, never()).save(any()); + verify(jwtHeadersUserUtil.languageRepository, never()).save(any()); + + //user was saved + verify(jwtHeadersUserUtil.userRepository).save(userDetails); + } + + /** + * we have the config setup so it doesn't get any write access from the database + * + user IS in DB + */ + @Test + public void testSimplestCaseAlreadyExists() { + User user = new User(); + user.setUsername("testcaseUser@example.com"); + user.setName("testcaseUser"); + user.setId(666); + + doReturn(user) + .when(jwtHeadersUserUtil.authProvider).loadUserByUsername("testcaseUser@example.com"); + + JwtHeadersConfiguration basicConfig = JwtHeadersIntegrationTest.getBasicConfig(); + basicConfig.setUpdateGroup(false); + basicConfig.setUpdateProfile(false); + + var trivialUser = new JwtHeadersTrivialUser("testcaseUser@example.com"); + trivialUser = spy(trivialUser); + + User userDetails = (User) jwtHeadersUserUtil.getUser(trivialUser, basicConfig); + + Assert.assertEquals("testcaseUser@example.com", userDetails.getUsername()); + Assert.assertEquals("testcaseUser", userDetails.getName()); + + //verify helper methods called + verify(jwtHeadersUserUtil.authProvider).loadUserByUsername("testcaseUser@example.com"); + + + // these shouldn't ever be looked at + verify(jwtHeadersUserUtil, never()).createUser(trivialUser, basicConfig); + verify(jwtHeadersUserUtil, never()).updateGroups(any(), any()); + verify(trivialUser, never()).getProfile(); + verify(trivialUser, never()).getProfileGroups(); + + //db should not have been saved to + verify(jwtHeadersUserUtil.groupRepository, never()).save(any()); + verify(jwtHeadersUserUtil.userGroupRepository, never()).save(any()); + verify(jwtHeadersUserUtil.languageRepository, never()).save(any()); + + //user wasn't saved (no modification) + verify(jwtHeadersUserUtil.userRepository, never()).save(userDetails); + } + + + /** + * we have the config setup so it writes user data to DB + * + no user in DB + * + new user created + * + validate that profile is set + * + validate that user groups (in db) are updated + */ + @Test + public void testNewUserWithGroups() { + doThrow(new UsernameNotFoundException("")) + .when(jwtHeadersUserUtil.authProvider).loadUserByUsername(any()); + + //make sure that the group ID is set when saved. GN uses the ID in Set<> operations, so we must SET it. + when(jwtHeadersUserUtil.groupRepository.save(any())).thenAnswer(new Answer() { + @Override + public Group answer(InvocationOnMock invocation) throws Throwable { + ((Group) invocation.getArguments()[0]).setId(new Random().nextInt()); + return ((Group) invocation.getArguments()[0]); + } + }); + + JwtHeadersConfiguration basicConfig = JwtHeadersIntegrationTest.getBasicConfig(); + basicConfig.setUpdateGroup(true); + basicConfig.setUpdateProfile(true); + + var trivialUser = new JwtHeadersTrivialUser("testcaseUser@example.com"); + trivialUser.setProfile(Profile.Administrator); + + Map> profileGroups = new HashMap<>(); + profileGroups.put(Profile.Reviewer, Arrays.asList("group1", "group2")); + trivialUser.setProfileGroups(profileGroups); + + trivialUser = spy(trivialUser); + + User userDetails = (User) jwtHeadersUserUtil.getUser(trivialUser, basicConfig); + + Assert.assertEquals("testcaseUser@example.com", userDetails.getUsername()); + Assert.assertEquals("testcaseUser", userDetails.getName()); + + //verify helper methods called + verify(jwtHeadersUserUtil.authProvider).loadUserByUsername("testcaseUser@example.com"); + verify(jwtHeadersUserUtil).createUser(trivialUser, basicConfig); + + //user should be saved with the Profile (admin) + verify(jwtHeadersUserUtil.userRepository).save(userDetails); //user was saved + Assert.assertEquals(Profile.Administrator, userDetails.getProfile()); + + + //update groups method was called + verify(jwtHeadersUserUtil).updateGroups(profileGroups, userDetails); + + //group1 and group2 saved to db + //attempted to find them in DB + verify(jwtHeadersUserUtil.groupRepository).findByName("group1"); + verify(jwtHeadersUserUtil.groupRepository).findByName("group2"); + + //saved + ArgumentCaptor groupsCaptor = ArgumentCaptor.forClass(Group.class); + verify(jwtHeadersUserUtil.groupRepository, times(2)).save(groupsCaptor.capture()); + + Assert.assertEquals("group1", groupsCaptor.getAllValues().get(0).getName()); + Assert.assertEquals("group2", groupsCaptor.getAllValues().get(1).getName()); + + + //user connected to group and role + ArgumentCaptor setUserGroupCaptor = ArgumentCaptor.forClass(Set.class); + + verify(jwtHeadersUserUtil.userGroupRepository).updateUserGroups(eq(userDetails.getId()), setUserGroupCaptor.capture()); + Assert.assertEquals(1, setUserGroupCaptor.getAllValues().size()); + List userGroups = (List) setUserGroupCaptor.getAllValues().get(0).stream().collect(Collectors.toList()); + Collections.sort(userGroups, + (o1, o2) -> ((o1).getGroup().getName() + "-" + o1.getProfile()).compareTo((o2).getGroup().getName() + "-" + o2.getProfile())); + Assert.assertEquals(4, userGroups.size()); + + Assert.assertEquals(Profile.Editor, userGroups.get(0).getProfile()); + Assert.assertEquals(userDetails, userGroups.get(0).getUser()); + Assert.assertEquals("group1", userGroups.get(0).getGroup().getName()); + + Assert.assertEquals(Profile.Reviewer, userGroups.get(1).getProfile()); + Assert.assertEquals(userDetails, userGroups.get(1).getUser()); + Assert.assertEquals("group1", userGroups.get(1).getGroup().getName()); + + Assert.assertEquals(Profile.Editor, userGroups.get(2).getProfile()); + Assert.assertEquals(userDetails, userGroups.get(2).getUser()); + Assert.assertEquals("group2", userGroups.get(2).getGroup().getName()); + + Assert.assertEquals(Profile.Reviewer, userGroups.get(3).getProfile()); + Assert.assertEquals(userDetails, userGroups.get(3).getUser()); + Assert.assertEquals("group2", userGroups.get(3).getGroup().getName()); + } + + + /** + * we have the config setup so it writes user data to DB + * + user IS in DB + * + validate that profile is set + * + validate that user groups (in db) are updated + */ + @Test + public void testOldUserWithGroups() { + User user = new User(); + user.setUsername("testcaseUser@example.com"); + user.setName("testcaseUser"); + user.setId(666); + + doReturn(user) + .when(jwtHeadersUserUtil.authProvider).loadUserByUsername("testcaseUser@example.com"); + + + //make sure that the group ID is set when saved. GN uses the ID in Set<> operations, so we must SET it. + when(jwtHeadersUserUtil.groupRepository.save(any())).thenAnswer(new Answer() { + @Override + public Group answer(InvocationOnMock invocation) throws Throwable { + ((Group) invocation.getArguments()[0]).setId(new Random().nextInt()); + return ((Group) invocation.getArguments()[0]); + } + }); + + JwtHeadersConfiguration basicConfig = JwtHeadersIntegrationTest.getBasicConfig(); + basicConfig.setUpdateGroup(true); + basicConfig.setUpdateProfile(true); + + var trivialUser = new JwtHeadersTrivialUser("testcaseUser@example.com"); + trivialUser.setProfile(Profile.Administrator); + + Map> profileGroups = new HashMap<>(); + profileGroups.put(Profile.Reviewer, Arrays.asList("group1", "group2")); + trivialUser.setProfileGroups(profileGroups); + + trivialUser = spy(trivialUser); + + User userDetails = (User) jwtHeadersUserUtil.getUser(trivialUser, basicConfig); + + Assert.assertEquals("testcaseUser@example.com", userDetails.getUsername()); + Assert.assertEquals("testcaseUser", userDetails.getName()); + + //verify helper methods called + verify(jwtHeadersUserUtil.authProvider).loadUserByUsername("testcaseUser@example.com"); + verify(jwtHeadersUserUtil, never()).createUser(trivialUser, basicConfig); + + //user should be saved with the Profile (admin) + verify(jwtHeadersUserUtil.userRepository).save(userDetails); //user was saved + Assert.assertEquals(Profile.Administrator, userDetails.getProfile()); + + + //update groups method was called + verify(jwtHeadersUserUtil).updateGroups(profileGroups, userDetails); + + //group1 and group2 saved to db + //attempted to find them in DB + verify(jwtHeadersUserUtil.groupRepository).findByName("group1"); + verify(jwtHeadersUserUtil.groupRepository).findByName("group2"); + + //saved + ArgumentCaptor groupsCaptor = ArgumentCaptor.forClass(Group.class); + verify(jwtHeadersUserUtil.groupRepository, times(2)).save(groupsCaptor.capture()); + + Assert.assertEquals("group1", groupsCaptor.getAllValues().get(0).getName()); + Assert.assertEquals("group2", groupsCaptor.getAllValues().get(1).getName()); + + //user connected to group and role + ArgumentCaptor setUserGroupCaptor = ArgumentCaptor.forClass(Set.class); + + verify(jwtHeadersUserUtil.userGroupRepository).updateUserGroups(eq(userDetails.getId()), setUserGroupCaptor.capture()); + Assert.assertEquals(1, setUserGroupCaptor.getAllValues().size()); + List userGroups = (List) setUserGroupCaptor.getAllValues().get(0).stream().collect(Collectors.toList()); + Collections.sort(userGroups, + (o1, o2) -> ((o1).getGroup().getName() + "-" + o1.getProfile()).compareTo((o2).getGroup().getName() + "-" + o2.getProfile())); + Assert.assertEquals(4, userGroups.size()); + + Assert.assertEquals(Profile.Editor, userGroups.get(0).getProfile()); + Assert.assertEquals(userDetails, userGroups.get(0).getUser()); + Assert.assertEquals("group1", userGroups.get(0).getGroup().getName()); + + Assert.assertEquals(Profile.Reviewer, userGroups.get(1).getProfile()); + Assert.assertEquals(userDetails, userGroups.get(1).getUser()); + Assert.assertEquals("group1", userGroups.get(1).getGroup().getName()); + + Assert.assertEquals(Profile.Editor, userGroups.get(2).getProfile()); + Assert.assertEquals(userDetails, userGroups.get(2).getUser()); + Assert.assertEquals("group2", userGroups.get(2).getGroup().getName()); + + Assert.assertEquals(Profile.Reviewer, userGroups.get(3).getProfile()); + Assert.assertEquals(userDetails, userGroups.get(3).getUser()); + Assert.assertEquals("group2", userGroups.get(3).getGroup().getName()); + } + + +} diff --git a/docs/manual/docs/administrator-guide/managing-users-and-groups/authentication-mode.md b/docs/manual/docs/administrator-guide/managing-users-and-groups/authentication-mode.md index efc095df787..476f8aed044 100644 --- a/docs/manual/docs/administrator-guide/managing-users-and-groups/authentication-mode.md +++ b/docs/manual/docs/administrator-guide/managing-users-and-groups/authentication-mode.md @@ -6,6 +6,7 @@ By default the catalog uses the internal database for user management and authen - [Configuring LDAP - Hierarchy](authentication-mode.md#authentication-ldap-hierarchy) - [Configuring CAS](authentication-mode.md#authentication-cas) - [Configuring OAUTH2 OpenID Connect](authentication-mode.md#authentication-openid) +- [Configuring JWT/JSON Headers](authentication-mode.md#jwt-headers) - [Configuring Keycloak](authentication-mode.md#authentication-keycloak) - [Configuring Shibboleth](authentication-mode.md#authentication-shibboleth) @@ -818,6 +819,233 @@ sample:RegisteredUser A similar setup is described for geoserver in the [geoserver documentation](https://docs.geoserver.org/latest/en/user/community/keycloak/index.html). +## Configurating JWT/JSON Headers {#jwt-headers} + +The JWT Headers module provides a security module for header based security. It is equivalent to GeoServer's JWT Headers Module (both GeoServer and GeoNetwork share a code library to make them equivelent). + +This module allows [JSON-based](https://en.wikipedia.org/wiki/JSON) headers (for username and roles) as well as [JWT-based](https://en.wikipedia.org/wiki/JSON_Web_Token>) headers (for username and roles). It also allows for validating JWT-Based AccessTokens (i.e. via [OAUTH2](https://en.wikipedia.org/wiki/OAuth>)/[OpenID Connect](ttps://en.wikipedia.org/wiki/OpenID#OpenID_Connect_(OIDC)). + + +If you are using something like [Apache's mod_auth_openidc](https://github.com/OpenIDC/mod_auth_openidc), then this module will allow you to; + +1. Get the username from an Apache-provided `OIDC_*` header (either as simple-strings or as a component of a JSON object). +2. Get the user's roles from an Apache-provided `OIDC_*` header (as a component of a JSON object). +3. The user's roles can also be from any of the standard GeoServer providers (i.e. User Group Service, Role Service, or Request Header). + +If you are using [OAUTH2/OIDC Access Tokens](https://www.oauth.com/oauth2-servers/access-tokens/): + +1. Get the username from the attached JWT Access Token (via a path into the [Access Token's JSON Claims](https://auth0.com/docs/authenticate/login/oidc-conformant-authentication/oidc-adoption-access-tokens/)). +2. Get the user's roles from the JWT Access Token (via a path into the Token's JSON Claims). +3. Validate the Access Token + + * Validate its Signature + * Validate that it hasn't expired + * Validate the token against a token verifier URL ("userinfo_endpoint") and check that subjects match + * Validate components of the Access Token (like [aud (audience)](https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims>)) + +4. The user's roles can also be from any of the standard GeoServer providers (i.e. User Group Service, Role Service, or Request Header). +5. You can also extract roles from the JWT Access Token (via a JSON path). + +### JWT Headers configuration + + +The JWT Headers module covers three main use cases: + +1. Simple Text, JSON, or JWT headers for the username +2. Verification of JWT Access Tokens +3. Getting roles from a JSON header or an attached JWT Access Token claim + +#### Configuration Options + +You must turn on JWT Header Support by setting the `GEONETWORK_SECURITY_TYPE` environment variable to `jwt-headers`. + +``` +GEONETWORK_SECURITY_TYPE=jwt-headers +``` + +Please see the files for more detailed configuration: +* `config-security-jwt-header.xml` +* `config-security-jwt-header-overrides.properties` + +##### User Name Options + + +| Environment Variable | Meaning | +| ------------- | ------- | +|JWTHEADERS_UserNameHeaderFormat | The name of the HTTP header item that contains the user name. | +|JWTHEADERS_UserNameFormat| Format that the user name is in:
`STRING` - user name is the header's value.
`JSON` - The header is a JSON string. Use "JSON path" for where the user name is in the JSON.
`JWT` - The header is a JWT (base64) string. Use "JSON path" for where the user name is in the JWT claims. | +|JWTHEADERS_UserNameJsonPath | JSON path for the User Name. If the user name is in JSON or JWT format, this is the JSON path to the user's name.| + + + +If you are using [Apache's mod_auth_openidc](https://github.com/OpenIDC/mod_auth_openidc), then Apache will typically add: + +* an `OIDC_id_token_payload` header item (containing a JSON string of the ID token claims) +* an `OIDC_access_token` header item (containing a base64 JWT Access Token) +* optionally, a simple header item with individual claim values (i.e. `OIDC_access_token`) + +Here are some example values; + +STRING +``` +OIDC_preferred_username: david.blasby@geocat.net +``` + +JSON +``` +OIDC_id_token_payload: {"exp":1708555947,"iat":1708555647,"auth_time":1708555288,"jti":"42ee833e-89d3-4779-bd9d-06b979329c9f","iss":"http://localhost:7777/realms/dave-test2","aud":"live-key2","sub":"98cfe060-f980-4a05-8612-6c609219ffe9","typ":"ID","azp":"live-key2","nonce":"4PhqmZSJ355KBtJPbAP_PdwqiLnc7B1lA2SGpB0zXr4","session_state":"7712b364-339a-4053-ae0c-7d3adfca9005","at_hash":"2Tyw8q4ZMewuYrD38alCug","acr":"0","sid":"7712b364-339a-4053-ae0c-7d3adfca9005","upn":"david.blasby@geocat.net","resource_access":{"live-key2":{"roles":["GeonetworkAdministrator","GeoserverAdministrator"]}},"email_verified":false,"address":{},"name":"david blasby","groups":["default-roles-dave-test2","offline_access","uma_authorization"],"preferred_username":"david.blasby@geocat.net","given_name":"david","family_name":"blasby","email":"david.blasby@geocat.net"} +``` + +JWT +``` +OIDC_access_token: eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICItb0QyZXphcjF3ZHBUUmZCS0NqMFY4cm5ZVkJGQmxJLW5ldzFEREJCNTJrIn0.eyJleHAiOjE3MDg1NTU5NDcsImlhdCI6MTcwODU1NTY0NywiYXV0aF90aW1lIjoxNzA4NTU1Mjg4LCJqdGkiOiI0M2UyYjUwZS1hYjJkLTQ2OWQtYWJjOC01Nzc1YTY0MTMwNTkiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Ojc3NzcvcmVhbG1zL2RhdmUtdGVzdDIiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiOThjZmUwNjAtZjk4MC00YTA1LTg2MTItNmM2MDkyMTlmZmU5IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoibGl2ZS1rZXkyIiwibm9uY2UiOiI0UGhxbVpTSjM1NUtCdEpQYkFQX1Bkd3FpTG5jN0IxbEEyU0dwQjB6WHI0Iiwic2Vzc2lvbl9zdGF0ZSI6Ijc3MTJiMzY0LTMzOWEtNDA1My1hZTBjLTdkM2FkZmNhOTAwNSIsImFjciI6IjAiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1kYXZlLXRlc3QyIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImxpdmUta2V5MiI6eyJyb2xlcyI6WyJHZW9uZXR3b3JrQWRtaW5pc3RyYXRvciIsIkdlb3NlcnZlckFkbWluaXN0cmF0b3IiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIHBob25lIG9mZmxpbmVfYWNjZXNzIG1pY3JvcHJvZmlsZS1qd3QgcHJvZmlsZSBhZGRyZXNzIGVtYWlsIiwic2lkIjoiNzcxMmIzNjQtMzM5YS00MDUzLWFlMGMtN2QzYWRmY2E5MDA1IiwidXBuIjoiZGF2aWQuYmxhc2J5QGdlb2NhdC5uZXQiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsImFkZHJlc3MiOnt9LCJuYW1lIjoiZGF2aWQgYmxhc2J5IiwiZ3JvdXBzIjpbImRlZmF1bHQtcm9sZXMtZGF2ZS10ZXN0MiIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXSwicHJlZmVycmVkX3VzZXJuYW1lIjoiZGF2aWQuYmxhc2J5QGdlb2NhdC5uZXQiLCJnaXZlbl9uYW1lIjoiZGF2aWQiLCJmYW1pbHlfbmFtZSI6ImJsYXNieSIsImVtYWlsIjoiZGF2aWQuYmxhc2J5QGdlb2NhdC5uZXQifQ.Iq8YJ99s_HBd-gU2zaDqGbJadCE--7PlS2kRHaegYTil7WoNKfjfcH-K-59mHGzJm-V_SefE-iWG63z2c6ChddzhvG8I_O5vDNFoGlGOQFunZC379SqhqhCEdwscEUDkNA3iTTXvK9vn0muStDiv9OzpJ1zcpqYqsgxGbolGgLJgeuK8yNDH7kzDtoRzHiHw2rx4seeVpxUYAjyg_cCkEjRt3wzud7H3xlfQWRx75YfpJ0pnVphuXYR7Z8x9p6hCPtrBfDeriudm-wkwXtcV2LNlXrZ2zpKS_6Zdxzza2lN30q_6DQXHGo8EAIr8SiiQrxPQulNiX9r8XmQ917Ep0g +``` + + + +It is recommended to either use the `OIDC_id_token_payload` (JSON) or `OIDC_access_token` (JWT) header. + +For `OIDC_id_token_payload`: + +* Request header attribute for User Name: `OIDC_id_token_payload` +* Format the Header value is in: `JSON` +* JSON path for the User Name: `preferred_username` + +For `OIDC_access_token`: + +* Request header attribute for User Name: `OIDC_access_token` +* Format the Header value is in: `JWT` +* JSON path for the User Name: `preferred_username` + + + +#### Role Source Options + + +You can use the standard role source options in GeoNetwork (`Request Header`, `User Group Service`, or `Role Service`). The JWT Headers module adds two more role sources - `Header Containing JSON String` and `Header containing JWT`. + + +| Environment Variable | Meaning | +| ------------- | ------- | +|JWTHEADERS_RolesHeaderName| Name of the header item the JSON or JWT is contained in| +| JWTHEADERS_JwtHeaderRoleSource |Which Role Source to use:
`JSON` - The header is a JSON string. Use "JSON path" for where the roles are in the JSON.
`JWT` - The header is a JWT (base64) string. Use "JSON path" for where the roles are in the JWT claims. | +| JWTHEADERS_RolesJsonPath| Path in the JSON object or JWT claims that contains the roles. This should either be a simple string (single role) or a list of strings.| + + + +Using the example `OIDC_id_token_payload` (JSON) or `OIDC_access_token` (JWT) shown above, the claims are: + + +``` + { + "exp": 1708555947, + "iat": 1708555647, + "auth_time": 1708555288, + "jti": "42ee833e-89d3-4779-bd9d-06b979329c9f", + "iss": "http://localhost:7777/realms/dave-test2", + "aud": "live-key2", + "sub": "98cfe060-f980-4a05-8612-6c609219ffe9", + "typ": "ID", + "azp": "live-key2", + "nonce": "4PhqmZSJ355KBtJPbAP_PdwqiLnc7B1lA2SGpB0zXr4", + "session_state": "7712b364-339a-4053-ae0c-7d3adfca9005", + "at_hash": "2Tyw8q4ZMewuYrD38alCug", + "acr": "0", + "sid": "7712b364-339a-4053-ae0c-7d3adfca9005", + "upn": "david.blasby@geocat.net", + "resource_access": + { + "live-key2": + { + "roles": + [ + "GeonetworkAdministrator", + "GeoserverAdministrator" + ] + } + }, + "email_verified": false, + "address": { }, + "name": "david blasby", + "groups": ["default-roles-dave-test2", "offline_access", "uma_authorization"], + "preferred_username": "david.blasby@geocat.net", + "given_name": "david", + "family_name": "blasby", + "email": "david.blasby@geocat.net" + } +``` + +In this JSON set of claims (mirrored in the JWT claims of the Access Token), and the two roles from the IDP are "GeonetworkAdministrator", and "GeoserverAdministrator". The JSON path to the roles is `resource_access.live-key2.roles`. + +#### Role Conversion + + +The JWT Headers module also allows for converting roles (from the external IDP) to the GeoServer internal role names. + + +| Environment Variable | Meaning | +| ------------- | ------- | +|JWTHEADERS_RoleConverterString| Role Converter Map from External Roles to Geoserver Roles.
This is a ";" delimited map in the form of:
`ExternalRole1=GeoServerRole1;ExternalRole2=GeoServerRole2`| +|JWTHEADERS_OnlyExternalListedRoles | Only allow External Roles that are explicitly named above.
If true, external roles that are not mentioned in the conversion map will be ignored. If false, those external roles will be turned into GeoServer roles of the same name.| + + +For example, a conversion map like `GeonetworkAdministrator=ADMINISTRATOR` will convert our IDP "GeonetworkAdministrator" to the "ADMINISTRATOR" Profile.. + +In our example, the user has two roles "GeoserverAdministrator" and "GeonetworkAdministrator". If the "Only allow External Roles that are explicitly named above" is true, then GeoServer will only see the "ADMINISTRATOR" role. If true, it will see "ADMINISTRATOR" and "GeoserverAdministrator". In neither case will it see the converted "GeonetworkAdministrator" roles. + +##### Groups + +As equivelent with the OIDC and Keycloak providers, specify group permissions in the `:` format. + + +### JWT Validation + + +If you are using Apache's `mod_auth_openidc` module, then you do *not* have to do JWT validation - Apache will ensure they are valid when it attaches the headers to the request. + +However, if you are using robot access to GeoServer, you can attach an Access Token to the request header for access. + +``` +Authentication: Bearer `base64 JWT Access Token` +``` + +OR + +``` +Authentication: `base64 JWT Access Token` +``` + +You would then setup the user name to come from a JWT token in the `Authentication` header with a JSON path like `preferred_username`. + + + + +You can also extract roles from the Access Token in a similar manner - make sure your IDP imbeds roles inside the Access Token. + +| Environment Variable | Meaning | +| ------------- | ------- | +|JWTHEADERS_ValidateToken |Validate JWT (Access Token).
If false, do not do any validation. | +| JWTHEADERS_ValidateTokenExpiry|Validate Token Expiry.
If true, validate the `exp` claim in the JWT and ensure it is in the future. This should always be true so you do not allow expired tokens. | +| JWTHEADERS_ValidateTokenSignature| Validate JWT (Access Token) Signature.
If true, validate the Token's Signature| +|JWTHEADERS_ValidateTokenSignatureURL | JSON Web Key Set URL (jwks_uri).
URL for a JWK Set. This is typically called `jwks_uri` in the OIDC metadata configuration. This will be downloaded and used to check the JWT's signature. This should always be true to ensure that the JWT has not been modified.| +|JWTHEADERS_ValidateTokenAgainstURL | Validate JWT (Access Token) Against Endpoint.
If true, validate the access token against an IDP's token verification URL.| +| JWTHEADERS_ValidateTokenAgainstURLEndpoint| URL (userinfo_endpoint).
IDP's token validation URL. This URL will be retrieved by adding the Access Token to the `Authentiation: Bearer ` header. It should return a HTTP 200 status code if the token is valid. This is recommened by the OIDC specification.| +| JWTHEADERS_ValidateSubjectWithEndpoint|Also validate Subject.
If true, the `sub` claim of the Access Token and the "userinfo_endpoint" `sub` claim will be checked to ensure they are equal. This is recommened by the OIDC specification. | +|JWTHEADERS_ValidateTokenAudience | Validate JWT (Access Token) Audience.
If true, the audience of the Access Token is checked. This is recommened by the OIDC specification since this verifies that the Access Token is meant for us.| +|JWTHEADERS_ValidateTokenAudienceClaimName | Claim Name.
The name of the claim the audience is in (`aud`, `azp`, or `appid` claim) the Access Token.| +|JWTHEADERS_ValidateTokenAudienceClaimValue|Required Claim Value.
The value this claim must be (if the claim is a list of string, then it must contain this value). | + + +#### Using Headers or GeoNetwork Database for Profiles & Profile Groups + +Inside `JwtHeaderConfiguration`, use these values to determine where Profile and ProfileGroups come from. + +| Property | Meaning | +| ------------- | ------- | +|updateProfile| true -> update the DB with the information from OIDC (don't allow user to edit profile in the UI)
false -> don't update the DB (user must edit profile in UI). | +|updateGroup| true -> update the DB (user's group) with the information from OIDC (don't allow admin to edit user's groups in the UI)
false -> don't update the DB (admin must edit groups in UI).| + + ## Configuring EU Login {#authentication-ecas} EU Login is the central login mechanism of the European Commission. You can enable login against that central service in case your intended users have ar can acquire a EU Login. diff --git a/web/src/main/webapp/WEB-INF/config-security/config-security-jwt-headers-overrides.properties b/web/src/main/webapp/WEB-INF/config-security/config-security-jwt-headers-overrides.properties new file mode 100644 index 00000000000..b6c503095ec --- /dev/null +++ b/web/src/main/webapp/WEB-INF/config-security/config-security-jwt-headers-overrides.properties @@ -0,0 +1,50 @@ +# Copyright (C) 2024 Food and Agriculture Organization of the +# United Nations (FAO-UN), United Nations World Food Programme (WFP) +# and United Nations Environment Programme (UNEP) +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or (at +# your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA +# +# Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, +# Rome - Italy. email: geonetwork@osgeo.org +jwtheadersConfiguration.JwtConfiguration.userNameHeaderAttributeName=${JWTHEADERS_UserNameHeaderFormat:OIDC_id_token_payload} +jwtheadersConfiguration.JwtConfiguration.userNameFormatChoice=${JWTHEADERS_UserNameFormat:JSON} + +jwtheadersConfiguration.JwtConfiguration.UserNameJsonPath=${JWTHEADERS_UserNameJsonPath:preferred_username} + + +jwtheadersConfiguration.JwtConfiguration.rolesJsonPath=${JWTHEADERS_RolesJsonPath:resource_access.live-key2.roles} +jwtheadersConfiguration.JwtConfiguration.rolesHeaderName=${JWTHEADERS_RolesHeaderName:OIDC_id_token_payload} +jwtheadersConfiguration.JwtConfiguration.jwtHeaderRoleSource=${JWTHEADERS_JwtHeaderRoleSource:JSON} + +jwtheadersConfiguration.JwtConfiguration.roleConverterString=${JWTHEADERS_RoleConverterString:"GeonetworkAdministrator=ADMINISTRATOR"} +jwtheadersConfiguration.JwtConfiguration.onlyExternalListedRoles=${JWTHEADERS_OnlyExternalListedRoles:false} + +jwtheadersConfiguration.JwtConfiguration.validateToken=${JWTHEADERS_ValidateToken:false} + +jwtheadersConfiguration.JwtConfiguration.validateTokenExpiry=${JWTHEADERS_ValidateTokenExpiry:false} + + +jwtheadersConfiguration.JwtConfiguration.validateTokenAgainstURL=${JWTHEADERS_ValidateTokenAgainstURL:true} +jwtheadersConfiguration.JwtConfiguration.validateTokenAgainstURLEndpoint=${JWTHEADERS_ValidateTokenAgainstURLEndpoint:} +jwtheadersConfiguration.JwtConfiguration.validateSubjectWithEndpoint=${JWTHEADERS_ValidateSubjectWithEndpoint:true} + +jwtheadersConfiguration.JwtConfiguration.validateTokenAudience=${JWTHEADERS_ValidateTokenAudience:true} +jwtheadersConfiguration.JwtConfiguration.validateTokenAudienceClaimName=${JWTHEADERS_ValidateTokenAudienceClaimName:""} +jwtheadersConfiguration.JwtConfiguration.validateTokenAudienceClaimValue=${JWTHEADERS_ValidateTokenAudienceClaimValue:""} + +jwtheadersConfiguration.JwtConfiguration.validateTokenSignature=${JWTHEADERS_ValidateTokenSignature:true} +jwtheadersConfiguration.JwtConfiguration.validateTokenSignatureURL=${JWTHEADERS_ValidateTokenSignatureURL:""} + + diff --git a/web/src/main/webapp/WEB-INF/config-security/config-security-jwt-headers.xml b/web/src/main/webapp/WEB-INF/config-security/config-security-jwt-headers.xml new file mode 100644 index 00000000000..3ae063749b5 --- /dev/null +++ b/web/src/main/webapp/WEB-INF/config-security/config-security-jwt-headers.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/main/webapp/WEB-INF/config-security/config-security.xml b/web/src/main/webapp/WEB-INF/config-security/config-security.xml index 817d79df5ed..e740da2a657 100644 --- a/web/src/main/webapp/WEB-INF/config-security/config-security.xml +++ b/web/src/main/webapp/WEB-INF/config-security/config-security.xml @@ -54,6 +54,8 @@ keycloak - Keycloak security (see config-security-keycloak.xml for more details) openidconnect - OAUTH2 Open ID Connect (see config-security-openidconnect.xml and -overrides.properties for details) + openidconnect can be used instead of the keycloak provider + jwt-headers - Support for JSON/JWT headers for username & roles + Access Token validation + + (see config-security-jwt-headers.xml and -overrides.properties for details) ldap - ldap security (see config-security-ldap.xml for more details) ldap-recursive - ldap-recursive security (see config-security-ldap-recursive.xml for more details) ecas - ecas security (see config-security-ecas.xml for more details) From 8571521e2bd23172bb170ef0adc6d3f641b8d526 Mon Sep 17 00:00:00 2001 From: "david.blasby" Date: Tue, 26 Mar 2024 10:49:10 -0700 Subject: [PATCH 2/5] changes from jose's review --- core/pom.xml | 3 +-- .../authentication-mode.md | 24 +++++++++---------- pom.xml | 22 +++++++++++++++++ services/pom.xml | 1 - 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/core/pom.xml b/core/pom.xml index 167b88ab8e9..87f55650ddf 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -39,7 +39,7 @@ org.geoserver.community.jwt-headers jwt-headers-util - 2.25-SNAPSHOT + 2.26-SNAPSHOT @@ -314,7 +314,6 @@ com.jayway.jsonpath json-path - 2.4.0 org.locationtech.jts diff --git a/docs/manual/docs/administrator-guide/managing-users-and-groups/authentication-mode.md b/docs/manual/docs/administrator-guide/managing-users-and-groups/authentication-mode.md index 476f8aed044..ece4cebbc50 100644 --- a/docs/manual/docs/administrator-guide/managing-users-and-groups/authentication-mode.md +++ b/docs/manual/docs/administrator-guide/managing-users-and-groups/authentication-mode.md @@ -821,7 +821,7 @@ A similar setup is described for geoserver in the [geoserver documentation](http ## Configurating JWT/JSON Headers {#jwt-headers} -The JWT Headers module provides a security module for header based security. It is equivalent to GeoServer's JWT Headers Module (both GeoServer and GeoNetwork share a code library to make them equivelent). +The JWT Headers module provides a security module for header based security. It is equivalent to GeoServer's JWT Headers Module (both GeoServer and GeoNetwork share a code library to make them equivalent). This module allows [JSON-based](https://en.wikipedia.org/wiki/JSON) headers (for username and roles) as well as [JWT-based](https://en.wikipedia.org/wiki/JSON_Web_Token>) headers (for username and roles). It also allows for validating JWT-Based AccessTokens (i.e. via [OAUTH2](https://en.wikipedia.org/wiki/OAuth>)/[OpenID Connect](ttps://en.wikipedia.org/wiki/OpenID#OpenID_Connect_(OIDC)). @@ -830,7 +830,7 @@ If you are using something like [Apache's mod_auth_openidc](https://github.com/O 1. Get the username from an Apache-provided `OIDC_*` header (either as simple-strings or as a component of a JSON object). 2. Get the user's roles from an Apache-provided `OIDC_*` header (as a component of a JSON object). -3. The user's roles can also be from any of the standard GeoServer providers (i.e. User Group Service, Role Service, or Request Header). +3. The user's roles can also come from the GeoNetwork Database (managed by the administrator in the GeoNetwork GUI). If you are using [OAUTH2/OIDC Access Tokens](https://www.oauth.com/oauth2-servers/access-tokens/): @@ -843,7 +843,7 @@ If you are using [OAUTH2/OIDC Access Tokens](https://www.oauth.com/oauth2-server * Validate the token against a token verifier URL ("userinfo_endpoint") and check that subjects match * Validate components of the Access Token (like [aud (audience)](https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims>)) -4. The user's roles can also be from any of the standard GeoServer providers (i.e. User Group Service, Role Service, or Request Header). +4. The user's roles can also come from the GeoNetwork Database (managed by the administrator in the GeoNetwork GUI). 5. You can also extract roles from the JWT Access Token (via a JSON path). ### JWT Headers configuration @@ -980,22 +980,22 @@ In this JSON set of claims (mirrored in the JWT claims of the Access Token), and #### Role Conversion -The JWT Headers module also allows for converting roles (from the external IDP) to the GeoServer internal role names. +The JWT Headers module also allows for converting roles (from the external IDP) to the GeoNetwork internal role names. -| Environment Variable | Meaning | -| ------------- | ------- | -|JWTHEADERS_RoleConverterString| Role Converter Map from External Roles to Geoserver Roles.
This is a ";" delimited map in the form of:
`ExternalRole1=GeoServerRole1;ExternalRole2=GeoServerRole2`| -|JWTHEADERS_OnlyExternalListedRoles | Only allow External Roles that are explicitly named above.
If true, external roles that are not mentioned in the conversion map will be ignored. If false, those external roles will be turned into GeoServer roles of the same name.| +| Environment Variable | Meaning | +| ------------- |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +|JWTHEADERS_RoleConverterString| Role Converter Map from External Roles to GeoNetwork Roles.
This is a ";" delimited map in the form of:
`ExternalRole1=GeoNetworkRole1;ExternalRole2=GeoNetworkRole2` | +|JWTHEADERS_OnlyExternalListedRoles | Only allow External Roles that are explicitly named above.
If true, external roles that are not mentioned in the conversion map will be ignored. If false, those external roles will be turned into GeoNetwork roles of the same name.
These roles should either be a Profile ("Administrator", "Reviewer", etc..) or group-based permissions ("GroupName:ProfileName") | -For example, a conversion map like `GeonetworkAdministrator=ADMINISTRATOR` will convert our IDP "GeonetworkAdministrator" to the "ADMINISTRATOR" Profile.. +For example, a conversion map like `GeonetworkAdministrator=ADMINISTRATOR` will convert our IDP "GeonetworkAdministrator" to the "ADMINISTRATOR" Profile... -In our example, the user has two roles "GeoserverAdministrator" and "GeonetworkAdministrator". If the "Only allow External Roles that are explicitly named above" is true, then GeoServer will only see the "ADMINISTRATOR" role. If true, it will see "ADMINISTRATOR" and "GeoserverAdministrator". In neither case will it see the converted "GeonetworkAdministrator" roles. +In our example, the user has two roles "GeoserverAdministrator" and "GeonetworkAdministrator". If the "Only allow External Roles that are explicitly named above" is true, then GeoNetwork will only see the "ADMINISTRATOR" role. If true, it will see "ADMINISTRATOR" and "GeoserverAdministrator". In neither case will it see the converted "GeonetworkAdministrator" roles. ##### Groups -As equivelent with the OIDC and Keycloak providers, specify group permissions in the `:` format. +As equivalent with the OIDC and Keycloak providers, specify group permissions in the `:` format. ### JWT Validation @@ -1003,7 +1003,7 @@ As equivelent with the OIDC and Keycloak providers, specify group permissions in If you are using Apache's `mod_auth_openidc` module, then you do *not* have to do JWT validation - Apache will ensure they are valid when it attaches the headers to the request. -However, if you are using robot access to GeoServer, you can attach an Access Token to the request header for access. +However, if you are using robot access to GeoNetwork, you can attach an Access Token to the request header for access. ``` Authentication: Bearer `base64 JWT Access Token` diff --git a/pom.xml b/pom.xml index 47993745350..38240048676 100644 --- a/pom.xml +++ b/pom.xml @@ -897,6 +897,13 @@
+ + com.jayway.jsonpath + json-path + 2.4.0 + + + junit @@ -1414,6 +1421,21 @@ jmeter
+ + macOS-M-series + + + mac + aarch64 + + + + darwin-aarch64 + kibana.sh + darwin-aarch64 + tar.gz + + macOS diff --git a/services/pom.xml b/services/pom.xml index 949daa40507..0960e2312b3 100644 --- a/services/pom.xml +++ b/services/pom.xml @@ -130,7 +130,6 @@ com.jayway.jsonpath json-path - 2.4.0 com.jayway.jsonpath From 4fe7ce6c8126253991768750a74354bef6a76c06 Mon Sep 17 00:00:00 2001 From: "david.blasby" Date: Tue, 26 Mar 2024 15:57:56 -0700 Subject: [PATCH 3/5] make sure that token validation is being done --- .../security/jwtheaders/JwtHeadersTrivialUser.java | 12 +++++++++++- .../jwtheaders/JwtHeadersTrivialUserTest.java | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUser.java b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUser.java index a0e71c4c2ae..c042c31938e 100644 --- a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUser.java +++ b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUser.java @@ -28,10 +28,12 @@ import org.fao.geonet.utils.Log; import org.geoserver.security.jwtheaders.JwtConfiguration; import org.geoserver.security.jwtheaders.roles.JwtHeadersRolesExtractor; +import org.geoserver.security.jwtheaders.token.TokenValidator; import org.geoserver.security.jwtheaders.username.JwtHeaderUserNameExtractor; import org.springframework.util.StringUtils; import javax.servlet.http.HttpServletRequest; +import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -62,7 +64,7 @@ public JwtHeadersTrivialUser(String userName) { profileGroups = new HashMap<>(); } - public static JwtHeadersTrivialUser create(JwtConfiguration config, HttpServletRequest request) { + public static JwtHeadersTrivialUser create(JwtConfiguration config, HttpServletRequest request) throws IOException { if (request == null || config == null || config.getUserNameHeaderAttributeName() == null) { Log.debug(Geonet.SECURITY, "JwtHeadersUser.create called with null args!"); return null; // nothing to do @@ -81,6 +83,14 @@ public static JwtHeadersTrivialUser create(JwtConfiguration config, HttpServletR return null; // no username } + var tokenValidator = new TokenValidator(config); + try { + tokenValidator.validate(userNameHeader); + } + catch (Exception e) { + throw new IOException("JWT Token is invalid",e); + } + //get roles from the headers (pay attention to config) var result = new JwtHeadersTrivialUser(userName); handleRoles(result, config, request); diff --git a/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUserTest.java b/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUserTest.java index 15f220d05fc..82f2171ca0d 100644 --- a/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUserTest.java +++ b/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUserTest.java @@ -202,7 +202,7 @@ public void testHandleRolesJWT() { * this is dependent on the above methods, so this is just a quick test */ @Test - public void testCreate() { + public void testCreate() throws Exception { var config = JwtHeadersIntegrationTest.getBasicConfigJWT(); var request = new MockHttpServletRequest(); request.addHeader("TOKEN", JwtHeadersIntegrationTest.JWT); From ba3c2ff1f8037acdbd4b16a1b08c7a7dc183af55 Mon Sep 17 00:00:00 2001 From: "david.blasby" Date: Thu, 18 Apr 2024 19:18:59 -0700 Subject: [PATCH 4/5] allow two jwt-header auth filters at the same time + configuration --- .../jwtheaders/JwtHeadersAuthFilter.java | 14 ++- .../jwtheaders/JwtHeadersConfiguration.java | 38 +++--- .../jwtheaders/JwtHeadersSecurityConfig.java | 99 ++++++++++++++++ .../jwtheaders/JwtHeadersTrivialUser.java | 10 +- .../jwtheaders/JwtHeadersUserUtil.java | 37 +++--- ...rsUsernamePasswordAuthenticationToken.java | 5 +- .../jwtheaders/JwtHeadersIntegrationTest.java | 4 +- .../authentication-mode.md | 24 +++- ...ity-jwt-headers-multi-overrides.properties | 85 ++++++++++++++ .../config-security-jwt-headers-multi.xml | 108 ++++++++++++++++++ .../config-security/config-security.xml | 5 +- 11 files changed, 376 insertions(+), 53 deletions(-) create mode 100644 core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersSecurityConfig.java create mode 100644 web/src/main/webapp/WEB-INF/config-security/config-security-jwt-headers-multi-overrides.properties create mode 100644 web/src/main/webapp/WEB-INF/config-security/config-security-jwt-headers-multi.xml diff --git a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersAuthFilter.java b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersAuthFilter.java index 249a18e351f..9a35028cf2c 100644 --- a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersAuthFilter.java +++ b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersAuthFilter.java @@ -38,7 +38,6 @@ /** * This handles the JWT-Headers authentication filter. It's based on the Shibboleth filter. - * */ public class JwtHeadersAuthFilter extends GenericFilterBean { @@ -47,6 +46,10 @@ public class JwtHeadersAuthFilter extends GenericFilterBean { JwtHeadersConfiguration jwtHeadersConfiguration; + //uniquely identify this authfilter + //this is need if there are >1 Jwt-Header filters active at the same time + String filterId = java.util.UUID.randomUUID().toString(); + public JwtHeadersAuthFilter(JwtHeadersConfiguration jwtHeadersConfiguration) { this.jwtHeadersConfiguration = jwtHeadersConfiguration; @@ -63,8 +66,11 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo var user = JwtHeadersTrivialUser.create(config, request); + //if request is already logged in by us (same filterId), but there aren't any Jwt-Headers attached + //then log them out. if (user == null && existingAuth != null) { - if (existingAuth instanceof JwtHeadersUsernamePasswordAuthenticationToken) { + if (existingAuth instanceof JwtHeadersUsernamePasswordAuthenticationToken + && ((JwtHeadersUsernamePasswordAuthenticationToken) existingAuth).authFilterId.equals(filterId)) { //at this point, there isn't a JWT header, but there's an existing auth that was made by us (JWT header) // in this case, we need to log-off. They have a JSESSION auth that is no longer valid. logout(request); @@ -98,10 +104,9 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo var userDetails = jwtHeadersUserUtil.getUser(user, jwtHeadersConfiguration); if (userDetails != null) { UsernamePasswordAuthenticationToken auth = new JwtHeadersUsernamePasswordAuthenticationToken( - userDetails, null, userDetails.getAuthorities()); + filterId, userDetails, null, userDetails.getAuthorities()); auth.setDetails(userDetails); SecurityContextHolder.getContext().setAuthentication(auth); - } filterChain.doFilter(servletRequest, servletResponse); @@ -109,6 +114,7 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo /** * handle a logout - clear out the security context, and invalidate the session + * * @param request * @throws ServletException */ diff --git a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersConfiguration.java b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersConfiguration.java index c7642c57b3a..73d4fee4316 100644 --- a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersConfiguration.java +++ b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersConfiguration.java @@ -30,15 +30,14 @@ * configuration for the JWT Headers security filter. * See GN documentation. * This is based on GeoServer's JWT-Headers Module, so you can see there as well. - * + *

* This class handles the GN filter configuration details, and hands the actual configuration * for the filter to the JwtConfiguration class. This class is also used in Geoserver. - * */ -public class JwtHeadersConfiguration implements SecurityProviderConfiguration { +public class JwtHeadersConfiguration { - public LoginType loginType = LoginType.AUTOLOGIN; + public SecurityProviderConfiguration.LoginType loginType = SecurityProviderConfiguration.LoginType.AUTOLOGIN; /** * true -> update the DB with the information from OIDC (don't allow user to edit profile in the UI) * false -> don't update the DB (user must edit profile in UI). @@ -51,55 +50,54 @@ public class JwtHeadersConfiguration implements SecurityProviderConfiguration { public boolean updateGroup = true; protected JwtConfiguration jwtConfiguration; + //shared JwtHeadersSecurityConfig object + JwtHeadersSecurityConfig securityConfig; // getters/setters - - public JwtHeadersConfiguration() { + public JwtHeadersConfiguration(JwtHeadersSecurityConfig securityConfig) { + this.securityConfig = securityConfig; jwtConfiguration = new JwtConfiguration(); } public boolean isUpdateProfile() { - return updateProfile; + return securityConfig.isUpdateProfile(); } public void setUpdateProfile(boolean updateProfile) { - this.updateProfile = updateProfile; + securityConfig.setUpdateProfile(updateProfile); } public boolean isUpdateGroup() { - return updateGroup; + return securityConfig.isUpdateGroup(); } //---- abstract class methods public void setUpdateGroup(boolean updateGroup) { - this.updateGroup = updateGroup; + securityConfig.setUpdateGroup(updateGroup); } - @Override public String getLoginType() { - return loginType.toString(); + return securityConfig.getLoginType(); } - @Override + public String getSecurityProvider() { - return "JWT-HEADERS"; + return securityConfig.getSecurityProvider(); } - @Override + public boolean isUserProfileUpdateEnabled() { - // If updating profile from the security provider then disable the profile updates in the interface - return !updateProfile; + return securityConfig.isUserProfileUpdateEnabled(); } //======================================================================== - @Override + // @Override public boolean isUserGroupUpdateEnabled() { - // If updating group from the security provider then disable the group updates in the interface - return !updateGroup; + return securityConfig.isUserGroupUpdateEnabled(); } public org.geoserver.security.jwtheaders.JwtConfiguration getJwtConfiguration() { diff --git a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersSecurityConfig.java b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersSecurityConfig.java new file mode 100644 index 00000000000..3e311faaa3e --- /dev/null +++ b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersSecurityConfig.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2024 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ +package org.fao.geonet.kernel.security.jwtheaders; + +import org.fao.geonet.kernel.security.SecurityProviderConfiguration; + +/** + * GeoNetwork only allows one SecurityProviderConfiguration bean. + * In the jwt-headers-multi (2 auth filters) situation, we need to have a single SecurityProviderConfiguration. + * We, therefore, share a single one. + * This class is shared between all the JwtHeadersConfiguration objects. + */ +public class JwtHeadersSecurityConfig implements SecurityProviderConfiguration { + + + public SecurityProviderConfiguration.LoginType loginType = SecurityProviderConfiguration.LoginType.AUTOLOGIN; + /** + * true -> update the DB with the information from OIDC (don't allow user to edit profile in the UI) + * false -> don't update the DB (user must edit profile in UI). + */ + public boolean updateProfile = true; + /** + * true -> update the DB (user's group) with the information from OIDC (don't allow admin to edit user's groups in the UI) + * false -> don't update the DB (admin must edit groups in UI). + */ + public boolean updateGroup = true; + + + // getters/setters + + + public JwtHeadersSecurityConfig() { + + } + + public boolean isUpdateProfile() { + return updateProfile; + } + + public void setUpdateProfile(boolean updateProfile) { + this.updateProfile = updateProfile; + } + + public boolean isUpdateGroup() { + return updateGroup; + } + + + //---- abstract class methods + + public void setUpdateGroup(boolean updateGroup) { + this.updateGroup = updateGroup; + } + + //@Override + public String getLoginType() { + return loginType.toString(); + } + + // @Override + public String getSecurityProvider() { + return "JWT-HEADERS"; + } + + // @Override + public boolean isUserProfileUpdateEnabled() { + // If updating profile from the security provider then disable the profile updates in the interface + return !updateProfile; + } + + //======================================================================== + + // @Override + public boolean isUserGroupUpdateEnabled() { + // If updating group from the security provider then disable the group updates in the interface + return !updateGroup; + } + +} diff --git a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUser.java b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUser.java index c042c31938e..f6a3b021a03 100644 --- a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUser.java +++ b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUser.java @@ -85,10 +85,12 @@ public static JwtHeadersTrivialUser create(JwtConfiguration config, HttpServletR var tokenValidator = new TokenValidator(config); try { - tokenValidator.validate(userNameHeader); - } - catch (Exception e) { - throw new IOException("JWT Token is invalid",e); + var accessToken = userNameHeader.replaceFirst("^Bearer", ""); + accessToken = accessToken.replaceFirst("^bearer", ""); + accessToken = accessToken.trim(); + tokenValidator.validate(accessToken); + } catch (Exception e) { + throw new IOException("JWT Token is invalid", e); } //get roles from the headers (pay attention to config) diff --git a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUserUtil.java b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUserUtil.java index 40519830723..b5629c52183 100644 --- a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUserUtil.java +++ b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUserUtil.java @@ -36,7 +36,6 @@ import org.fao.geonet.repository.UserRepository; import org.fao.geonet.utils.Log; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; import java.util.HashSet; @@ -46,7 +45,6 @@ /** * This class handles GeoNetwork related User (and Group/UserGroup) activities. - * */ public class JwtHeadersUserUtil { @@ -67,20 +65,20 @@ public class JwtHeadersUserUtil { /** * Gets a user. - * 1. if the user currently existing in the GN DB: - * - user is retrieved from the GN DB - * - if the profile/profileGroup update is true then the DB is updated with info from `userFromHeaders` - * - otherwise, the header roles are ignored and profile/profileGroups are taken from the GN DB - * - * 2. if the user doesn't existing in the DB: - * - user is created and saved to the DB - * - if the profile/profileGroup update is true then the DB is updated with info from `userFromHeaders` - * - otherwise, the header roles are ignored and profile/profileGroups are taken from the GN DB - * - NOTE: in this case, the user will not have any profile/profileGraoup - - * an admin will have to manually set them in GN GUI + * 1. if the user currently existing in the GN DB: + * - user is retrieved from the GN DB + * - if the profile/profileGroup update is true then the DB is updated with info from `userFromHeaders` + * - otherwise, the header roles are ignored and profile/profileGroups are taken from the GN DB + *

+ * 2. if the user doesn't existing in the DB: + * - user is created and saved to the DB + * - if the profile/profileGroup update is true then the DB is updated with info from `userFromHeaders` + * - otherwise, the header roles are ignored and profile/profileGroups are taken from the GN DB + * - NOTE: in this case, the user will not have any profile/profileGraoup - + * an admin will have to manually set them in GN GUI * * @param userFromHeaders This is user info supplied in the request headers - * @param configuration Configuration of the JWT Headers filter + * @param configuration Configuration of the JWT Headers filter * @return */ public User getUser(JwtHeadersTrivialUser userFromHeaders, JwtHeadersConfiguration configuration) { @@ -96,7 +94,7 @@ public User getUser(JwtHeadersTrivialUser userFromHeaders, JwtHeadersConfigurati /** * given an existing user (both from GN DB and from the Request Headers), * update roles (profile/profileGroups). - * + *

* isUpdateProfile/isUpdateGroup control if the DB is updated from the request Headers * * @param userFromDb @@ -130,11 +128,12 @@ public void injectRoles(User userFromDb, JwtHeadersTrivialUser userFromHeaders, /** * creates a new user based on what was in the request headers. - * - * profile updating (in GN DB) is controlled by isUpdateGroup - * profileGroup updating (in GN DB) is controlled by isUpdateGroup - * + *

+ * profile updating (in GN DB) is controlled by isUpdateGroup + * profileGroup updating (in GN DB) is controlled by isUpdateGroup + *

* cf. updateGroups for how the profile/profileGroups are updated + * * @param userFromHeaders * @param configuration * @return diff --git a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUsernamePasswordAuthenticationToken.java b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUsernamePasswordAuthenticationToken.java index dc48e815bf9..1e83d15e2bb 100644 --- a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUsernamePasswordAuthenticationToken.java +++ b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersUsernamePasswordAuthenticationToken.java @@ -33,8 +33,11 @@ */ public class JwtHeadersUsernamePasswordAuthenticationToken extends UsernamePasswordAuthenticationToken { + //ID of the JwtHeaderAuthFilter that authenticated the user + String authFilterId; - public JwtHeadersUsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection authorities) { + public JwtHeadersUsernamePasswordAuthenticationToken(String authFilterId, Object principal, Object credentials, Collection authorities) { super(principal, credentials, authorities); + this.authFilterId = authFilterId; } } diff --git a/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersIntegrationTest.java b/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersIntegrationTest.java index 32755d5e946..f133167989c 100644 --- a/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersIntegrationTest.java +++ b/core/src/test/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersIntegrationTest.java @@ -65,7 +65,7 @@ public class JwtHeadersIntegrationTest { * standard configuration for testing JSON */ public static JwtHeadersConfiguration getBasicConfig() { - JwtHeadersConfiguration config = new JwtHeadersConfiguration(); + JwtHeadersConfiguration config = new JwtHeadersConfiguration(new JwtHeadersSecurityConfig()); var jwtheadersConfiguration = config.getJwtConfiguration(); jwtheadersConfiguration.setUserNameHeaderAttributeName("OIDC_id_token_payload"); @@ -100,7 +100,7 @@ public static JwtHeadersConfiguration getBasicConfig() { * standard configuration for testing JWT */ public static JwtHeadersConfiguration getBasicConfigJWT() { - JwtHeadersConfiguration config = new JwtHeadersConfiguration(); + JwtHeadersConfiguration config = new JwtHeadersConfiguration(new JwtHeadersSecurityConfig()); var jwtheadersConfiguration = config.getJwtConfiguration(); jwtheadersConfiguration.setUserNameHeaderAttributeName("TOKEN"); diff --git a/docs/manual/docs/administrator-guide/managing-users-and-groups/authentication-mode.md b/docs/manual/docs/administrator-guide/managing-users-and-groups/authentication-mode.md index ece4cebbc50..7026e9c804b 100644 --- a/docs/manual/docs/administrator-guide/managing-users-and-groups/authentication-mode.md +++ b/docs/manual/docs/administrator-guide/managing-users-and-groups/authentication-mode.md @@ -863,7 +863,7 @@ You must turn on JWT Header Support by setting the `GEONETWORK_SECURITY_TYPE` en GEONETWORK_SECURITY_TYPE=jwt-headers ``` -Please see the files for more detailed configuration: +Please see these files for more detailed configuration: * `config-security-jwt-header.xml` * `config-security-jwt-header-overrides.properties` @@ -1038,13 +1038,33 @@ You can also extract roles from the Access Token in a similar manner - make sure #### Using Headers or GeoNetwork Database for Profiles & Profile Groups -Inside `JwtHeaderConfiguration`, use these values to determine where Profile and ProfileGroups come from. +Inside `JwtHeaderSecurityConfig`, use these values to determine where Profile and ProfileGroups come from. | Property | Meaning | | ------------- | ------- | |updateProfile| true -> update the DB with the information from OIDC (don't allow user to edit profile in the UI)
false -> don't update the DB (user must edit profile in UI). | |updateGroup| true -> update the DB (user's group) with the information from OIDC (don't allow admin to edit user's groups in the UI)
false -> don't update the DB (admin must edit groups in UI).| +### Using JWT Headers for both OIDC and OAUTH2 (Simultaneously) + +Using the above configuration, you can configure JWT Headers for either OIDC-based browser access (i.e. with Apache `mod_auth_openidc`) ***or*** for OAUTH2 based Bearer Token access. However, you cannot do both at the same time. + +To configure JWT Headers to simultaneously provide OIDC and OAUTH2 access, you can use the `jwt-headers-multi` configuration. + +To use this, set the `GEONETWORK_SECURITY_TYPE` to `jwt-headers-multi` + +``` +GEONETWORK_SECURITY_TYPE=jwt-headers-multi +``` + +Please see these files for more detailed configuration: +* `config-security-jwt-header-multi.xml` +* `config-security-jwt-header-multi-overrides.properties` + +This creates two JWT Header authentication filters for GeoNetwork - one for OIDC based Browser access, and one for OAUTH2 based Robot access. + +You configure each of these independently using the same environment variables described above. +For the first filter, use the environment variables defined above (ie. `JWTHEADERS_UserNameFormat`). For the second filter, add a `2` at the end of the environment variable (i.e. `JWTHEADERS_UserNameFormat2`). ## Configuring EU Login {#authentication-ecas} diff --git a/web/src/main/webapp/WEB-INF/config-security/config-security-jwt-headers-multi-overrides.properties b/web/src/main/webapp/WEB-INF/config-security/config-security-jwt-headers-multi-overrides.properties new file mode 100644 index 00000000000..33681a08726 --- /dev/null +++ b/web/src/main/webapp/WEB-INF/config-security/config-security-jwt-headers-multi-overrides.properties @@ -0,0 +1,85 @@ +# Copyright (C) 2024 Food and Agriculture Organization of the +# United Nations (FAO-UN), United Nations World Food Programme (WFP) +# and United Nations Environment Programme (UNEP) +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or (at +# your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA +# +# Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, +# Rome - Italy. email: geonetwork@osgeo.org + +## This contains configuration options for TWO Jwt-Headers auth filters. + + +## configuration for the FIRST filter + + +jwtheadersConfiguration.JwtConfiguration.userNameHeaderAttributeName=${JWTHEADERS_UserNameHeaderFormat:OIDC_id_token_payload} +jwtheadersConfiguration.JwtConfiguration.userNameFormatChoice=${JWTHEADERS_UserNameFormat:JSON} + +jwtheadersConfiguration.JwtConfiguration.UserNameJsonPath=${JWTHEADERS_UserNameJsonPath:preferred_username} + +jwtheadersConfiguration.JwtConfiguration.rolesJsonPath=${JWTHEADERS_RolesJsonPath:resource_access.live-key2.roles} +jwtheadersConfiguration.JwtConfiguration.rolesHeaderName=${JWTHEADERS_RolesHeaderName:OIDC_id_token_payload} +jwtheadersConfiguration.JwtConfiguration.jwtHeaderRoleSource=${JWTHEADERS_JwtHeaderRoleSource:JSON} + +jwtheadersConfiguration.JwtConfiguration.roleConverterString=${JWTHEADERS_RoleConverterString:"GeonetworkAdministrator=ADMINISTRATOR"} +jwtheadersConfiguration.JwtConfiguration.onlyExternalListedRoles=${JWTHEADERS_OnlyExternalListedRoles:false} + +jwtheadersConfiguration.JwtConfiguration.validateToken=${JWTHEADERS_ValidateToken:false} + +jwtheadersConfiguration.JwtConfiguration.validateTokenExpiry=${JWTHEADERS_ValidateTokenExpiry:false} + +jwtheadersConfiguration.JwtConfiguration.validateTokenAgainstURL=${JWTHEADERS_ValidateTokenAgainstURL:true} +jwtheadersConfiguration.JwtConfiguration.validateTokenAgainstURLEndpoint=${JWTHEADERS_ValidateTokenAgainstURLEndpoint:} +jwtheadersConfiguration.JwtConfiguration.validateSubjectWithEndpoint=${JWTHEADERS_ValidateSubjectWithEndpoint:true} + +jwtheadersConfiguration.JwtConfiguration.validateTokenAudience=${JWTHEADERS_ValidateTokenAudience:true} +jwtheadersConfiguration.JwtConfiguration.validateTokenAudienceClaimName=${JWTHEADERS_ValidateTokenAudienceClaimName:""} +jwtheadersConfiguration.JwtConfiguration.validateTokenAudienceClaimValue=${JWTHEADERS_ValidateTokenAudienceClaimValue:""} + +jwtheadersConfiguration.JwtConfiguration.validateTokenSignature=${JWTHEADERS_ValidateTokenSignature:true} +jwtheadersConfiguration.JwtConfiguration.validateTokenSignatureURL=${JWTHEADERS_ValidateTokenSignatureURL:""} + + +## configuration for the SECOND filter. The only diffence between this and the above (first filter) is that +## this is configuring the 2nd filter configuration (jwtheadersConfiguration2) +## all the environment variables are the same EXCEPT they end in "2" + +jwtheadersConfiguration2.JwtConfiguration.userNameHeaderAttributeName=${JWTHEADERS_UserNameHeaderFormat2:OIDC_id_token_payload} +jwtheadersConfiguration2.JwtConfiguration.userNameFormatChoice=${JWTHEADERS_UserNameFormat2:JSON} + +jwtheadersConfiguration2.JwtConfiguration.UserNameJsonPath=${JWTHEADERS_UserNameJsonPath2:preferred_username} + +jwtheadersConfiguration2.JwtConfiguration.rolesJsonPath=${JWTHEADERS_RolesJsonPath2:resource_access.live-key2.roles} +jwtheadersConfiguration2.JwtConfiguration.rolesHeaderName=${JWTHEADERS_RolesHeaderName2:OIDC_id_token_payload} +jwtheadersConfiguration2.JwtConfiguration.jwtHeaderRoleSource=${JWTHEADERS_JwtHeaderRoleSource2:JSON} + +jwtheadersConfiguration2.JwtConfiguration.roleConverterString=${JWTHEADERS_RoleConverterString2:"GeonetworkAdministrator=ADMINISTRATOR"} +jwtheadersConfiguration2.JwtConfiguration.onlyExternalListedRoles=${JWTHEADERS_OnlyExternalListedRoles2:false} + +jwtheadersConfiguration2.JwtConfiguration.validateToken=${JWTHEADERS_ValidateToken2:false} + +jwtheadersConfiguration2.JwtConfiguration.validateTokenExpiry=${JWTHEADERS_ValidateTokenExpiry2:false} + +jwtheadersConfiguration2.JwtConfiguration.validateTokenAgainstURL=${JWTHEADERS_ValidateTokenAgainstURL2:true} +jwtheadersConfiguration2.JwtConfiguration.validateTokenAgainstURLEndpoint=${JWTHEADERS_ValidateTokenAgainstURLEndpoint2:} +jwtheadersConfiguration2.JwtConfiguration.validateSubjectWithEndpoint=${JWTHEADERS_ValidateSubjectWithEndpoint2:true} + +jwtheadersConfiguration2.JwtConfiguration.validateTokenAudience=${JWTHEADERS_ValidateTokenAudience2:true} +jwtheadersConfiguration2.JwtConfiguration.validateTokenAudienceClaimName=${JWTHEADERS_ValidateTokenAudienceClaimName2:""} +jwtheadersConfiguration2.JwtConfiguration.validateTokenAudienceClaimValue=${JWTHEADERS_ValidateTokenAudienceClaimValue2:""} + +jwtheadersConfiguration2.JwtConfiguration.validateTokenSignature=${JWTHEADERS_ValidateTokenSignature2:true} +jwtheadersConfiguration2.JwtConfiguration.validateTokenSignatureURL=${JWTHEADERS_ValidateTokenSignatureURL2:""} diff --git a/web/src/main/webapp/WEB-INF/config-security/config-security-jwt-headers-multi.xml b/web/src/main/webapp/WEB-INF/config-security/config-security-jwt-headers-multi.xml new file mode 100644 index 00000000000..4f388202810 --- /dev/null +++ b/web/src/main/webapp/WEB-INF/config-security/config-security-jwt-headers-multi.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/main/webapp/WEB-INF/config-security/config-security.xml b/web/src/main/webapp/WEB-INF/config-security/config-security.xml index e740da2a657..b618dd2727a 100644 --- a/web/src/main/webapp/WEB-INF/config-security/config-security.xml +++ b/web/src/main/webapp/WEB-INF/config-security/config-security.xml @@ -55,7 +55,10 @@ openidconnect - OAUTH2 Open ID Connect (see config-security-openidconnect.xml and -overrides.properties for details) + openidconnect can be used instead of the keycloak provider jwt-headers - Support for JSON/JWT headers for username & roles + Access Token validation - + (see config-security-jwt-headers.xml and -overrides.properties for details) + + (see config-security-jwt-headers.xml and -overrides.properties for details) + jwt-headers-multi - This adds two configurable jwt-headers filters. This is useful for BOTH OIDC and OAUTH2 (access token) + at the same time. + + (see config-security-jwt-headers-multi.xml and -overrides.properties for details) ldap - ldap security (see config-security-ldap.xml for more details) ldap-recursive - ldap-recursive security (see config-security-ldap-recursive.xml for more details) ecas - ecas security (see config-security-ecas.xml for more details) From 8133ef91d54079ee89c40cc3736aa6e1ff0bd5f8 Mon Sep 17 00:00:00 2001 From: david blasby Date: Wed, 8 May 2024 12:57:51 -0700 Subject: [PATCH 5/5] fix config problem, remove some unnecessary code --- .../security/jwtheaders/JwtHeadersTrivialUser.java | 11 +++++++---- .../config-security/config-security-jwt-headers.xml | 7 +++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUser.java b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUser.java index f6a3b021a03..de22d9f5ca3 100644 --- a/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUser.java +++ b/core/src/main/java/org/fao/geonet/kernel/security/jwtheaders/JwtHeadersTrivialUser.java @@ -85,10 +85,10 @@ public static JwtHeadersTrivialUser create(JwtConfiguration config, HttpServletR var tokenValidator = new TokenValidator(config); try { - var accessToken = userNameHeader.replaceFirst("^Bearer", ""); - accessToken = accessToken.replaceFirst("^bearer", ""); - accessToken = accessToken.trim(); - tokenValidator.validate(accessToken); +// var accessToken = userNameHeader.replaceFirst("^Bearer", ""); +// accessToken = accessToken.replaceFirst("^bearer", ""); +// accessToken = accessToken.trim(); + tokenValidator.validate(userNameHeader); } catch (Exception e) { throw new IOException("JWT Token is invalid", e); } @@ -133,6 +133,9 @@ public static void updateUserWithRoles(JwtHeadersTrivialUser user, Collection + - - + + + @@ -49,6 +51,7 @@ +