From 671f07b8634cb18205fac176bcc1e81898ec7cf3 Mon Sep 17 00:00:00 2001 From: Gabriel Roldan Date: Thu, 14 Apr 2022 13:28:39 -0300 Subject: [PATCH] Implement missing user sec- headers, and JSON header contributor for user and org --- .../headers/HeaderFiltersConfiguration.java | 5 + ...chestraOrganizationHeadersContributor.java | 2 +- .../GeorchestraUserHeadersContributor.java | 51 ++--- .../JsonPayloadHeadersContributor.java | 99 ++++++++ .../model/GeorchestraOrganizations.java | 3 +- .../model/GeorchestraTargetConfig.java | 3 +- .../gateway/model/HeaderMappings.java | 186 +++++++-------- .../gateway/model/RoleBasedAccessRule.java | 12 +- .../BasicAuthenticatedUserMapper.java | 1 - .../security/GeorchestraUserMapper.java | 42 ++-- .../GeorchestraUserMapperExtension.java | 14 +- .../ResolveGeorchestraUserGlobalFilter.java | 24 +- .../accessrules/AccessRulesCustomizer.java | 69 ++++-- .../LdapAccountManagementConfiguration.java | 13 +- .../ldap/LdapAuthenticatedUserMapper.java | 10 +- .../ldap/LdapSecurityConfiguration.java | 29 +++ ...AddSecHeadersGatewayFilterFactoryTest.java | 95 ++++++++ ...traOrganizationHeadersContributorTest.java | 111 +++++++++ ...GeorchestraUserHeadersContributorTest.java | 202 +++++++++-------- .../JsonPayloadHeadersContributorTest.java | 132 +++++++++++ .../SecProxyHeaderContributorTest.java | 49 ++++ .../security/GeorchestraUserMapperTest.java | 94 ++++++++ ...esolveGeorchestraUserGlobalFilterTest.java | 125 ++++++++++ .../AccessRulesCustomizerTest.java | 213 ++++++++++++++++++ .../ldap/LdapAuthenticatedUserMapperTest.java | 99 ++++++++ 25 files changed, 1406 insertions(+), 277 deletions(-) create mode 100644 gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/JsonPayloadHeadersContributor.java create mode 100644 gateway/src/test/java/org/georchestra/gateway/filter/headers/AddSecHeadersGatewayFilterFactoryTest.java create mode 100644 gateway/src/test/java/org/georchestra/gateway/filter/headers/providers/GeorchestraOrganizationHeadersContributorTest.java create mode 100644 gateway/src/test/java/org/georchestra/gateway/filter/headers/providers/JsonPayloadHeadersContributorTest.java create mode 100644 gateway/src/test/java/org/georchestra/gateway/filter/headers/providers/SecProxyHeaderContributorTest.java create mode 100644 gateway/src/test/java/org/georchestra/gateway/security/GeorchestraUserMapperTest.java create mode 100644 gateway/src/test/java/org/georchestra/gateway/security/ResolveGeorchestraUserGlobalFilterTest.java create mode 100644 gateway/src/test/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizerTest.java create mode 100644 gateway/src/test/java/org/georchestra/gateway/security/ldap/LdapAuthenticatedUserMapperTest.java diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/HeaderFiltersConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/HeaderFiltersConfiguration.java index 08ad4cf9..9e3ebb8e 100644 --- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/HeaderFiltersConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/HeaderFiltersConfiguration.java @@ -22,6 +22,7 @@ import org.georchestra.gateway.filter.headers.providers.GeorchestraOrganizationHeadersContributor; import org.georchestra.gateway.filter.headers.providers.GeorchestraUserHeadersContributor; +import org.georchestra.gateway.filter.headers.providers.JsonPayloadHeadersContributor; import org.georchestra.gateway.filter.headers.providers.SecProxyHeaderContributor; import org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory; import org.springframework.context.ApplicationContext; @@ -58,6 +59,10 @@ public class HeaderFiltersConfiguration { return new GeorchestraOrganizationHeadersContributor(); } + public @Bean JsonPayloadHeadersContributor jsonPayloadHeadersContributor() { + return new JsonPayloadHeadersContributor(); + } + /** * General purpose {@link GatewayFilterFactory} to remove incoming HTTP request * headers based on a Java regular expression diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/GeorchestraOrganizationHeadersContributor.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/GeorchestraOrganizationHeadersContributor.java index 4163c7ca..67a51e4a 100644 --- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/GeorchestraOrganizationHeadersContributor.java +++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/GeorchestraOrganizationHeadersContributor.java @@ -37,7 +37,7 @@ public class GeorchestraOrganizationHeadersContributor extends HeaderContributor .ifPresent(mappings -> { Optional org = GeorchestraOrganizations.resolve(exchange); add(headers, "sec-orgname", mappings.getOrgname(), org.map(Organization::getName)); - add(headers, "sec-org-id", mappings.getOrgid(), org.map(Organization::getId)); + add(headers, "sec-orgid", mappings.getOrgid(), org.map(Organization::getId)); add(headers, "sec-org-lastupdated", mappings.getOrgid(), org.map(Organization::getLastUpdated)); }); }; diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/GeorchestraUserHeadersContributor.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/GeorchestraUserHeadersContributor.java index 497a427f..b4ceb481 100644 --- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/GeorchestraUserHeadersContributor.java +++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/GeorchestraUserHeadersContributor.java @@ -33,39 +33,36 @@ /** * Contributes user-related {@literal sec-*} request headers. * - *

- * For any - * * @see GeorchestraUsers#resolve * @see GeorchestraTargetConfig */ public class GeorchestraUserHeadersContributor extends HeaderContributor { - public @Override Consumer prepare(ServerWebExchange exchange) { - return headers -> { - GeorchestraTargetConfig.getTarget(exchange)// - .map(GeorchestraTargetConfig::headers)// - .ifPresent(mappings -> { - Optional user = GeorchestraUsers.resolve(exchange); - add(headers, "sec-userid", mappings.getUserid(), user.map(GeorchestraUser::getId)); - add(headers, "sec-username", mappings.getUsername(), user.map(GeorchestraUser::getUsername)); - add(headers, "sec-org", mappings.getOrg(), user.map(GeorchestraUser::getOrganization)); - add(headers, "sec-email", mappings.getEmail(), user.map(GeorchestraUser::getEmail)); - add(headers, "sec-firstname", mappings.getFirstname(), user.map(GeorchestraUser::getFirstName)); - add(headers, "sec-lastname", mappings.getLastname(), user.map(GeorchestraUser::getLastName)); - add(headers, "sec-tel", mappings.getTel(), user.map(GeorchestraUser::getTelephoneNumber)); + public @Override Consumer prepare(ServerWebExchange exchange) { + return headers -> { + GeorchestraTargetConfig.getTarget(exchange)// + .map(GeorchestraTargetConfig::headers)// + .ifPresent(mappings -> { + Optional user = GeorchestraUsers.resolve(exchange); + add(headers, "sec-userid", mappings.getUserid(), user.map(GeorchestraUser::getId)); + add(headers, "sec-username", mappings.getUsername(), user.map(GeorchestraUser::getUsername)); + add(headers, "sec-org", mappings.getOrg(), user.map(GeorchestraUser::getOrganization)); + add(headers, "sec-email", mappings.getEmail(), user.map(GeorchestraUser::getEmail)); + add(headers, "sec-firstname", mappings.getFirstname(), user.map(GeorchestraUser::getFirstName)); + add(headers, "sec-lastname", mappings.getLastname(), user.map(GeorchestraUser::getLastName)); + add(headers, "sec-tel", mappings.getTel(), user.map(GeorchestraUser::getTelephoneNumber)); - List roles = user.map(GeorchestraUser::getRoles).orElse(List.of()).stream() - .map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r).collect(Collectors.toList()); + List roles = user.map(GeorchestraUser::getRoles).orElse(List.of()).stream() + .map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r).collect(Collectors.toList()); - add(headers, "sec-roles", mappings.getRoles(), roles); + add(headers, "sec-roles", mappings.getRoles(), roles); - add(headers, "sec-lastupdated", mappings.getLastUpdated(), - user.map(GeorchestraUser::getLastUpdated)); - add(headers, "sec-address", mappings.getAddress(), user.map(GeorchestraUser::getPostalAddress)); - add(headers, "sec-title", mappings.getTitle(), user.map(GeorchestraUser::getTitle)); - add(headers, "sec-notes", mappings.getNotes(), user.map(GeorchestraUser::getNotes)); - }); - }; - } + add(headers, "sec-lastupdated", mappings.getLastUpdated(), + user.map(GeorchestraUser::getLastUpdated)); + add(headers, "sec-address", mappings.getAddress(), user.map(GeorchestraUser::getPostalAddress)); + add(headers, "sec-title", mappings.getTitle(), user.map(GeorchestraUser::getTitle)); + add(headers, "sec-notes", mappings.getNotes(), user.map(GeorchestraUser::getNotes)); + }); + }; + } } diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/JsonPayloadHeadersContributor.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/JsonPayloadHeadersContributor.java new file mode 100644 index 00000000..70aedf7b --- /dev/null +++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/JsonPayloadHeadersContributor.java @@ -0,0 +1,99 @@ +/* + * 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.filter.headers.providers; + +import java.util.Optional; +import java.util.function.Consumer; + +import org.georchestra.commons.security.SecurityHeaders; +import org.georchestra.ds.security.OrganizationsApiImpl; +import org.georchestra.gateway.filter.headers.HeaderContributor; +import org.georchestra.gateway.model.GeorchestraOrganizations; +import org.georchestra.gateway.model.GeorchestraTargetConfig; +import org.georchestra.gateway.model.GeorchestraUsers; +import org.georchestra.gateway.model.HeaderMappings; +import org.georchestra.security.model.GeorchestraUser; +import org.georchestra.security.model.Organization; +import org.springframework.http.HttpHeaders; +import org.springframework.web.server.ServerWebExchange; + +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +/** + * Contributes {@literal sec-user} and {@literal sec-organization} + * Base64-encoded JSON payloads, based on {@link HeaderMappings#getJsonUser()} + * and {@link HeaderMappings#getJsonOrganization()} matched-route headers + * configuration. + * + * @see GeorchestraUsers#resolve + * @see GeorchestraOrganizations#resolve + * @see GeorchestraTargetConfig + */ +public class JsonPayloadHeadersContributor extends HeaderContributor { + + /** + * Encoder to create the JSON String value for a {@link GeorchestraUser} + * obtained from {@link OrganizationsApiImpl} + */ + private ObjectMapper encoder; + + public JsonPayloadHeadersContributor() { + this.encoder = new ObjectMapper(); + this.encoder.configure(SerializationFeature.INDENT_OUTPUT, Boolean.FALSE); + this.encoder.configure(SerializationFeature.WRITE_SINGLE_ELEM_ARRAYS_UNWRAPPED, Boolean.FALSE); + this.encoder.setSerializationInclusion(Include.NON_NULL); + } + + public @Override Consumer prepare(ServerWebExchange exchange) { + return headers -> { + GeorchestraTargetConfig.getTarget(exchange)// + .map(GeorchestraTargetConfig::headers)// + .ifPresent(mappings -> { + Optional user = GeorchestraUsers.resolve(exchange); + Optional org = GeorchestraOrganizations.resolve(exchange); + + addJson(headers, "sec-user", mappings.getJsonUser(), user); + addJson(headers, "sec-organization", mappings.getJsonOrganization(), org); + }); + }; + } + + private void addJson(HttpHeaders target, String headerName, Optional enabled, Optional toEncode) { + if (enabled.orElse(false)) { + toEncode.map(this::encodeJson)// + .map(this::encodeBase64)// + .ifPresent(encoded -> target.add(headerName, encoded)); + } + } + + private String encodeJson(Object payloadObject) { + try { + return this.encoder.writer().writeValueAsString(payloadObject); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private String encodeBase64(String json) { + return SecurityHeaders.encodeBase64(json); + } +} diff --git a/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraOrganizations.java b/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraOrganizations.java index 34f9cf44..749beeeb 100644 --- a/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraOrganizations.java +++ b/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraOrganizations.java @@ -28,7 +28,8 @@ public class GeorchestraOrganizations { static final String GEORCHESTRA_ORGANIZATION_KEY = GeorchestraOrganizations.class.getCanonicalName(); public static Optional resolve(ServerWebExchange exchange) { - return Optional.ofNullable(exchange.getAttribute(GEORCHESTRA_ORGANIZATION_KEY)).map(Organization.class::cast); + return Optional.ofNullable(exchange.getAttributes().get(GEORCHESTRA_ORGANIZATION_KEY)) + .map(Organization.class::cast); } public static void store(ServerWebExchange exchange, Organization org) { diff --git a/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraTargetConfig.java b/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraTargetConfig.java index e1ffd804..fd3a82ce 100644 --- a/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraTargetConfig.java +++ b/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraTargetConfig.java @@ -41,7 +41,8 @@ public class GeorchestraTargetConfig { private List accessRules; public static Optional getTarget(ServerWebExchange exchange) { - return Optional.ofNullable(exchange.getAttributes().get(TARGET_CONFIG_KEY)).map(GeorchestraTargetConfig.class::cast); + return Optional.ofNullable(exchange.getAttributes().get(TARGET_CONFIG_KEY)) + .map(GeorchestraTargetConfig.class::cast); } public static void setTarget(ServerWebExchange exchange, GeorchestraTargetConfig config) { diff --git a/gateway/src/main/java/org/georchestra/gateway/model/HeaderMappings.java b/gateway/src/main/java/org/georchestra/gateway/model/HeaderMappings.java index 9277e498..c487305a 100644 --- a/gateway/src/main/java/org/georchestra/gateway/model/HeaderMappings.java +++ b/gateway/src/main/java/org/georchestra/gateway/model/HeaderMappings.java @@ -30,97 +30,97 @@ */ @Data public class HeaderMappings { - ///////// User info headers /////////////// - - /** Append the standard {@literal sec-proxy=true} header to proxied requests */ - private Optional proxy = Optional.empty(); - - /** Append the standard {@literal sec-userid} header to proxied requests */ - private Optional userid = Optional.empty(); - - /** Append the standard {@literal sec-lastupdated} header to proxied requests */ - private Optional lastUpdated = Optional.empty(); - - /** Append the standard {@literal sec-username} header to proxied requests */ - private Optional username = Optional.empty(); - - /** Append the standard {@literal sec-roles} header to proxied requests */ - private Optional roles = Optional.empty(); - - /** Append the standard {@literal sec-org} header to proxied requests */ - private Optional org = Optional.empty(); - - /** Append the standard {@literal sec-email} header to proxied requests */ - private Optional email = Optional.empty(); - - /** Append the standard {@literal sec-firstname} header to proxied requests */ - private Optional firstname = Optional.empty(); - - /** Append the standard {@literal sec-lastname} header to proxied requests */ - private Optional lastname = Optional.empty(); - - /** Append the standard {@literal sec-tel} header to proxied requests */ - private Optional tel = Optional.empty(); - - /** Append the standard {@literal sec-address} header to proxied requests */ - private Optional address = Optional.empty(); - - /** Append the standard {@literal sec-title} header to proxied requests */ - private Optional title = Optional.empty(); - - /** Append the standard {@literal sec-notes} header to proxied requests */ - private Optional notes = Optional.empty(); - /** - * Append the standard {@literal sec-user} (Base64 JSON payload) header to - * proxied requests - */ - private Optional jsonUser = Optional.empty(); - - ///////// Organization info headers /////////////// - - /** Append the standard {@literal sec-orgname} header to proxied requests */ - private Optional orgname = Optional.empty(); - - /** Append the standard {@literal sec-orgid} header to proxied requests */ - private Optional orgid = Optional.empty(); - - /** - * Append the standard {@literal sec-org-lastupdated} header to proxied requests - */ - private Optional orgLastUpdated = Optional.empty(); - - /** - * Append the standard {@literal sec-organization} (Base64 JSON payload) header - * to proxied requests - */ - private Optional jsonOrganization = Optional.empty(); - - public @VisibleForTesting void enableAll() { - this.setAll(Optional.of(Boolean.TRUE)); - } - - public @VisibleForTesting void disableAll() { - this.setAll(Optional.of(Boolean.FALSE)); - } - - private void setAll(Optional val) { - this.proxy = val; - this.userid = val; - this.lastUpdated = val; - this.username = val; - this.roles = val; - this.org = val; - this.email = val; - this.firstname = val; - this.lastname = val; - this.tel = val; - this.address = val; - this.title = val; - this.notes = val; - this.jsonUser = val; - this.orgname = val; - this.orgid = val; - this.orgLastUpdated = val; - this.jsonOrganization = val; - } + ///////// User info headers /////////////// + + /** Append the standard {@literal sec-proxy=true} header to proxied requests */ + private Optional proxy = Optional.empty(); + + /** Append the standard {@literal sec-userid} header to proxied requests */ + private Optional userid = Optional.empty(); + + /** Append the standard {@literal sec-lastupdated} header to proxied requests */ + private Optional lastUpdated = Optional.empty(); + + /** Append the standard {@literal sec-username} header to proxied requests */ + private Optional username = Optional.empty(); + + /** Append the standard {@literal sec-roles} header to proxied requests */ + private Optional roles = Optional.empty(); + + /** Append the standard {@literal sec-org} header to proxied requests */ + private Optional org = Optional.empty(); + + /** Append the standard {@literal sec-email} header to proxied requests */ + private Optional email = Optional.empty(); + + /** Append the standard {@literal sec-firstname} header to proxied requests */ + private Optional firstname = Optional.empty(); + + /** Append the standard {@literal sec-lastname} header to proxied requests */ + private Optional lastname = Optional.empty(); + + /** Append the standard {@literal sec-tel} header to proxied requests */ + private Optional tel = Optional.empty(); + + /** Append the standard {@literal sec-address} header to proxied requests */ + private Optional address = Optional.empty(); + + /** Append the standard {@literal sec-title} header to proxied requests */ + private Optional title = Optional.empty(); + + /** Append the standard {@literal sec-notes} header to proxied requests */ + private Optional notes = Optional.empty(); + /** + * Append the standard {@literal sec-user} (Base64 JSON payload) header to + * proxied requests + */ + private Optional jsonUser = Optional.empty(); + + ///////// Organization info headers /////////////// + + /** Append the standard {@literal sec-orgname} header to proxied requests */ + private Optional orgname = Optional.empty(); + + /** Append the standard {@literal sec-orgid} header to proxied requests */ + private Optional orgid = Optional.empty(); + + /** + * Append the standard {@literal sec-org-lastupdated} header to proxied requests + */ + private Optional orgLastUpdated = Optional.empty(); + + /** + * Append the standard {@literal sec-organization} (Base64 JSON payload) header + * to proxied requests + */ + private Optional jsonOrganization = Optional.empty(); + + public @VisibleForTesting void enableAll() { + this.setAll(Optional.of(Boolean.TRUE)); + } + + public @VisibleForTesting void disableAll() { + this.setAll(Optional.of(Boolean.FALSE)); + } + + private void setAll(Optional val) { + this.proxy = val; + this.userid = val; + this.lastUpdated = val; + this.username = val; + this.roles = val; + this.org = val; + this.email = val; + this.firstname = val; + this.lastname = val; + this.tel = val; + this.address = val; + this.title = val; + this.notes = val; + this.jsonUser = val; + this.orgname = val; + this.orgid = val; + this.orgLastUpdated = val; + this.jsonOrganization = val; + } } 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 bc74ef61..3bf74674 100644 --- a/gateway/src/main/java/org/georchestra/gateway/model/RoleBasedAccessRule.java +++ b/gateway/src/main/java/org/georchestra/gateway/model/RoleBasedAccessRule.java @@ -24,6 +24,7 @@ import org.springframework.security.core.GrantedAuthority; import lombok.Data; +import lombok.experimental.Accessors; /** * Models access rules to intercepted Ant-pattern URIs based on roles. @@ -33,6 +34,7 @@ * {@link GrantedAuthority#getAuthority()}) . */ @Data +@Accessors(chain = true) public class RoleBasedAccessRule { /** @@ -43,10 +45,18 @@ public class RoleBasedAccessRule { /** * Whether anonymous (unauthenticated) access is to be granted to the - * intercepted URIs. + * intercepted URIs. If {@code true}, no further specification is applied to the + * intercepted urls (i.e. if set, {@link #allowedRoles} are ignored). */ private boolean anonymous = false; + /** + * Requires an authenticated user, regardless of the roles. If there are + * {@link #allowedRoles} defined, this property is assumed to be {@code true}, + * despite whether it's set or not. + */ + private boolean authenticated = false; + /** * 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 diff --git a/gateway/src/main/java/org/georchestra/gateway/security/BasicAuthenticatedUserMapper.java b/gateway/src/main/java/org/georchestra/gateway/security/BasicAuthenticatedUserMapper.java index edb82805..1247b428 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/BasicAuthenticatedUserMapper.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/BasicAuthenticatedUserMapper.java @@ -31,7 +31,6 @@ import org.springframework.security.core.GrantedAuthority; /** - * @author groldan * */ public class BasicAuthenticatedUserMapper implements GeorchestraUserMapperExtension { diff --git a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapper.java b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapper.java index 007e7787..d88405f8 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapper.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapper.java @@ -19,41 +19,49 @@ package org.georchestra.gateway.security; import java.util.List; -import java.util.Map; import java.util.Optional; import org.georchestra.gateway.model.GeorchestraUsers; import org.georchestra.security.model.GeorchestraUser; +import org.springframework.core.Ordered; import org.springframework.security.core.Authentication; -import org.springframework.web.server.ServerWebExchange; import lombok.NonNull; import lombok.RequiredArgsConstructor; /** + * Aids {@link ResolveGeorchestraUserGlobalFilter} in resolving the + * {@link GeorchestraUser} from the current request's {@link Authentication} + * token. + *

* Relies on the provided {@link GeorchestraUserMapperExtension}s to map an * {@link Authentication} to a {@link GeorchestraUsers}. + *

+ * {@literal GeorchestraUserMapperExtension} beans specialize in mapping auth + * tokens for specific authentication sources (e.g. LDAP, OAuth2, OAuth2+OpenID, + * etc). */ @RequiredArgsConstructor public class GeorchestraUserMapper { - static final String GEORCHESTRA_USER_KEY = GeorchestraUser.class.getCanonicalName(); - + /** + * {@link Ordered ordered} list of user mapper extensions. + */ private final @NonNull List resolvers; - public Optional resolve(Authentication authToken) { - return resolvers.stream().map(resolver -> resolver.resolve(authToken)).filter(Optional::isPresent) - .map(Optional::get).findFirst(); - } - - public static ServerWebExchange store(@NonNull ServerWebExchange exchange, GeorchestraUser user) { - Map attributes = exchange.getAttributes(); - if (user == null) { - attributes.remove(GEORCHESTRA_USER_KEY); - } else { - attributes.put(GEORCHESTRA_USER_KEY, user); - } - return exchange; + /** + * @return the first non-empty user from + * {@link GeorchestraUserMapperExtension#resolve asking} the extension + * point implementations to resolve the user from the token, or + * {@link Optional#empty()} if no extension point implementation can + * handle the auth token. + */ + public Optional resolve(@NonNull Authentication authToken) { + return resolvers.stream()// + .map(resolver -> resolver.resolve(authToken))// + .filter(Optional::isPresent)// + .map(Optional::get)// + .findFirst(); } } \ No newline at end of file diff --git a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapperExtension.java b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapperExtension.java index 5bef0dd9..e60c9a35 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapperExtension.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapperExtension.java @@ -26,10 +26,22 @@ import org.springframework.security.core.Authentication; /** - * + * Extension point to decouple the authentication origin from the logic to + * convey geOrchestra-specific HTTP security request headers to back-end + * services. + *

+ * Beans of this type will be asked by {@link GeorchestraUserMapper} to obtain a + * {@link GeorchestraUser} from the current request authentication token. An + * instance that knows how to perform such mapping based on the kind of + * authentication represented by the token shall return a non-empty user. */ public interface GeorchestraUserMapperExtension extends Ordered { + /** + * @return the mapped {@link GeorchestraUser} based on the provided auth token, + * or {@link Optional#empty()} if this instance can't perform such + * mapping. + */ Optional resolve(Authentication authToken); default int getOrder() { diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ResolveGeorchestraUserGlobalFilter.java b/gateway/src/main/java/org/georchestra/gateway/security/ResolveGeorchestraUserGlobalFilter.java index eb1a64fb..777201d7 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ResolveGeorchestraUserGlobalFilter.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ResolveGeorchestraUserGlobalFilter.java @@ -18,19 +18,16 @@ */ package org.georchestra.gateway.security; -import java.util.Set; - import org.georchestra.gateway.filter.global.ResolveTargetGlobalFilter; import org.georchestra.gateway.model.GeorchestraTargetConfig; import org.georchestra.gateway.model.GeorchestraUsers; +import org.georchestra.security.model.GeorchestraUser; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.cloud.gateway.filter.RouteToRequestUrlFilter; import org.springframework.cloud.gateway.route.Route; import org.springframework.core.Ordered; -import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.web.server.ServerWebExchange; import lombok.NonNull; @@ -39,9 +36,13 @@ import reactor.core.publisher.Mono; /** - * A {@link GlobalFilter} that resolves the {@link GeorchestraUsers} from the + * A {@link GlobalFilter} that resolves the {@link GeorchestraUser} from the * request's {@link Authentication} so it can be {@link GeorchestraUsers#resolve - * retrieved} down the road during the filter chain. + * retrieved} down the road during a server web exchange filter chain execution. + *

+ * The resolved per-request {@link GeorchestraUser user} object can then, for + * example, be used to append the necessary {@literal sec-*} headers that relate + * to user information to proxied http requests. * * @see GeorchestraUserMapper */ @@ -51,9 +52,6 @@ public class ResolveGeorchestraUserGlobalFilter implements GlobalFilter, Ordered public static final int ORDER = RouteToRequestUrlFilter.ROUTE_TO_URL_FILTER_ORDER + 1; - private static final Authentication NONE = new AnonymousAuthenticationToken("none", new Object(), - Set.of(new SimpleGrantedAuthority("UNAUTHENTICATED"))); - private final @NonNull GeorchestraUserMapper resolver; /** @@ -77,10 +75,10 @@ public class ResolveGeorchestraUserGlobalFilter implements GlobalFilter, Ordered .doOnNext(p -> log.debug("resolving user from {}", p.getClass().getName()))// .filter(Authentication.class::isInstance)// .map(Authentication.class::cast)// - .defaultIfEmpty(NONE)// - .map(resolver::resolve).map(user -> { - return GeorchestraUsers.store(exchange, user.orElse(null)); - }).flatMap(chain::filter); + .map(resolver::resolve)// + .map(user -> GeorchestraUsers.store(exchange, user.orElse(null)))// + .defaultIfEmpty(exchange)// + .flatMap(chain::filter); } } \ No newline at end of file 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 896114af..9cada88d 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 @@ -19,8 +19,8 @@ package org.georchestra.gateway.security.accessrules; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; -import java.util.stream.Stream; import org.georchestra.gateway.model.GatewayConfigProperties; import org.georchestra.gateway.model.RoleBasedAccessRule; @@ -30,6 +30,8 @@ import org.springframework.security.config.web.server.ServerHttpSecurity.AuthorizeExchangeSpec; import org.springframework.security.config.web.server.ServerHttpSecurity.AuthorizeExchangeSpec.Access; +import com.google.common.annotations.VisibleForTesting; + import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -51,7 +53,7 @@ @Slf4j(topic = "org.georchestra.gateway.config.security.accessrules") public class AccessRulesCustomizer implements ServerHttpSecurityCustomizer { - private final GatewayConfigProperties config; + private final @NonNull GatewayConfigProperties config; @Override public void customize(ServerHttpSecurity http) { @@ -78,27 +80,66 @@ private void apply(AuthorizeExchangeSpec authorizeExchange, List antPatterns = rule.getInterceptUrl(); - boolean anonymous = rule.isAnonymous(); - List allowedRoles = rule.getAllowedRoles() == null ? List.of() : rule.getAllowedRoles(); - Access access = authorizeExchange.pathMatchers(antPatterns.toArray(String[]::new)); + @VisibleForTesting + void apply(AuthorizeExchangeSpec authorizeExchange, RoleBasedAccessRule rule) { + final List antPatterns = resolveAntPatterns(rule); + final boolean anonymous = rule.isAnonymous(); + final boolean authenticated = rule.isAuthenticated(); + final List allowedRoles = rule.getAllowedRoles() == null ? List.of() : rule.getAllowedRoles(); + Access access = authorizeExchange(authorizeExchange, antPatterns); if (anonymous) { - log.info("Access rule: {} anonymous", antPatterns); - access.permitAll(); + log.debug("Access rule: {} anonymous", antPatterns); + permitAll(access); + } else if (authenticated) { + requireAuthenticatedUser(access); } else if (!allowedRoles.isEmpty()) { - String[] roles = allowedRoles.stream().map(this::ensureRolePrefix).toArray(String[]::new); - log.info("Access rule: {} has any role: {}", antPatterns, - Stream.of(roles).collect(Collectors.joining(","))); - access.hasAnyAuthority(roles); + List roles = resolveRoles(antPatterns, allowedRoles); + hasAnyAuthority(access, roles); } else { log.warn( "The following intercepted URL's don't have any access rule defined. Defaulting to 'authenticated': {}", antPatterns); - access.authenticated(); + requireAuthenticatedUser(access); } } + private List resolveAntPatterns(RoleBasedAccessRule rule) { + List antPatterns = rule.getInterceptUrl(); + Objects.requireNonNull(antPatterns, "intercept-urls is null"); + antPatterns.forEach(Objects::requireNonNull); + if (antPatterns.isEmpty()) + throw new IllegalArgumentException("No ant-pattern(s) defined for rule " + rule); + antPatterns.forEach(Objects::requireNonNull); + return antPatterns; + } + + @VisibleForTesting + Access authorizeExchange(AuthorizeExchangeSpec authorizeExchange, List antPatterns) { + return authorizeExchange.pathMatchers(antPatterns.toArray(String[]::new)); + } + + private List resolveRoles(List antPatterns, List allowedRoles) { + List roles = allowedRoles.stream().map(this::ensureRolePrefix).collect(Collectors.toList()); + if (log.isDebugEnabled()) + log.debug("Access rule: {} has any role: {}", antPatterns, roles.stream().collect(Collectors.joining(","))); + return roles; + } + + @VisibleForTesting + void requireAuthenticatedUser(Access access) { + access.authenticated(); + } + + @VisibleForTesting + void hasAnyAuthority(Access access, List roles) { + access.hasAnyAuthority(roles.toArray(String[]::new)); + } + + @VisibleForTesting + void permitAll(Access access) { + access.permitAll(); + } + private String ensureRolePrefix(@NonNull String roleName) { return roleName.startsWith("ROLE_") ? roleName : ("ROLE_" + roleName); } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapAccountManagementConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapAccountManagementConfiguration.java index 50e9c6c5..7dc10dce 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapAccountManagementConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapAccountManagementConfiguration.java @@ -28,18 +28,27 @@ import org.georchestra.ds.users.AccountDao; import org.georchestra.ds.users.AccountDaoImpl; import org.georchestra.ds.users.UserRule; +import org.georchestra.gateway.security.GeorchestraUserMapperExtension; import org.georchestra.security.api.OrganizationsApi; import org.georchestra.security.api.RolesApi; import org.georchestra.security.api.UsersApi; +import org.georchestra.security.model.GeorchestraUser; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.ldap.core.ContextSource; import org.springframework.ldap.core.LdapTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.ldap.userdetails.LdapUserDetails; /** - * + * Sets up a {@link GeorchestraUserMapperExtension} that knows how to map an + * authentication credentials given by a + * {@link UsernamePasswordAuthenticationToken} with an {@link LdapUserDetails} + * (i.e., if the user authenticated with LDAP), to a {@link GeorchestraUser}, + * making use of geOrchestra's {@literal georchestra-ldap-account-management} + * module's {@link UsersApi}. */ @Configuration(proxyBeanMethods = false) @ComponentScan(basePackageClasses = UsersApiImpl.class) @@ -66,7 +75,7 @@ LdapAuthenticatedUserMapper ldapAuthenticatedUserMapperExtension(// UsersApi users, // OrganizationsApi orgs, // RolesApi roles) { - return new LdapAuthenticatedUserMapper(users, orgs, roles); + return new LdapAuthenticatedUserMapper(users); } ////////////////////////////////////////////// diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapAuthenticatedUserMapper.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapAuthenticatedUserMapper.java index 30054096..4a5bb0d3 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapAuthenticatedUserMapper.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapAuthenticatedUserMapper.java @@ -23,8 +23,6 @@ import org.georchestra.gateway.security.BasicAuthenticatedUserMapper; import org.georchestra.gateway.security.GeorchestraUserMapperExtension; -import org.georchestra.security.api.OrganizationsApi; -import org.georchestra.security.api.RolesApi; import org.georchestra.security.api.UsersApi; import org.georchestra.security.model.GeorchestraUser; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -35,14 +33,14 @@ import lombok.RequiredArgsConstructor; /** - * + * {@link GeorchestraUserMapperExtension} that maps LDAP-authenticated token to + * {@link GeorchestraUser} by calling {@link UsersApi#findByUsername(String)}, + * with the authentication token's principal name as argument. */ @RequiredArgsConstructor public class LdapAuthenticatedUserMapper implements GeorchestraUserMapperExtension { private final @NonNull UsersApi users; - private final @NonNull OrganizationsApi organizations; - private final @NonNull RolesApi roles; @Override public Optional resolve(Authentication authToken) { @@ -54,7 +52,7 @@ public Optional resolve(Authentication authToken) { } Optional map(UsernamePasswordAuthenticationToken token) { - final String username = token.getName(); + String username = ((LdapUserDetails) token.getPrincipal()).getUsername(); return users.findByUsername(username); } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapSecurityConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapSecurityConfiguration.java index 79abb2f6..aa24f7b2 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapSecurityConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapSecurityConfiguration.java @@ -31,17 +31,46 @@ import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authentication.ReactiveAuthenticationManagerAdapter; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper; +import org.springframework.security.core.context.SecurityContext; import org.springframework.security.ldap.authentication.BindAuthenticator; import org.springframework.security.ldap.authentication.LdapAuthenticationProvider; import org.springframework.security.ldap.search.FilterBasedLdapUserSearch; import org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator; +import org.springframework.security.ldap.userdetails.LdapUserDetails; import org.springframework.security.web.server.authentication.AuthenticationWebFilter; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; import lombok.extern.slf4j.Slf4j; +/** + * {@link ServerHttpSecurityCustomizer} to enable LDAP based authentication and + * authorization. + *

+ * This configuration sets up the required beans for spring-based LDAP + * authentication and authorization, using {@link LdapConfigProperties} to get + * {@link LdapConfigProperties#getUrl() connection URL} and the + * {@link LdapConfigProperties#getBaseDn() base DN}. + *

+ * As a result, the {@link ServerHttpSecurity} will have HTTP-Basic + * authentication enabled and {@link ServerHttpSecurity#formLogin() form login} + * set up. + *

+ * Upon successful authentication, the corresponding {@link Authentication} with + * an {@link LdapUserDetails} as {@link Authentication#getPrincipal() principal} + * and the roles extracted from LDAP as {@link Authentication#getAuthorities() + * authorities}, will be set as the security context's + * {@link SecurityContext#getAuthentication() authentication} property. + *

+ * Note however, this may not be enough information to convey + * geOrchestra-specific HTTP request headers to backend services, depending on + * the matching gateway-route configuration. See + * {@link LdapAccountManagementConfiguration} for further details. + * + * @see LdapAccountManagementConfiguration + */ @Configuration(proxyBeanMethods = true) @EnableConfigurationProperties(LdapConfigProperties.class) @Slf4j(topic = "org.georchestra.gateway.security.ldap") diff --git a/gateway/src/test/java/org/georchestra/gateway/filter/headers/AddSecHeadersGatewayFilterFactoryTest.java b/gateway/src/test/java/org/georchestra/gateway/filter/headers/AddSecHeadersGatewayFilterFactoryTest.java new file mode 100644 index 00000000..c7df6034 --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/filter/headers/AddSecHeadersGatewayFilterFactoryTest.java @@ -0,0 +1,95 @@ +/* + * 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.filter.headers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory.NameConfig; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.ServerWebExchange; + +/** + * Test suite for {@link AddSecHeadersGatewayFilterFactory} + * + */ +class AddSecHeadersGatewayFilterFactoryTest { + + private AddSecHeadersGatewayFilterFactory factory; + private List providers; + + private GatewayFilterChain mockChain; + + @BeforeEach + void setUp() throws Exception { + providers = new ArrayList<>(); + factory = new AddSecHeadersGatewayFilterFactory(providers); + + mockChain = mock(GatewayFilterChain.class); + } + + @Test + void test() { + MockServerHttpRequest request = MockServerHttpRequest.get("/test").build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); + + HeaderContributor extension1 = mock(HeaderContributor.class); + HeaderContributor extension2 = mock(HeaderContributor.class); + Consumer consumer1 = headers -> headers.add("header-from-extension1", "true"); + Consumer consumer2 = headers -> headers.add("header-from-extension2", "true"); + when(extension1.prepare(any())).thenReturn(consumer1); + when(extension2.prepare(any())).thenReturn(consumer2); + + providers.add(extension1); + providers.add(extension2); + + GatewayFilter filter = factory.apply((NameConfig) null); + filter.filter(exchange, mockChain); + + ArgumentCaptor mutatedExchangeCaptor = ArgumentCaptor.forClass(ServerWebExchange.class); + verify(mockChain, times(1)).filter(mutatedExchangeCaptor.capture()); + verify(extension1, times(1)).prepare(same(exchange)); + verify(extension2, times(1)).prepare(same(exchange)); + + ServerWebExchange mutatedExchange = mutatedExchangeCaptor.getValue(); + assertNotSame(exchange, mutatedExchange); + HttpHeaders finalHeaders = mutatedExchange.getRequest().getHeaders(); + assertEquals("true", finalHeaders.toSingleValueMap().get("header-from-extension1")); + assertEquals("true", finalHeaders.toSingleValueMap().get("header-from-extension2")); + } + +} diff --git a/gateway/src/test/java/org/georchestra/gateway/filter/headers/providers/GeorchestraOrganizationHeadersContributorTest.java b/gateway/src/test/java/org/georchestra/gateway/filter/headers/providers/GeorchestraOrganizationHeadersContributorTest.java new file mode 100644 index 00000000..4a864193 --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/filter/headers/providers/GeorchestraOrganizationHeadersContributorTest.java @@ -0,0 +1,111 @@ +/* + * 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.filter.headers.providers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.georchestra.gateway.filter.headers.HeaderContributor; +import org.georchestra.gateway.model.GeorchestraOrganizations; +import org.georchestra.gateway.model.GeorchestraTargetConfig; +import org.georchestra.gateway.model.HeaderMappings; +import org.georchestra.security.model.Organization; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.web.server.ServerWebExchange; + +/** + * Test suite for the {@link GeorchestraOrganizationHeadersContributor} + * {@link HeaderContributor} + * + */ +class GeorchestraOrganizationHeadersContributorTest { + + GeorchestraOrganizationHeadersContributor headerContributor; + ServerWebExchange exchange; + HeaderMappings matchedRouteHeadersConfig; + + @BeforeEach + void init() { + headerContributor = new GeorchestraOrganizationHeadersContributor(); + matchedRouteHeadersConfig = new HeaderMappings(); + GeorchestraTargetConfig matchedRouteConfig = new GeorchestraTargetConfig().headers(matchedRouteHeadersConfig); + + exchange = mock(ServerWebExchange.class); + Map exchangeAttributes = new HashMap<>(); + when(exchange.getAttributes()).thenReturn(exchangeAttributes); + + GeorchestraTargetConfig.setTarget(exchange, matchedRouteConfig); + } + + @Test + void testNoMatchedRouteConfig() { + GeorchestraTargetConfig.setTarget(exchange, null); + assertTrue(GeorchestraTargetConfig.getTarget(exchange).isEmpty()); + + Consumer contributor = headerContributor.prepare(exchange); + assertNotNull(contributor); + + HttpHeaders target = new HttpHeaders(); + contributor.accept(target); + assertTrue(target.isEmpty()); + } + + @Test + void testNoOrganization() { + Consumer contributor = headerContributor.prepare(exchange); + assertNotNull(contributor); + + HttpHeaders target = new HttpHeaders(); + contributor.accept(target); + assertTrue(target.isEmpty()); + } + + @Test + void testContributesHeadersFromOrganization() { + Organization org = new Organization(); + org.setId("abc"); + org.setName("PSC"); + org.setLastUpdated("123"); + + GeorchestraOrganizations.store(exchange, org); + + matchedRouteHeadersConfig.enableAll(); + + Consumer contributor = headerContributor.prepare(exchange); + assertNotNull(contributor); + + HttpHeaders target = new HttpHeaders(); + contributor.accept(target); + + assertEquals(List.of(org.getId()), target.get("sec-orgid")); + assertEquals(List.of(org.getName()), target.get("sec-orgname")); + assertEquals(List.of(org.getLastUpdated()), target.get("sec-org-lastupdated")); + } +} diff --git a/gateway/src/test/java/org/georchestra/gateway/filter/headers/providers/GeorchestraUserHeadersContributorTest.java b/gateway/src/test/java/org/georchestra/gateway/filter/headers/providers/GeorchestraUserHeadersContributorTest.java index 1641926c..47c738fa 100644 --- a/gateway/src/test/java/org/georchestra/gateway/filter/headers/providers/GeorchestraUserHeadersContributorTest.java +++ b/gateway/src/test/java/org/georchestra/gateway/filter/headers/providers/GeorchestraUserHeadersContributorTest.java @@ -19,7 +19,9 @@ package org.georchestra.gateway.filter.headers.providers; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -30,6 +32,7 @@ import java.util.function.Consumer; import java.util.stream.Collectors; +import org.georchestra.gateway.filter.headers.HeaderContributor; import org.georchestra.gateway.model.GeorchestraTargetConfig; import org.georchestra.gateway.model.GeorchestraUsers; import org.georchestra.gateway.model.HeaderMappings; @@ -40,106 +43,107 @@ import org.springframework.web.server.ServerWebExchange; /** - * @author groldan + * Test suite for the {@link GeorchestraUserHeadersContributor} + * {@link HeaderContributor} * */ class GeorchestraUserHeadersContributorTest { - GeorchestraUserHeadersContributor headerContributor; - ServerWebExchange exchange; - HeaderMappings matchedRouteHeadersConfig; - - @BeforeEach void init() { - headerContributor = new GeorchestraUserHeadersContributor(); - matchedRouteHeadersConfig = new HeaderMappings(); - GeorchestraTargetConfig matchedRouteConfig = new GeorchestraTargetConfig().headers(matchedRouteHeadersConfig); - - exchange = mock(ServerWebExchange.class); - Map exchangeAttributes = new HashMap<>(); - when(exchange.getAttributes()).thenReturn(exchangeAttributes); - - GeorchestraTargetConfig.setTarget(exchange, matchedRouteConfig); - } - - @Test - void testNoMatchedRouteConfig() { - GeorchestraTargetConfig.setTarget(exchange, null); - assertTrue(GeorchestraTargetConfig.getTarget(exchange).isEmpty()); - - Consumer contributor = headerContributor.prepare(exchange); - assertNotNull(contributor); - - HttpHeaders target = new HttpHeaders(); - contributor.accept(target); - assertTrue(target.isEmpty()); - } - - @Test - void testNoUser() { - Consumer contributor = headerContributor.prepare(exchange); - assertNotNull(contributor); - - HttpHeaders target = new HttpHeaders(); - contributor.accept(target); - assertTrue(target.isEmpty()); - } - - @Test - void testContributesHeadersFromUser() { - GeorchestraUser user = new GeorchestraUser(); - user.setId("abc"); - user.setUsername("testuser"); - user.setOrganization("PSC"); - user.setEmail("testuser@example.com"); - user.setFirstName("Test"); - user.setLastName("User"); - user.setTelephoneNumber("34144444"); - user.setTitle("Advisor"); - user.setPostalAddress("123 happy street"); - user.setNotes(":)"); - user.setRoles(List.of("ROLE_ADMIN", "ROLE_USER")); - - GeorchestraUsers.store(exchange, user); - - matchedRouteHeadersConfig.enableAll(); - - Consumer contributor = headerContributor.prepare(exchange); - assertNotNull(contributor); - - HttpHeaders target = new HttpHeaders(); - contributor.accept(target); - - assertEquals(List.of(user.getId()), target.get("sec-userid")); - assertEquals(List.of(user.getUsername()), target.get("sec-username")); - assertEquals(List.of(user.getFirstName()), target.get("sec-firstname")); - assertEquals(List.of(user.getLastName()), target.get("sec-lastname")); - assertEquals(List.of(user.getOrganization()), target.get("sec-org")); - assertEquals(List.of(user.getEmail()), target.get("sec-email")); - assertEquals(List.of(user.getTelephoneNumber()), target.get("sec-tel")); - assertEquals(List.of(user.getPostalAddress()), target.get("sec-address")); - assertEquals(List.of(user.getTitle()), target.get("sec-title")); - assertEquals(List.of(user.getNotes()), target.get("sec-notes")); - - String roles = user.getRoles().stream().collect(Collectors.joining(";")); - assertEquals(List.of(roles), target.get("sec-roles")); - } - - @Test - void testRolePrefixAppendedToRoleNames() { - - List actual = List.of("ROLE_ADMIN", "USER", "EDITOR"); - List expected = List.of("ROLE_ADMIN;ROLE_USER;ROLE_EDITOR"); - - GeorchestraUser user = new GeorchestraUser(); - user.setRoles(actual); - - GeorchestraUsers.store(exchange, user); - - matchedRouteHeadersConfig.disableAll(); - matchedRouteHeadersConfig.setRoles(Optional.of(true)); - - HttpHeaders target = new HttpHeaders(); - headerContributor.prepare(exchange).accept(target); - assertEquals(expected, target.get("sec-roles")); - } + GeorchestraUserHeadersContributor headerContributor; + ServerWebExchange exchange; + HeaderMappings matchedRouteHeadersConfig; + + @BeforeEach + void init() { + headerContributor = new GeorchestraUserHeadersContributor(); + matchedRouteHeadersConfig = new HeaderMappings(); + GeorchestraTargetConfig matchedRouteConfig = new GeorchestraTargetConfig().headers(matchedRouteHeadersConfig); + + exchange = mock(ServerWebExchange.class); + Map exchangeAttributes = new HashMap<>(); + when(exchange.getAttributes()).thenReturn(exchangeAttributes); + + GeorchestraTargetConfig.setTarget(exchange, matchedRouteConfig); + } + + @Test + void testNoMatchedRouteConfig() { + GeorchestraTargetConfig.setTarget(exchange, null); + assertTrue(GeorchestraTargetConfig.getTarget(exchange).isEmpty()); + + Consumer contributor = headerContributor.prepare(exchange); + assertNotNull(contributor); + + HttpHeaders target = new HttpHeaders(); + contributor.accept(target); + assertTrue(target.isEmpty()); + } + + @Test + void testNoUser() { + Consumer contributor = headerContributor.prepare(exchange); + assertNotNull(contributor); + + HttpHeaders target = new HttpHeaders(); + contributor.accept(target); + assertTrue(target.isEmpty()); + } + + @Test + void testContributesHeadersFromUser() { + GeorchestraUser user = new GeorchestraUser(); + user.setId("abc"); + user.setUsername("testuser"); + user.setOrganization("PSC"); + user.setEmail("testuser@example.com"); + user.setFirstName("Test"); + user.setLastName("User"); + user.setTelephoneNumber("34144444"); + user.setTitle("Advisor"); + user.setPostalAddress("123 happy street"); + user.setNotes(":)"); + user.setRoles(List.of("ROLE_ADMIN", "ROLE_USER")); + + GeorchestraUsers.store(exchange, user); + + matchedRouteHeadersConfig.enableAll(); + + Consumer contributor = headerContributor.prepare(exchange); + assertNotNull(contributor); + + HttpHeaders target = new HttpHeaders(); + contributor.accept(target); + + assertEquals(List.of(user.getId()), target.get("sec-userid")); + assertEquals(List.of(user.getUsername()), target.get("sec-username")); + assertEquals(List.of(user.getFirstName()), target.get("sec-firstname")); + assertEquals(List.of(user.getLastName()), target.get("sec-lastname")); + assertEquals(List.of(user.getOrganization()), target.get("sec-org")); + assertEquals(List.of(user.getEmail()), target.get("sec-email")); + assertEquals(List.of(user.getTelephoneNumber()), target.get("sec-tel")); + assertEquals(List.of(user.getPostalAddress()), target.get("sec-address")); + assertEquals(List.of(user.getTitle()), target.get("sec-title")); + assertEquals(List.of(user.getNotes()), target.get("sec-notes")); + + String roles = user.getRoles().stream().collect(Collectors.joining(";")); + assertEquals(List.of(roles), target.get("sec-roles")); + } + + @Test + void testRolePrefixAppendedToRoleNames() { + + GeorchestraUser user = new GeorchestraUser(); + user.setRoles(List.of("ROLE_ADMIN", "USER", "EDITOR")); + + final List expected = List.of("ROLE_ADMIN;ROLE_USER;ROLE_EDITOR"); + + GeorchestraUsers.store(exchange, user); + + matchedRouteHeadersConfig.disableAll(); + matchedRouteHeadersConfig.setRoles(Optional.of(true)); + + HttpHeaders target = new HttpHeaders(); + headerContributor.prepare(exchange).accept(target); + assertEquals(expected, target.get("sec-roles")); + } } diff --git a/gateway/src/test/java/org/georchestra/gateway/filter/headers/providers/JsonPayloadHeadersContributorTest.java b/gateway/src/test/java/org/georchestra/gateway/filter/headers/providers/JsonPayloadHeadersContributorTest.java new file mode 100644 index 00000000..1e062b67 --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/filter/headers/providers/JsonPayloadHeadersContributorTest.java @@ -0,0 +1,132 @@ +/* + * 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.filter.headers.providers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; + +import org.georchestra.commons.security.SecurityHeaders; +import org.georchestra.gateway.filter.headers.HeaderContributor; +import org.georchestra.gateway.model.GeorchestraOrganizations; +import org.georchestra.gateway.model.GeorchestraTargetConfig; +import org.georchestra.gateway.model.GeorchestraUsers; +import org.georchestra.gateway.model.HeaderMappings; +import org.georchestra.security.model.GeorchestraUser; +import org.georchestra.security.model.Organization; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.web.server.ServerWebExchange; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Test suite for the {@link JsonPayloadHeadersContributorTest} + * {@link HeaderContributor} + * + */ +class JsonPayloadHeadersContributorTest { + + JsonPayloadHeadersContributor headerContributor; + ServerWebExchange exchange; + HeaderMappings matchedRouteHeadersConfig; + + @BeforeEach + void init() { + headerContributor = new JsonPayloadHeadersContributor(); + matchedRouteHeadersConfig = new HeaderMappings(); + GeorchestraTargetConfig matchedRouteConfig = new GeorchestraTargetConfig().headers(matchedRouteHeadersConfig); + + exchange = mock(ServerWebExchange.class); + Map exchangeAttributes = new HashMap<>(); + when(exchange.getAttributes()).thenReturn(exchangeAttributes); + + GeorchestraTargetConfig.setTarget(exchange, matchedRouteConfig); + + matchedRouteHeadersConfig.disableAll(); + matchedRouteHeadersConfig.setJsonUser(Optional.of(true)); + matchedRouteHeadersConfig.setJsonOrganization(Optional.of(true)); + } + + @Test + void testUser() throws Exception { + GeorchestraUser user = new GeorchestraUser(); + user.setId("abc"); + user.setUsername("testuser"); + user.setOrganization("PSC"); + user.setEmail("testuser@example.com"); + user.setFirstName("Test"); + user.setLastName("User"); + user.setTelephoneNumber("34144444"); + user.setTitle("Advisor"); + user.setPostalAddress("123 happy street"); + user.setNotes(":)"); + user.setRoles(List.of("ROLE_ADMIN", "ROLE_USER")); + + GeorchestraUsers.store(exchange, user); + + testContributesJsonHeader(user, "sec-user"); + } + + @Test + void testOrganization() throws Exception { + Organization org = new Organization(); + org.setId("abc"); + org.setName("PSC"); + org.setShortName("Project Steering Committee"); + org.setCategory("category"); + org.setDescription("desc"); + org.setLastUpdated("123"); + org.setLinkage("http://test.com"); + org.setMembers(List.of("homer", "march", "lisa", "bart", "maggie")); + org.setNotes("notes"); + org.setPostalAddress("123 springfield"); + + GeorchestraOrganizations.store(exchange, org); + + testContributesJsonHeader(org, "sec-organization"); + } + + private void testContributesJsonHeader(Object object, String headerName) + throws JsonProcessingException, JsonMappingException { + Consumer contributor = headerContributor.prepare(exchange); + assertNotNull(contributor); + + HttpHeaders target = new HttpHeaders(); + contributor.accept(target); + + List val = target.get(headerName); + assertNotNull(val); + String base64Ecnoded = val.get(0); + String json = SecurityHeaders.decode(base64Ecnoded); + Object decoded = new ObjectMapper().readValue(json, object.getClass()); + assertEquals(object, decoded); + } +} diff --git a/gateway/src/test/java/org/georchestra/gateway/filter/headers/providers/SecProxyHeaderContributorTest.java b/gateway/src/test/java/org/georchestra/gateway/filter/headers/providers/SecProxyHeaderContributorTest.java new file mode 100644 index 00000000..fa774b74 --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/filter/headers/providers/SecProxyHeaderContributorTest.java @@ -0,0 +1,49 @@ +/* + * 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.filter.headers.providers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; + +import java.util.List; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.web.server.ServerWebExchange; + +/** + * Test suite for {@link SecProxyHeaderContributor} + * + */ +class SecProxyHeaderContributorTest { + + @Test + void test() { + ServerWebExchange exchange = mock(ServerWebExchange.class); + Consumer consumer = new SecProxyHeaderContributor().prepare(exchange); + assertNotNull(consumer); + HttpHeaders headers = new HttpHeaders(); + consumer.accept(headers); + assertEquals(List.of("true"), headers.get("sec-proxy")); + } + +} diff --git a/gateway/src/test/java/org/georchestra/gateway/security/GeorchestraUserMapperTest.java b/gateway/src/test/java/org/georchestra/gateway/security/GeorchestraUserMapperTest.java new file mode 100644 index 00000000..df0a8550 --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/security/GeorchestraUserMapperTest.java @@ -0,0 +1,94 @@ +/* + * 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; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.georchestra.security.model.GeorchestraUser; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.Authentication; + +/** + * Test suite for {@link GeorchestraUserMapper} + */ +class GeorchestraUserMapperTest { + + @Test + void testResolve_null_auth_token() { + assertThrows(NullPointerException.class, () -> new GeorchestraUserMapper(List.of()).resolve(null)); + } + + @Test + void testResolve_no_extensions() { + GeorchestraUserMapper mapper = new GeorchestraUserMapper(List.of()); + Authentication auth = mock(Authentication.class); + Optional resolved = mapper.resolve(auth); + assertNotNull(resolved); + assertTrue(resolved.isEmpty()); + } + + @Test + void testResolve() { + GeorchestraUserMapperExtension ext1 = mock(GeorchestraUserMapperExtension.class); + when(ext1.resolve(any(Authentication.class))).thenReturn(Optional.empty()); + + GeorchestraUser expected = mock(GeorchestraUser.class); + Authentication auth = mock(Authentication.class); + + GeorchestraUserMapperExtension ext2 = mock(GeorchestraUserMapperExtension.class); + when(ext2.resolve(same(auth))).thenReturn(Optional.of(expected)); + + List resolvers = List.of(ext1, ext2); + GeorchestraUserMapper mapper = new GeorchestraUserMapper(resolvers); + Optional resolved = mapper.resolve(auth); + assertTrue(resolved.isPresent()); + assertSame(expected, resolved.get()); + } + + @Test + void testResolveOrder() { + Authentication auth = mock(Authentication.class); + + GeorchestraUser user1 = mock(GeorchestraUser.class); + GeorchestraUserMapperExtension ext1 = mock(GeorchestraUserMapperExtension.class); + when(ext1.resolve(same(auth))).thenReturn(Optional.of(user1)); + + GeorchestraUser user2 = mock(GeorchestraUser.class); + GeorchestraUserMapperExtension ext2 = mock(GeorchestraUserMapperExtension.class); + when(ext2.resolve(same(auth))).thenReturn(Optional.of(user2)); + + List resolvers = List.of(ext1, ext2); + GeorchestraUserMapper mapper = new GeorchestraUserMapper(resolvers); + Optional resolved = mapper.resolve(auth); + assertTrue(resolved.isPresent()); + assertSame(user1, resolved.get()); + } + +} diff --git a/gateway/src/test/java/org/georchestra/gateway/security/ResolveGeorchestraUserGlobalFilterTest.java b/gateway/src/test/java/org/georchestra/gateway/security/ResolveGeorchestraUserGlobalFilterTest.java new file mode 100644 index 00000000..fab3bc43 --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/security/ResolveGeorchestraUserGlobalFilterTest.java @@ -0,0 +1,125 @@ +/* + * 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; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.security.Principal; +import java.util.Optional; + +import org.georchestra.gateway.model.GeorchestraUsers; +import org.georchestra.security.model.GeorchestraUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.core.Authentication; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * Test suite for {@link ResolveGeorchestraUserGlobalFilter} + * + */ +class ResolveGeorchestraUserGlobalFilterTest { + + private ResolveGeorchestraUserGlobalFilter filter; + private GeorchestraUserMapper mockMapper; + + private GatewayFilterChain mockChain; + private MockServerHttpRequest request; + private MockServerWebExchange exchange; + + /** + * @throws java.lang.Exception + */ + @BeforeEach + void setUp() throws Exception { + mockMapper = mock(GeorchestraUserMapper.class); + filter = new ResolveGeorchestraUserGlobalFilter(mockMapper); + mockChain = mock(GatewayFilterChain.class); + when(mockChain.filter(any())).thenReturn(Mono.empty()); + request = MockServerHttpRequest.get("/test").build(); + exchange = MockServerWebExchange.from(request); + + } + + @Test + void testFilter_NoAuthenticatedUser() { + Mono ret = filter.filter(exchange, mockChain); + assertNotNull(ret); + ret.block(); + verify(mockChain, times(1)).filter(same(exchange)); + verify(mockMapper, never()).resolve(any()); + } + + @Test + void testFilter_PrincipalIsNotAnAuthentication() { + Mono principal = Mono.just(mock(Principal.class)); + ServerWebExchange exchange = this.exchange.mutate().principal(principal).build(); + + filter.filter(exchange, mockChain).block(); + + verify(mockChain, times(1)).filter(same(exchange)); + verify(mockMapper, never()).resolve(any()); + } + + @Test + void testFilter_NoUseResolved() { + Mono principal = Mono.just(mock(Authentication.class)); + ServerWebExchange exchange = this.exchange.mutate().principal(principal).build(); + + filter.filter(exchange, mockChain).block(); + + verify(mockChain, times(1)).filter(same(exchange)); + verify(mockMapper, times(1)).resolve(any()); + + assertTrue(GeorchestraUsers.resolve(exchange).isEmpty()); + } + + @Test + void testFilter_UseResolved() { + Authentication auth1 = mock(Authentication.class); + GeorchestraUser user1 = mock(GeorchestraUser.class); + when(mockMapper.resolve(same(auth1))).thenReturn(Optional.of(user1)); + + ServerWebExchange exchange = this.exchange.mutate().principal(Mono.just(auth1)).build(); + + filter.filter(exchange, mockChain).block(); + + verify(mockChain, times(1)).filter(same(exchange)); + verify(mockMapper, times(1)).resolve(any()); + + Optional resolved = GeorchestraUsers.resolve(exchange); + assertSame(user1, resolved.orElseThrow()); + } +} 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 new file mode 100644 index 00000000..e00601fe --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizerTest.java @@ -0,0 +1,213 @@ +/* + * 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.accessrules; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.georchestra.gateway.model.GatewayConfigProperties; +import org.georchestra.gateway.model.RoleBasedAccessRule; +import org.georchestra.gateway.model.Service; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity.AuthorizeExchangeSpec; +import org.springframework.security.config.web.server.ServerHttpSecurity.AuthorizeExchangeSpec.Access; + +/** + * Test suite for {@link AccessRulesCustomizer} + * + */ +class AccessRulesCustomizerTest { + + private GatewayConfigProperties config; + private AccessRulesCustomizer customizer; + private ServerHttpSecurity http; + + @BeforeEach + void setUp() throws Exception { + config = new GatewayConfigProperties(); + customizer = new AccessRulesCustomizer(config); + http = spy(new ServerHttpSecurity() { + }); + } + + @Test + void testConstructorDoesNotAcceptNullConfig() { + assertThrows(NullPointerException.class, () -> new AccessRulesCustomizer(null)); + } + + @Test + void testCustomize_empty_config() { + customizer.customize(http); + verify(http, atLeastOnce()).authorizeExchange(); + verifyNoMoreInteractions(http); + } + + @Test + void testCustomize_applies_global_rules_before_service_rules() { + + RoleBasedAccessRule global1 = mock(RoleBasedAccessRule.class); + RoleBasedAccessRule global2 = mock(RoleBasedAccessRule.class); + RoleBasedAccessRule service1Rule1 = mock(RoleBasedAccessRule.class); + RoleBasedAccessRule service1Rule2 = mock(RoleBasedAccessRule.class); + + Service service1 = new Service(); + service1.setAccessRules(List.of(service1Rule1, service1Rule2)); + + config.setGlobalAccessRules(List.of(global1, global2)); + config.setServices(Map.of("service1", service1)); + + customizer = spy(customizer); + + ArgumentCaptor ruleCaptor = ArgumentCaptor.forClass(RoleBasedAccessRule.class); + + doNothing().when(customizer).apply(any(), any()); + customizer.customize(http); + verify(customizer, times(4)).apply(any(), ruleCaptor.capture()); + assertSame(global1, ruleCaptor.getAllValues().get(0)); + assertSame(global2, ruleCaptor.getAllValues().get(1)); + assertSame(service1Rule1, ruleCaptor.getAllValues().get(2)); + assertSame(service1Rule2, ruleCaptor.getAllValues().get(3)); + } + + @Test + void testApplyRule_EmptyInterceptUrls() { + AuthorizeExchangeSpec spec = http.authorizeExchange(); + RoleBasedAccessRule rule = rule().setAnonymous(true); + + assertThrows(IllegalArgumentException.class, () -> customizer.apply(spec, rule), + "No ant-pattern(s) defined for rule"); + } + + @Test + void testApplyRule_AuthorizeExchangeWithAntPatterns() { + AuthorizeExchangeSpec spec = http.authorizeExchange(); + + RoleBasedAccessRule rule = rule("/test/**", "/page1"); + customizer = spy(customizer); + customizer.apply(spec, rule); + + verify(customizer, times(1)).authorizeExchange(same(spec), eq(List.of("/test/**", "/page1"))); + } + + @Test + void testApplyRule_anonymous() { + AuthorizeExchangeSpec spec = http.authorizeExchange(); + + RoleBasedAccessRule rule = rule("/test/**", "/page1").setAnonymous(true); + customizer = spy(customizer); + customizer.apply(spec, rule); + + verify(customizer, times(1)).authorizeExchange(same(spec), eq(List.of("/test/**", "/page1"))); + verify(customizer, times(1)).permitAll(any()); + } + + @Test + void testApplyRule_anonymous_has_precedence_over_authenticated_and_roles_list() { + AuthorizeExchangeSpec spec = http.authorizeExchange(); + + RoleBasedAccessRule rule = rule("/test/**", "/page1").setAnonymous(true).setAuthenticated(true) + .setAllowedRoles(List.of("ROLE_ADMIN")); + customizer = spy(customizer); + customizer.apply(spec, rule); + + 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()); + } + + @Test + void testApplyRule_authenticated() { + AuthorizeExchangeSpec spec = http.authorizeExchange(); + + RoleBasedAccessRule rule = rule("/test/**", "/page1").setAuthenticated(true); + customizer = spy(customizer); + customizer.apply(spec, rule); + + verify(customizer, times(1)).authorizeExchange(same(spec), eq(List.of("/test/**", "/page1"))); + verify(customizer, times(1)).requireAuthenticatedUser(any()); + } + + @Test + void testApplyRule_authenticated_has_precedence_over_roles_list() { + AuthorizeExchangeSpec spec = http.authorizeExchange(); + + RoleBasedAccessRule rule = rule("/test/**", "/page1").setAuthenticated(true).setAnonymous(false) + .setAllowedRoles(List.of("ROLE_ADMIN")); + customizer = spy(customizer); + customizer.apply(spec, rule); + + verify(customizer, times(1)).authorizeExchange(same(spec), eq(List.of("/test/**", "/page1"))); + verify(customizer, times(1)).requireAuthenticatedUser(any()); + verify(customizer, times(0)).hasAnyAuthority(any(), any()); + } + + @Test + void testApplyRule_roles() { + AuthorizeExchangeSpec spec = http.authorizeExchange(); + + List roles = List.of("ROLE_ADMIN", "ROLE_TESTER"); + RoleBasedAccessRule rule = rule("/test/**", "/page1").setAllowedRoles(roles); + customizer = spy(customizer); + customizer.apply(spec, rule); + + verify(customizer, times(1)).authorizeExchange(same(spec), eq(List.of("/test/**", "/page1"))); + verify(customizer, times(1)).hasAnyAuthority(any(), eq(roles)); + } + + @Test + void testApplyRule_roles_prefix_added_if_missing() { + AuthorizeExchangeSpec spec = http.authorizeExchange(); + + List roles = List.of("ADMIN", "TESTER"); + List expected = List.of("ROLE_ADMIN", "ROLE_TESTER"); + RoleBasedAccessRule rule = rule("/test/**", "/page1").setAllowedRoles(roles); + customizer = spy(customizer); + customizer.apply(spec, rule); + + verify(customizer, times(1)).authorizeExchange(same(spec), eq(List.of("/test/**", "/page1"))); + verify(customizer, times(1)).hasAnyAuthority(any(), eq(expected)); + } + + private RoleBasedAccessRule rule(String... interceptUrls) { + RoleBasedAccessRule rule = new RoleBasedAccessRule(); + rule.setInterceptUrl(List.of(interceptUrls)); + return rule; + } +} diff --git a/gateway/src/test/java/org/georchestra/gateway/security/ldap/LdapAuthenticatedUserMapperTest.java b/gateway/src/test/java/org/georchestra/gateway/security/ldap/LdapAuthenticatedUserMapperTest.java new file mode 100644 index 00000000..91042290 --- /dev/null +++ b/gateway/src/test/java/org/georchestra/gateway/security/ldap/LdapAuthenticatedUserMapperTest.java @@ -0,0 +1,99 @@ +/* + * 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.ldap; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.georchestra.security.api.UsersApi; +import org.georchestra.security.model.GeorchestraUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.ldap.userdetails.LdapUserDetails; + +/** + * Test suite for {@link LdapAuthenticatedUserMapper} + * + */ +class LdapAuthenticatedUserMapperTest { + + private LdapAuthenticatedUserMapper mapper; + private UsersApi mockUsers; + + @BeforeEach + void before() { + mockUsers = mock(UsersApi.class); + when(mockUsers.findByUsername(anyString())).thenReturn(Optional.empty()); + mapper = new LdapAuthenticatedUserMapper(mockUsers); + } + + @Test + void testNotAUserNamePasswordAuthenticationToken() { + Authentication auth = mock(Authentication.class); + Optional resolve = mapper.resolve(auth); + assertNotNull(resolve); + assertTrue(resolve.isEmpty()); + verifyNoInteractions(mockUsers); + } + + @Test + void testNotAnLdapUserDetails() { + UserDetails principal = new User("testuser", "secret", List.of()); + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(principal, null); + + Optional resolve = mapper.resolve(auth); + assertNotNull(resolve); + assertTrue(resolve.isEmpty()); + + verifyNoInteractions(mockUsers); + } + + @Test + void testLdapUserDetails() { + GeorchestraUser expected = mock(GeorchestraUser.class); + LdapUserDetails principal = mock(LdapUserDetails.class); + when(principal.getUsername()).thenReturn("ldapuser"); + when(mockUsers.findByUsername(eq("ldapuser"))).thenReturn(Optional.of(expected)); + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(principal, null); + + Optional resolve = mapper.resolve(auth); + assertNotNull(resolve); + assertSame(expected, resolve.orElseThrow()); + + verify(mockUsers, atLeastOnce()).findByUsername(eq("ldapuser")); + verifyNoMoreInteractions(mockUsers); + } +}