diff --git a/docs/authzn.adoc b/docs/authzn.adoc index 2fad2ad5..d71e126b 100644 --- a/docs/authzn.adoc +++ b/docs/authzn.adoc @@ -177,6 +177,15 @@ Both standard and non-standard claims can be used to set the `GeorchestraUser`'s | |JSONPath expression to extract the user role names from the OIDC claims map +|`georchestra.gateway.security.oidc.claims.roles.json.split` +| false +|Whether to split parsed strings into multiple strings using `${georchestra.gateway.security.oidc.claims.roles.json.split-on}` as separator. + For example, if the expression evaluates to `ROLE_1, ROLE_2`, split it into two roles `[ROLE_1, ROLE_2]`. + +|`georchestra.gateway.security.oidc.claims.roles.json.split-on` +| `,` (comma) +| String literal to use as separator for splitting string values evaluated by the JSONPath expression. + |`georchestra.gateway.security.oidc.claims.roles.uppercase` | true |Whether to return mapped role names as upper-case. @@ -209,6 +218,7 @@ Take as example the following claims provided by an OIDC ID Token: "GDI Planer", "GDI Editor (exten)" ], + "permission": "GDI Planner, GDI Admin" "PartyOrganisationID": "6007280321" } ---- @@ -253,10 +263,13 @@ georchestra: # } # # The resulting list of roles will be ["ORG_6007280321", "GDI_PLANER_EXTERN", "GDI_EDITOR_EXTERN"] - # and the request header will be `sec-roles: ROLE_ORG_6007280321;ROLE_GDI_PLANER;ROLE_GDI_EDITOR_EXTERN;ROLE_USER` - json.path: - - "$.concat(\"ORG_\", $.PartyOrganisationID)" - - "$.groups_json..['name']" + # and the request header will be `sec-roles: ROLE_ORG_6007280321;ROLE_GDI_PLANER;ROLE_GDI_EDITOR_EXTERN;ROLE_ADMIN;ROLE_USER` + json: + split: true + path: + - "$.concat(\"ORG_\", $.PartyOrganisationID)" + - "$.groups_json..['name']" + - "$.permission" uppercase: true normalize: true append: true @@ -265,7 +278,7 @@ georchestra: Resulting in the following property values for the `GeorchestraUser` instance associated to the request: ``` -roles = ["ROLE_GDI_PLANER", "ROLE_GDI_EDITOR_EXTERN"] +roles = ["ROLE_GDI_PLANER", "ROLE_GDI_EDITOR_EXTERN", "ROLE_GDI_ADMIN"] organization = "6007280321" ``` diff --git a/gateway/src/main/java/org/georchestra/gateway/accounts/admin/AbstractAccountsManager.java b/gateway/src/main/java/org/georchestra/gateway/accounts/admin/AbstractAccountsManager.java index 5e384f38..e341325b 100644 --- a/gateway/src/main/java/org/georchestra/gateway/accounts/admin/AbstractAccountsManager.java +++ b/gateway/src/main/java/org/georchestra/gateway/accounts/admin/AbstractAccountsManager.java @@ -50,8 +50,8 @@ protected Optional find(GeorchestraUser mappedUser) { } protected Optional findInternal(GeorchestraUser mappedUser) { - if (null != mappedUser.getOAuth2ProviderId()) { - return findByOAuth2ProviderId(mappedUser.getOAuth2ProviderId()); + if ((null != mappedUser.getOAuth2Provider()) && (null != mappedUser.getOAuth2Uid())) { + return findByOAuth2Uid(mappedUser.getOAuth2Provider(), mappedUser.getOAuth2Uid()); } return findByUsername(mappedUser.getUsername()); } @@ -73,7 +73,7 @@ GeorchestraUser createIfMissing(GeorchestraUser mapped) { } } - protected abstract Optional findByOAuth2ProviderId(String oauth2ProviderId); + protected abstract Optional findByOAuth2Uid(String oauth2Provider, String oauth2Uid); protected abstract Optional findByUsername(String username); diff --git a/gateway/src/main/java/org/georchestra/gateway/accounts/admin/CreateAccountUserCustomizer.java b/gateway/src/main/java/org/georchestra/gateway/accounts/admin/CreateAccountUserCustomizer.java index 04d8bd5a..158fb3a2 100644 --- a/gateway/src/main/java/org/georchestra/gateway/accounts/admin/CreateAccountUserCustomizer.java +++ b/gateway/src/main/java/org/georchestra/gateway/accounts/admin/CreateAccountUserCustomizer.java @@ -61,7 +61,7 @@ public class CreateAccountUserCustomizer implements GeorchestraUserCustomizerExt final boolean isOauth2 = auth instanceof OAuth2AuthenticationToken; final boolean isPreAuth = auth instanceof PreAuthenticatedAuthenticationToken; if (isOauth2) { - Objects.requireNonNull(mappedUser.getOAuth2ProviderId(), "GeorchestraUser.oAuth2ProviderId is null"); + Objects.requireNonNull(mappedUser.getOAuth2Uid(), "GeorchestraUser.oAuth2ProviderId is null"); } if (isPreAuth) { Objects.requireNonNull(mappedUser.getUsername(), "GeorchestraUser.username is null"); diff --git a/gateway/src/main/java/org/georchestra/gateway/accounts/admin/ldap/LdapAccountsManager.java b/gateway/src/main/java/org/georchestra/gateway/accounts/admin/ldap/LdapAccountsManager.java index ae1b3894..35f8393f 100644 --- a/gateway/src/main/java/org/georchestra/gateway/accounts/admin/ldap/LdapAccountsManager.java +++ b/gateway/src/main/java/org/georchestra/gateway/accounts/admin/ldap/LdapAccountsManager.java @@ -73,8 +73,8 @@ public LdapAccountsManager(Consumer eventPublisher, AccountDao a } @Override - protected Optional findByOAuth2ProviderId(@NonNull String oauth2ProviderId) { - return usersApi.findByOAuth2ProviderId(oauth2ProviderId).map(this::ensureRolesPrefixed); + protected Optional findByOAuth2Uid(@NonNull String oAuth2Provider, @NonNull String oAuth2Uid) { + return usersApi.findByOAuth2Uid(oAuth2Provider, oAuth2Uid).map(this::ensureRolesPrefixed); } @Override @@ -145,10 +145,11 @@ private Account mapToAccountBrief(@NonNull GeorchestraUser preAuth) { String phone = ""; String title = ""; String description = ""; - final @javax.annotation.Nullable String oAuth2ProviderId = preAuth.getOAuth2ProviderId(); + final @javax.annotation.Nullable String oAuth2Provider = preAuth.getOAuth2Provider(); + final @javax.annotation.Nullable String oAuth2Uid = preAuth.getOAuth2Uid(); Account newAccount = AccountFactory.createBrief(username, password, firstName, lastName, email, phone, title, - description, oAuth2ProviderId); + description, oAuth2Provider, oAuth2Uid); newAccount.setPending(false); if (StringUtils.isEmpty(org) && !StringUtils.isBlank(defaultOrganization)) { newAccount.setOrg(defaultOrganization); diff --git a/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqAccountCreatedEventSender.java b/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqAccountCreatedEventSender.java index f64ae41f..4ac349ca 100644 --- a/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqAccountCreatedEventSender.java +++ b/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqAccountCreatedEventSender.java @@ -44,23 +44,28 @@ public RabbitmqAccountCreatedEventSender(AmqpTemplate eventTemplate) { @EventListener(AccountCreated.class) public void on(AccountCreated event) { GeorchestraUser user = event.getUser(); - final String oAuth2ProviderId = user.getOAuth2ProviderId(); - if (null != oAuth2ProviderId) { + final String oAuth2Provider = user.getOAuth2Provider(); + if (null != oAuth2Provider) { String fullName = user.getFirstName() + " " + user.getLastName(); + String localUid = user.getUsername(); String email = user.getEmail(); - String provider = oAuth2ProviderId; - sendNewOAuthAccountMessage(fullName, email, provider); + String organization = user.getOrganization(); + String oAuth2Uid = user.getOAuth2Uid(); + sendNewOAuthAccountMessage(fullName, localUid, email, organization, oAuth2Provider, oAuth2Uid); } } - public void sendNewOAuthAccountMessage(String fullName, String email, String provider) { - // beans getting a reference to the sender + public void sendNewOAuthAccountMessage(String fullName, String localUid, String email, String organization, + String providerName, String providerUid) { JSONObject jsonObj = new JSONObject(); jsonObj.put("uid", UUID.randomUUID()); jsonObj.put("subject", OAUTH2_ACCOUNT_CREATION); - jsonObj.put("username", fullName); // bean - jsonObj.put("email", email); // bean - jsonObj.put("provider", provider); // bean + jsonObj.put("fullName", fullName); + jsonObj.put("localUid", localUid); + jsonObj.put("email", email); + jsonObj.put("organization", organization); + jsonObj.put("providerName", providerName); + jsonObj.put("providerUid", providerUid); eventTemplate.convertAndSend("routing-gateway", jsonObj.toString());// send } } \ No newline at end of file diff --git a/gateway/src/main/java/org/georchestra/gateway/app/GeorchestraGatewayApplication.java b/gateway/src/main/java/org/georchestra/gateway/app/GeorchestraGatewayApplication.java index 4253b7e7..f760667c 100644 --- a/gateway/src/main/java/org/georchestra/gateway/app/GeorchestraGatewayApplication.java +++ b/gateway/src/main/java/org/georchestra/gateway/app/GeorchestraGatewayApplication.java @@ -18,15 +18,7 @@ */ package org.georchestra.gateway.app; -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Optional; - -import javax.annotation.PostConstruct; - +import lombok.extern.slf4j.Slf4j; import org.georchestra.gateway.security.GeorchestraUserMapper; import org.georchestra.gateway.security.ldap.LdapConfigProperties; import org.georchestra.security.model.GeorchestraUser; @@ -36,7 +28,6 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cloud.gateway.route.RouteLocator; import org.springframework.context.MessageSource; import org.springframework.context.annotation.Bean; @@ -44,39 +35,38 @@ import org.springframework.context.support.ReloadableResourceBundleMessageSource; import org.springframework.core.env.Environment; import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.util.unit.DataSize; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.reactive.result.view.Rendering; import org.springframework.web.server.ServerWebExchange; - -import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Mono; +import javax.annotation.PostConstruct; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.*; + @Controller @Slf4j @SpringBootApplication -@EnableConfigurationProperties(LdapConfigProperties.class) public class GeorchestraGatewayApplication { private @Autowired RouteLocator routeLocator; private @Autowired GeorchestraUserMapper userMapper; private @Autowired(required = false) LdapConfigProperties ldapConfigProperties; - - private boolean ldapEnabled = false; - private @Autowired(required = false) OAuth2ClientProperties oauth2ClientConfig; - private @Value("${georchestra.gateway.headerEnabled:true}") boolean headerEnabled; + private boolean ldapEnabled = false; + private @Value("${georchestra.gateway.headerUrl:/header/}") String georchestraHeaderUrl; + private @Value("${georchestra.gateway.headerHeight:90}") String georchestraHeaderHeight; private @Value("${georchestra.gateway.footerUrl:#{null}}") String georchestraFooterUrl; private @Value("${spring.messages.basename:}") String messagesBasename; - public static void main(String[] args) { - SpringApplication.run(GeorchestraGatewayApplication.class, args); - } - @PostConstruct void initialize() { if (ldapConfigProperties != null) { @@ -84,6 +74,10 @@ void initialize() { } } + public static void main(String[] args) { + SpringApplication.run(GeorchestraGatewayApplication.class, args); + } + @GetMapping(path = "/whoami", produces = "application/json") @ResponseBody public Mono> whoami(Authentication principal, ServerWebExchange exchange) { @@ -100,32 +94,28 @@ public Mono> whoami(Authentication principal, ServerWebExcha // Mono.just(Map.of(principal.getClass().getCanonicalName(), principal)); } - @GetMapping(path = "/logout") - public String logout(Model mdl) { - mdl.addAttribute("header_enabled", headerEnabled); - return "logout"; - } - @GetMapping(path = "/login") - public String loginPage(@RequestParam Map allRequestParams, Model mdl) { + public String loginPage(Model mdl) { Map oauth2LoginLinks = new HashMap(); if (oauth2ClientConfig != null) { oauth2ClientConfig.getRegistration().forEach((k, v) -> { - String clientName = Optional.ofNullable(v.getClientName()).orElse(k); - oauth2LoginLinks.put("/oauth2/authorization/" + k, clientName); + oauth2LoginLinks.put("/oauth2/authorization/" + k, v.getClientName()); }); } - mdl.addAttribute("header_enabled", headerEnabled); + mdl.addAttribute("header_url", georchestraHeaderUrl); + mdl.addAttribute("header_height", georchestraHeaderHeight); mdl.addAttribute("footer_url", georchestraFooterUrl); mdl.addAttribute("ldapEnabled", ldapEnabled); mdl.addAttribute("oauth2LoginLinks", oauth2LoginLinks); - boolean expired = "expired_password".equals(allRequestParams.get("error")); - mdl.addAttribute("passwordExpired", expired); - boolean invalidCredentials = "invalid_credentials".equals(allRequestParams.get("error")); - mdl.addAttribute("invalidCredentials", invalidCredentials); + return "login"; } + @GetMapping(path = "/logout", produces = "text/html") + public Mono> logout(Authentication principal, ServerWebExchange exchange) { + return Mono.just(Rendering.view("logout")); + } + @EventListener(ApplicationReadyEvent.class) public void onApplicationReady(ApplicationReadyEvent e) { Environment env = e.getApplicationContext().getEnvironment(); @@ -161,4 +151,5 @@ public MessageSource messageSource() { messageSource.setDefaultEncoding(StandardCharsets.UTF_8.name()); return messageSource; } + } diff --git a/gateway/src/main/java/org/georchestra/gateway/model/RoleBasedAccessRule.java b/gateway/src/main/java/org/georchestra/gateway/model/RoleBasedAccessRule.java index 3d740cb6..4e38036d 100644 --- a/gateway/src/main/java/org/georchestra/gateway/model/RoleBasedAccessRule.java +++ b/gateway/src/main/java/org/georchestra/gateway/model/RoleBasedAccessRule.java @@ -20,6 +20,7 @@ import java.util.List; +import org.georchestra.security.model.GeorchestraUser; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; @@ -64,7 +65,13 @@ public class RoleBasedAccessRule { /** * Role names that the authenticated user must be part of to be granted access * to the intercepted URIs. The ROLE_ prefix is optional. For example, the role - * set [ROLE_USER, ROLE_AUDITOR] is equivalent to [USER, AUDITOR] + * set [ROLE_USER, ROLE_AUDITOR] is equivalent to [USER, AUDITOR]. The check + * will be performed against the {@link GeorchestraUser#getRoles()} list, as the + * effectively resolved role names, which will always be prefixed with + * {@literal ROLE_}, as opposed to + * {@link org.springframework.security.core.Authentication#getAuthorities()}, + * where {@link GrantedAuthority} names are preserved as provided by the + * authentication manager that produced them. */ private List allowedRoles = List.of(); } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/GatewaySecurityConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/security/GatewaySecurityConfiguration.java index a5d77d16..f37f2ca4 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/GatewaySecurityConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/GatewaySecurityConfiguration.java @@ -18,11 +18,7 @@ */ package org.georchestra.gateway.security; -import java.net.URI; -import java.util.List; -import java.util.Map; -import java.util.stream.Stream; - +import lombok.extern.slf4j.Slf4j; import org.georchestra.gateway.model.GatewayConfigProperties; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -31,14 +27,12 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; -import org.springframework.security.config.web.server.ServerHttpSecurity.LogoutSpec; -import org.springframework.security.oauth2.client.oidc.web.server.logout.OidcClientInitiatedServerLogoutSuccessHandler; -import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; import org.springframework.security.web.server.SecurityWebFilterChain; -import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler; import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler; -import lombok.extern.slf4j.Slf4j; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; /** * {@link Configuration} to initialize the Gateway's @@ -55,7 +49,7 @@ */ @Configuration(proxyBeanMethods = false) @EnableWebFluxSecurity -@EnableConfigurationProperties({ GatewayConfigProperties.class }) +@EnableConfigurationProperties(GatewayConfigProperties.class) @Slf4j(topic = "org.georchestra.gateway.security") public class GatewaySecurityConfiguration { @@ -64,23 +58,16 @@ public class GatewaySecurityConfiguration { private @Value("${georchestra.gateway.logoutUrl:/?logout}") String georchestraLogoutUrl; -// @Primary -// @Bean -// ReactiveAuthenticationManager authManagerDelegator(List managers) { -// return new DelegatingReactiveAuthenticationManager(managers); -// } - /** * Relies on available {@link ServerHttpSecurityCustomizer} extensions to * configure the different aspects of the {@link ServerHttpSecurity} used to * {@link ServerHttpSecurity#build build} the {@link SecurityWebFilterChain}. */ @Bean - SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, + public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, List customizers) throws Exception { log.info("Initializing security filter chain..."); - // disable CSRF protection, considering it will be managed // by proxified webapps, not the gateway. http.csrf().disable(); @@ -96,11 +83,11 @@ SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, log.info("Security filter chain initialized"); - RedirectServerLogoutSuccessHandler defaultRedirect = new RedirectServerLogoutSuccessHandler(); - defaultRedirect.setLogoutSuccessUrl(URI.create(georchestraLogoutUrl)); - - LogoutSpec logoutUrl = http.formLogin().loginPage("/login").and().logout().logoutUrl("/logout") - .logoutSuccessHandler(oidcLogoutSuccessHandler != null ? oidcLogoutSuccessHandler : defaultRedirect); + ServerHttpSecurity.LogoutSpec logoutUrl = http.formLogin().loginPage("/login").and().logout() + .logoutUrl("/logout"); + if (oidcLogoutSuccessHandler != null) { + logoutUrl = logoutUrl.logoutSuccessHandler(oidcLogoutSuccessHandler); + } return logoutUrl.and().build(); } @@ -109,14 +96,12 @@ private Stream sortedCustomizers(List Integer.compare(c1.getOrder(), c2.getOrder())); } - @Bean - GeorchestraUserMapper georchestraUserResolver(List resolvers, + public @Bean GeorchestraUserMapper georchestraUserResolver(List resolvers, List customizers) { return new GeorchestraUserMapper(resolvers, customizers); } - @Bean - ResolveGeorchestraUserGlobalFilter resolveGeorchestraUserGlobalFilter(GeorchestraUserMapper resolver) { + public @Bean ResolveGeorchestraUserGlobalFilter resolveGeorchestraUserGlobalFilter(GeorchestraUserMapper resolver) { return new ResolveGeorchestraUserGlobalFilter(resolver); } @@ -124,11 +109,14 @@ ResolveGeorchestraUserGlobalFilter resolveGeorchestraUserGlobalFilter(Georchestr * Extension to make {@link GeorchestraUserMapper} append user roles based on * {@link GatewayConfigProperties#getRolesMappings()} */ - @Bean - RolesMappingsUserCustomizer rolesMappingsUserCustomizer(GatewayConfigProperties config) { + public @Bean RolesMappingsUserCustomizer rolesMappingsUserCustomizer(GatewayConfigProperties config) { Map> rolesMappings = config.getRolesMappings(); log.info("Creating {}", RolesMappingsUserCustomizer.class.getSimpleName()); return new RolesMappingsUserCustomizer(rolesMappings); } + @Bean + RolePrefixGeorchestraUserCustomizerExtension rolePrefixGeorchestraUserCustomizerExtension() { + return new RolePrefixGeorchestraUserCustomizerExtension(); + } } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/RolePrefixGeorchestraUserCustomizerExtension.java b/gateway/src/main/java/org/georchestra/gateway/security/RolePrefixGeorchestraUserCustomizerExtension.java new file mode 100644 index 00000000..63eb231f --- /dev/null +++ b/gateway/src/main/java/org/georchestra/gateway/security/RolePrefixGeorchestraUserCustomizerExtension.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2023 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra 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 3 of the License, or (at your option) + * any later version. + * + * geOrchestra 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 + * geOrchestra. If not, see . + */ +package org.georchestra.gateway.security; + +import java.util.List; +import java.util.stream.Collectors; + +import org.georchestra.security.model.GeorchestraUser; +import org.springframework.core.Ordered; +import org.springframework.security.core.Authentication; + +import lombok.NonNull; + +/** + * Extension to add the {@literal ROLE_} prefix to resolved user roles. + *

+ * Lowest precedence {@link GeorchestraUserCustomizerExtension} all + * {@link GeorchestraUser#getRoles() roles} in the final user's effective roles + * (both coming from the {@link Authentication} and resolved from configuration) + * have the {@literal ROLE_} prefix. + */ +public class RolePrefixGeorchestraUserCustomizerExtension implements GeorchestraUserCustomizerExtension { + + public @Override int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } + + @Override + public GeorchestraUser apply(Authentication t, GeorchestraUser u) { + List prefixed = u.getRoles().stream().map(RolePrefixGeorchestraUserCustomizerExtension::toRole) + .collect(Collectors.toList()); + u.setRoles(prefixed); + return u; + } + + public static String toRole(@NonNull String authority) { + return authority.startsWith("ROLE_") ? authority : "ROLE_" + authority; + } + +} diff --git a/gateway/src/main/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizer.java b/gateway/src/main/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizer.java index 0b3b8d54..ff5d9e5f 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizer.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizer.java @@ -26,6 +26,7 @@ import org.georchestra.gateway.model.RoleBasedAccessRule; import org.georchestra.gateway.model.Service; import org.georchestra.gateway.security.GeorchestraUserMapper; +import org.georchestra.gateway.security.RolePrefixGeorchestraUserCustomizerExtension; import org.georchestra.gateway.security.ServerHttpSecurityCustomizer; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity.AuthorizeExchangeSpec; @@ -115,9 +116,9 @@ void apply(AuthorizeExchangeSpec authorizeExchange, RoleBasedAccessRule rule) { log.debug("Granting access to any authenticated user for {}", antPatterns); requireAuthenticatedUser(access); } else { - List roles = resolveRoles(antPatterns, allowedRoles); + List roles = resolveRoles(allowedRoles); log.debug("Granting access to roles {} for {}", roles, antPatterns); - hasAnyAuthority(access, roles); + hasAnyRole(access, roles); } } @@ -136,8 +137,9 @@ Access authorizeExchange(AuthorizeExchangeSpec authorizeExchange, List a return authorizeExchange.pathMatchers(antPatterns.toArray(String[]::new)); } - private List resolveRoles(List antPatterns, List allowedRoles) { - return allowedRoles.stream().map(this::ensureRolePrefix).collect(Collectors.toList()); + private List resolveRoles(List allowedRoles) { + return allowedRoles.stream().map(RolePrefixGeorchestraUserCustomizerExtension::toRole) + .collect(Collectors.toList()); } @VisibleForTesting @@ -146,12 +148,11 @@ void requireAuthenticatedUser(Access access) { } @VisibleForTesting - void hasAnyAuthority(Access access, List roles) { - // Checks against the effective set of rules (both provided by the Authorization - // service and derived from roles mappings) - access.access( - GeorchestraUserRolesAuthorizationManager.hasAnyAuthority(userMapper, roles.toArray(String[]::new))); - // access.hasAnyAuthority(roles.toArray(String[]::new)); + void hasAnyRole(Access access, List roles) { + // Checks against the effective set of rules (in GeorchestraUser.getRoles(), + // i.e. both provided by the Authorization service and derived from roles + // mappings) + access.access(GeorchestraUserRolesAuthorizationManager.hasAnyRole(userMapper, roles.toArray(String[]::new))); } @VisibleForTesting @@ -163,8 +164,4 @@ void permitAll(Access access) { void denyAll(Access access) { access.denyAll(); } - - private String ensureRolePrefix(@NonNull String roleName) { - return roleName.startsWith("ROLE_") ? roleName : ("ROLE_" + roleName); - } } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/accessrules/GeorchestraUserRolesAuthorizationManager.java b/gateway/src/main/java/org/georchestra/gateway/security/accessrules/GeorchestraUserRolesAuthorizationManager.java index 95ecac78..b16f4fed 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/accessrules/GeorchestraUserRolesAuthorizationManager.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/accessrules/GeorchestraUserRolesAuthorizationManager.java @@ -24,6 +24,7 @@ import java.util.stream.Stream; import org.georchestra.gateway.security.GeorchestraUserMapper; +import org.georchestra.gateway.security.RolePrefixGeorchestraUserCustomizerExtension; import org.georchestra.security.model.GeorchestraUser; import org.springframework.security.authorization.AuthorityAuthorizationDecision; import org.springframework.security.authorization.AuthorityReactiveAuthorizationManager; @@ -42,7 +43,7 @@ * Variant of {@link AuthorityReactiveAuthorizationManager} that * {@link #check(Mono, Object) checks} access based on the effectively resolved * set of role names in a {@link GeorchestraUser}, as opposed to only on the - * {@link Authentication#getAuthorities() Authenticateion authorities}. + * {@link Authentication#getAuthorities() Authentication authorities}. *

* This is so because the authorization provider (e.g. OAuth2/OIDC) returns an * {@link Authentication} object from which {@link GeorchestraUserMapper} will @@ -58,10 +59,10 @@ class GeorchestraUserRolesAuthorizationManager implements ReactiveAuthorizati private final Set authorityFilter; private final AuthorityAuthorizationDecision unauthorized; - GeorchestraUserRolesAuthorizationManager(GeorchestraUserMapper userMapper, String... authorities) { + GeorchestraUserRolesAuthorizationManager(GeorchestraUserMapper userMapper, String... roles) { this.userMapper = userMapper; - this.authorities = AuthorityUtils.createAuthorityList(authorities); - this.authorityFilter = Set.of(authorities); + this.authorities = AuthorityUtils.createAuthorityList(roles); + this.authorityFilter = Set.of(roles); this.unauthorized = new AuthorityAuthorizationDecision(false, this.authorities); } @@ -94,7 +95,7 @@ boolean authorize(Authentication authentication) { * @param the type of object being authorized * @return the new instance */ - public static GeorchestraUserRolesAuthorizationManager hasAuthority(GeorchestraUserMapper userMapper, + private static GeorchestraUserRolesAuthorizationManager hasAuthority(GeorchestraUserMapper userMapper, String authority) { Assert.notNull(authority, "authority cannot be null"); return new GeorchestraUserRolesAuthorizationManager<>(userMapper, authority); @@ -108,7 +109,7 @@ public static GeorchestraUserRolesAuthorizationManager hasAuthority(Georc * @param the type of object being authorized * @return the new instance */ - public static GeorchestraUserRolesAuthorizationManager hasAnyAuthority(GeorchestraUserMapper userMapper, + private static GeorchestraUserRolesAuthorizationManager hasAnyAuthority(GeorchestraUserMapper userMapper, String... authorities) { Assert.notNull(authorities, "authorities cannot be null"); for (String authority : authorities) { @@ -121,21 +122,21 @@ public static GeorchestraUserRolesAuthorizationManager hasAnyAuthority(Ge * Creates an instance of {@link GeorchestraUserRolesAuthorizationManager} with * the provided authority. * - * @param role the authority to check for prefixed with "ROLE_" + * @param role the authority to check for prefixed with {@literal ROLE_} * @param the type of object being authorized * @return the new instance */ public static GeorchestraUserRolesAuthorizationManager hasRole(GeorchestraUserMapper userMapper, String role) { Assert.notNull(role, "role cannot be null"); - return hasAuthority(userMapper, "ROLE_" + role); + return hasAuthority(userMapper, RolePrefixGeorchestraUserCustomizerExtension.toRole(role)); } /** * Creates an instance of {@link GeorchestraUserRolesAuthorizationManager} with * the provided authorities. * - * @param roles the authorities to check for prefixed with "ROLE_" + * @param roles the authorities to check for prefixed with {@literal ROLE_} * @param the type of object being authorized * @return the new instance */ @@ -149,11 +150,6 @@ public static GeorchestraUserRolesAuthorizationManager hasAnyRole(Georche } private static String[] toNamedRolesArray(String... roles) { - String[] result = new String[roles.length]; - for (int i = 0; i < roles.length; i++) { - result[i] = "ROLE_" + roles[i]; - } - return result; + return Stream.of(roles).map(RolePrefixGeorchestraUserCustomizerExtension::toRole).toArray(String[]::new); } - } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraLdapAuthenticatedUserMapper.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraLdapAuthenticatedUserMapper.java index 8235aa69..d1d8e7e1 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraLdapAuthenticatedUserMapper.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraLdapAuthenticatedUserMapper.java @@ -19,18 +19,12 @@ package org.georchestra.gateway.security.ldap.extended; -import java.util.List; import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; import org.georchestra.gateway.security.GeorchestraUserMapperExtension; import org.georchestra.security.api.UsersApi; import org.georchestra.security.model.GeorchestraUser; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.ldap.userdetails.LdapUserDetails; import org.springframework.security.ldap.userdetails.LdapUserDetailsImpl; @@ -62,8 +56,8 @@ public Optional resolve(Authentication authToken) { .flatMap(this::map); } - Optional map(GeorchestraUserNamePasswordAuthenticationToken token) { - final LdapUserDetails principal = (LdapUserDetails) token.getPrincipal(); + Optional map(GeorchestraUserNamePasswordAuthenticationToken token) { + final LdapUserDetailsImpl principal = (LdapUserDetailsImpl) token.getPrincipal(); final String ldapConfigName = token.getConfigName(); final String username = principal.getUsername(); @@ -72,36 +66,15 @@ Optional map(GeorchestraUserNamePasswordAuthenticationToken tok user = users.findByEmail(ldapConfigName, username); } - return user.map(u -> fixPrefixedRoleNames(u, token)); - } - - private GeorchestraUser fixPrefixedRoleNames(GeorchestraUser user, - GeorchestraUserNamePasswordAuthenticationToken token) { - - final LdapUserDetailsImpl principal = (LdapUserDetailsImpl) token.getPrincipal(); - - // Fix role name mismatch between authority provider (adds ROLE_ prefix) and - // users api - Stream authorityRoleNames = token.getAuthorities().stream() - .filter(SimpleGrantedAuthority.class::isInstance).map(GrantedAuthority::getAuthority) - .map(this::normalize); - - Stream userRoles = user.getRoles().stream().map(this::normalize); - - List roles = Stream.concat(authorityRoleNames, userRoles).distinct().collect(Collectors.toList()); - - user.setRoles(roles); - if (principal.getTimeBeforeExpiration() < Integer.MAX_VALUE) { - user.setLdapWarn(true); - user.setLdapRemainingDays(String.valueOf(principal.getTimeBeforeExpiration() / (60 * 60 * 24))); - } else { - user.setLdapWarn(false); - } + user.ifPresent(u -> { + if (principal.getTimeBeforeExpiration() < Integer.MAX_VALUE) { + u.setLdapWarn(true); + u.setLdapRemainingDays(String.valueOf(principal.getTimeBeforeExpiration() / (60 * 60 * 24))); + } else { + u.setLdapWarn(false); + } + }); return user; } - - private String normalize(String role) { - return role.startsWith("ROLE_") ? role : "ROLE_" + role; - } } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2Configuration.java b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2Configuration.java index 3fbc1961..55e8fd71 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2Configuration.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2Configuration.java @@ -18,105 +18,82 @@ */ package org.georchestra.gateway.security.oauth2; -import java.lang.reflect.Field; -import java.nio.charset.StandardCharsets; -import java.text.ParseException; -import java.util.Arrays; -import java.util.Collections; - -import javax.crypto.spec.SecretKeySpec; - import org.georchestra.gateway.security.ServerHttpSecurityCustomizer; -import org.georchestra.gateway.security.ldap.LdapConfigProperties; +import org.georchestra.gateway.security.oauth2.OAuth2ConfigurationProperties.OAuth2ProxyConfigProperties; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.gateway.config.GatewayReactiveOAuth2AutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.context.annotation.Primary; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity.OAuth2LoginSpec; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProviderBuilder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthorizationCodeReactiveAuthenticationManager; import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.WebClientReactiveAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.WebClientReactiveRefreshTokenTokenResponseClient; import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService; -import org.springframework.security.oauth2.client.oidc.web.server.logout.OidcClientInitiatedServerLogoutSuccessHandler; import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; import org.springframework.security.oauth2.client.userinfo.DefaultReactiveOAuth2UserService; -import org.springframework.security.oauth2.jose.jws.MacAlgorithm; -import org.springframework.security.oauth2.jwt.BadJwtException; -import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.client.web.DefaultReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoderFactory; -import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler; import org.springframework.web.reactive.function.client.WebClient; -import com.nimbusds.jwt.JWT; -import com.nimbusds.jwt.JWTParser; - import lombok.extern.slf4j.Slf4j; -import reactor.netty.http.client.HttpClient; -import reactor.netty.transport.ProxyProvider; - -import java.util.Map; -import java.util.stream.Collectors; @Configuration(proxyBeanMethods = false) -@EnableConfigurationProperties({ OAuth2ProxyConfigProperties.class, OpenIdConnectCustomClaimsConfigProperties.class, - LdapConfigProperties.class, ExtendedOAuth2ClientProperties.class }) +@EnableConfigurationProperties({ OAuth2ConfigurationProperties.class }) @Slf4j(topic = "org.georchestra.gateway.security.oauth2") public class OAuth2Configuration { public static final class OAuth2AuthenticationCustomizer implements ServerHttpSecurityCustomizer { + private WebClient oauth2WebClient; + + public OAuth2AuthenticationCustomizer(WebClient oauth2WebClient) { + this.oauth2WebClient = oauth2WebClient; + } public @Override void customize(ServerHttpSecurity http) { log.info("Enabling authentication support using an OAuth 2.0 and/or OpenID Connect 1.0 Provider"); + +// http.oauth2Client(); + http.oauth2Client(c -> { + c.authenticationManager(getClientAuthenticationManager()); + }); http.oauth2Login(); } - } - @Bean - @Profile("!test") - ServerLogoutSuccessHandler oidcLogoutSuccessHandler( - InMemoryReactiveClientRegistrationRepository clientRegistrationRepository, - ExtendedOAuth2ClientProperties properties) { - clientRegistrationRepository.forEach(client -> { - if (client.getProviderDetails().getConfigurationMetadata().isEmpty() - && properties.getProvider().get(client.getRegistrationId()) != null - && properties.getProvider().get(client.getRegistrationId()).getEndSessionUri() != null) { - try { - Field field = ClientRegistration.ProviderDetails.class.getDeclaredField("configurationMetadata"); - field.setAccessible(true); - field.set(client.getProviderDetails(), Collections.singletonMap("end_session_endpoint", - properties.getProvider().get(client.getRegistrationId()).getEndSessionUri())); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new RuntimeException(e); - } - } - }); - - OidcClientInitiatedServerLogoutSuccessHandler oidcLogoutSuccessHandler = new OidcClientInitiatedServerLogoutSuccessHandler( - clientRegistrationRepository); - oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}/login?logout"); - return oidcLogoutSuccessHandler; + private ReactiveAuthenticationManager getClientAuthenticationManager() { + WebClientReactiveAuthorizationCodeTokenResponseClient accessTokenResponseClient = new WebClientReactiveAuthorizationCodeTokenResponseClient(); + accessTokenResponseClient.setWebClient(oauth2WebClient); + return new OAuth2AuthorizationCodeReactiveAuthenticationManager(accessTokenResponseClient); + } } @Bean - ServerHttpSecurityCustomizer oauth2LoginEnablingCustomizer() { - return new OAuth2AuthenticationCustomizer(); + ServerHttpSecurityCustomizer oauth2LoginEnablingCustomizer( + @Qualifier("oauth2WebClient") WebClient oauth2WebClient) { + return new OAuth2AuthenticationCustomizer(oauth2WebClient); } @Bean - OAuth2UserMapper oAuth2GeorchestraUserUserMapper() { - return new OAuth2UserMapper(); + OAuth2UserMapper oAuth2GeorchestraUserUserMapper(OAuth2ConfigurationProperties config) { + return new OAuth2UserMapper(config); } @Bean - OpenIdConnectUserMapper openIdConnectGeorchestraUserUserMapper( - OpenIdConnectCustomClaimsConfigProperties nonStandardClaimsConfig) { - return new OpenIdConnectUserMapper(nonStandardClaimsConfig); + OpenIdConnectUserMapper openIdConnectGeorchestraUserUserMapper(OAuth2ConfigurationProperties config) { + return new OpenIdConnectUserMapper(config); } /** @@ -142,6 +119,42 @@ public ReactiveOAuth2AccessTokenResponseClient configurer.accessTokenResponseClient(refreshTokenTokenResponseClient))// + .build(); + + DefaultReactiveOAuth2AuthorizedClientManager authorizedClientManager; + authorizedClientManager = new DefaultReactiveOAuth2AuthorizedClientManager(// + clientRegistrationRepository, // + authorizedClientRepository); + authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider); + + return authorizedClientManager; + } + /** * Custom JWT decoder factory to use the web client that can be set up to go * through an HTTP proxy @@ -149,39 +162,17 @@ public ReactiveOAuth2AccessTokenResponseClient idTokenDecoderFactory( @Qualifier("oauth2WebClient") WebClient oauth2WebClient) { - return (clientRegistration) -> (token) -> { - try { - JWT parsedJwt = JWTParser.parse(token); - MacAlgorithm macAlgorithm = MacAlgorithm.from(parsedJwt.getHeader().getAlgorithm().getName()); - NimbusReactiveJwtDecoder jwtDecoder; - if (macAlgorithm != null) { - var secretKey = clientRegistration.getClientSecret().getBytes(StandardCharsets.UTF_8); - if (secretKey.length < 64) { - secretKey = Arrays.copyOf(secretKey, 64); - } - SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey, macAlgorithm.getName()); - jwtDecoder = NimbusReactiveJwtDecoder.withSecretKey(secretKeySpec).macAlgorithm(macAlgorithm) - .build(); - } else { - jwtDecoder = NimbusReactiveJwtDecoder - .withJwkSetUri(clientRegistration.getProviderDetails().getJwkSetUri()) - .webClient(oauth2WebClient).build(); - } - return jwtDecoder.decode(token).map(jwt -> new Jwt(jwt.getTokenValue(), jwt.getIssuedAt(), - jwt.getExpiresAt(), jwt.getHeaders(), removeNullClaims(jwt.getClaims()))); - } catch (ParseException exception) { - throw new BadJwtException( - "An error occurred while attempting to decode the Jwt: " + exception.getMessage(), exception); - } + return (clientRegistration) -> { + ProviderDetails providerDetails = clientRegistration.getProviderDetails(); + String jwkSetUri = providerDetails.getJwkSetUri(); + return NimbusReactiveJwtDecoder// + .withJwkSetUri(jwkSetUri)// + .jwsAlgorithm(SignatureAlgorithm.RS256)// + .webClient(oauth2WebClient)// + .build(); }; } - // Some IDPs return claims with null value but Spring does not handle them - private Map removeNullClaims(Map claims) { - return claims.entrySet().stream().filter(entry -> entry.getValue() != null) - .collect(Collectors.toMap((entry) -> entry.getKey(), (entry) -> entry.getValue())); - } - @Bean public DefaultReactiveOAuth2UserService reactiveOAuth2UserService( @Qualifier("oauth2WebClient") WebClient oauth2WebClient) { @@ -211,31 +202,9 @@ public OidcReactiveOAuth2UserService oidcReactiveOAuth2UserService( * through System properties ({@literal http(s).proxyHost} * and {@literal http(s).proxyPort}), if any. */ +// @Primary @Bean("oauth2WebClient") - public WebClient oauth2WebClient(OAuth2ProxyConfigProperties proxyConfig) { - final String proxyHost = proxyConfig.getHost(); - final Integer proxyPort = proxyConfig.getPort(); - final String proxyUser = proxyConfig.getUsername(); - final String proxyPassword = proxyConfig.getPassword(); - - HttpClient httpClient = HttpClient.create(); - if (proxyConfig.isEnabled()) { - if (proxyHost == null || proxyPort == null) { - throw new IllegalStateException("OAuth2 client HTTP proxy is enabled, but host and port not provided"); - } - log.info("Oauth2 client will use HTTP proxy {}:{}", proxyHost, proxyPort); - httpClient = httpClient.proxy(proxy -> proxy.type(ProxyProvider.Proxy.HTTP).host(proxyHost).port(proxyPort) - .username(proxyUser).password(user -> { - return proxyPassword; - })); - } else { - log.info("Oauth2 client will use HTTP proxy from System properties if provided"); - httpClient = httpClient.proxyWithSystemProperties(); - } - ReactorClientHttpConnector conn = new ReactorClientHttpConnector(httpClient); - - WebClient webClient = WebClient.builder().clientConnector(conn).build(); - return webClient; + public WebClient oauth2WebClient(OAuth2ConfigurationProperties config) { + return new ProxyAwareWebClient(config.getProxy()); } - } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2ConfigurationProperties.java b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2ConfigurationProperties.java new file mode 100644 index 00000000..0f7abe06 --- /dev/null +++ b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2ConfigurationProperties.java @@ -0,0 +1,258 @@ +/* + * Copyright (C) 2023 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra 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 3 of the License, or (at your option) + * any later version. + * + * geOrchestra 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 + * geOrchestra. If not, see . + */ +package org.georchestra.gateway.security.oauth2; + +import java.text.Normalizer; +import java.text.Normalizer.Form; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.georchestra.security.model.GeorchestraUser; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.StringUtils; + +import com.google.common.base.Splitter; +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NonNull; +import lombok.experimental.Accessors; +import lombok.extern.slf4j.Slf4j; + +@ConfigurationProperties(prefix = "georchestra.gateway.security.oauth2") +@Slf4j(topic = "org.georchestra.gateway.security.oauth2") +public @Data class OAuth2ConfigurationProperties { + + private @NonNull OAuth2ProxyConfigProperties proxy = new OAuth2ProxyConfigProperties(); + + private @NonNull RoleNameNormalizer roles = new RoleNameNormalizer(); + + private @NonNull OpenIdConnectConfigProperties oidc = new OpenIdConnectConfigProperties(); + + public static @Data class OAuth2ProxyConfigProperties { + private boolean enabled; + private String host; + private Integer port; + private String username; + private String password; + } + + @Accessors(chain = true) + public static @Data class RoleNameNormalizer { + /** + * Whether to return mapped role names as upper-case + */ + private boolean uppercase = true; + + /** + * Whether to remove special characters and replace spaces and dots by + * underscores + */ + private boolean normalize = true; + + public String applyTransforms(@NonNull String authority) { + return applyTransforms(authority, uppercase, normalize); + } + + public static String applyTransforms(@NonNull String authority, boolean uppercase, boolean normalize) { + String result = uppercase ? authority.toUpperCase() : authority; + if (normalize) { + result = normalize(result); + } + return result; + } + + public static String normalize(@NonNull String value) { + // apply Unicode Normalization (NFC: a + ◌̂ = â) (see + // https://www.unicode.org/reports/tr15/) + String normalized = Normalizer.normalize(value, Form.NFC); + + // remove unicode accents and diacritics + normalized = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); + + // replace all whitespace groups by a single underscore + normalized = normalized.replaceAll("\\s+", "_"); + + // remove remaining characters like parenthesis, commas, etc + normalized = normalized.replaceAll("[^a-zA-Z0-9_.]", ""); + return normalized; + } + } + + public static @Data class OpenIdConnectConfigProperties { + private @NonNull OpenIdConnectCustomClaimsConfigProperties claims = new OpenIdConnectCustomClaimsConfigProperties(); + } + + public static @Data class OpenIdConnectCustomClaimsConfigProperties { + + private JsonPathExtractor id = new JsonPathExtractor(); + private OidcRolesMappingConfig roles = new OidcRolesMappingConfig(); + private JsonPathExtractor organization = new JsonPathExtractor(); + + public Optional id() { + return Optional.ofNullable(id); + } + + public Optional roles() { + return Optional.ofNullable(roles); + } + + public Optional organization() { + return Optional.ofNullable(organization); + } + + @Accessors(chain = true) + @EqualsAndHashCode(callSuper = true) + public static @Data class OidcRolesMappingConfig extends RoleNameNormalizer { + + private JsonPathExtractor json = new JsonPathExtractor(); + + /** + * Whether to append the resolved role names to the roles given by the OAuth2 + * authentication (true), or replace them (false). + */ + private boolean append = true; + + public Optional json() { + return Optional.ofNullable(json); + } + + public void apply(Map claims, GeorchestraUser target) { + + json().ifPresent(json -> { + List rawValues = json.extract(claims); + Set roles = rawValues.stream().map(this::applyTransforms) + // make sure the resulting list is mutable, Stream.toList() is not + .collect(Collectors.toCollection(TreeSet::new)); + if (roles.isEmpty()) { + return; + } + if (append) { + roles.addAll(target.getRoles()); + } + target.getRoles().clear(); + target.getRoles().addAll(roles); + }); + } + } + + @Accessors(chain = true) + public static @Data class JsonPathExtractor { + /** + * JsonPath expression(s) to extract the role names from the + * {@literal Map} containing all OIDC authentication token + * claims. + *

+ * For example, if the claims map contains a JSON object under the + * {@literal groups_json} key with the value + * + *

+             * {@code
+             * [
+             *     [
+             *       {
+             *         "name": "GDI FTTH Planer (extern)",
+             *         "targetSystem": "gdiFibreAdmin",
+             *         "parameter": []
+             *       }
+             *     ]
+             *   ]
+             * }
+             * 
+ * + * The JsonPath expression {@literal $.groups_json[0][0].name} would match only + * the first group name, while the expression {@literal $.groups_json..['name']} + * would match them all to a {@code List}. + */ + private List path = new ArrayList<>(); + + /** + * Whether to first interpret each value as a comma-separated value and split it + * to an array + */ + private boolean split = true; + + private String splitOn = ","; + + /** + * @param claims + * @return + */ + public @NonNull List extract(@NonNull Map claims) { + return this.path.stream()// + .map(jsonPathExpression -> this.extract(jsonPathExpression, claims))// + .flatMap(List::stream)// + .sorted().distinct().collect(Collectors.toList()); + } + + private List extract(final String jsonPathExpression, Map claims) { + if (!StringUtils.hasText(jsonPathExpression)) { + return List.of(); + } + // if we call claims.get(key) and the result is a JSON object, + // the json api used is a shaded version of org.json at package + // com.nimbusds.jose.shaded.json, we don't want to use that + // since it's obviously internal to com.nimbusds.jose + // JsonPath works fine with it though, as it's designed + // to work on POJOS, JSONObject is a Map and JSONArray is a List so it's ok + DocumentContext context = JsonPath.parse(claims); + Object matched = context.read(jsonPathExpression); + + if (null == matched) { + log.warn("The JSONPath expession {} evaluates to null", jsonPathExpression); + return List.of(); + } + + final List list = (matched instanceof List) ? (List) matched : List.of(matched); + + List values = IntStream.range(0, list.size())// + .mapToObj(list::get)// + .filter(Objects::nonNull)// + .map(value -> validateValueIsString(jsonPathExpression, value))// + .map(this::split).flatMap(List::stream).collect(Collectors.toList()); + + return values; + } + + private String validateValueIsString(final String jsonPathExpression, @NonNull Object v) { + if (v instanceof String) + return (String) v; + + String msg = String.format("The JSONPath expression %s evaluates to %s instead of String. Value: %s", + jsonPathExpression, v.getClass().getCanonicalName(), v); + throw new IllegalStateException(msg); + + } + + private List split(@NonNull String value) { + String separator = null == splitOn ? "," : splitOn; + return Splitter.on(separator).trimResults().omitEmptyStrings().splitToList(value); + } + } + } +} diff --git a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2ProxyConfigProperties.java b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2ProxyConfigProperties.java deleted file mode 100644 index aac5bc32..00000000 --- a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2ProxyConfigProperties.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (C) 2022 by the geOrchestra PSC - * - * This file is part of geOrchestra. - * - * geOrchestra 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 3 of the License, or (at your option) - * any later version. - * - * geOrchestra 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 - * geOrchestra. If not, see . - */ -package org.georchestra.gateway.security.oauth2; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -import lombok.Data; - -@ConfigurationProperties(prefix = "georchestra.gateway.security.oauth2.proxy") -public @Data class OAuth2ProxyConfigProperties { - private boolean enabled; - private String host; - private Integer port; - private String username; - private String password; -} diff --git a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2UserMapper.java b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2UserMapper.java index f5ed3f00..04192159 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2UserMapper.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2UserMapper.java @@ -35,6 +35,8 @@ import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.core.user.OAuth2User; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** @@ -56,9 +58,12 @@ * {@literal ROLE_SCOPE_} or {@code SCOPE_}. * */ +@RequiredArgsConstructor @Slf4j(topic = "org.georchestra.gateway.security.oauth2") public class OAuth2UserMapper implements GeorchestraUserMapperExtension { + protected final @NonNull OAuth2ConfigurationProperties config; + @Override public Optional resolve(Authentication authToken) { return Optional.ofNullable(authToken)// @@ -78,10 +83,8 @@ protected Optional map(OAuth2AuthenticationToken token) { OAuth2User oAuth2User = token.getPrincipal(); GeorchestraUser user = new GeorchestraUser(); - final String oAuth2ProviderId = String.format("%s;%s", token.getAuthorizedClientRegistrationId(), - token.getName()); - user.setOAuth2ProviderId(oAuth2ProviderId); - + user.setOAuth2Provider(token.getAuthorizedClientRegistrationId()); + user.setOAuth2Uid(token.getName()); Map attributes = oAuth2User.getAttributes(); List roles = resolveRoles(oAuth2User.getAuthorities()); @@ -101,14 +104,8 @@ protected Optional map(OAuth2AuthenticationToken token) { } protected List resolveRoles(Collection authorities) { - List roles = authorities.stream().map(GrantedAuthority::getAuthority).filter(scope -> { - if (scope.startsWith("ROLE_SCOPE_") || scope.startsWith("SCOPE_")) { - logger().debug("Excluding granted authority {}", scope); - return false; - } - return true; - }).collect(Collectors.toList()); - return roles; + return authorities.stream().map(GrantedAuthority::getAuthority).map(config.getRoles()::applyTransforms) + .collect(Collectors.toList()); } protected void apply(Consumer setter, String... candidates) { diff --git a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectCustomClaimsConfigProperties.java b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectCustomClaimsConfigProperties.java deleted file mode 100644 index 7bea006d..00000000 --- a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectCustomClaimsConfigProperties.java +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright (C) 2022 by the geOrchestra PSC - * - * This file is part of geOrchestra. - * - * geOrchestra 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 3 of the License, or (at your option) - * any later version. - * - * geOrchestra 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 - * geOrchestra. If not, see . - */ -package org.georchestra.gateway.security.oauth2; - -import java.text.Normalizer; -import java.text.Normalizer.Form; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; -import java.util.stream.IntStream; - -import org.georchestra.security.model.GeorchestraUser; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.util.StringUtils; - -import com.jayway.jsonpath.DocumentContext; -import com.jayway.jsonpath.JsonPath; - -import lombok.Data; -import lombok.NonNull; -import lombok.experimental.Accessors; -import lombok.extern.slf4j.Slf4j; - -@ConfigurationProperties(prefix = "georchestra.gateway.security.oidc.claims") -@Slf4j(topic = "org.georchestra.gateway.security.oauth2") -public @Data class OpenIdConnectCustomClaimsConfigProperties { - - private JsonPathExtractor id = new JsonPathExtractor(); - private RolesMapping roles = new RolesMapping(); - private JsonPathExtractor organization = new JsonPathExtractor(); - - public Optional id() { - return Optional.ofNullable(id); - } - - public Optional roles() { - return Optional.ofNullable(roles); - } - - public Optional organization() { - return Optional.ofNullable(organization); - } - - @Accessors(chain = true) - public static @Data class RolesMapping { - - private JsonPathExtractor json = new JsonPathExtractor(); - - /** - * Whether to return mapped role names as upper-case - */ - private boolean uppercase = true; - - /** - * Whether to remove special characters and replace spaces by underscores - */ - private boolean normalize = true; - - /** - * Whether to append the resolved role names to the roles given by the OAuth2 - * authentication (true), or replace them (false). - */ - private boolean append = true; - - public Optional json() { - return Optional.ofNullable(json); - } - - public void apply(Map claims, GeorchestraUser target) { - - json().ifPresent(json -> { - List rawValues = json.extract(claims); - List roles = rawValues.stream().map(this::applyTransforms) - // make sure the resulting list is mutable, Stream.toList() is not - .collect(Collectors.toList()); - if (roles.isEmpty()) { - return; - } - if (append) { - target.getRoles().addAll(0, roles); - } else { - target.setRoles(roles); - } - }); - } - - private String applyTransforms(String value) { - String result = uppercase ? value.toUpperCase() : value; - if (normalize) { - result = normalize(result); - } - return result; - } - - public String normalize(@NonNull String value) { - // apply Unicode Normalization (NFC: a + ◌̂ = â) (see - // https://www.unicode.org/reports/tr15/) - String normalized = Normalizer.normalize(value, Form.NFC); - - // remove unicode accents and diacritics - normalized = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); - - // replace all whitespace groups by a single underscore - normalized = value.replaceAll("\\s+", "_"); - - // remove remaining characters like parenthesis, commas, etc - normalized = normalized.replaceAll("[^a-zA-Z0-9_]", ""); - return normalized; - } - } - - @Accessors(chain = true) - public static @Data class JsonPathExtractor { - /** - * JsonPath expression(s) to extract the role names from the - * {@literal Map} containing all OIDC authentication token - * claims. - *

- * For example, if the claims map contains a JSON object under the - * {@literal groups_json} key with the value - * - *

-         * {@code
-         * [
-         *     [
-         *       {
-         *         "name": "GDI FTTH Planer (extern)",
-         *         "targetSystem": "gdiFibreAdmin",
-         *         "parameter": []
-         *       }
-         *     ]
-         *   ]
-         * }
-         * 
- * - * The JsonPath expression {@literal $.groups_json[0][0].name} would match only - * the first group name, while the expression {@literal $.groups_json..['name']} - * would match them all to a {@code List}. - */ - private List path = new ArrayList<>(); - - /** - * @param claims - * @return - */ - public @NonNull List extract(@NonNull Map claims) { - return this.path.stream()// - .map(jsonPathExpression -> this.extract(jsonPathExpression, claims))// - .flatMap(List::stream)// - .collect(Collectors.toList()); - } - - private List extract(final String jsonPathExpression, Map claims) { - if (!StringUtils.hasText(jsonPathExpression)) { - return List.of(); - } - // if we call claims.get(key) and the result is a JSON object, - // the json api used is a shaded version of org.json at package - // com.nimbusds.jose.shaded.json, we don't want to use that - // since it's obviously internal to com.nimbusds.jose - // JsonPath works fine with it though, as it's designed - // to work on POJOS, JSONObject is a Map and JSONArray is a List so it's ok - DocumentContext context = JsonPath.parse(claims); - Object matched = context.read(jsonPathExpression); - - if (null == matched) { - log.warn("The JSONPath expession {} evaluates to null", jsonPathExpression); - return List.of(); - } - - final List list = (matched instanceof List) ? (List) matched : List.of(matched); - - List values = IntStream.range(0, list.size())// - .mapToObj(list::get)// - .filter(Objects::nonNull)// - .map(value -> validateValueIsString(jsonPathExpression, value))// - .collect(Collectors.toList()); - - return values; - } - - private String validateValueIsString(final String jsonPathExpression, @NonNull Object v) { - if (v instanceof String) - return (String) v; - - String msg = String.format("The JSONPath expression %s evaluates to %s instead of String. Value: %s", - jsonPathExpression, v.getClass().getCanonicalName(), v); - throw new IllegalStateException(msg); - - } - } -} diff --git a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectUserMapper.java b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectUserMapper.java index 559fd445..338f97d3 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectUserMapper.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectUserMapper.java @@ -19,19 +19,23 @@ package org.georchestra.gateway.security.oauth2; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.function.Consumer; import java.util.function.Predicate; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.georchestra.gateway.security.ldap.LdapConfigProperties; +import org.georchestra.gateway.security.oauth2.OAuth2ConfigurationProperties.OpenIdConnectCustomClaimsConfigProperties; import org.georchestra.security.model.GeorchestraUser; import org.slf4j.Logger; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.core.Ordered; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.core.oidc.AddressStandardClaim; import org.springframework.security.oauth2.core.oidc.OidcIdToken; @@ -42,7 +46,6 @@ import com.google.common.annotations.VisibleForTesting; import lombok.NonNull; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** @@ -130,12 +133,13 @@ * to {@code true}. * */ -@RequiredArgsConstructor @EnableConfigurationProperties({ LdapConfigProperties.class }) @Slf4j(topic = "org.georchestra.gateway.security.oauth2") public class OpenIdConnectUserMapper extends OAuth2UserMapper { - private final @NonNull OpenIdConnectCustomClaimsConfigProperties nonStandardClaimsConfig; + public OpenIdConnectUserMapper(@NonNull OAuth2ConfigurationProperties config) { + super(config); + } protected @Override Predicate tokenFilter() { return token -> token.getPrincipal() instanceof OidcUser; @@ -152,6 +156,8 @@ public class OpenIdConnectUserMapper extends OAuth2UserMapper { try { applyStandardClaims(oidcUser, user); applyNonStandardClaims(oidcUser.getClaims(), user); + user.setUsername((token.getAuthorizedClientRegistrationId() + "_" + user.getUsername()) + .replaceAll("[^a-zA-Z0-9-_]", "_").toLowerCase()); } catch (Exception e) { log.error("Error mapping non-standard OIDC claims for authenticated user", e); throw new IllegalStateException(e); @@ -159,6 +165,18 @@ public class OpenIdConnectUserMapper extends OAuth2UserMapper { return Optional.of(user); } + @Override + protected List resolveRoles(Collection authorities) { + List roles = authorities.stream().map(GrantedAuthority::getAuthority).filter(scope -> { + if (scope.startsWith("ROLE_SCOPE_") || scope.startsWith("SCOPE_")) { + logger().debug("Excluding granted authority {}", scope); + return false; + } + return true; + }).map(config.getOidc().getClaims().getRoles()::applyTransforms).collect(Collectors.toList()); + return roles; + } + /** * @param claims OpenId Connect merged claims from {@link OidcUserInfo} and * {@link OidcIdToken} @@ -167,6 +185,8 @@ public class OpenIdConnectUserMapper extends OAuth2UserMapper { @VisibleForTesting void applyNonStandardClaims(Map claims, GeorchestraUser target) { + OpenIdConnectCustomClaimsConfigProperties nonStandardClaimsConfig = super.config.getOidc().getClaims(); + nonStandardClaimsConfig.id().map(jsonEvaluator -> jsonEvaluator.extract(claims))// .map(List::stream)// .flatMap(Stream::findFirst)// diff --git a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/ProxyAwareWebClient.java b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/ProxyAwareWebClient.java new file mode 100644 index 00000000..f4999f1b --- /dev/null +++ b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/ProxyAwareWebClient.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2022 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra 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 3 of the License, or (at your option) + * any later version. + * + * geOrchestra 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 + * geOrchestra. If not, see . + */ + +package org.georchestra.gateway.security.oauth2; + +import org.georchestra.gateway.security.oauth2.OAuth2ConfigurationProperties.OAuth2ProxyConfigProperties; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; + +import lombok.NonNull; +import lombok.experimental.Delegate; +import lombok.extern.slf4j.Slf4j; +import reactor.netty.http.client.HttpClient; +import reactor.netty.transport.ProxyProvider; + +@Slf4j +class ProxyAwareWebClient implements WebClient { + + @Delegate + private final @NonNull WebClient delegate; + + public ProxyAwareWebClient(@NonNull OAuth2ProxyConfigProperties proxyConfig) { + final String proxyHost = proxyConfig.getHost(); + final Integer proxyPort = proxyConfig.getPort(); + final String proxyUser = proxyConfig.getUsername(); + final String proxyPassword = proxyConfig.getPassword(); + + HttpClient httpClient = HttpClient.create(); + if (proxyConfig.isEnabled()) { + if (proxyHost == null || proxyPort == null) { + throw new IllegalStateException("OAuth2 client HTTP proxy is enabled, but host and port not provided"); + } + log.info("Oauth2 client will use HTTP proxy {}:{}", proxyHost, proxyPort); + httpClient = httpClient.proxy(proxy -> proxy.type(ProxyProvider.Proxy.HTTP).host(proxyHost).port(proxyPort) + .username(proxyUser).password(user -> { + return proxyPassword; + })); + } else { + log.info("Oauth2 client will use HTTP proxy from System properties if provided"); + httpClient = httpClient.proxyWithSystemProperties(); + } + + httpClient = httpClient + .doOnRequest((req, conn) -> log.info("Performing proxied request to {}", req.resourceUrl())) + .doOnResponse( + (resp, conn) -> log.info("Proxied response: {}, url: {}", resp.status(), resp.resourceUrl())); + + // httpClient = httpClient.wiretap(true); + ReactorClientHttpConnector conn = new ReactorClientHttpConnector(httpClient); + + WebClient webClient = WebClient.builder().clientConnector(conn).build(); + this.delegate = webClient; + } +} \ No newline at end of file diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index ead19e59..b6595eae 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -83,12 +83,27 @@ georchestra: password: ${rabbitmqPassword} oauth2: enabled: false + roles: + uppercase: true + normalize: true proxy: enabled: false host: localhost port: 8000 username: jack password: insecure + oidc: + claims: + id.path: "$.sub" + organization.path: + roles: + uppercase: true + normalize: true + append: true + json: + split: true + # path: + # - "$.concat(\"ORG_\", $.PartyOrganisationID)" ldap: # Multiple LDAP data sources are supported. The first key defines a simple # name for them. The `default` one here, disabled by default, is pre-configured diff --git a/gateway/src/test/java/org/georchestra/gateway/autoconfigure/security/OAuth2SecurityAutoConfigurationTest.java b/gateway/src/test/java/org/georchestra/gateway/autoconfigure/security/OAuth2SecurityAutoConfigurationTest.java index 442902c5..eb6be3ab 100644 --- a/gateway/src/test/java/org/georchestra/gateway/autoconfigure/security/OAuth2SecurityAutoConfigurationTest.java +++ b/gateway/src/test/java/org/georchestra/gateway/autoconfigure/security/OAuth2SecurityAutoConfigurationTest.java @@ -23,26 +23,53 @@ import java.util.List; +import org.georchestra.gateway.security.oauth2.OAuth2Configuration; import org.georchestra.gateway.security.oauth2.OAuth2Configuration.OAuth2AuthenticationCustomizer; -import org.georchestra.gateway.security.oauth2.OAuth2ProxyConfigProperties; -import org.georchestra.gateway.security.oauth2.OpenIdConnectCustomClaimsConfigProperties; -import org.georchestra.gateway.security.oauth2.OpenIdConnectCustomClaimsConfigProperties.RolesMapping; +import org.georchestra.gateway.security.oauth2.OAuth2ConfigurationProperties; +import org.georchestra.gateway.security.oauth2.OAuth2ConfigurationProperties.OAuth2ProxyConfigProperties; +import org.georchestra.gateway.security.oauth2.OAuth2ConfigurationProperties.OpenIdConnectCustomClaimsConfigProperties; +import org.georchestra.gateway.security.oauth2.OAuth2ConfigurationProperties.OpenIdConnectCustomClaimsConfigProperties.OidcRolesMappingConfig; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.security.config.oauth2.client.CommonOAuth2Provider; import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService; +import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; import org.springframework.security.oauth2.client.userinfo.DefaultReactiveOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService; +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.server.UnAuthenticatedServerOAuth2AuthorizedClientRepository; /** * Assert context contributions of {@link OAuth2SecurityAutoConfiguration} * */ class OAuth2SecurityAutoConfigurationTest { - private ReactiveWebApplicationContextRunner runner = new ReactiveWebApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(OAuth2SecurityAutoConfiguration.class)) - .withPropertyValues("spring.profiles.active=test"); + + /** + * bean to satisfy the dependency of + * {@link OAuth2Configuration#gatewayOverrideReactiveOAuth2AuthorizedClientManager()} + */ + private ReactiveClientRegistrationRepository repo = new InMemoryReactiveClientRegistrationRepository( + CommonOAuth2Provider.GOOGLE.getBuilder("google").clientId("testcid").clientSecret("testsecret").build()); + + @SuppressWarnings("deprecation") + private ApplicationContextRunner runner = new ApplicationContextRunner() + /** + * bean to satisfy the dependency of + * {@link OAuth2Configuration#gatewayOverrideReactiveOAuth2AuthorizedClientManager()} + */ + .withBean(ReactiveClientRegistrationRepository.class, () -> repo) + /** + * bean to satisfy the dependency of + * {@link OAuth2Configuration#gatewayOverrideReactiveOAuth2AuthorizedClientManager()} + */ + .withBean(ServerOAuth2AuthorizedClientRepository.class, + () -> new UnAuthenticatedServerOAuth2AuthorizedClientRepository()) + .withConfiguration(AutoConfigurations.of(// + OAuth2SecurityAutoConfiguration.class)); @Test void testDisabledByDefault() { @@ -60,7 +87,7 @@ void testEnabled() { "georchestra.gateway.security.oauth2.enabled=true")// .run(context -> { - assertThat(context).hasSingleBean(OAuth2ProxyConfigProperties.class); + assertThat(context).hasSingleBean(OAuth2ConfigurationProperties.class); assertThat(context).hasSingleBean(OAuth2AuthenticationCustomizer.class); assertThat(context).hasSingleBean(ReactiveOAuth2AccessTokenResponseClient.class); assertThat(context).hasSingleBean(DefaultReactiveOAuth2UserService.class); @@ -76,19 +103,24 @@ void testEnabled() { void testOpenIdConnectCustomClaimsConfigProperties() { runner.withPropertyValues(// "georchestra.gateway.security.oauth2.enabled: true" // - , "georchestra.gateway.security.oidc.claims.organization.path: $.PartyOrganisationID" // - , "georchestra.gateway.security.oidc.claims.roles.json.path: $.groups_json..['name']" // - , "georchestra.gateway.security.oidc.claims.roles.uppercase: false" // - , "georchestra.gateway.security.oidc.claims.roles.normalize: false" // - , "georchestra.gateway.security.oidc.claims.roles.append: false" // + , "georchestra.gateway.security.oauth2.oidc.claims.organization.path: $.PartyOrganisationID" // + , "georchestra.gateway.security.oauth2.oidc.claims.roles.json.path: $.groups_json..['name']" // + , "georchestra.gateway.security.oauth2.oidc.claims.roles.json.split: true" // + , "georchestra.gateway.security.oauth2.oidc.claims.roles.json.split-on: ;" // + , "georchestra.gateway.security.oauth2.oidc.claims.roles.uppercase: false" // + , "georchestra.gateway.security.oauth2.oidc.claims.roles.normalize: false" // + , "georchestra.gateway.security.oauth2.oidc.claims.roles.append: false" // )// .run(context -> { assertThat(context).hasNotFailed(); OpenIdConnectCustomClaimsConfigProperties claimsConfig = context - .getBean(OpenIdConnectCustomClaimsConfigProperties.class); + .getBean(OAuth2ConfigurationProperties.class).getOidc().getClaims(); assertThat(claimsConfig.getOrganization().getPath()).isEqualTo(List.of("$.PartyOrganisationID")); - RolesMapping rolesMapping = claimsConfig.getRoles(); + OidcRolesMappingConfig rolesMapping = claimsConfig.getRoles(); assertThat(rolesMapping.getJson().getPath()).isEqualTo(List.of("$.groups_json..['name']")); + assertThat(rolesMapping.getJson().isSplit()).isTrue(); + assertThat(rolesMapping.getJson().getSplitOn()).isEqualTo(";"); + assertThat(rolesMapping.isUppercase()).isFalse(); assertThat(rolesMapping.isNormalize()).isFalse(); assertThat(rolesMapping.isAppend()).isFalse(); @@ -100,19 +132,19 @@ void testOpenIdConnectCustomClaimsConfigProperties() { void testOpenIdConnectCustomClaimsConfigProperties_multiple_role_expressions() { runner.withPropertyValues(// "georchestra.gateway.security.oauth2.enabled: true", // - "georchestra.gateway.security.oidc.claims.roles.json.path[0]: $.concat(\"ORG_\", $.PartyOrganisationID)", // - "georchestra.gateway.security.oidc.claims.roles.json.path[1]: $.groups_json..['name']" // + "georchestra.gateway.security.oauth2.oidc.claims.roles.json.path[0]: $.concat(\"ORG_\", $.PartyOrganisationID)", // + "georchestra.gateway.security.oauth2.oidc.claims.roles.json.path[1]: $.groups_json..['name']" // )// .run(context -> { assertThat(context).hasNotFailed(); OpenIdConnectCustomClaimsConfigProperties claimsConfig = context - .getBean(OpenIdConnectCustomClaimsConfigProperties.class); + .getBean(OAuth2ConfigurationProperties.class).getOidc().getClaims(); List expected = List.of(// "$.concat(\"ORG_\", $.PartyOrganisationID)", // "$.groups_json..['name']"); - RolesMapping rolesMapping = claimsConfig.getRoles(); + OidcRolesMappingConfig rolesMapping = claimsConfig.getRoles(); List actual = rolesMapping.getJson().getPath(); assertThat(actual).size().isEqualTo(2); assertThat(actual).isEqualTo(expected); @@ -120,7 +152,7 @@ void testOpenIdConnectCustomClaimsConfigProperties_multiple_role_expressions() { ; } - private void testDisabled(ReactiveWebApplicationContextRunner runner) { + private void testDisabled(ApplicationContextRunner runner) { runner.run(context -> { assertThat(context).doesNotHaveBean(OAuth2ProxyConfigProperties.class); assertThat(context).doesNotHaveBean(OAuth2AuthenticationCustomizer.class); diff --git a/gateway/src/test/java/org/georchestra/gateway/security/MockUserGeorchestraUserMapperConfiguration.java b/gateway/src/test/java/org/georchestra/gateway/security/MockUserGeorchestraUserMapperConfiguration.java new file mode 100644 index 00000000..8eaa8136 --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/security/MockUserGeorchestraUserMapperConfiguration.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2023 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra 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 3 of the License, or (at your option) + * any later version. + * + * geOrchestra 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 + * geOrchestra. If not, see . + */ +package org.georchestra.gateway.security; + +import java.util.Optional; +import java.util.stream.Collectors; + +import org.georchestra.security.model.GeorchestraUser; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.ldap.userdetails.LdapUserDetails; + +/** + * Catch and convert {@link Authentication}s created by {@literal @WithMockUser} + */ +public @Configuration class MockUserGeorchestraUserMapperConfiguration { + + @Bean + MockUserGeorchestraUserMapperConfiguration.TestUserMapper testUserMapper() { + return new TestUserMapper(); + } + + static class TestUserMapper implements GeorchestraUserMapperExtension { + public @Override Optional resolve(Authentication authToken) { + return Optional.ofNullable(authToken)// + .filter(UsernamePasswordAuthenticationToken.class::isInstance) + .map(UsernamePasswordAuthenticationToken.class::cast)// + .filter(token -> !(token.getPrincipal() instanceof LdapUserDetails))// + .filter(token -> token.getPrincipal() instanceof org.springframework.security.core.userdetails.User) + .map(t -> { + GeorchestraUser user = new GeorchestraUser(); + user.setUsername(t.getName()); + user.setRoles(t.getAuthorities().stream().map(GrantedAuthority::getAuthority) + .collect(Collectors.toList())); + return user; + }); + } + + } +} \ No newline at end of file diff --git a/gateway/src/test/java/org/georchestra/gateway/security/RolesMappingsUserCustomizerIT.java b/gateway/src/test/java/org/georchestra/gateway/security/RolesMappingsUserCustomizerIT.java new file mode 100644 index 00000000..b7704392 --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/security/RolesMappingsUserCustomizerIT.java @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2023 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra 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 3 of the License, or (at your option) + * any later version. + * + * geOrchestra 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 + * geOrchestra. If not, see . + */ + +package org.georchestra.gateway.security; + +import org.georchestra.gateway.app.GeorchestraGatewayApplication; +import org.georchestra.gateway.security.oauth2.OAuth2Configuration; +import org.georchestra.gateway.security.oauth2.OAuth2ConfigurationProperties; +import org.georchestra.gateway.security.oauth2.OAuth2ConfigurationProperties.OpenIdConnectCustomClaimsConfigProperties.OidcRolesMappingConfig; +import org.georchestra.gateway.security.oauth2.OAuth2UserMapper; +import org.georchestra.gateway.security.oauth2.OpenIdConnectUserMapper; +import org.georchestra.gateway.test.context.support.WithMockOAuth2User; +import org.georchestra.gateway.test.context.support.WithMockOidcUser; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.reactive.server.WebTestClient; + +/** + * Verify that {@link GrantedAuthority} names get first translated to role names + * (e.g. {@literal GP.TEST.SAMPLE} -> {@literal GP_TEST_SAMPLE}) and then the + * translated role names are used to add extra roles as defined by + * {@code georchestra.gateway.roles-mappings.*} properties + */ +@SpringBootTest(classes = { // + GeorchestraGatewayApplication.class, // + MockUserGeorchestraUserMapperConfiguration.class, // + RolesMappingsUserCustomizerIT.ForceOidcUserMapperBean.class// +}, webEnvironment = WebEnvironment.MOCK, properties = { "georchestra.datadir=../datadir", // + "georchestra.gateway.security.ldap.default.enabled=false", // + "georchestra.gateway.security.oauth2.enabled=false",// +}) +@AutoConfigureWebTestClient(timeout = "PT120S") +class RolesMappingsUserCustomizerIT { + + private @Autowired WebTestClient testClient; + + private @Autowired OAuth2ConfigurationProperties config; + + /** + * Bypass {@link OAuth2Configuration} since + * {@code georchestra.gateway.security.oauth2.enabled=false} but contribute + * {@link OAuth2ConfigurationProperties} and {@link OpenIdConnectUserMapper} to + * resolve tokens from {@link WithMockOidcUser @WithMockOidcUser}. + */ + @Configuration + @EnableConfigurationProperties({ OAuth2ConfigurationProperties.class }) + static class ForceOidcUserMapperBean { + @Bean + OAuth2UserMapper oAuth2GeorchestraUserUserMapper(OAuth2ConfigurationProperties config) { + return new OAuth2UserMapper(config); + } + + @Bean + OpenIdConnectUserMapper openIdConnectGeorchestraUserUserMapper(OAuth2ConfigurationProperties config) { + return new OpenIdConnectUserMapper(config); + } + } + + private void verifyMappedUser(String expected) { + testClient.get().uri("/whoami").exchange()// + .expectStatus().isOk()// + .expectBody()// +// .consumeWith(e -> System.err.println(new String(e.getResponseBodyContent()))) + .json(expected); + + } + + @WithMockUser(authorities = { "GP.TEST SAMPLE" }) + public @Test void role_prefix_added_but_role_name_not_changed_if_token_is_not_oauth() { + + verifyMappedUser("{\"GeorchestraUser\":{\"roles\":[\"ROLE_GP.TEST SAMPLE\"]}}"); + } + + @WithMockOAuth2User(authorities = { "GP.TEST SAMPLE", "OAuth2 Sample Authority" }) + public @Test void oauth2_provided_authorities_are_normalized_and_prefixed() { + + verifyMappedUser("{\"GeorchestraUser\":{\"username\":\"test-user\"," + + "\"roles\":[\"ROLE_GP.TEST_SAMPLE\",\"ROLE_OAUTH2_SAMPLE_AUTHORITY\"]}}"); + } + + @WithMockOAuth2User(authorities = { "GP.TEST SAMPLE", "OAuth2 Sample Authority" }) + public @Test void oauth2_provided_authorities_are_prefixed_but_not_normalized() { + config.getRoles().setNormalize(false); + config.getRoles().setUppercase(false); + + verifyMappedUser("{\"GeorchestraUser\":{\"username\":\"test-user\"," + + "\"roles\":[\"ROLE_GP.TEST SAMPLE\",\"ROLE_OAuth2 Sample Authority\"]}}"); + } + + /** + * The provided {@link OAuth2AuthenticationToken#getAuthorities() authority + * names} are normalized, + * {@code georchestra.gateway.security.oidc.roles.normalize|uppercase} only + * apply to roles extracted from non standard claims ( + * {@code georchestra.gateway.security.oidc.roles.json.path.*}) + * + */ + @WithMockOidcUser(authorities = { "GP.OIDC ROLE1" }) + public @Test void oidc_provided_authorities_are_normalized_and_prefixed() { + + config.getOidc().getClaims().getRoles().setNormalize(true); + config.getOidc().getClaims().getRoles().setUppercase(true); + + verifyMappedUser("{\"GeorchestraUser\":{\"roles\":[\"ROLE_GP.OIDC_ROLE1\"]}}"); + } + + @WithMockOidcUser(authorities = { "GP.OIDC role1" }) + public @Test void oidc_provided_authorities_are_prefixed_but_not_normalized() { + + config.getOidc().getClaims().getRoles().setNormalize(false); + config.getOidc().getClaims().getRoles().setUppercase(false); + + verifyMappedUser("{\"GeorchestraUser\":{\"roles\":[\"ROLE_GP.OIDC role1\"]}}"); + } + + @WithMockOidcUser(// + authorities = { "AUTHORITY_1" }, // + nonStandardClaims = { // + "permission=GP.OIDC.role 1, GP.OIDC.role 2"// + }) + public @Test void oidc_roles_from_non_standard_claims_normalized_and_role_prefix_added() { + + OidcRolesMappingConfig oidcRolesMappingConfig = config.getOidc().getClaims().getRoles(); + oidcRolesMappingConfig.getJson().setSplit(true); + oidcRolesMappingConfig.getJson().getPath().add("$.permission"); + oidcRolesMappingConfig.setNormalize(true); + oidcRolesMappingConfig.setUppercase(true); + + verifyMappedUser("{\"GeorchestraUser\":{\"username\":\"testclient_user\"," + + "\"roles\":[\"ROLE_AUTHORITY_1\",\"ROLE_GP.OIDC.ROLE_1\",\"ROLE_GP.OIDC.ROLE_2\"]}}"); + } + + @WithMockOidcUser(// + authorities = { "AUTHORITY_1" }, // + nonStandardClaims = { // + "permission=GP.OIDC.ROLE 1, GP.OIDC.ROLE 2"// + }) + public @Test void oidc_roles_from_non_standard_claims_prefixed_but_not_normalized() { + + OidcRolesMappingConfig oidcRolesMappingConfig = config.getOidc().getClaims().getRoles(); + oidcRolesMappingConfig.setNormalize(false); + oidcRolesMappingConfig.setUppercase(false); + oidcRolesMappingConfig.getJson().setSplit(true); + oidcRolesMappingConfig.getJson().getPath().add("$.permission"); + + verifyMappedUser("{\"GeorchestraUser\":{\"username\":\"testclient_user\"," + + "\"roles\":[\"ROLE_AUTHORITY_1\",\"ROLE_GP.OIDC.ROLE 1\",\"ROLE_GP.OIDC.ROLE 2\"]}}"); + } +} diff --git a/gateway/src/test/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizerIT.java b/gateway/src/test/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizerIT.java index 7ee5ed25..40dc32d3 100644 --- a/gateway/src/test/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizerIT.java +++ b/gateway/src/test/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizerIT.java @@ -28,6 +28,7 @@ import java.net.URI; import org.georchestra.gateway.app.GeorchestraGatewayApplication; +import org.georchestra.gateway.security.MockUserGeorchestraUserMapperConfiguration; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.springframework.beans.factory.annotation.Autowired; @@ -51,10 +52,11 @@ * the default {@literal gateway.yml} config file. * */ -@SpringBootTest(classes = GeorchestraGatewayApplication.class, webEnvironment = WebEnvironment.MOCK, properties = { - "georchestra.datadir=../datadir"// - , "georchestra.gateway.security.ldap.default.enabled=false"// -}) +@SpringBootTest(classes = { GeorchestraGatewayApplication.class, + MockUserGeorchestraUserMapperConfiguration.class }, webEnvironment = WebEnvironment.MOCK, properties = { + "georchestra.datadir=../datadir", // + "georchestra.gateway.security.ldap.default.enabled=false", // + "georchestra.gateway.security.oauth2.enabled=false" }) @AutoConfigureWebTestClient(timeout = "PT20S") @ActiveProfiles("it") @Slf4j @@ -67,7 +69,7 @@ class AccessRulesCustomizerIT { /** * Configure the target service mappings to call the {@link #mockService} at its * dynamically allocated port - * + * * @see #mockServiceTarget */ @DynamicPropertySource @@ -76,14 +78,14 @@ static void registerPgProperties(DynamicPropertyRegistry registry) { mockService.getRuntimeInfo().getHttpBaseUrl()); mockServiceTarget(registry, "header", "/header"); + mockServiceTarget(registry, "mapfishapp", "/mapfishapp"); mockServiceTarget(registry, "geoserver", "/geoserver"); mockServiceTarget(registry, "console", "/console"); mockServiceTarget(registry, "analytics", "/analytics"); mockServiceTarget(registry, "datafeeder", "/datafeeder"); mockServiceTarget(registry, "import", "/import"); + mockServiceTarget(registry, "atlas", "/atlas"); mockServiceTarget(registry, "geowebcache", "/geowebcache"); - mockServiceTarget(registry, "geonetwork", "/geonetwork"); - mockServiceTarget(registry, "mapstore", "/mapstore"); } /** @@ -94,11 +96,11 @@ static void registerPgProperties(DynamicPropertyRegistry registry) { * For example, if the Wiremock service is at port 7654, for the * {@literal header} service with {@literal /header} target URI, the config * property would be: - * + * *
      * {@code georchestra.gateway.services.header.target=http://localhost:7654/header}
      * 
- * + * * @param registry the dynamic property source to contribute to the * application context's environment * @param serviceName the name of the service for a @@ -138,9 +140,71 @@ private static void mockServiceTarget(DynamicPropertyRegistry registry, String s } /** + * Revisit: not sure how to force a 403 (Forbidden) instead of a redirect to the + * login page when not yet authenticated + * *
      * {@code
-     * georchestra.gateway.services.import:
+     * georchestra.gateway.services.mapfishapp: 
+     *   access-rules:
+     *   - intercept-url: /mapfishapp/ogcproxy/**
+     *     forbidden: true
+     *   - intercept-url: /**
+     *     anonymous: true
+     * }
+     */
+    public @Test void testMapfishApp_ogproxy_access_denied_to_anonymous() {
+        mockService.stubFor(get(urlMatching("/mapfishapp/ogcproxy(/.*)?")).willReturn(ok()));
+        mockService.stubFor(get(urlMatching("/mapfishapp(/.*)?")).willReturn(ok()));
+
+        testClient.get().uri("/mapfishapp/ogcproxy")//
+                .header("accept", "text/html")//
+                .exchange()//
+                .expectHeader().location("/login");
+
+        testClient.get().uri("/mapfishapp/ogcproxy/somethingprivate")//
+                .header("accept", "text/html")//
+                .exchange()//
+                .expectHeader().location("/login");
+
+        testClient.get().uri("/mapfishapp/somethingpublic")//
+                .exchange()//
+                .expectStatus().isOk();
+    }
+
+    /**
+     * 
+     * {@code
+     * georchestra.gateway.services.mapfishapp: 
+     *   access-rules:
+     *   - intercept-url: /mapfishapp/ogcproxy/**
+     *     forbidden: true
+     *   - intercept-url: /**
+     *     anonymous: true
+     * }
+     */
+    @WithMockUser(authorities = { "USER" })
+    public @Test void testMapfishApp_ogproxy_access_denied_to_authenticated_user() {
+        mockService.stubFor(get(urlMatching("/mapfishapp/ogcproxy(/.*)?")).willReturn(ok()));
+        mockService.stubFor(get(urlMatching("/mapfishapp(/.*)?")).willReturn(ok()));
+
+        testClient.get().uri("/mapfishapp/ogcproxy")//
+                .exchange()//
+                .expectStatus().isForbidden();
+
+        testClient.get().uri("/mapfishapp/ogcproxy/test")//
+                .exchange()//
+                .expectStatus().isForbidden();
+
+        testClient.get().uri("/mapfishapp/should_be_ok")//
+                .exchange()//
+                .expectStatus().isOk();
+    }
+
+    /**
+     * 
+     * {@code
+     * georchestra.gateway.services.import: 
      *   access-rules:
      *   - intercept-url: /import/**
      *     anonymous: false
@@ -150,24 +214,25 @@ private static void mockServiceTarget(DynamicPropertyRegistry registry, String s
         mockService.stubFor(get(urlMatching("/import(/.*)?")).willReturn(noContent()));
 
         testClient.get().uri("/import")//
+                .header("Accept", "text/html")//
                 .exchange()//
-                .expectStatus().isFound();
+                .expectHeader().location("/login");
 
         testClient.get().uri("/import/any/thing")//
-                .exchange()//
-                .expectStatus().isFound();
+                .header("Accept", "text/html").exchange()//
+                .expectHeader().location("/login");
     }
 
     /**
      * 
      * {@code
-     * georchestra.gateway.services.import:
+     * georchestra.gateway.services.import: 
      *   access-rules:
      *   - intercept-url: /import/**
      *     anonymous: false
      * }
      */
-    @WithMockUser(authorities = { "ROLE_DOESNTMATTER" })
+    @WithMockUser(authorities = { "DOESNTMATTER" })
     public @Test void testService_requires_any_authenticated_user() {
         mockService.stubFor(get(urlMatching("/import(/.*)?")).willReturn(ok()));
 
@@ -183,13 +248,13 @@ private static void mockServiceTarget(DynamicPropertyRegistry registry, String s
     /**
      * 
      * {@code
-     * georchestra.gateway.services.analytics:
+     * georchestra.gateway.services.analytics: 
      *   access-rules:
      *   - intercept-url: /analytics/**
      *     allowed-roles: SUPERUSER,ORGADMIN
      * }
      */
-    @WithMockUser(authorities = { "ROLE_USER", "ROLE_EDITOR" })
+    @WithMockUser(authorities = { "USER", "EDITOR" })
     public @Test void testService_requires_specific_role_forbidden_for_non_matching_roles() {
         mockService.stubFor(get(urlMatching("/analytics(/.*)?")).willReturn(ok()));
 
@@ -205,13 +270,13 @@ private static void mockServiceTarget(DynamicPropertyRegistry registry, String s
     /**
      * 
      * {@code
-     * georchestra.gateway.services.analytics:
+     * georchestra.gateway.services.analytics: 
      *   access-rules:
      *   - intercept-url: /analytics/**
      *     allowed-roles: SUPERUSER,ORGADMIN
      * }
      */
-    @WithMockUser(authorities = { "ROLE_USER", "ROLE_ORGADMIN" })
+    @WithMockUser(authorities = { "ORGADMIN" })
     public @Test void testService_requires_specific_role_allowed_for_matching_roles() {
         mockService.stubFor(get(urlMatching("/analytics(/.*)?")).willReturn(ok()));
 
@@ -227,7 +292,7 @@ private static void mockServiceTarget(DynamicPropertyRegistry registry, String s
     /**
      * 
      * {@code
-     * georchestra.gateway.services.analytics:
+     * georchestra.gateway.services.analytics: 
      *   access-rules:
      *   - intercept-url: /analytics/**
      *     allowed-roles: SUPERUSER,ORGADMIN
@@ -237,12 +302,14 @@ private static void mockServiceTarget(DynamicPropertyRegistry registry, String s
         mockService.stubFor(get(urlMatching("/analytics(/.*)?")).willReturn(ok()));
 
         testClient.get().uri("/analytics")//
+                .header("Accept", "text/html")//
                 .exchange()//
-                .expectStatus().isFound();
+                .expectHeader().location("/login");
 
         testClient.get().uri("/analytics/any/thing")//
+                .header("Accept", "text/html")//
                 .exchange()//
-                .expectStatus().isFound();
+                .expectHeader().location("/login");
     }
 
     /**
@@ -254,47 +321,20 @@ private static void mockServiceTarget(DynamicPropertyRegistry registry, String s
      * 	      - /**
      * 	      anonymous: true
      * 	    services:
-     * 	      mapstore:
-     * 	        target: http://mapstore:8080/mapstore/
+     * 	      atlas: 
+     * 	        target: http://atlas:8080/atlas/
      * }
      * 
*/ @Test void testGlobalAccessRule() { - mockService.stubFor(get(urlMatching("/mapstore(/.*)?")).willReturn(ok())); - - testClient.get().uri("/mapstore")// - .exchange()// - .expectStatus().isOk(); - - testClient.get().uri("/mapstore/any/thing")// - .exchange()// - .expectStatus().isOk(); - } - - @Test - void testQueryParamAuthentication_forbidden_when_anonymous() { - mockService.stubFor(get(urlMatching("/header(.*)?")).willReturn(ok())); - - testClient.get().uri("/header?login")// - .exchange()// - .expectStatus().is3xxRedirection(); - - testClient.get().uri("/header")// - .exchange()// - .expectStatus().isOk(); - } - - @Test - @WithMockUser(authorities = { "ROLE_USER" }) - void testQueryParamAuthentication_authorized_if_logged_in() { - mockService.stubFor(get(urlMatching("/header(.*)?")).willReturn(ok())); + mockService.stubFor(get(urlMatching("/atlas(/.*)?")).willReturn(ok())); - testClient.get().uri("/header?login")// + testClient.get().uri("/atlas")// .exchange()// .expectStatus().isOk(); - testClient.get().uri("/header")// + testClient.get().uri("/atlas/any/thing")// .exchange()// .expectStatus().isOk(); } diff --git a/gateway/src/test/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizerTest.java b/gateway/src/test/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizerTest.java index 1727aaeb..2934c1b6 100644 --- a/gateway/src/test/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizerTest.java +++ b/gateway/src/test/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizerTest.java @@ -148,7 +148,7 @@ void testApplyRule_anonymous_has_precedence_over_roles_list() { verify(customizer, times(1)).authorizeExchange(same(spec), eq(List.of("/test/**", "/page1"))); verify(customizer, times(1)).permitAll(any()); verify(customizer, times(0)).requireAuthenticatedUser(any()); - verify(customizer, times(0)).hasAnyAuthority(any(), any()); + verify(customizer, times(0)).hasAnyRole(any(), any()); } @Test @@ -173,7 +173,7 @@ void testApplyRule_roles() { customizer.apply(spec, rule); verify(customizer, times(1)).authorizeExchange(same(spec), eq(List.of("/test/**", "/page1"))); - verify(customizer, times(1)).hasAnyAuthority(any(), eq(roles)); + verify(customizer, times(1)).hasAnyRole(any(), eq(roles)); } @Test @@ -187,7 +187,7 @@ void testApplyRule_roles_prefix_added_if_missing() { customizer.apply(spec, rule); verify(customizer, times(1)).authorizeExchange(same(spec), eq(List.of("/test/**", "/page1"))); - verify(customizer, times(1)).hasAnyAuthority(any(), eq(expected)); + verify(customizer, times(1)).hasAnyRole(any(), eq(expected)); } @Test @@ -199,7 +199,7 @@ void testApplyRule_forbidden() { verify(customizer, times(1)).denyAll(any()); verify(customizer, never()).requireAuthenticatedUser(any()); - verify(customizer, never()).hasAnyAuthority(any(), any()); + verify(customizer, never()).hasAnyRole(any(), any()); verify(customizer, never()).permitAll(any()); } diff --git a/gateway/src/test/java/org/georchestra/gateway/security/accessrules/GeorchestraUserRolesAuthorizationManagerTest.java b/gateway/src/test/java/org/georchestra/gateway/security/accessrules/GeorchestraUserRolesAuthorizationManagerTest.java index 75a9fa6e..2c6e288c 100644 --- a/gateway/src/test/java/org/georchestra/gateway/security/accessrules/GeorchestraUserRolesAuthorizationManagerTest.java +++ b/gateway/src/test/java/org/georchestra/gateway/security/accessrules/GeorchestraUserRolesAuthorizationManagerTest.java @@ -44,28 +44,28 @@ void setup() { user = new GeorchestraUser(); when(userMapper.resolve(any())).thenReturn(Optional.of(user)); - authManager = GeorchestraUserRolesAuthorizationManager.hasAnyAuthority(userMapper, "GDI_ADMIN", "SUPERUSER", + authManager = GeorchestraUserRolesAuthorizationManager.hasAnyRole(userMapper, "GDI_ADMIN", "SUPERUSER", "ROLE_ADMIN"); } - private TestingAuthenticationToken authentication(String... roles) { - return new TestingAuthenticationToken("gabe", null, roles); + private TestingAuthenticationToken authentication(String... authorities) { + return new TestingAuthenticationToken("gabe", null, authorities); } @Test - void hasAnyAuthority_notAuthenticated() { + void hasAnyRole_notAuthenticated() { TestingAuthenticationToken authentication = authentication(); authentication.setAuthenticated(false); assertThat(authManager.authorize(authentication)).isFalse(); } @Test - void hasAnyAuthority() { + void hasAnyRole() { TestingAuthenticationToken authentication = authentication("ROLE_USER"); - user.setRoles(List.of("ROLE_USER", "GDI_ADMIN")); + user.setRoles(List.of("ROLE_USER", "ROLE_GDI_ADMIN")); assertThat(authManager.authorize(authentication)).isTrue(); - user.setRoles(List.of("ROLE_USER", "SUPERUSER")); + user.setRoles(List.of("ROLE_USER", "ROLE_SUPERUSER")); assertThat(authManager.authorize(authentication)).isTrue(); user.setRoles(List.of("ROLE_USER", "ROLE_ADMIN")); @@ -76,14 +76,14 @@ void hasAnyAuthority() { } @Test - void hasAnyAuthority_joins_user_and_authentication_authorities() { - TestingAuthenticationToken authentication = authentication("GDI_ADMIN"); + void hasAnyRole_joins_user_and_authentication_authorities() { + TestingAuthenticationToken authentication = authentication("ROLE_GDI_ADMIN"); user.setRoles(List.of("ROLE_USER")); assertThat(authManager.authorize(authentication)).isTrue(); } @Test - void hasAnyAuthority_noResolvedUser_nor_grantedAuthorities() { + void hasAnyRole_noResolvedUser_nor_grantedAuthorities() { TestingAuthenticationToken authentication = authentication(); when(userMapper.resolve(any())).thenReturn(Optional.empty()); @@ -91,8 +91,8 @@ void hasAnyAuthority_noResolvedUser_nor_grantedAuthorities() { } @Test - void hasAnyAuthority_noResolvedUser_resolved_grantedAuthorities() { - TestingAuthenticationToken authentication = authentication("GDI_ADMIN"); + void hasAnyRole_noResolvedUser_resolved_grantedAuthorities() { + TestingAuthenticationToken authentication = authentication("ROLE_GDI_ADMIN"); when(userMapper.resolve(any())).thenReturn(Optional.empty()); assertThat(authManager.authorize(authentication)).isTrue(); diff --git a/gateway/src/test/java/org/georchestra/gateway/security/oauth2/OpenIdConnectUserMapperTest.java b/gateway/src/test/java/org/georchestra/gateway/security/oauth2/OpenIdConnectUserMapperTest.java index cc2d8dcf..f0576b40 100644 --- a/gateway/src/test/java/org/georchestra/gateway/security/oauth2/OpenIdConnectUserMapperTest.java +++ b/gateway/src/test/java/org/georchestra/gateway/security/oauth2/OpenIdConnectUserMapperTest.java @@ -27,6 +27,8 @@ import java.util.List; import java.util.Map; +import org.georchestra.gateway.security.oauth2.OAuth2ConfigurationProperties.OpenIdConnectCustomClaimsConfigProperties; +import org.georchestra.gateway.security.oauth2.OAuth2ConfigurationProperties.OpenIdConnectCustomClaimsConfigProperties.JsonPathExtractor; import org.georchestra.security.model.GeorchestraUser; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -49,8 +51,9 @@ class OpenIdConnectUserMapperTest { */ @BeforeEach void setUp() throws Exception { - nonStandardClaimsConfig = new OpenIdConnectCustomClaimsConfigProperties(); - mapper = new OpenIdConnectUserMapper(nonStandardClaimsConfig); + OAuth2ConfigurationProperties config = new OAuth2ConfigurationProperties(); + nonStandardClaimsConfig = config.getOidc().getClaims(); + mapper = new OpenIdConnectUserMapper(config); } @Test @@ -128,7 +131,7 @@ void applyNonStandardClaims_jsonPath_nested_array_multiple_values_to_roles() thr GeorchestraUser target = new GeorchestraUser(); mapper.applyNonStandardClaims(claims, target); - List expected = List.of("GDI_PLANER_EXTERN", "GDI_EDITOR_EXTERN"); + List expected = List.of("GDI_EDITOR_EXTERN", "GDI_PLANER_EXTERN"); List actual = target.getRoles(); assertEquals(expected, actual); } @@ -160,7 +163,61 @@ void applyNonStandardClaims_jsonPath_multiple_json_paths() throws ParseException GeorchestraUser target = new GeorchestraUser(); mapper.applyNonStandardClaims(claims, target); - List expected = List.of("ORG_6007280321", "GDI_PLANER_EXTERN", "GDI_EDITOR_EXTERN"); + List expected = List.of("GDI_EDITOR_EXTERN", "GDI_PLANER_EXTERN", "ORG_6007280321"); + List actual = target.getRoles(); + assertEquals(expected, actual); + } + + @Test + void applyNonStandardClaims_jsonPath_comma_separated_values_to_roles() throws ParseException { + + final String jsonPath = "$.permission"; + final String groupsJsonPath = "$.groups_json..['name']"; + + JsonPathExtractor jsonpathConfig = nonStandardClaimsConfig.getRoles().getJson(); + jsonpathConfig.getPath().add(jsonPath); + jsonpathConfig.getPath().add(groupsJsonPath); + jsonpathConfig.setSplit(true); + + // duplicate values in .$groups_json..['name'] and .$permission should be + // deduplicated. Note "GDI.Editor (extern)" and "GDI Editor (extern)" should + // both be mapped to GDI_EDITOR_EXTERN + final String json = // + "{" // + + "'groups_json': [ [ " // + + " {'name': 'GDI Planer (extern)'}, " // + + " {'name': 'GDI.Editor (extern)'} " // + + "] ] " // + + ",'permission': 'GDI Planer (extern), GDI.Editor (extern), GDI admin'" // + + ",'PartyOrganisationID': '6007280321'"// + + "}"; + + Map claims = sampleClaims(json); + + GeorchestraUser target = new GeorchestraUser(); + mapper.applyNonStandardClaims(claims, target); + + List expected = List.of("GDI.EDITOR_EXTERN", "GDI_ADMIN", "GDI_PLANER_EXTERN"); + List actual = target.getRoles(); + assertEquals(expected, actual); + } + + @Test + void applyNonStandardClaims_jsonPath_multiple_jsonpaths_deduplicates() throws ParseException { + + final String jsonPath = "$.permission"; + nonStandardClaimsConfig.getRoles().getJson().getPath().add(jsonPath); + nonStandardClaimsConfig.getRoles().getJson().setSplit(true); + + final String json = // + "{'permission': 'GDI Planer (extern), GDI Planer (extern), GDI Editor (extern), GDI Editor (extern)'}"; + + Map claims = sampleClaims(json); + + GeorchestraUser target = new GeorchestraUser(); + mapper.applyNonStandardClaims(claims, target); + + List expected = List.of("GDI_EDITOR_EXTERN", "GDI_PLANER_EXTERN"); List actual = target.getRoles(); assertEquals(expected, actual); } diff --git a/gateway/src/test/java/org/georchestra/gateway/security/preauth/PreauthAccessRuleCustomizerIT.java b/gateway/src/test/java/org/georchestra/gateway/security/preauth/PreauthAccessRuleCustomizerIT.java new file mode 100644 index 00000000..c686a496 --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/security/preauth/PreauthAccessRuleCustomizerIT.java @@ -0,0 +1,106 @@ +package org.georchestra.gateway.security.preauth; + +import lombok.extern.slf4j.Slf4j; +import org.georchestra.gateway.app.GeorchestraGatewayApplication; +import org.georchestra.testcontainers.ldap.GeorchestraLdapContainer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import java.util.Arrays; + +@SpringBootTest(classes = GeorchestraGatewayApplication.class) +@AutoConfigureWebTestClient(timeout = "PT20S") +@ActiveProfiles({ "preauth", "preauth-with-testcontainer-ldap", "echoservice" }) +@Slf4j +public class PreauthAccessRuleCustomizerIT { + + private @Autowired WebTestClient testClient; + + public static GeorchestraLdapContainer ldap = new GeorchestraLdapContainer(); + + public static GenericContainer httpEcho = new GenericContainer(DockerImageName.parse("ealen/echo-server")) { + @Override + protected void doStart() { + super.doStart(); + Integer mappedPort = this.getMappedPort(80); + System.setProperty("httpEchoHost", this.getHost()); + System.setProperty("httpEchoPort", mappedPort.toString()); + System.out.println("Automatically set system property httpEchoHost=" + this.getHost()); + System.out.println("Automatically set system property httpEchoPort=" + mappedPort); + } + }; + + public static @BeforeAll void startUpContainers() { + httpEcho.setExposedPorts(Arrays.asList(new Integer[] { 80 })); + httpEcho.start(); + ldap.start(); + } + + public static @AfterAll void shutDownContainers() { + ldap.stop(); + httpEcho.stop(); + } + + public @Test void testAdminAccess_NoAuthoritiesButAdminGeorchestraRoleFromLdap() { + // The intent of this test is that even if the authorities are not populated in + // case of a PreAuthenticatedAuthenticationToken + // being used, we can still access a protected resource (e.g. reserved to an + // admin) because the gateway + // will make use of the resolved roles onto the GeorchestraUser object. + testClient.get().uri("/whoami")// + .header("sec-georchestra-preauthenticated", "true")// + .header("preauth-username", "testadmin")// + .header("preauth-email", "psc+testadmin@georchestra.org")// + .header("preauth-firstname", "Admin")// + .header("preauth-lastname", "Test")// + .header("preauth-org", "GEORCHESTRA")// + .exchange()// + .expectBody().jsonPath(".GeorchestraUser.roles").isNotEmpty()// + .jsonPath( + ".'org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken'.authorities") + .isEmpty(); + + testClient.get().uri("/echo/administrator")// + .header("sec-georchestra-preauthenticated", "true")// + .header("preauth-username", "testadmin")// + .header("preauth-email", "psc+testadmin@georchestra.org")// + .header("preauth-firstname", "Admin")// + .header("preauth-lastname", "Test")// + .header("preauth-org", "GEORCHESTRA").exchange()// + .expectStatus()// + .is2xxSuccessful(); + } + + public @Test void testAuthenticatedAccess_NoExplicitGroupNeeded() { + testClient.get().uri("/echo/connected")// + .header("sec-georchestra-preauthenticated", "true")// + .header("preauth-username", "testuser")// + .header("preauth-email", "psc+testuser@georchestra.org")// + .header("preauth-firstname", "User")// + .header("preauth-lastname", "Test")// + .header("preauth-org", "GEORCHESTRA").exchange()// + .expectStatus()// + .is2xxSuccessful(); + } + + public @Test void testAnonymousAccess_BeingConnectedAsAdmin() { + testClient.get().uri("/echo/anonymous")// + .header("sec-georchestra-preauthenticated", "true")// + .header("preauth-username", "testadmin")// + .header("preauth-email", "psc+testadmin@georchestra.org")// + .header("preauth-firstname", "Admin")// + .header("preauth-lastname", "Test")// + .header("preauth-org", "GEORCHESTRA").exchange()// + .expectStatus()// + .is2xxSuccessful(); + } + +} diff --git a/gateway/src/test/java/org/georchestra/gateway/test/context/support/WithMockOAuth2SecurityContextFactory.java b/gateway/src/test/java/org/georchestra/gateway/test/context/support/WithMockOAuth2SecurityContextFactory.java new file mode 100644 index 00000000..e5b4acd5 --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/test/context/support/WithMockOAuth2SecurityContextFactory.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2023 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra 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 3 of the License, or (at your option) + * any later version. + * + * geOrchestra 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 + * geOrchestra. If not, see . + */ +package org.georchestra.gateway.test.context.support; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.test.context.support.WithSecurityContextFactory; +import org.springframework.util.Assert; + +/** + * {@link WithSecurityContextFactory} for {@link WithMockOAuth2User} + */ +class WithMockOAuth2SecurityContextFactory implements WithSecurityContextFactory { + + @Override + public SecurityContext createSecurityContext(WithMockOAuth2User withUser) { + Collection authorities = AuthorityUtils.createAuthorityList(withUser.authorities()); + Map attributes = toAttributes(withUser); + String nameAttributeKey = withUser.nameAttributeKey(); + + OAuth2User principal = new DefaultOAuth2User(authorities, attributes, nameAttributeKey); + String clientRegistrationId = withUser.clientRegistrationId(); + OAuth2AuthenticationToken token = new OAuth2AuthenticationToken(principal, authorities, clientRegistrationId); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(token); + return context; + } + + private Map toAttributes(WithMockOAuth2User withUser) { + Map attributes = new HashMap<>(); + attributes.put("login", withUser.login()); + attributes.put("email", withUser.email()); + if (null != withUser.attributes()) { + Stream.of(withUser.attributes()).peek(kvp -> { + if (!kvp.matches("(.*)=(.*)")) { + throw new IllegalArgumentException("attributes have to be provided as key=value pairs, got " + kvp); + } + }).map(kvp -> kvp.split("=")).peek( + arr -> Assert.isTrue(arr.length == 2, "attributes have to be provided as key=value pairs, got " + + Stream.of(arr).collect(Collectors.joining("=")))) + .forEach(arr -> { + String attribute = arr[0].trim(); + String value = arr[1].trim(); + attributes.put(attribute, value); + }); + } + return attributes; + } + +} \ No newline at end of file diff --git a/gateway/src/test/java/org/georchestra/gateway/test/context/support/WithMockOAuth2User.java b/gateway/src/test/java/org/georchestra/gateway/test/context/support/WithMockOAuth2User.java new file mode 100644 index 00000000..931ca2c0 --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/test/context/support/WithMockOAuth2User.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2023 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra 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 3 of the License, or (at your option) + * any later version. + * + * geOrchestra 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 + * geOrchestra. If not, see . + */ +package org.georchestra.gateway.test.context.support; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.test.context.support.TestExecutionEvent; +import org.springframework.security.test.context.support.WithSecurityContext; +import org.springframework.test.context.TestContext; + +/** + */ +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +@WithSecurityContext(factory = WithMockOAuth2SecurityContextFactory.class) +public @interface WithMockOAuth2User { + + /** + * {@link OAuth2AuthenticationToken#getAuthorizedClientRegistrationId()} + */ + String clientRegistrationId() default "testclient"; + + /** + * The authorities to use. A {@link GrantedAuthority} will be created for each + * value on {@link OAuth2AuthenticationToken#getAuthorities()} + */ + String[] authorities() default {}; + + /** + * List of key/value pairs for additional {@link OAuth2User#getAttributes() + * OAuth2User attributes} as a list of {@code key=value} properties + */ + String[] attributes() default {}; + + /** + * The key used to access the user's "name" from + * {@link OAuth2User#getAttributes()} + * + * @return + */ + String nameAttributeKey() default "login"; + + /** + * Value for the {@code login} attribute + */ + String login() default "test-user"; + + /** + * Value for the {@code email} attribute + */ + String email() default ""; + + /** + * Determines when the {@link SecurityContext} is setup. The default is before + * {@link TestExecutionEvent#TEST_METHOD} which occurs during + * {@link org.springframework.test.context.TestExecutionListener#beforeTestMethod(TestContext)} + * + * @return the {@link TestExecutionEvent} to initialize before + * @since 5.1 + */ + @AliasFor(annotation = WithSecurityContext.class) + TestExecutionEvent setupBefore() default TestExecutionEvent.TEST_METHOD; + +} diff --git a/gateway/src/test/java/org/georchestra/gateway/test/context/support/WithMockOidcSecurityContextFactory.java b/gateway/src/test/java/org/georchestra/gateway/test/context/support/WithMockOidcSecurityContextFactory.java new file mode 100644 index 00000000..9d79afb4 --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/test/context/support/WithMockOidcSecurityContextFactory.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2023 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra 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 3 of the License, or (at your option) + * any later version. + * + * geOrchestra 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 + * geOrchestra. If not, see . + */ +package org.georchestra.gateway.test.context.support; + +import java.util.Collection; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcIdToken.Builder; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.test.context.support.WithSecurityContextFactory; +import org.springframework.util.Assert; + +/** + * {@link WithSecurityContextFactory} for {@link WithMockOidcUser} + */ +class WithMockOidcSecurityContextFactory implements WithSecurityContextFactory { + + @Override + public SecurityContext createSecurityContext(WithMockOidcUser withUser) { + Collection authorities = AuthorityUtils.createAuthorityList(withUser.authorities()); + + Builder builder = OidcIdToken.withTokenValue("test-token-value"); + addStandardClaims(builder, withUser); + addNonStandardClaims(builder, withUser); + OidcIdToken idToken = builder.build(); + + DefaultOidcUser principal = new DefaultOidcUser(authorities, idToken); + String clientRegistrationId = withUser.clientRegistrationId(); + OAuth2AuthenticationToken token = new OAuth2AuthenticationToken(principal, authorities, clientRegistrationId); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(token); + return context; + } + + private void addNonStandardClaims(Builder builder, WithMockOidcUser withUser) { + String[] nonStandardClaims = withUser.nonStandardClaims(); + if (null == nonStandardClaims) { + return; + } + Stream.of(nonStandardClaims).peek(kvp -> { + if (!kvp.matches("(.*)=(.*)")) { + throw new IllegalArgumentException( + "non standard claims have to be provided as key=value pairs, got " + kvp); + } + }).map(kvp -> kvp.split("=")).peek( + arr -> Assert.isTrue(arr.length == 2, "non standard claims have to be provided as key=value pairs, got " + + Stream.of(arr).collect(Collectors.joining("=")))) + .forEach(arr -> { + String claim = arr[0].trim(); + String value = arr[1].trim(); + builder.claim(claim, value); + }); + } + + private void addStandardClaims(Builder builder, WithMockOidcUser withUser) { + builder.subject(withUser.sub()).claim("preferred_username", withUser.preferred_username()) + .claim("name", withUser.name()).claim("given_name", withUser.given_name()) + .claim("family_name", withUser.family_name()).claim("middle_name", withUser.middle_name()) + .claim("nickname", withUser.nickname()).claim("profile", withUser.profile()) + .claim("picture", withUser.picture()).claim("website", withUser.website()) + .claim("email", withUser.email()).claim("email_verified", withUser.email_verified()) + .claim("gender", withUser.gender()).claim("birthdate", withUser.birthdate()) + .claim("locale", withUser.locale()).claim("phone_number", withUser.phone_number()) + .claim("phone_number_verified", withUser.phone_number_verified()) +// .claim("address", withUser.address()) + .claim("updated_at", withUser.updated_at()); + } + +} \ No newline at end of file diff --git a/gateway/src/test/java/org/georchestra/gateway/test/context/support/WithMockOidcUser.java b/gateway/src/test/java/org/georchestra/gateway/test/context/support/WithMockOidcUser.java new file mode 100644 index 00000000..f8fb103a --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/test/context/support/WithMockOidcUser.java @@ -0,0 +1,317 @@ +/* + * Copyright (C) 2023 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra 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 3 of the License, or (at your option) + * any later version. + * + * geOrchestra 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 + * geOrchestra. If not, see . + */ +package org.georchestra.gateway.test.context.support; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Map; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.test.context.support.TestExecutionEvent; +import org.springframework.security.test.context.support.WithSecurityContext; +import org.springframework.test.context.TestContext; + +/** + *
    + *
  • {@link #sub} string Subject - Identifier for the End-User at the Issuer. + *
  • {@link #name} string End-User's full name in displayable form including + * all name parts, possibly including titles and suffixes, ordered according to + * the End-User's locale and preferences. + *
  • {@link #given_name} string Given name(s) or first name(s) of the + * End-User. Note that in some cultures, people can have multiple given names; + * all can be present, with the names being separated by space characters. + *
  • {@link #family_name} string Surname(s) or last name(s) of the End-User. + * Note that in some cultures, people can have multiple family names or no + * family name; all can be present, with the names being separated by space + * characters. + *
  • {@link #middle_name} string Middle name(s) of the End-User. Note that in + * some cultures, people can have multiple middle names; all can be present, + * with the names being separated by space characters. Also note that in some + * cultures, middle names are not used. + *
  • {@link #nickname} string Casual name of the End-User that may or may not + * be the same as the given_name. For instance, a nickname value of Mike might + * be returned alongside a given_name value of Michael. + *
  • {@link #preferred_username} string Shorthand name by which the End-User + * wishes to be referred to at the RP, such as janedoe or j.doe. This value MAY + * be any valid JSON string including special characters such as @, /, or + * whitespace. The RP MUST NOT rely upon this value being unique, as discussed + * in Section 5.7. + *
  • {@link #profile} string URL of the End-User's profile page. The contents + * of this Web page SHOULD be about the End-User. + *
  • {@link #picture} string URL of the End-User's profile picture. This URL + * MUST refer to an image file (for example, a PNG, JPEG, or GIF image file), + * rather than to a Web page containing an image. Note that this URL SHOULD + * specifically reference a profile photo of the End-User suitable for + * displaying when describing the End-User, rather than an arbitrary photo taken + * by the End-User. + *
  • {@link #website} string URL of the End-User's Web page or blog. This Web + * page SHOULD contain information published by the End-User or an organization + * that the End-User is affiliated with. + *
  • {@link #email} string End-User's preferred e-mail address. Its value MUST + * conform to the RFC 5322 [RFC5322] addr-spec syntax. The RP MUST NOT rely upon + * this value being unique, as discussed in Section 5.7. + *
  • {@link #email_verified} boolean True if the End-User's e-mail address has + * been verified; otherwise false. When this Claim Value is true, this means + * that the OP took affirmative steps to ensure that this e-mail address was + * controlled by the End-User at the time the verification was performed. The + * means by which an e-mail address is verified is context-specific, and + * dependent upon the trust framework or contractual agreements within which the + * parties are operating. + *
  • {@link #gender} string End-User's gender. Values defined by this + * specification are female and male. Other values MAY be used when neither of + * the defined values are applicable. + *
  • {@link #birthdate} string End-User's birthday, represented as an ISO + * 8601:2004 [ISO8601‑2004] YYYY-MM-DD format. The year MAY be 0000, indicating + * that it is omitted. To represent only the year, YYYY format is allowed. Note + * that depending on the underlying platform's date related function, providing + * just year can result in varying month and day, so the implementers need to + * take this factor into account to correctly process the dates. + *
  • {@link #zoneinfo} string String from zoneinfo [zoneinfo] time zone + * database representing the End-User's time zone. For example, Europe/Paris or + * America/Los_Angeles. + *
  • {@link #locale} string End-User's locale, represented as a BCP47 + * [RFC5646] language tag. This is typically an ISO 639-1 Alpha-2 [ISO639‑1] + * language code in lowercase and an ISO 3166-1 Alpha-2 [ISO3166‑1] country code + * in uppercase, separated by a dash. For example, en-US or fr-CA. As a + * compatibility note, some implementations have used an underscore as the + * separator rather than a dash, for example, en_US; Relying Parties MAY choose + * to accept this locale syntax as well. + *
  • {@link #phone_number} string End-User's preferred telephone number. E.164 + * [E.164] is RECOMMENDED as the format of this Claim, for example, +1 (425) + * 555-1212 or +56 (2) 687 2400. If the phone number contains an extension, it + * is RECOMMENDED that the extension be represented using the RFC 3966 [RFC3966] + * extension syntax, for example, +1 (604) 555-1234;ext=5678. + *
  • {@link #phone_number_verified} boolean True if the End-User's phone + * number has been verified; otherwise false. When this Claim Value is true, + * this means that the OP took affirmative steps to ensure that this phone + * number was controlled by the End-User at the time the verification was + * performed. The means by which a phone number is verified is context-specific, + * and dependent upon the trust framework or contractual agreements within which + * the parties are operating. When true, the phone_number Claim MUST be in E.164 + * format and any extensions MUST be represented in RFC 3966 format. + *
  • {@link #address} JSON object End-User's preferred postal address. The + * value of the address member is a JSON [RFC4627] structure containing some or + * all of the members defined in Section 5.1.1. + *
  • {@link #updated_at} number Time the End-User's information was last + * updated. Its value is a JSON number representing the number of seconds from + * 1970-01-01T0:0:0Z as measured in UTC until the date/time. + *
+ */ +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +@WithSecurityContext(factory = WithMockOidcSecurityContextFactory.class) +public @interface WithMockOidcUser { + + /** + * {@link OAuth2AuthenticationToken#getAuthorizedClientRegistrationId()} + */ + String clientRegistrationId() default "testclient"; + + /** + * The authorities to use. A {@link GrantedAuthority} will be created for each + * value. + */ + String[] authorities() default {}; + + /** + * List of key/value pairs of non-standard claims + * + * @return + */ + String[] nonStandardClaims() default {}; + + /** + * {@code sub}: Subject - Identifier for the End-User at the Issuer. + */ + String sub() default "test-user"; + + /** + * {@code preferred_username}: Shorthand name by which the End-User wishes to be + * referred to at the RP, such as janedoe or j.doe. This value MAY be any valid + * JSON string including special characters such as @, /, or whitespace. The RP + * MUST NOT rely upon this value being unique, as discussed in Section 5.7. + */ + String preferred_username() default "user"; + + /** + * {@code name}: End-User's full name in displayable form including all name + * parts, possibly including titles and suffixes, ordered according to the + * End-User's locale and preferences. + */ + String name() default ""; + + /** + * {@code given_name}: Given name(s) or first name(s) of the End-User. + *

+ * Note that in some cultures, people can have multiple given names; all can be + * present, with the names being separated by space characters. + */ + String given_name() default ""; + + /** + * {@code family_name}: string Surname(s) or last name(s) of the End-User. Note + * that in some cultures, people can have multiple family names or no family + * name; all can be present, with the names being separated by space characters. + */ + String family_name() default ""; + + /** + * {@code middle_name}: Middle name(s) of the End-User. Note that in some + * cultures, people can have multiple middle names; all can be present, with the + * names being separated by space characters. Also note that in some cultures, + * middle names are not used. + */ + String middle_name() default ""; + + /** + * {@code nickname}: string, Casual name of the End-User that may or may not be + * the same as the given_name. For instance, a nickname value of Mike might be + * returned alongside a given_name value of Michael. + */ + String nickname() default ""; + + /** + * {@code profile}: string, URL of the End-User's profile page. The contents of + * this Web page SHOULD be about the End-User. + */ + String profile() default ""; + + /** + * {@code picture}: string, URL of the End-User's profile picture. This URL MUST + * refer to an image file (for example, a PNG, JPEG, or GIF image file), rather + * than to a Web page containing an image. Note that this URL SHOULD + * specifically reference a profile photo of the End-User suitable for + * displaying when describing the End-User, rather than an arbitrary photo taken + * by the End-User. + */ + String picture() default ""; + + /** + * {@code website}: string, URL of the End-User's Web page or blog. This Web + * page SHOULD contain information published by the End-User or an organization + * that the End-User is affiliated with. + */ + String website() default ""; + + /** + * {@code email}: string, End-User's preferred e-mail address. Its value MUST + * conform to the RFC 5322 [RFC5322] addr-spec syntax. The RP MUST NOT rely upon + * this value being unique, as discussed in Section 5.7. + */ + String email() default ""; + + /** + * {@code email_verified }: boolean, True if the End-User's e-mail address has + * been verified; otherwise false. When this Claim Value is true, this means + * that the OP took affirmative steps to ensure that this e-mail address was + * controlled by the End-User at the time the verification was performed. The + * means by which an e-mail address is verified is context-specific, and + * dependent upon the trust framework or contractual agreements within which the + * parties are operating. + */ + boolean email_verified() default false; + + /** + * {@code gender} string End-User's gender. Values defined by this specification + * are female and male. Other values MAY be used when neither of the defined + * values are applicable. + */ + String gender() default ""; + + /** + * {@code birthdate}: string End-User's birthday, represented as an ISO + * 8601:2004 [ISO8601‑2004] YYYY-MM-DD format. The year MAY be 0000, indicating + * that it is omitted. To represent only the year, YYYY format is allowed. Note + * that depending on the underlying platform's date related function, providing + * just year can result in varying month and day, so the implementers need to + * take this factor into account to correctly process the dates. + *

  • zoneinfo string String from zoneinfo [zoneinfo] time zone database + * representing the End-User's time zone. For example, Europe/Paris or + * America/Los_Angeles. + */ + String birthdate() default ""; + + /** + * {@code locale}: string End-User's locale, represented as a BCP47 [RFC5646] + * language tag. This is typically an ISO 639-1 Alpha-2 [ISO639‑1] language code + * in lowercase and an ISO 3166-1 Alpha-2 [ISO3166‑1] country code in uppercase, + * separated by a dash. For example, en-US or fr-CA. As a compatibility note, + * some implementations have used an underscore as the separator rather than a + * dash, for example, en_US; Relying Parties MAY choose to accept this locale + * syntax as well. + */ + String locale() default ""; + + /** + * {@code phone_number}: string End-User's preferred telephone number. E.164 + * [E.164] is RECOMMENDED as the format of this Claim, for example, +1 (425) + * 555-1212 or +56 (2) 687 2400. If the phone number contains an extension, it + * is RECOMMENDED that the extension be represented using the RFC 3966 [RFC3966] + * extension syntax, for example, +1 (604) 555-1234;ext=5678. + */ + String phone_number() default ""; + + /** + * {@code phone_number_verified} boolean True if the End-User's phone number has + * been verified; otherwise false. When this Claim Value is true, this means + * that the OP took affirmative steps to ensure that this phone number was + * controlled by the End-User at the time the verification was performed. The + * means by which a phone number is verified is context-specific, and dependent + * upon the trust framework or contractual agreements within which the parties + * are operating. When true, the phone_number Claim MUST be in E.164 format and + * any extensions MUST be represented in RFC 3966 format. + */ + boolean phone_number_verified() default false; + + /** + * {@code address} JSON object End-User's preferred postal address. The value of + * the address member is a JSON [RFC4627] structure containing some or all of + * the members defined in Section 5.1.1. + */ + String address() default ""; + + /** + * {@code updated_at} number Time the End-User's information was last updated. + * Its value is a JSON number representing the number of seconds from + * 1970-01-01T0:0:0Z as measured in UTC until the date/time. + * + */ + long updated_at() default 0L; + + /** + * Determines when the {@link SecurityContext} is setup. The default is before + * {@link TestExecutionEvent#TEST_METHOD} which occurs during + * {@link org.springframework.test.context.TestExecutionListener#beforeTestMethod(TestContext)} + * + * @return the {@link TestExecutionEvent} to initialize before + * @since 5.1 + */ + @AliasFor(annotation = WithSecurityContext.class) + TestExecutionEvent setupBefore() default TestExecutionEvent.TEST_METHOD; + +} diff --git a/gateway/src/test/resources/application-echoservice.yml b/gateway/src/test/resources/application-echoservice.yml new file mode 100644 index 00000000..965c0aeb --- /dev/null +++ b/gateway/src/test/resources/application-echoservice.yml @@ -0,0 +1,20 @@ +spring: + cloud: + gateway: + routes: + - id: echo + uri: http://${httpEchoHost:localhost}:${httpEchoPort:9095} + predicates: + - Path=/echo/** +georchestra: + gateway: + services: + echo: + target: http://${httpEchoHost}:${httpEchoPort} + access-rules: + - intercept-url: /echo/administrator + allowed-roles: ADMINISTRATOR + - intercept-url: /echo/connected + anonymous: false + - intercept-url: /echo/anonymous + anonymous: true \ No newline at end of file diff --git a/gateway/src/test/resources/application-preauth.yml b/gateway/src/test/resources/application-preauth.yml index 967d7fdd..fb7c3efa 100644 --- a/gateway/src/test/resources/application-preauth.yml +++ b/gateway/src/test/resources/application-preauth.yml @@ -56,3 +56,16 @@ spring: - AddSecHeaders httpclient.wiretap: true httpserver.wiretap: false + +--- +spring: + config: + activate: + on-profile: "preauth-with-testcontainer-ldap" +georchestra: + gateway: + security: + ldap: + default: + enabled: true + url: ldap://${ldapHost}:${ldapPort}/