filter = pattern.asMatchPredicate();
- return Streams.stream(propertySources)//
- .filter(EnumerablePropertySource.class::isInstance)//
- .map(EnumerablePropertySource.class::cast)//
- .map(EnumerablePropertySource::getPropertyNames)//
- .flatMap(Stream::of)//
- .filter(filter)//
- .toList();
+ return Streams.stream(propertySources).filter(EnumerablePropertySource.class::isInstance)
+ .map(EnumerablePropertySource.class::cast).map(EnumerablePropertySource::getPropertyNames)
+ .flatMap(Stream::of).filter(filter).toList();
}
-
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/ConditionalOnHeaderPreAuthentication.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/ConditionalOnHeaderPreAuthentication.java
index dfbd381f..e7f565bd 100644
--- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/ConditionalOnHeaderPreAuthentication.java
+++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/ConditionalOnHeaderPreAuthentication.java
@@ -16,7 +16,6 @@
* You should have received a copy of the GNU General Public License along with
* geOrchestra. If not, see .
*/
-
package org.georchestra.gateway.autoconfigure.security;
import java.lang.annotation.Documented;
@@ -29,7 +28,14 @@
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
/**
+ * Condition that enables a bean if header-based pre-authentication is enabled.
+ *
+ * This annotation ensures that a component is only loaded when the
+ * {@code georchestra.gateway.security.preauth.enabled} property is set to
+ * {@code true}.
+ *
*
+ * @see HeaderPreauthConfigProperties
*/
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/ConditionalOnLdapEnabled.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/ConditionalOnLdapEnabled.java
index 6e4326ba..7ec0e3f0 100644
--- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/ConditionalOnLdapEnabled.java
+++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/ConditionalOnLdapEnabled.java
@@ -16,7 +16,6 @@
* You should have received a copy of the GNU General Public License along with
* geOrchestra. If not, see .
*/
-
package org.georchestra.gateway.autoconfigure.security;
import java.lang.annotation.Documented;
@@ -30,7 +29,19 @@
import org.springframework.ldap.core.LdapTemplate;
/**
+ * Condition that enables a bean if LDAP support is available and at least one
+ * LDAP data source is enabled.
+ *
+ * This annotation ensures that a component is only loaded when:
+ *
+ * The {@link LdapTemplate} class is present on the classpath.
+ * At least one LDAP configuration is enabled, as determined by
+ * {@link AtLeastOneLdapDatasourceEnabledCondition}.
+ *
+ *
*
+ * @see AtLeastOneLdapDatasourceEnabledCondition
+ * @see LdapTemplate
*/
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/HeaderPreAuthenticationAutoConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/HeaderPreAuthenticationAutoConfiguration.java
index cf3fcf02..c9c3db94 100644
--- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/HeaderPreAuthenticationAutoConfiguration.java
+++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/HeaderPreAuthenticationAutoConfiguration.java
@@ -23,13 +23,18 @@
import org.springframework.context.annotation.Import;
/**
- * {@link AutoConfiguration @AutoConfiguration} to enable request headers
- * pre-authentication.
+ * Auto-configuration for request headers pre-authentication.
*
- * This feature shall be enabled through the
- * {@code georchestra.gateway.security.header-authentication.enabled=true}
- * config property.
- *
+ * This configuration enables header-based pre-authentication when the
+ * {@code georchestra.gateway.security.header-authentication.enabled} property
+ * is set to {@code true}.
+ *
+ *
+ *
+ * It imports {@link HeaderPreAuthenticationConfiguration}, which provides the
+ * necessary beans for handling pre-authentication via request headers.
+ *
+ *
* @see ConditionalOnHeaderPreAuthentication
* @see HeaderPreAuthenticationConfiguration
*/
diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/LdapSecurityAutoConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/LdapSecurityAutoConfiguration.java
index fc71708c..895359b3 100644
--- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/LdapSecurityAutoConfiguration.java
+++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/LdapSecurityAutoConfiguration.java
@@ -22,14 +22,27 @@
import org.georchestra.gateway.security.ldap.LdapAuthenticationConfiguration;
import org.springframework.boot.autoconfigure.AutoConfiguration;
-import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.Import;
import lombok.extern.slf4j.Slf4j;
/**
- * {@link EnableAutoConfiguration AutoConfiguration} to set up LDAP security
- *
+ * Auto-configuration for LDAP-based authentication in geOrchestra.
+ *
+ * This configuration enables LDAP authentication when at least one LDAP data
+ * source is enabled and the required dependencies are available.
+ *
+ *
+ *
+ * It imports {@link LdapAuthenticationConfiguration}, which sets up the
+ * necessary beans for LDAP authentication.
+ *
+ *
+ *
+ * Upon initialization, this configuration logs a message indicating that LDAP
+ * authentication has been enabled.
+ *
+ *
* @see LdapAuthenticationConfiguration
*/
@AutoConfiguration
@@ -38,7 +51,11 @@
@Slf4j(topic = "org.georchestra.gateway.autoconfigure.security")
public class LdapSecurityAutoConfiguration {
- public @PostConstruct void log() {
+ /**
+ * Logs a message when LDAP security is enabled.
+ */
+ @PostConstruct
+ public void log() {
log.info("georchestra LDAP security enabled");
}
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/OAuth2SecurityAutoConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/OAuth2SecurityAutoConfiguration.java
index 9c157d66..74f7588b 100644
--- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/OAuth2SecurityAutoConfiguration.java
+++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/OAuth2SecurityAutoConfiguration.java
@@ -28,23 +28,66 @@
import lombok.extern.slf4j.Slf4j;
+/**
+ * Auto-configuration for OAuth2-based authentication in geOrchestra.
+ *
+ * This configuration conditionally enables or disables OAuth2 security based on
+ * the property {@code georchestra.gateway.security.oauth2.enabled}.
+ *
+ *
+ *
+ * It imports either:
+ *
+ * {@link Enabled} when OAuth2 is enabled.
+ * {@link Disabled} when OAuth2 is disabled (default).
+ *
+ *
+ *
+ * @see OAuth2Configuration
+ */
@AutoConfiguration
@Slf4j(topic = "org.georchestra.gateway.autoconfigure.security")
@Import({ OAuth2SecurityAutoConfiguration.Enabled.class, OAuth2SecurityAutoConfiguration.Disabled.class })
public class OAuth2SecurityAutoConfiguration {
+
private static final String ENABLED_PROP = "georchestra.gateway.security.oauth2.enabled";
+ /**
+ * Configuration that enables OAuth2 security when explicitly enabled.
+ *
+ * This configuration is loaded if
+ * {@code georchestra.gateway.security.oauth2.enabled} is set to {@code true}.
+ *
+ */
@Import(OAuth2Configuration.class)
@ConditionalOnProperty(name = ENABLED_PROP, havingValue = "true", matchIfMissing = false)
static @Configuration class Enabled {
- public @PostConstruct void log() {
+
+ /**
+ * Logs a message indicating that OAuth2 security is enabled.
+ */
+ @PostConstruct
+ public void log() {
log.info("georchestra OAuth2 security enabled");
}
}
+ /**
+ * Configuration that disables OAuth2 security by default.
+ *
+ * This configuration is loaded if
+ * {@code georchestra.gateway.security.oauth2.enabled} is set to {@code false}
+ * or is missing.
+ *
+ */
@ConditionalOnProperty(name = ENABLED_PROP, havingValue = "false", matchIfMissing = true)
static @Configuration class Disabled {
- public @PostConstruct void log() {
+
+ /**
+ * Logs a message indicating that OAuth2 security is disabled.
+ */
+ @PostConstruct
+ public void log() {
log.info("georchestra OAuth2 security disabled");
}
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/WebSecurityAutoConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/WebSecurityAutoConfiguration.java
index 5a4ff71a..bdac034c 100644
--- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/WebSecurityAutoConfiguration.java
+++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/WebSecurityAutoConfiguration.java
@@ -24,6 +24,28 @@
import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity;
import org.springframework.context.annotation.Import;
+/**
+ * Auto-configuration for web security in geOrchestra Gateway.
+ *
+ * This configuration is applied only when Spring Security's default web
+ * security is enabled, as determined by
+ * {@link ConditionalOnDefaultWebSecurity}.
+ *
+ *
+ *
+ * It imports:
+ *
+ * {@link GatewaySecurityConfiguration} - Configures security settings for
+ * the gateway.
+ * {@link AccessRulesConfiguration} - Manages access rules and security
+ * policies.
+ *
+ *
+ *
+ * @see GatewaySecurityConfiguration
+ * @see AccessRulesConfiguration
+ * @see ConditionalOnDefaultWebSecurity
+ */
@AutoConfiguration
@ConditionalOnDefaultWebSecurity
@Import({ GatewaySecurityConfiguration.class, AccessRulesConfiguration.class })
diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/global/ApplicationErrorGatewayFilterFactory.java b/gateway/src/main/java/org/georchestra/gateway/filter/global/ApplicationErrorGatewayFilterFactory.java
index 770ec931..d4d1d8cc 100644
--- a/gateway/src/main/java/org/georchestra/gateway/filter/global/ApplicationErrorGatewayFilterFactory.java
+++ b/gateway/src/main/java/org/georchestra/gateway/filter/global/ApplicationErrorGatewayFilterFactory.java
@@ -38,17 +38,20 @@
import reactor.core.publisher.Mono;
/**
- * Filter to allow custom error pages to be used when an application behind the
- * gateways returns an error, only for idempotent HTTP response status codes
- * (i.e. GET, HEAD, OPTIONS).
+ * Gateway filter that enables custom error pages when a proxied application
+ * responds with an error status, applicable only for idempotent HTTP methods
+ * (e.g., GET, HEAD, OPTIONS).
*
- * {@link GatewayFilterFactory} providing a {@link GatewayFilter} that throws a
- * {@link ResponseStatusException} with the proxied response status code if the
- * target responded with a {@code 400...} or {@code 500...} status code.
- *
+ * This {@link GatewayFilterFactory} provides a {@link GatewayFilter} that
+ * throws a {@link ResponseStatusException} with the response status code if the
+ * proxied service returns a {@code 400...} or {@code 500...} status. The
+ * gateway will then apply its custom error handling.
+ *
*
- * Usage: to enable it globally, add this to application.yaml :
- *
+ * Usage: To enable this filter globally, add the following to
+ * {@code application.yaml}:
+ *
+ *
*
*
* spring:
@@ -58,10 +61,12 @@
* - ApplicationError
*
*
- *
- * To enable it only on some routes, add this to concerned routes in
- * {@literal routes.yaml}:
- *
+ *
+ *
+ * To enable it only for specific routes, configure the filter in
+ * {@code routes.yaml}:
+ *
+ *
*
*
* filters:
@@ -81,11 +86,22 @@ public GatewayFilter apply(final Object config) {
return new ServiceErrorGatewayFilter();
}
+ /**
+ * Gateway filter that intercepts error responses and triggers a
+ * {@link ResponseStatusException} to allow the gateway to render a custom error
+ * page.
+ */
private class ServiceErrorGatewayFilter implements GatewayFilter, Ordered {
+
/**
- * @return {@link Ordered#HIGHEST_PRECEDENCE} or
- * {@link ApplicationErrorConveyorHttpResponse#beforeCommit(Supplier)}
- * won't be called
+ * Returns the order of this filter to ensure it runs at the highest precedence.
+ *
+ * This is necessary so that
+ * {@link ApplicationErrorConveyorHttpResponse#beforeCommit(Supplier)} gets
+ * executed properly.
+ *
+ *
+ * @return {@link Ordered#HIGHEST_PRECEDENCE}
*/
@Override
public int getOrder() {
@@ -93,11 +109,18 @@ public int getOrder() {
}
/**
- * If the request method is idempotent and accepts {@literal text/html}, applies
- * a filter that when the routed response receives an error status code, will
- * throw a {@link ResponseStatusException} with the same status, for the gateway
- * to apply the customized error template, also when the status code comes from
- * a proxied service response
+ * Applies the filter logic by wrapping the response in a decorator that checks
+ * for error statuses.
+ *
+ * If the request method is idempotent and the request accepts
+ * {@code text/html}, the response is wrapped in a
+ * {@link ApplicationErrorConveyorHttpResponse}, which throws a
+ * {@link ResponseStatusException} if an error status code is encountered.
+ *
+ *
+ * @param exchange the current server exchange
+ * @param chain the gateway filter chain
+ * @return a {@link Mono} that completes when the filter chain is executed
*/
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
@@ -108,16 +131,37 @@ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
}
}
+ /**
+ * Wraps the server exchange's response with an
+ * {@link ApplicationErrorConveyorHttpResponse} to intercept error statuses.
+ *
+ * @param exchange the server exchange to decorate
+ * @return a new {@link ServerWebExchange} instance with the decorated response
+ */
ServerWebExchange decorate(ServerWebExchange exchange) {
var response = new ApplicationErrorConveyorHttpResponse(exchange.getResponse());
exchange = exchange.mutate().response(response).build();
return exchange;
}
+ /**
+ * Determines if the request should be filtered based on method idempotency and
+ * accepted content types.
+ *
+ * @param request the incoming HTTP request
+ * @return {@code true} if the request should be filtered, {@code false}
+ * otherwise
+ */
boolean canFilter(ServerHttpRequest request) {
return methodIsIdempotent(request.getMethod()) && acceptsHtml(request);
}
+ /**
+ * Checks if the request method is idempotent (i.e., does not modify state).
+ *
+ * @param method the HTTP method to check
+ * @return {@code true} if the method is idempotent, {@code false} otherwise
+ */
boolean methodIsIdempotent(HttpMethod method) {
return switch (method) {
case GET, HEAD, OPTIONS, TRACE -> true;
@@ -125,15 +169,21 @@ boolean methodIsIdempotent(HttpMethod method) {
};
}
+ /**
+ * Determines whether the request accepts HTML responses.
+ *
+ * @param request the incoming HTTP request
+ * @return {@code true} if the request accepts {@code text/html}, {@code false}
+ * otherwise
+ */
boolean acceptsHtml(ServerHttpRequest request) {
return request.getHeaders().getAccept().stream().anyMatch(MediaType.TEXT_HTML::isCompatibleWith);
}
/**
- * A response decorator that throws a {@link ResponseStatusException} at
- * {@link #beforeCommit} if the status code is an error code, thus letting the
- * gateway render the appropriate custom error page instead of the original
- * application response body.
+ * A response decorator that throws a {@link ResponseStatusException} in
+ * {@link #beforeCommit} if the status code is an error, allowing the gateway to
+ * handle the error with a custom response page.
*/
private static class ApplicationErrorConveyorHttpResponse extends ServerHttpResponseDecorator {
@@ -148,6 +198,10 @@ public void beforeCommit(Supplier extends Mono> action) {
super.beforeCommit(() -> checkedAction);
}
+ /**
+ * Throws a {@link ResponseStatusException} if the response status is in the 4xx
+ * or 5xx range, allowing the gateway to apply custom error handling.
+ */
private void checkStatusCode() {
HttpStatus statusCode = getStatusCode();
log.debug("native status code: {}", statusCode);
diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/global/LoginParamRedirectGatewayFilterFactory.java b/gateway/src/main/java/org/georchestra/gateway/filter/global/LoginParamRedirectGatewayFilterFactory.java
index 3856a80f..f5c89de7 100644
--- a/gateway/src/main/java/org/georchestra/gateway/filter/global/LoginParamRedirectGatewayFilterFactory.java
+++ b/gateway/src/main/java/org/georchestra/gateway/filter/global/LoginParamRedirectGatewayFilterFactory.java
@@ -48,12 +48,18 @@
import reactor.core.publisher.Mono;
/**
- * {@link GatewayFilterFactory} that redirects to {@literal /login} if the
- * request's query string contains a {@literal login} parameter and the request
- * is not already authenticated.
+ * {@link GatewayFilterFactory} that redirects unauthenticated requests to
+ * {@literal /login} when the query string contains a {@literal login}
+ * parameter.
*
- * Sample usage:
- *
+ * This filter applies only to idempotent HTTP methods (GET, HEAD, OPTIONS,
+ * TRACE) and ensures that authenticated users proceed without redirection.
+ *
+ *
+ * Usage: Add the following to {@code application.yaml} to enable this
+ * filter for specific routes:
+ *
+ *
*
*
* spring:
@@ -66,12 +72,11 @@
* - LoginParamRedirect
*
*
- *
*/
@Slf4j
public class LoginParamRedirectGatewayFilterFactory extends AbstractGatewayFilterFactory {
- private static final Set redirectMethods = Set.of(GET, HEAD, OPTIONS, TRACE);
+ private static final Set REDIRECT_METHODS = Set.of(GET, HEAD, OPTIONS, TRACE);
@Override
public LoginParamRedirectGatewayFilter apply(Object config) {
@@ -82,6 +87,10 @@ public LoginParamRedirectGatewayFilter apply(Object config) {
return new LoginParamRedirectGatewayFilter(delegate);
}
+ /**
+ * Gateway filter that applies redirection logic when an unauthenticated request
+ * contains a {@code login} query parameter.
+ */
@RequiredArgsConstructor
public static class LoginParamRedirectGatewayFilter implements GatewayFilter {
@@ -90,10 +99,24 @@ public static class LoginParamRedirectGatewayFilter implements GatewayFilter {
private final @NonNull GatewayFilter delegate;
+ /**
+ * Intercepts requests and redirects to {@code /login} if:
+ *
+ * The HTTP method is idempotent (GET, HEAD, OPTIONS, TRACE)
+ * The request contains a {@code login} query parameter
+ * The user is not authenticated
+ *
+ * If the user is already authenticated, the request proceeds without
+ * redirection.
+ *
+ * @param exchange the current server exchange
+ * @param chain the gateway filter chain
+ * @return a {@link Mono} that completes when the filter chain is executed
+ */
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
HttpMethod method = exchange.getRequest().getMethod();
- if (redirectMethods.contains(method) && containsLoginQueryParam(exchange)) {
+ if (REDIRECT_METHODS.contains(method) && containsLoginQueryParam(exchange)) {
log.info("Applying ?login query param redirect filter for {} {}", method,
exchange.getRequest().getURI());
return redirectToLoginIfNotAuthenticated(exchange, chain);
@@ -101,33 +124,50 @@ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return chain.filter(exchange);
}
+ /**
+ * Redirects the user to {@code /login} if they are not authenticated.
+ *
+ * @param exchange the server exchange
+ * @param chain the gateway filter chain
+ * @return a {@link Mono} that either redirects to {@code /login} or proceeds
+ * with the request if already authenticated
+ */
private Mono redirectToLoginIfNotAuthenticated(ServerWebExchange exchange, GatewayFilterChain chain) {
-
return getAuthentication()//
.filter(Authentication::isAuthenticated)//
.switchIfEmpty(Mono.just(UNAUTHENTICATED))//
.flatMap(authentication -> {
- // delegate to the redirect filter otherwise
if (authentication instanceof AnonymousAuthenticationToken) {
- log.info("redirecting to /login: {}", exchange.getRequest().getURI());
+ log.info("Redirecting to /login: {}", exchange.getRequest().getURI());
return delegate.filter(exchange, chain);
}
- // proceed if already authenticated
- log.info("already authenticated ({}), proceeding without redirection to /login",
+ log.info("Already authenticated ({}), proceeding without redirection to /login",
authentication.getName());
return chain.filter(exchange);
});
}
+ /**
+ * Retrieves the current authentication context.
+ *
+ * @return a {@link Mono} containing the {@link Authentication} object, or an
+ * empty Mono if unavailable
+ */
@VisibleForTesting
public Mono getAuthentication() {
return ReactiveSecurityContextHolder.getContext().map(SecurityContext::getAuthentication);
}
+ /**
+ * Checks if the request contains a {@code login} query parameter.
+ *
+ * @param exchange the server exchange
+ * @return {@code true} if the query parameter is present, {@code false}
+ * otherwise
+ */
private boolean containsLoginQueryParam(ServerWebExchange exchange) {
ServerHttpRequest request = exchange.getRequest();
return request.getQueryParams().containsKey("login");
}
-
}
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/global/ResolveTargetGlobalFilter.java b/gateway/src/main/java/org/georchestra/gateway/filter/global/ResolveTargetGlobalFilter.java
index 9458eed2..deee655f 100644
--- a/gateway/src/main/java/org/georchestra/gateway/filter/global/ResolveTargetGlobalFilter.java
+++ b/gateway/src/main/java/org/georchestra/gateway/filter/global/ResolveTargetGlobalFilter.java
@@ -46,38 +46,51 @@
import reactor.core.publisher.Mono;
/**
- * A {@link GlobalFilter} that resolves the {@link GeorchestraTargetConfig
- * configuration} for the request's matched {@link Route} and
- * {@link GeorchestraTargetConfig#setTarget stores} it to be
- * {@link GeorchestraTargetConfig#getTarget acquired} by non-global filters as
- * needed.
+ * A {@link GlobalFilter} that resolves and stores the
+ * {@link GeorchestraTargetConfig} for the matched {@link Route}, enabling
+ * subsequent filters to access configuration details such as role-based access
+ * rules and HTTP header mappings.
+ *
+ * This filter executes after user resolution in
+ * {@link ResolveGeorchestraUserGlobalFilter} and before request routing in
+ * {@link RouteToRequestUrlFilter}.
+ *
*/
@RequiredArgsConstructor
@Slf4j
public class ResolveTargetGlobalFilter implements GlobalFilter, Ordered {
+ /**
+ * The execution order of this filter, ensuring it runs after user resolution
+ * but before request routing.
+ */
public static final int ORDER = ResolveGeorchestraUserGlobalFilter.ORDER + 1;
private final @NonNull GatewayConfigProperties config;
/**
- * @return a lower precedence than {@link RouteToRequestUrlFilter}'s, in order
- * to make sure the matched {@link Route} has been set as a
- * {@link ServerWebExchange#getAttributes attribute} when
- * {@link #filter} is called.
+ * Ensures that this filter runs after the matched {@link Route} has been set as
+ * an attribute in the {@link ServerWebExchange}.
+ *
+ * @return the execution order of this filter
*/
- public @Override int getOrder() {
+ @Override
+ public int getOrder() {
return ResolveTargetGlobalFilter.ORDER;
}
/**
- * Resolves the matched {@link Route} and its corresponding
- * {@link GeorchestraTargetConfig}, if possible, and proceeds with the filter
- * chain.
+ * Resolves the {@link GeorchestraTargetConfig} for the matched {@link Route}
+ * and stores it in the request exchange attributes.
+ *
+ * @param exchange the current server exchange
+ * @param chain the gateway filter chain
+ * @return a {@link Mono} that proceeds with the filter chain execution
*/
- public @Override Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
+ @Override
+ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
Route route = (Route) exchange.getAttributes().get(GATEWAY_ROUTE_ATTR);
- Objects.requireNonNull(route, "no route matched, filter shouldn't be hit");
+ Objects.requireNonNull(route, "No route matched, filter should not be executed");
GeorchestraTargetConfig targetConfig = resolveTarget(route);
log.debug("Storing geOrchestra target config for Route {} request context", route.getId());
@@ -85,28 +98,54 @@ public class ResolveTargetGlobalFilter implements GlobalFilter, Ordered {
return chain.filter(exchange);
}
+ /**
+ * Resolves the {@link GeorchestraTargetConfig} for the given route by applying
+ * the service-specific or global access rules and header mappings.
+ *
+ * @param route the matched route
+ * @return a {@link GeorchestraTargetConfig} containing the relevant
+ * configuration
+ */
@VisibleForTesting
@NonNull
GeorchestraTargetConfig resolveTarget(@NonNull Route route) {
-
GeorchestraTargetConfig target = new GeorchestraTargetConfig();
Optional service = findService(route);
-
setAccessRules(target, service);
setHeaderMappings(target, service);
return target;
}
+ /**
+ * Determines the applicable access rules for the target configuration.
+ *
+ * If the matched service defines access rules, they are applied; otherwise, the
+ * global access rules from {@link GatewayConfigProperties} are used.
+ *
+ *
+ * @param target the target configuration to update
+ * @param service the matched service, if available
+ */
private void setAccessRules(GeorchestraTargetConfig target, Optional service) {
List globalAccessRules = config.getGlobalAccessRules();
- var targetAccessRules = service.map(Service::getAccessRules).filter(Objects::nonNull).filter(l -> !l.isEmpty())
- .orElse(globalAccessRules);
+ List targetAccessRules = service.map(Service::getAccessRules).filter(Objects::nonNull)
+ .filter(l -> !l.isEmpty()).orElse(globalAccessRules);
target.accessRules(targetAccessRules);
}
+ /**
+ * Determines the applicable HTTP header mappings for the target configuration.
+ *
+ * If the matched service defines custom header mappings, they are merged with
+ * the global default headers. Otherwise, only the global defaults are applied.
+ *
+ *
+ * @param target the target configuration to update
+ * @param service the matched service, if available
+ */
private void setHeaderMappings(GeorchestraTargetConfig target, Optional service) {
HeaderMappings defaultHeaders = config.getDefaultHeaders();
HeaderMappings mergedHeaders = service.flatMap(Service::headers)
@@ -115,10 +154,24 @@ private void setHeaderMappings(GeorchestraTargetConfig target, Optional
target.headers(mergedHeaders);
}
+ /**
+ * Merges the default global headers with service-specific headers.
+ *
+ * @param defaults the global default headers
+ * @param service the service-specific headers
+ * @return a merged {@link HeaderMappings} instance
+ */
private HeaderMappings merge(HeaderMappings defaults, HeaderMappings service) {
return defaults.copy().merge(service);
}
+ /**
+ * Finds the matching service definition for the given route.
+ *
+ * @param route the matched route
+ * @return an {@link Optional} containing the matched {@link Service}, or empty
+ * if not found
+ */
private Optional findService(@NonNull Route route) {
final URI routeURI = route.getUri();
@@ -128,8 +181,6 @@ private Optional findService(@NonNull Route route) {
return Optional.of(service);
}
}
-
return Optional.empty();
}
-
-}
\ No newline at end of file
+}
diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/AddSecHeadersGatewayFilterFactory.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/AddSecHeadersGatewayFilterFactory.java
index 17c23382..b15ee2ec 100644
--- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/AddSecHeadersGatewayFilterFactory.java
+++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/AddSecHeadersGatewayFilterFactory.java
@@ -33,39 +33,97 @@
import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Mono;
+/**
+ * {@link AbstractGatewayFilterFactory} that adds geOrchestra-specific security
+ * headers to proxied requests.
+ *
+ * This filter allows customizable security headers to be appended to requests,
+ * using a set of {@link HeaderContributor} providers. If the request exchange
+ * contains the attribute {@link #DISABLE_SECURITY_HEADERS}, the filter is
+ * bypassed.
+ *
+ *
+ * Sample usage in {@code application.yaml} to apply the filter globally:
+ *
+ *
+ *
+ *
+ * spring:
+ * cloud:
+ * gateway:
+ * default-filters:
+ * - AddSecHeaders
+ *
+ *
+ */
public class AddSecHeadersGatewayFilterFactory
extends AbstractGatewayFilterFactory {
+ /**
+ * Attribute key to disable the security headers for a specific request. If this
+ * attribute is present in the request exchange, the filter is skipped.
+ */
public static final String DISABLE_SECURITY_HEADERS = "%s.DISABLE_SECURITY_HEADERS"
.formatted(AddSecHeadersGatewayFilterFactory.class.getName());
private final List providers;
+ /**
+ * Creates a new instance of the security headers filter factory.
+ *
+ * @param providers the list of {@link HeaderContributor} providers that
+ * generate the security headers
+ */
public AddSecHeadersGatewayFilterFactory(List providers) {
super(NameConfig.class);
this.providers = providers;
}
- public @Override List shortcutFieldOrder() {
+ /**
+ * Defines the order of configuration fields for shortcut configuration in
+ * {@code application.yaml}.
+ *
+ * @return the ordered list of configuration field names
+ */
+ @Override
+ public List shortcutFieldOrder() {
return Arrays.asList(NAME_KEY);
}
- public @Override GatewayFilter apply(NameConfig config) {
+ /**
+ * Creates a new {@link GatewayFilter} instance with the configured providers.
+ *
+ * @param config the filter configuration
+ * @return the configured {@link GatewayFilter}
+ */
+ @Override
+ public GatewayFilter apply(NameConfig config) {
return new AddSecHeadersGatewayFilter(providers);
}
+ /**
+ * {@link GatewayFilter} implementation that applies the configured security
+ * headers to proxied requests.
+ */
@RequiredArgsConstructor
private static class AddSecHeadersGatewayFilter implements GatewayFilter, Ordered {
private final @NonNull List providers;
- public @Override Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
+ /**
+ * Applies the configured security headers to the request unless the
+ * {@link #DISABLE_SECURITY_HEADERS} attribute is present.
+ *
+ * @param exchange the current server exchange
+ * @param chain the gateway filter chain
+ * @return a {@link Mono} that proceeds with the filter chain execution
+ */
+ @Override
+ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
if (exchange.getAttribute(DISABLE_SECURITY_HEADERS) == null) {
ServerHttpRequest.Builder requestBuilder = exchange.getRequest().mutate();
- providers.stream()//
- .map(provider -> provider.prepare(exchange))//
- .forEach(requestBuilder::headers);
+ providers.stream().map(provider -> provider.prepare(exchange)).forEach(requestBuilder::headers);
ServerHttpRequest request = requestBuilder.build();
ServerWebExchange updatedExchange = exchange.mutate().request(request).build();
@@ -74,10 +132,15 @@ private static class AddSecHeadersGatewayFilter implements GatewayFilter, Ordere
return chain.filter(exchange);
}
+ /**
+ * Specifies the execution order of this filter to run immediately after
+ * {@link ResolveTargetGlobalFilter}.
+ *
+ * @return the execution order of this filter
+ */
@Override
public int getOrder() {
return ResolveTargetGlobalFilter.ORDER + 1;
}
}
-
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/CookieAffinityGatewayFilterFactory.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/CookieAffinityGatewayFilterFactory.java
index 1073ea8d..6d922e14 100644
--- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/CookieAffinityGatewayFilterFactory.java
+++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/CookieAffinityGatewayFilterFactory.java
@@ -1,3 +1,21 @@
+/*
+ * 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.filter.headers;
import javax.validation.constraints.NotEmpty;
@@ -16,30 +34,99 @@
import lombok.Setter;
import reactor.core.publisher.Mono;
+/**
+ * {@link AbstractGatewayFilterFactory} that modifies the path of a specific
+ * HTTP response cookie, enabling cookie-based session affinity between
+ * different backend services.
+ *
+ * This filter allows rewriting the cookie's path from a specified {@code from}
+ * path to a different {@code to} path. The original domain, security settings,
+ * and expiration remain unchanged.
+ *
+ *
+ * Sample usage in {@code application.yaml} to apply this filter on specific
+ * routes:
+ *
+ *
+ *
+ *
+ * spring:
+ * cloud:
+ * gateway:
+ * routes:
+ * - id: some-service
+ * uri: http://backend-service
+ * filters:
+ * - name: CookieAffinity
+ * args:
+ * name: JSESSIONID
+ * from: /serviceA
+ * to: /serviceB
+ *
+ *
+ */
public class CookieAffinityGatewayFilterFactory
extends AbstractGatewayFilterFactory {
+
+ /**
+ * Creates a new instance of the cookie affinity filter factory.
+ */
public CookieAffinityGatewayFilterFactory() {
super(CookieAffinityGatewayFilterFactory.CookieAffinity.class);
}
+ /**
+ * Creates a {@link GatewayFilter} that applies the cookie path transformation.
+ *
+ * @param config the filter configuration
+ * @return the configured {@link GatewayFilter}
+ */
@Override
public GatewayFilter apply(final CookieAffinityGatewayFilterFactory.CookieAffinity config) {
return new CookieAffinityGatewayFilter(config);
}
+ /**
+ * Configuration class for {@link CookieAffinityGatewayFilterFactory}. Defines
+ * the cookie name and path mapping.
+ */
@Validated
public static class CookieAffinity {
+
+ /**
+ * The name of the cookie to modify.
+ */
private @NotEmpty @Getter @Setter String name;
+
+ /**
+ * The original path of the cookie.
+ */
private @NotEmpty @Getter @Setter String from;
+
+ /**
+ * The new path to which the cookie should be rewritten.
+ */
private @NotEmpty @Getter @Setter String to;
}
+ /**
+ * {@link GatewayFilter} implementation that modifies the path of a specific
+ * cookie in the response headers.
+ */
@RequiredArgsConstructor
private static class CookieAffinityGatewayFilter implements GatewayFilter, Ordered {
private final CookieAffinity config;
- public @Override Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
+ /**
+ * Processes the response to update the path of the specified cookie.
+ *
+ * @param exchange the current server exchange
+ * @param chain the gateway filter chain
+ * @return a {@link Mono} that proceeds with the filter chain execution
+ */
+ @Override
+ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
exchange.getResponse().getHeaders().getValuesAsList("Set-Cookie").stream()
.flatMap(c -> java.net.HttpCookie.parse(c).stream())
@@ -54,6 +141,12 @@ private static class CookieAffinityGatewayFilter implements GatewayFilter, Order
}));
}
+ /**
+ * Specifies the execution order of this filter to run immediately after
+ * {@link ResolveTargetGlobalFilter}.
+ *
+ * @return the execution order of this filter
+ */
@Override
public int getOrder() {
return ResolveTargetGlobalFilter.ORDER + 1;
diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/HeaderContributor.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/HeaderContributor.java
index 1e5c03a9..b4c14a05 100644
--- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/HeaderContributor.java
+++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/HeaderContributor.java
@@ -25,44 +25,61 @@
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.core.Ordered;
import org.springframework.http.HttpHeaders;
-import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.web.server.ServerWebExchange;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
/**
- * Extension point to aid {@link AddSecHeadersGatewayFilterFactory} in appending
- * the required HTTP request headers to proxied requests.
+ * Strategy interface for contributing HTTP request headers to proxied requests.
*
- * Beans of this type are strategy objects that contribute zero or more HTTP
- * request headers to be appended to proxied requests to back-end services.
- *
- * @see SecProxyHeaderContributor
- * @see GeorchestraUserHeadersContributor
- * @see GeorchestraOrganizationHeadersContributor
+ * Implementations of this class define specific security headers that should be
+ * appended to proxied requests based on authentication and request context.
+ *
+ *
+ * These implementations are used by {@link AddSecHeadersGatewayFilterFactory}
+ * to determine which headers should be added.
+ *
+ *
+ * Implementations
+ *
+ * {@link SecProxyHeaderContributor}: Appends the {@code sec-proxy}
+ * header
+ * {@link GeorchestraUserHeadersContributor}: Adds user-related security
+ * headers
+ * {@link GeorchestraOrganizationHeadersContributor}: Appends organization
+ * information headers
+ * {@link JsonPayloadHeadersContributor}: Encodes security attributes as a
+ * JSON payload
+ *
+ *
+ * @see AddSecHeadersGatewayFilterFactory
*/
@Slf4j(topic = "org.georchestra.gateway.filter.headers")
public abstract class HeaderContributor implements Ordered {
/**
- * Prepare a header contributor for the given HTTP request-response interaction.
+ * Prepares a consumer that modifies {@link HttpHeaders} for a proxied request.
*
- * The returned consumer will {@link HttpHeaders#set(String, String) set} or
- * {@link HttpHeaders#add(String, String) add} whatever request headers are
- * appropriate for the backend service.
+ * Implementations should return a consumer that either sets or appends headers
+ * based on the security context and request attributes.
+ *
+ *
+ * @param exchange the current {@link ServerWebExchange}
+ * @return a {@link Consumer} that modifies the request headers
*/
public abstract Consumer prepare(ServerWebExchange exchange);
/**
* {@inheritDoc}
- *
- * @return {@code 0} as default order, implementations should override as needed
- * in case they need to apply their customizations to
- * {@link ServerHttpSecurity} in a specific order.
+ *
+ * @return {@code 0} as the default order. Implementations may override this
+ * method to control execution order when multiple contributors are
+ * applied.
* @see Ordered#HIGHEST_PRECEDENCE
* @see Ordered#LOWEST_PRECEDENCE
*/
@@ -70,21 +87,46 @@ public abstract class HeaderContributor implements Ordered {
return 0;
}
+ /**
+ * Appends a header to the request if it is enabled and has a valid value.
+ *
+ * @param target the target {@link HttpHeaders}
+ * @param header the header name
+ * @param enabled whether the header should be included
+ * @param value the header value
+ */
protected void add(@NonNull HttpHeaders target, @NonNull String header, @NonNull Optional enabled,
@NonNull Optional value) {
add(target, header, enabled, value.orElse(null));
}
+ /**
+ * Appends a header to the request if it is enabled and has a valid list of
+ * values.
+ *
+ * @param target the target {@link HttpHeaders}
+ * @param header the header name
+ * @param enabled whether the header should be included
+ * @param values the list of header values
+ */
protected void add(@NonNull HttpHeaders target, @NonNull String header, @NonNull Optional enabled,
@NonNull List values) {
String val = values.isEmpty() ? null : values.stream().collect(Collectors.joining(";"));
add(target, header, enabled, val);
}
+ /**
+ * Appends a header to the request if it is enabled and has a valid value.
+ *
+ * @param target the target {@link HttpHeaders}
+ * @param header the header name
+ * @param enabled whether the header should be included
+ * @param value the header value
+ */
protected void add(@NonNull HttpHeaders target, @NonNull String header, @NonNull Optional enabled,
String value) {
- if (enabled.orElse(Boolean.FALSE).booleanValue()) {
- if (null == value) {
+ if (enabled.orElse(Boolean.FALSE)) {
+ if (value == null) {
log.trace("Value for header {} is not present", header);
} else {
log.debug("Appending header {}: {}", header, value);
@@ -95,8 +137,15 @@ protected void add(@NonNull HttpHeaders target, @NonNull String header, @NonNull
}
}
+ /**
+ * Appends a header to the request if it has a valid value.
+ *
+ * @param target the target {@link HttpHeaders}
+ * @param header the header name
+ * @param value the header value
+ */
protected void add(@NonNull HttpHeaders target, @NonNull String header, String value) {
- if (null == value) {
+ if (value == null) {
log.trace("Value for header {} is not present", header);
} else {
log.debug("Appending header {}: {}", header, value);
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 fe30e6f2..bb1966dc 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
@@ -32,55 +32,100 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+/**
+ * {@link Configuration} for security-related header filters in the gateway.
+ *
+ * This configuration defines various {@link GatewayFilterFactory} beans for
+ * handling geOrchestra security headers in proxied requests.
+ *
+ *
+ * @see AddSecHeadersGatewayFilterFactory
+ * @see RemoveHeadersGatewayFilterFactory
+ * @see RemoveSecurityHeadersGatewayFilterFactory
+ */
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(GatewayConfigProperties.class)
public class HeaderFiltersConfiguration {
/**
- * {@link GatewayFilterFactory} to add all necessary {@literal sec-*} request
+ * {@link GatewayFilterFactory} to append all necessary {@code sec-*} request
* headers to proxied requests.
- *
+ *
* @param providers the list of configured {@link HeaderContributor}s in the
* {@link ApplicationContext}
- * @see #secProxyHeaderProvider()
+ * @return the configured {@link AddSecHeadersGatewayFilterFactory}
+ * @see #secProxyHeaderProvider(GatewayConfigProperties)
* @see #userSecurityHeadersProvider()
* @see #organizationSecurityHeadersProvider()
+ * @see #jsonPayloadHeadersContributor()
*/
-
@Bean
AddSecHeadersGatewayFilterFactory addSecHeadersGatewayFilterFactory(List providers) {
return new AddSecHeadersGatewayFilterFactory(providers);
}
+ /**
+ * {@link GatewayFilterFactory} that modifies the affinity of a cookie by
+ * rewriting its path.
+ *
+ * @return the configured {@link CookieAffinityGatewayFilterFactory}
+ */
@Bean
CookieAffinityGatewayFilterFactory cookieAffinityGatewayFilterFactory() {
return new CookieAffinityGatewayFilterFactory();
}
+ /**
+ * {@link HeaderContributor} that appends geOrchestra user-related {@code sec-*}
+ * request headers.
+ *
+ * @return the configured {@link GeorchestraUserHeadersContributor}
+ */
@Bean
GeorchestraUserHeadersContributor userSecurityHeadersProvider() {
return new GeorchestraUserHeadersContributor();
}
+ /**
+ * {@link HeaderContributor} that appends the {@code sec-proxy} request header
+ * when enabled in the gateway configuration.
+ *
+ * @param configProps the gateway security configuration properties
+ * @return the configured {@link SecProxyHeaderContributor}
+ */
@Bean
SecProxyHeaderContributor secProxyHeaderProvider(GatewayConfigProperties configProps) {
BooleanSupplier secProxyEnabledSupplier = () -> configProps.getDefaultHeaders().getProxy().orElse(false);
return new SecProxyHeaderContributor(secProxyEnabledSupplier);
}
+ /**
+ * {@link HeaderContributor} that appends geOrchestra organization-related
+ * {@code sec-*} request headers.
+ *
+ * @return the configured {@link GeorchestraOrganizationHeadersContributor}
+ */
@Bean
GeorchestraOrganizationHeadersContributor organizationSecurityHeadersProvider() {
return new GeorchestraOrganizationHeadersContributor();
}
+ /**
+ * {@link HeaderContributor} that appends {@code sec-user} and
+ * {@code sec-organization} Base64-encoded JSON payloads.
+ *
+ * @return the configured {@link JsonPayloadHeadersContributor}
+ */
@Bean
JsonPayloadHeadersContributor jsonPayloadHeadersContributor() {
return new JsonPayloadHeadersContributor();
}
/**
- * General purpose {@link GatewayFilterFactory} to remove incoming HTTP request
- * headers based on a Java regular expression
+ * General-purpose {@link GatewayFilterFactory} to remove incoming HTTP request
+ * headers based on a Java regular expression.
+ *
+ * @return the configured {@link RemoveHeadersGatewayFilterFactory}
*/
@Bean
RemoveHeadersGatewayFilterFactory removeHeadersGatewayFilterFactory() {
@@ -88,8 +133,10 @@ RemoveHeadersGatewayFilterFactory removeHeadersGatewayFilterFactory() {
}
/**
- * {@link GatewayFilterFactory} to remove incoming HTTP {@literal sec-*} HTTP
- * request headers to prevent impersonation from outside
+ * {@link GatewayFilterFactory} to remove incoming {@code sec-*} HTTP request
+ * headers to prevent impersonation from external sources.
+ *
+ * @return the configured {@link RemoveSecurityHeadersGatewayFilterFactory}
*/
@Bean
RemoveSecurityHeadersGatewayFilterFactory removeSecurityHeadersGatewayFilterFactory() {
diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/RemoveHeadersGatewayFilterFactory.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/RemoveHeadersGatewayFilterFactory.java
index 196e967e..bb0195b8 100644
--- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/RemoveHeadersGatewayFilterFactory.java
+++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/RemoveHeadersGatewayFilterFactory.java
@@ -19,7 +19,6 @@
package org.georchestra.gateway.filter.headers;
import java.util.Arrays;
-import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -41,12 +40,18 @@
* {@link GatewayFilterFactory} to remove incoming HTTP request headers whose
* names match a Java regular expression.
*
- * Use a {@code RemoveHeaders=} filter in a
- * {@code spring.cloud.gateway.routes.filters} route config to remove all
- * incoming request headers matching the regex.
+ * This filter ensures that unwanted headers are stripped from incoming requests
+ * before being forwarded to backend services, improving security and request
+ * integrity.
+ *
*
- * Sample usage:
- *
+ * Usage:
+ *
+ *
+ * Add a {@code RemoveHeaders=} filter to a route in the
+ * {@code spring.cloud.gateway.routes.filters} configuration.
+ *
+ *
*
*
* spring:
@@ -59,7 +64,26 @@
* - RemoveHeaders=(?i)(sec-.*|Authorization)
*
*
- *
+ *
+ *
+ * Since version {@code 1.2.0}, the regular expression can match both header
+ * names and values. This allows filtering specific header values while
+ * preserving others. For example, to strip Basic authentication headers but
+ * keep Bearer tokens, the following configuration can be used:
+ *
+ *
+ *
+ *
+ * spring:
+ * cloud:
+ * gateway:
+ * routes:
+ * - id: root
+ * uri: http://backend-service/context
+ * filters:
+ * - RemoveHeaders=(?i)^(sec-.*|Authorization:(?!\s*Bearer\s*$))
+ *
+ *
*/
@Slf4j(topic = "org.georchestra.gateway.filter.headers")
public class RemoveHeadersGatewayFilterFactory extends AbstractGatewayFilterFactory {
@@ -76,55 +100,95 @@ public List shortcutFieldOrder() {
@Override
public GatewayFilter apply(RegExConfig regexConfig) {
return (exchange, chain) -> {
- final RegExConfig config = regexConfig;// == null ? DEFAULT_SECURITY_HEADERS_CONFIG : regexConfig;
-
- ServerHttpRequest request = exchange.getRequest().mutate().headers(config::removeMatching).build();
+ ServerHttpRequest request = exchange.getRequest().mutate().headers(regexConfig::removeMatching).build();
exchange = exchange.mutate().request(request).build();
-
return chain.filter(exchange);
};
}
+ /**
+ * Configuration class that holds the regular expression for header removal.
+ *
+ * The provided regular expression is compiled and used to match both header
+ * names and values. Headers that match the pattern are removed from incoming
+ * requests before they are forwarded.
+ *
+ */
@NoArgsConstructor
public static class RegExConfig {
private @Getter String regEx;
-
private Pattern compiled;
+ /**
+ * Constructs a {@link RegExConfig} with the given regular expression.
+ *
+ * @param regEx the regular expression for matching headers to remove
+ */
public RegExConfig(String regEx) {
setRegEx(regEx);
}
+ /**
+ * Sets the regular expression used to match header names and values.
+ *
+ * @param regEx the regular expression to use
+ */
public void setRegEx(String regEx) {
- Objects.requireNonNull(regEx, "regular expression can't be null");
+ Objects.requireNonNull(regEx, "Regular expression can't be null");
this.regEx = regEx;
this.compiled = Pattern.compile(regEx);
}
private Pattern pattern() {
- Objects.requireNonNull(compiled, "regular expression can't be null");
+ Objects.requireNonNull(compiled, "Regular expression is not initialized");
return compiled;
}
+ /**
+ * Checks if any headers in the given {@link HttpHeaders} match the configured
+ * regular expression.
+ *
+ * @param httpHeaders the HTTP headers to check
+ * @return {@code true} if any headers match, otherwise {@code false}
+ */
boolean anyMatches(@NonNull HttpHeaders httpHeaders) {
- return httpHeaders.keySet().stream().anyMatch(h -> this.matches(h, httpHeaders.get(h)));
+ return httpHeaders.keySet().stream().anyMatch(header -> matches(header, httpHeaders.get(header)));
}
+ /**
+ * Checks if a given header name or its value matches the configured regular
+ * expression.
+ *
+ * @param headerNameOrTuple the header name or header name-value pair
+ * @return {@code true} if it matches, otherwise {@code false}
+ */
boolean matches(@NonNull String headerNameOrTuple) {
return pattern().matcher(headerNameOrTuple).matches();
}
+ /**
+ * Checks if a given header name and its values match the configured regular
+ * expression.
+ *
+ * @param headerName the name of the header
+ * @param values the list of header values
+ * @return {@code true} if any value matches, otherwise {@code false}
+ */
boolean matches(@NonNull String headerName, List values) {
return values.stream().map(value -> "%s: %s".formatted(headerName, value)).anyMatch(this::matches);
}
+ /**
+ * Removes all headers from the given {@link HttpHeaders} that match the
+ * configured regular expression.
+ *
+ * @param headers the HTTP headers from which matching headers should be removed
+ */
void removeMatching(@NonNull HttpHeaders headers) {
- List.copyOf(headers.entrySet()).stream().filter(e -> matches(e.getKey()))//
- .filter(e -> matches(e.getKey(), e.getValue())).map(Map.Entry::getKey)//
- .peek(name -> log.trace("Removing header {}", name))//
+ List.copyOf(headers.entrySet()).stream().filter(entry -> matches(entry.getKey(), entry.getValue()))
+ .map(Map.Entry::getKey).peek(name -> log.trace("Removing header {}", name))
.forEach(headers::remove);
}
}
-
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/RemoveSecurityHeadersGatewayFilterFactory.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/RemoveSecurityHeadersGatewayFilterFactory.java
index 57d1a76d..dea0a67d 100644
--- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/RemoveSecurityHeadersGatewayFilterFactory.java
+++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/RemoveSecurityHeadersGatewayFilterFactory.java
@@ -23,12 +23,26 @@
import org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory;
/**
- * Georchestra-specific {@link GatewayFilterFactory} to remove all incoming
- * {@code sec-*} and {@code Authorization} (basic auth) request headers, hence
- * preventing impersonating geOrchestra authenticated users from incoming
- * requests.
+ * A geOrchestra-specific {@link GatewayFilterFactory} that removes all incoming
+ * security-related request headers, preventing unauthorized impersonation of
+ * authenticated users.
*
- * Sample usage:
+ * This filter is designed to strip:
+ *
+ *
+ * All headers prefixed with {@code sec-*}, which geOrchestra uses for
+ * user-related security information.
+ * The {@code Authorization} header when it contains Basic authentication
+ * credentials.
+ *
+ *
+ * By removing these headers from incoming requests, the gateway ensures that
+ * authentication and authorization are enforced properly and prevents external
+ * clients from injecting unauthorized credentials.
+ *
+ *
+ * Usage example:
+ *
*
*
*
@@ -53,11 +67,23 @@ public class RemoveSecurityHeadersGatewayFilterFactory extends AbstractGatewayFi
private final RemoveHeadersGatewayFilterFactory.RegExConfig config = new RemoveHeadersGatewayFilterFactory.RegExConfig(
DEFAULT_SEC_HEADERS_PATTERN);
+ /**
+ * Creates a new instance of {@code RemoveSecurityHeadersGatewayFilterFactory}
+ * that removes security-sensitive headers from incoming requests.
+ */
public RemoveSecurityHeadersGatewayFilterFactory() {
super(Object.class);
delegate = new RemoveHeadersGatewayFilterFactory();
}
+ /**
+ * Applies the filter by delegating to {@link RemoveHeadersGatewayFilterFactory}
+ * with a pre-configured regular expression that matches security-related
+ * headers.
+ *
+ * @param unused the configuration object (not used)
+ * @return a {@link GatewayFilter} instance that removes security headers
+ */
@Override
public GatewayFilter apply(Object unused) {
return delegate.apply(config);
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 1c3a3e91..9e80b7ab 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
@@ -28,18 +28,44 @@
import org.springframework.http.HttpHeaders;
import org.springframework.web.server.ServerWebExchange;
+/**
+ * {@link HeaderContributor} that appends organization-related security headers
+ * to proxied requests.
+ *
+ * This contributor extracts organization information from the current request
+ * context and applies the configured security headers based on
+ * {@link GeorchestraTargetConfig}.
+ *
+ *
+ * Appended Headers
+ *
+ * {@code sec-orgname} - Organization name
+ * {@code sec-orgid} - Organization ID
+ * {@code sec-org-lastupdated} - Last updated timestamp of the
+ * organization
+ *
+ */
public class GeorchestraOrganizationHeadersContributor extends HeaderContributor {
+ /**
+ * Prepares a header contributor that appends organization-related security
+ * headers to the request.
+ *
+ * Headers are only added if the organization is resolved from the request and
+ * the corresponding configuration enables them.
+ *
+ *
+ * @param exchange the current {@link ServerWebExchange}
+ * @return a {@link Consumer} that modifies the request headers
+ */
public @Override Consumer prepare(ServerWebExchange exchange) {
return headers -> {
- GeorchestraTargetConfig.getTarget(exchange)//
- .map(GeorchestraTargetConfig::headers)//
- .ifPresent(mappings -> {
- Optional org = GeorchestraOrganizations.resolve(exchange);
- add(headers, "sec-orgname", mappings.getOrgname(), org.map(Organization::getName));
- add(headers, "sec-orgid", mappings.getOrgid(), org.map(Organization::getId));
- add(headers, "sec-org-lastupdated", mappings.getOrgid(), org.map(Organization::getLastUpdated));
- });
+ GeorchestraTargetConfig.getTarget(exchange).map(GeorchestraTargetConfig::headers).ifPresent(mappings -> {
+ Optional org = GeorchestraOrganizations.resolve(exchange);
+ add(headers, "sec-orgname", mappings.getOrgname(), org.map(Organization::getName));
+ 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 dc8f354d..342385e6 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
@@ -45,16 +45,48 @@
import org.springframework.web.server.ServerWebExchange;
/**
- * Contributes user-related {@literal sec-*} request headers.
- *
- * @see GeorchestraUsers#resolve
- * @see GeorchestraTargetConfig
+ * {@link HeaderContributor} that appends user-related {@literal sec-*} security
+ * headers to proxied requests.
+ *
+ * This contributor extracts user information from the current request context
+ * and applies the configured security headers based on
+ * {@link GeorchestraTargetConfig}.
+ *
+ *
+ * Appended Headers
+ *
+ * {@code sec-userid} - User ID
+ * {@code sec-username} - Username
+ * {@code sec-org} - Organization
+ * {@code sec-email} - Email address
+ * {@code sec-firstname} - First name
+ * {@code sec-lastname} - Last name
+ * {@code sec-tel} - Telephone number
+ * {@code sec-roles} - List of user roles
+ * {@code sec-lastupdated} - Last updated timestamp
+ * {@code sec-address} - Postal address
+ * {@code sec-title} - User title
+ * {@code sec-notes} - Notes
+ * {@code sec-ldap-remaining-days} - LDAP password expiration warning
+ * {@code sec-external-authentication} - Whether the user is authenticated
+ * externally
+ *
*/
public class GeorchestraUserHeadersContributor extends HeaderContributor {
+ /**
+ * Prepares a header contributor that appends user-related security headers to
+ * the request.
+ *
+ * Headers are only added if the user is resolved from the request and the
+ * corresponding configuration enables them.
+ *
+ *
+ * @param exchange the current {@link ServerWebExchange}
+ * @return a {@link Consumer} that modifies the request headers
+ */
public @Override Consumer prepare(ServerWebExchange exchange) {
- return headers -> GeorchestraTargetConfig.getTarget(exchange)//
- .map(GeorchestraTargetConfig::headers)//
+ 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));
@@ -66,19 +98,19 @@ public class GeorchestraUserHeadersContributor extends HeaderContributor {
add(headers, SEC_TEL, mappings.getTel(), user.map(GeorchestraUser::getTelephoneNumber));
List roles = user.map(GeorchestraUser::getRoles).orElse(List.of());
-
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_LDAP_REMAINING_DAYS,
- Optional.of(
- user.isPresent() && user.get().getLdapWarn() != null && user.get().getLdapWarn()),
+ Optional.of(user.isPresent() && Boolean.TRUE.equals(user.get().getLdapWarn())),
user.map(GeorchestraUser::getLdapRemainingDays));
+
add(headers, SEC_EXTERNAL_AUTHENTICATION, Optional.of(user.isPresent()),
- String.valueOf(user.isPresent() && user.get().getIsExternalAuth()));
+ String.valueOf(user.isPresent() && Boolean.TRUE.equals(user.get().getIsExternalAuth())));
});
}
}
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
index f72b1e36..59be43a7 100644
--- 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
@@ -23,7 +23,6 @@
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;
@@ -40,11 +39,24 @@
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.
- *
+ * {@link HeaderContributor} that appends user and organization information as
+ * Base64-encoded JSON payloads to proxied requests.
+ *
+ * This contributor enables the following security headers based on the matched
+ * route configuration:
+ *
+ *
+ * {@code sec-user} - Contains a Base64-encoded JSON representation of the
+ * authenticated {@link GeorchestraUser}.
+ * {@code sec-organization} - Contains a Base64-encoded JSON representation
+ * of the resolved {@link Organization}.
+ *
+ *
+ * The encoding process ensures the data is included only when explicitly
+ * enabled via {@link HeaderMappings#getJsonUser()} and
+ * {@link HeaderMappings#getJsonOrganization()}.
+ *
+ *
* @see GeorchestraUsers#resolve
* @see GeorchestraOrganizations#resolve
* @see GeorchestraTargetConfig
@@ -52,35 +64,45 @@
public class JsonPayloadHeadersContributor extends HeaderContributor {
/**
- * Encoder to create the JSON String value for a {@link GeorchestraUser}
- * obtained from {@link OrganizationsApiImpl}
+ * JSON encoder for serializing {@link GeorchestraUser} and {@link Organization}
+ * objects.
*/
- private ObjectMapper encoder;
+ private final ObjectMapper encoder;
+ /**
+ * Initializes a new {@link JsonPayloadHeadersContributor} with a configured
+ * JSON 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.configure(SerializationFeature.INDENT_OUTPUT, false);
+ this.encoder.configure(SerializationFeature.WRITE_SINGLE_ELEM_ARRAYS_UNWRAPPED, false);
this.encoder.setSerializationInclusion(Include.NON_NULL);
}
+ /**
+ * Prepares a header contributor that appends JSON-based security headers for
+ * the request.
+ *
+ * @param exchange the current {@link ServerWebExchange}
+ * @return a {@link Consumer} that modifies the request headers
+ */
public @Override Consumer prepare(ServerWebExchange exchange) {
- return headers -> GeorchestraTargetConfig.getTarget(exchange)//
- .map(GeorchestraTargetConfig::headers)//
+ return headers -> GeorchestraTargetConfig.getTarget(exchange).map(GeorchestraTargetConfig::headers)
.ifPresent(mappings -> addJsonPayloads(exchange, mappings, headers));
}
private void addJsonPayloads(final ServerWebExchange exchange, final HeaderMappings mappings, HttpHeaders headers) {
Optional user = GeorchestraUsers.resolve(exchange);
Optional org = GeorchestraOrganizations.resolve(exchange);
+
addJson(headers, "sec-user", mappings.getJsonUser().orElse(false), user);
addJson(headers, "sec-organization", mappings.getJsonOrganization().orElse(false), org);
}
private void addJson(HttpHeaders target, String headerName, boolean enabled, Optional> toEncode) {
if (enabled) {
- toEncode.map(this::encodeJson)//
- .map(this::encodeBase64)//
+ toEncode.map(this::encodeJson).map(this::encodeBase64)
.ifPresent(encoded -> target.add(headerName, encoded));
}
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/SecProxyHeaderContributor.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/SecProxyHeaderContributor.java
index 5990c3b0..269186c9 100644
--- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/SecProxyHeaderContributor.java
+++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/SecProxyHeaderContributor.java
@@ -29,19 +29,36 @@
import lombok.RequiredArgsConstructor;
/**
- * Contributes the {@literal sec-proxy: true} request header based on the value
- * returned by the provided {@link BooleanSupplier}, which is required by all
- * backend services as a flag indicating the request is authenticated.
+ * {@link HeaderContributor} that appends the {@code sec-proxy: true} request
+ * header to indicate that the request is authenticated through the gateway.
+ *
+ * This header is required by all backend services to differentiate between
+ * authenticated and unauthenticated requests.
+ *
+ *
+ * The contribution of this header is controlled by a {@link BooleanSupplier},
+ * allowing dynamic enablement based on external conditions.
+ *
+ *
+ * @see HeaderContributor
*/
@RequiredArgsConstructor
public class SecProxyHeaderContributor extends HeaderContributor {
private final @NonNull BooleanSupplier secProxyHeaderEnabled;
+ /**
+ * Prepares a header contributor that appends the {@code sec-proxy} header if
+ * enabled.
+ *
+ * @param exchange the current {@link ServerWebExchange}
+ * @return a {@link Consumer} that modifies the request headers
+ */
public @Override Consumer prepare(ServerWebExchange exchange) {
return headers -> {
- if (secProxyHeaderEnabled.getAsBoolean())
+ if (secProxyHeaderEnabled.getAsBoolean()) {
add(headers, "sec-proxy", "true");
+ }
};
}
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/handler/predicate/QueryParamRoutePredicateFactory.java b/gateway/src/main/java/org/georchestra/gateway/handler/predicate/QueryParamRoutePredicateFactory.java
index d089c063..b34d8b8b 100644
--- a/gateway/src/main/java/org/georchestra/gateway/handler/predicate/QueryParamRoutePredicateFactory.java
+++ b/gateway/src/main/java/org/georchestra/gateway/handler/predicate/QueryParamRoutePredicateFactory.java
@@ -30,62 +30,98 @@
import org.springframework.web.server.ServerWebExchange;
/**
- * URI predicate filter based on the existence of a given query parameter
+ * A route predicate factory that evaluates whether an HTTP request contains a
+ * specified query parameter.
*
- * Usage:
+ * This predicate allows routing based on the presence of a query parameter in
+ * the request URI.
+ *
+ *
+ * Usage example:
+ *
*
*
- *
- * {@code
- * - id:
- * uri:
+ *
+ * - id: example-route
+ * uri: http://example.com
* predicates:
- * - QueryParam=
- * }
+ * - QueryParam=token
+ *
*
+ *
+ * The above configuration will route requests to {@code http://example.com}
+ * only if the query string contains the parameter {@code token}.
+ *
*/
public class QueryParamRoutePredicateFactory
extends AbstractRoutePredicateFactory {
public static final String PARAM_KEY = "param";
+ /**
+ * Constructs a new {@code QueryParamRoutePredicateFactory}.
+ */
public QueryParamRoutePredicateFactory() {
super(QueryParamRoutePredicateFactory.Config.class);
}
+ /**
+ * Specifies the order of the fields when using the shortcut configuration.
+ *
+ * @return a list containing the expected field order
+ */
@Override
public List shortcutFieldOrder() {
return Arrays.asList(PARAM_KEY);
}
+ /**
+ * Applies the predicate filter to check for the presence of the configured
+ * query parameter in the request.
+ *
+ * @param config the predicate configuration containing the query parameter name
+ * @return a {@link Predicate} that tests whether the request contains the
+ * specified query parameter
+ */
@Override
public Predicate apply(QueryParamRoutePredicateFactory.Config config) {
return new GatewayPredicate() {
@Override
public boolean test(ServerWebExchange exchange) {
- String param = config.param;
- if (exchange.getRequest().getQueryParams().containsKey(param)) {
- return true;
- }
- return false;
+ return exchange.getRequest().getQueryParams().containsKey(config.param);
}
- public @Override String toString() {
+ @Override
+ public String toString() {
return String.format("Query: param=%s", config.getParam());
}
};
}
+ /**
+ * Configuration class for {@code QueryParamRoutePredicateFactory}.
+ */
@Validated
public static class Config {
@NotEmpty
private String param;
+ /**
+ * Retrieves the configured query parameter name.
+ *
+ * @return the name of the query parameter
+ */
public String getParam() {
return param;
}
+ /**
+ * Sets the query parameter name to check for.
+ *
+ * @param param the query parameter name
+ * @return this {@code Config} instance for method chaining
+ */
public QueryParamRoutePredicateFactory.Config setParam(String param) {
this.param = param;
return this;
diff --git a/gateway/src/main/java/org/georchestra/gateway/model/GatewayConfigProperties.java b/gateway/src/main/java/org/georchestra/gateway/model/GatewayConfigProperties.java
index fef1284e..2afbafcd 100644
--- a/gateway/src/main/java/org/georchestra/gateway/model/GatewayConfigProperties.java
+++ b/gateway/src/main/java/org/georchestra/gateway/model/GatewayConfigProperties.java
@@ -28,33 +28,39 @@
import lombok.Generated;
/**
- * Model object representing the externalized configuration properties used to
- * set up URI based access rules and HTTP request headers appended to proxied
- * requests to back-end services.
- *
+ * Configuration properties for the geOrchestra Gateway.
+ *
+ * This model object represents the externalized configuration used to define
+ * URI-based access rules and HTTP request headers appended to proxied requests
+ * to back-end services.
+ *
*/
@Data
@Generated
@ConfigurationProperties("georchestra.gateway")
public class GatewayConfigProperties {
+ /**
+ * Role mappings that define additional roles granted to users based on their
+ * existing roles.
+ */
private Map> rolesMappings = Map.of();
/**
- * Configures the global security headers to append to all proxied http requests
+ * Default security headers to append to all proxied HTTP requests.
*/
private HeaderMappings defaultHeaders = new HeaderMappings();
/**
- * Incoming request URI pattern matching for requests that don't match any of
- * the service-specific rules under
- * {@literal georchestra.gateway.services.[service].access-rules}
+ * Global access rules that apply to requests that do not match any
+ * service-specific rules under
+ * {@code georchestra.gateway.services.[service].access-rules}.
*/
private List globalAccessRules = List.of();
/**
- * Maps a logical service name to its back-end service URL and security settings
+ * Maps logical service names to their corresponding back-end service URLs and
+ * security settings.
*/
private Map services = Collections.emptyMap();
-
}
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 b1d96e5e..dc69e9df 100644
--- a/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraOrganizations.java
+++ b/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraOrganizations.java
@@ -25,17 +25,42 @@
import lombok.experimental.UtilityClass;
+/**
+ * Utility class for handling geOrchestra organization attributes within a
+ * {@link ServerWebExchange}.
+ *
+ * This class provides methods to store and retrieve an {@link Organization}
+ * instance associated with an exchange.
+ *
+ */
@UtilityClass
public class GeorchestraOrganizations {
+ /**
+ * Attribute key used to store the organization in the exchange.
+ */
static final String GEORCHESTRA_ORGANIZATION_KEY = GeorchestraOrganizations.class.getCanonicalName();
+ /**
+ * Retrieves the stored {@link Organization} from the exchange, if available.
+ *
+ * @param exchange the {@link ServerWebExchange} containing the attributes
+ * @return an {@link Optional} containing the stored organization, or empty if
+ * none exists
+ */
public static Optional resolve(ServerWebExchange exchange) {
return Optional.ofNullable(exchange.getAttributes().get(GEORCHESTRA_ORGANIZATION_KEY))
.map(Organization.class::cast);
}
+ /**
+ * Stores an {@link Organization} instance in the exchange attributes.
+ *
+ * @param exchange the {@link ServerWebExchange} where the organization should
+ * be stored
+ * @param org the {@link Organization} instance to store
+ */
public static void store(ServerWebExchange exchange, Organization org) {
exchange.getAttributes().put(GEORCHESTRA_ORGANIZATION_KEY, org);
}
-}
\ No newline at end of file
+}
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 99533dd1..e8d1ece2 100644
--- a/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraTargetConfig.java
+++ b/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraTargetConfig.java
@@ -29,24 +29,53 @@
import lombok.experimental.Accessors;
/**
- * The HTTP request headers and role-based access rules of a matched
- * {@link Route}
+ * Represents the security and HTTP request header settings for a matched
+ * {@link Route}.
+ *
+ * This class defines role-based access rules and headers to be applied to
+ * proxied requests for a given route.
+ *
*/
@Data
@Generated
@Accessors(fluent = true, chain = true)
public class GeorchestraTargetConfig {
+ /**
+ * Attribute key used to store the target configuration in the exchange.
+ */
private static final String TARGET_CONFIG_KEY = GeorchestraTargetConfig.class.getCanonicalName() + ".target";
+ /**
+ * HTTP request headers to append when forwarding requests.
+ */
private HeaderMappings headers;
+
+ /**
+ * Role-based access rules for controlling request authorization.
+ */
private List accessRules;
+ /**
+ * Retrieves the stored {@link GeorchestraTargetConfig} from the exchange, if
+ * available.
+ *
+ * @param exchange the {@link ServerWebExchange} containing the attributes
+ * @return an {@link Optional} containing the stored target configuration, or
+ * empty if none exists
+ */
public static Optional getTarget(ServerWebExchange exchange) {
return Optional.ofNullable(exchange.getAttributes().get(TARGET_CONFIG_KEY))
.map(GeorchestraTargetConfig.class::cast);
}
+ /**
+ * Stores a {@link GeorchestraTargetConfig} instance in the exchange attributes.
+ *
+ * @param exchange the {@link ServerWebExchange} where the configuration should
+ * be stored
+ * @param config the {@link GeorchestraTargetConfig} instance to store
+ */
public static void setTarget(ServerWebExchange exchange, GeorchestraTargetConfig config) {
exchange.getAttributes().put(TARGET_CONFIG_KEY, config);
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraUsers.java b/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraUsers.java
index e9ef3756..6a9b642e 100644
--- a/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraUsers.java
+++ b/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraUsers.java
@@ -27,15 +27,44 @@
import lombok.NonNull;
import lombok.experimental.UtilityClass;
+/**
+ * Utility class for managing geOrchestra user attributes within a
+ * {@link ServerWebExchange}.
+ *
+ * This class provides methods to store and retrieve a {@link GeorchestraUser}
+ * instance associated with an exchange.
+ *
+ */
@UtilityClass
public class GeorchestraUsers {
+ /**
+ * Attribute key used to store the geOrchestra user in the exchange.
+ */
static final String GEORCHESTRA_USER_KEY = GeorchestraUsers.class.getCanonicalName();
+ /**
+ * Retrieves the stored {@link GeorchestraUser} from the exchange, if available.
+ *
+ * @param exchange the {@link ServerWebExchange} containing the attributes
+ * @return an {@link Optional} containing the stored user, or empty if none
+ * exists
+ */
public static Optional resolve(ServerWebExchange exchange) {
return Optional.ofNullable(exchange.getAttributes().get(GEORCHESTRA_USER_KEY)).map(GeorchestraUser.class::cast);
}
+ /**
+ * Stores a {@link GeorchestraUser} instance in the exchange attributes.
+ *
+ * If the provided user is {@code null}, the attribute is removed.
+ *
+ *
+ * @param exchange the {@link ServerWebExchange} where the user should be stored
+ * @param user the {@link GeorchestraUser} instance to store, or
+ * {@code null} to remove it
+ * @return the updated {@link ServerWebExchange} instance
+ */
public static ServerWebExchange store(@NonNull ServerWebExchange exchange, GeorchestraUser user) {
Map attributes = exchange.getAttributes();
if (user == null) {
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 e80c6172..587fbab5 100644
--- a/gateway/src/main/java/org/georchestra/gateway/model/HeaderMappings.java
+++ b/gateway/src/main/java/org/georchestra/gateway/model/HeaderMappings.java
@@ -26,87 +26,112 @@
import lombok.Generated;
/**
- * Models which geOrchestra-specific HTTP request headers to append to proxied
- * requests.
+ * Configuration model for geOrchestra-specific HTTP request headers.
+ *
+ * This class defines which security-related headers should be appended to
+ * proxied requests. Each header can be individually enabled or disabled.
+ *
*/
@Data
@Generated
public class HeaderMappings {
+
///////// User info headers ///////////////
- /** Append the standard {@literal sec-proxy=true} header to proxied requests */
+ /** 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 */
+ /** 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 */
+ /**
+ * 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 */
+ /** 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 */
+ /** 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 */
+ /** 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 */
+ /** 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 */
+ /** 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 */
+ /** 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 */
+ /** 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 */
+ /** 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 */
+ /** 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 */
+ /** 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
+ * proxied requests.
*/
private Optional jsonUser = Optional.empty();
///////// Organization info headers ///////////////
- /** Append the standard {@literal sec-orgname} header to proxied requests */
+ /** 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 */
+ /** 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
+ * 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
+ * to proxied requests.
*/
private Optional jsonOrganization = Optional.empty();
- public @VisibleForTesting HeaderMappings enableAll() {
+ /**
+ * Enables all headers.
+ *
+ * @return this instance with all headers set to {@code true}
+ */
+ @VisibleForTesting
+ public HeaderMappings enableAll() {
this.setAll(Optional.of(Boolean.TRUE));
return this;
}
- public @VisibleForTesting HeaderMappings disableAll() {
+ /**
+ * Disables all headers.
+ *
+ * @return this instance with all headers set to {@code false}
+ */
+ @VisibleForTesting
+ public HeaderMappings disableAll() {
this.setAll(Optional.of(Boolean.FALSE));
return this;
}
+ /**
+ * Sets all header options to the given value.
+ *
+ * @param val the value to set for all headers
+ */
private void setAll(Optional val) {
this.proxy = val;
this.userid = val;
@@ -128,23 +153,43 @@ private void setAll(Optional val) {
this.jsonOrganization = val;
}
+ /**
+ * Enables or disables the {@literal sec-userid} header.
+ *
+ * @param b {@code true} to enable, {@code false} to disable
+ * @return this instance with the updated configuration
+ */
public HeaderMappings userid(boolean b) {
setUserid(Optional.of(b));
return this;
}
+ /**
+ * Enables or disables the {@literal sec-user} (Base64 JSON) header.
+ *
+ * @param b {@code true} to enable, {@code false} to disable
+ * @return this instance with the updated configuration
+ */
public HeaderMappings jsonUser(boolean b) {
setJsonUser(Optional.of(b));
return this;
}
+ /**
+ * Enables or disables the {@literal sec-organization} (Base64 JSON) header.
+ *
+ * @param b {@code true} to enable, {@code false} to disable
+ * @return this instance with the updated configuration
+ */
public HeaderMappings jsonOrganization(boolean b) {
setJsonOrganization(Optional.of(b));
return this;
}
/**
- * @return a copy of this object
+ * Creates a copy of this object.
+ *
+ * @return a new {@link HeaderMappings} instance with the same values
*/
public HeaderMappings copy() {
HeaderMappings copy = new HeaderMappings();
@@ -153,7 +198,10 @@ public HeaderMappings copy() {
}
/**
- * Applies the non-empty fields from {@code other} to this one, and returns this
+ * Merges the non-empty fields from {@code other} into this instance.
+ *
+ * @param other the other {@link HeaderMappings} instance
+ * @return this instance with the merged values
*/
public HeaderMappings merge(HeaderMappings other) {
proxy = merge(proxy, other.proxy);
@@ -177,6 +225,13 @@ public HeaderMappings merge(HeaderMappings other) {
return this;
}
+ /**
+ * Merges two {@link Optional} values, preferring the second if present.
+ *
+ * @param a the first value
+ * @param b the second value (preferred if present)
+ * @return the merged {@link Optional} value
+ */
private Optional merge(Optional a, Optional b) {
return b.isEmpty() ? a : b;
}
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 292de2b7..36602db6 100644
--- a/gateway/src/main/java/org/georchestra/gateway/model/RoleBasedAccessRule.java
+++ b/gateway/src/main/java/org/georchestra/gateway/model/RoleBasedAccessRule.java
@@ -28,11 +28,12 @@
import lombok.experimental.Accessors;
/**
- * Models access rules to intercepted Ant-pattern URIs based on roles.
+ * Defines access rules for intercepted Ant-pattern URIs based on user roles.
*
- * Role names are defined by the authenticated user's
- * {@link AbstractAuthenticationToken#getAuthorities() authority names} (i.e.
- * {@link GrantedAuthority#getAuthority()}) .
+ * Role names correspond to the authenticated user's
+ * {@link AbstractAuthenticationToken#getAuthorities() authority names}, which
+ * are obtained via {@link GrantedAuthority#getAuthority()}.
+ *
*/
@Data
@Generated
@@ -40,31 +41,39 @@
public class RoleBasedAccessRule {
/**
- * List of Ant pattern URI's, excluding the application context, the Gateway
- * shall intercept and apply the access rules defined here. E.g.
+ * List of Ant pattern URIs (excluding the application context) that the gateway
+ * intercepts to enforce access rules.
*/
private List interceptUrl = List.of();
/**
- * Highest precedence rule, if {@code true}, forbids access to the intercepted
- * URLs
+ * Specifies whether access to the intercepted URLs is explicitly forbidden.
+ *
+ * If set to {@code true}, access is denied to all users, regardless of role.
+ *
*/
private boolean forbidden = false;
/**
- * Whether anonymous (unauthenticated) access is to be granted to the
- * intercepted URIs. If {@code true}, no further specification is applied to the
- * intercepted urls (i.e. if set, {@link #allowedRoles} are ignored). If
- * {@code false} and the {@link #getAllowedRoles() allowed roles} is empty, then
- * any authenticated user is granted access to the {@link #getInterceptUrl()
- * intercepted URLs}.
+ * Determines whether anonymous (unauthenticated) access is allowed.
+ *
+ * If set to {@code true}, no additional role-based access checks are applied to
+ * the intercepted URLs, meaning all users can access them.
+ * If set to {@code false} and {@link #allowedRoles} is empty, access is granted
+ * to any authenticated user.
+ *
*/
private boolean anonymous = 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
- * set [ROLE_USER, ROLE_AUDITOR] is equivalent to [USER, AUDITOR]
+ * Specifies the roles required to access the intercepted URIs.
+ *
+ * If the list is empty and {@link #anonymous} is {@code false}, any
+ * authenticated user is granted access.
+ * Role names can be provided with or without the {@code ROLE_} prefix. For
+ * example, the role set {@code [ROLE_USER, ROLE_AUDITOR]} is equivalent to
+ * {@code [USER, AUDITOR]}.
+ *
*/
private List allowedRoles = List.of();
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/model/Service.java b/gateway/src/main/java/org/georchestra/gateway/model/Service.java
index d86b0304..89360b45 100644
--- a/gateway/src/main/java/org/georchestra/gateway/model/Service.java
+++ b/gateway/src/main/java/org/georchestra/gateway/model/Service.java
@@ -26,31 +26,47 @@
import lombok.Generated;
/**
- * Model object used to configure which authenticated user's roles can reach a
- * given backend service URIs, and which HTTP request headers to append to the
- * proxied requests.
- *
+ * Represents the configuration of a backend service within the geOrchestra
+ * Gateway.
+ *
+ * This model defines the target service URL, role-based access rules, and
+ * security headers to be applied to proxied requests.
+ *
*/
@Data
@Generated
public class Service {
+
/**
- * Back end service URL the Gateway will use to proxy incoming requests to,
- * based on the {@link #getAccessRules() access rules}
- * {@link RoleBasedAccessRule#getInterceptUrl() intercept-URLs}
+ * The backend service URL to which the gateway will proxy incoming requests.
+ *
+ * The routing is determined based on the {@link #getAccessRules() access rules}
+ * and their associated {@link RoleBasedAccessRule#getInterceptUrl()
+ * intercept-URLs}.
+ *
*/
private URI target;
/**
- * Service-specific security headers configuration
+ * Service-specific security headers configuration.
+ *
+ * These headers will be appended to requests forwarded to the backend service.
+ *
*/
private HeaderMappings headers;
/**
- * List of Ant-pattern based access rules for the given back-end service
+ * List of Ant-pattern based access rules for controlling access to the backend
+ * service.
*/
private List accessRules = List.of();
+ /**
+ * Retrieves the optional security headers configuration for this service.
+ *
+ * @return an {@link Optional} containing the {@link HeaderMappings}, or empty
+ * if not defined
+ */
public Optional headers() {
return Optional.ofNullable(headers);
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/CustomAccessDeniedHandler.java b/gateway/src/main/java/org/georchestra/gateway/security/CustomAccessDeniedHandler.java
index 500420c8..1377b9ef 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/CustomAccessDeniedHandler.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/CustomAccessDeniedHandler.java
@@ -1,3 +1,21 @@
+/*
+ * Copyright (C) 2024 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.springframework.http.HttpStatus;
@@ -7,10 +25,18 @@
import reactor.core.publisher.Mono;
+/**
+ * Custom implementation of {@link ServerAccessDeniedHandler} to handle access
+ * denied scenarios.
+ *
+ * This handler throws an {@link AccessDeniedException} with an HTTP 403
+ * (Forbidden) status whenever access is denied.
+ *
+ */
public class CustomAccessDeniedHandler implements ServerAccessDeniedHandler {
@Override
public Mono handle(ServerWebExchange serverWebExchange, AccessDeniedException accessDeniedException) {
throw new AccessDeniedException(HttpStatus.FORBIDDEN.name());
}
-}
\ No newline at end of file
+}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ExtendedRedirectServerAuthenticationFailureHandler.java b/gateway/src/main/java/org/georchestra/gateway/security/ExtendedRedirectServerAuthenticationFailureHandler.java
index 814f8dda..1ee3076b 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ExtendedRedirectServerAuthenticationFailureHandler.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ExtendedRedirectServerAuthenticationFailureHandler.java
@@ -1,3 +1,21 @@
+/*
+ * Copyright (C) 2024 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.net.URI;
@@ -11,21 +29,53 @@
import reactor.core.publisher.Mono;
+/**
+ * Extended version of {@link RedirectServerAuthenticationFailureHandler} to
+ * provide more granular authentication failure handling.
+ *
+ * This handler inspects the cause of authentication failure and redirects to
+ * different locations based on the type of exception encountered.
+ *
+ *
+ * Specifically, it:
+ *
+ * Redirects to {@code login?error=invalid_credentials} for bad
+ * credentials.
+ * Redirects to {@code login?error=expired_password} for expired
+ * passwords.
+ * Defaults to {@code login?error} for other authentication failures.
+ *
+ *
+ */
public class ExtendedRedirectServerAuthenticationFailureHandler extends RedirectServerAuthenticationFailureHandler {
private URI location;
- private static String INVALID_CREDENTIALS = "invalid_credentials";
- private static String EXPIRED_PASSWORD = "expired_password";
- private static String EXPIRED_MESSAGE = "Your password has expired";
- private ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
+ private static final String INVALID_CREDENTIALS = "invalid_credentials";
+ private static final String EXPIRED_PASSWORD = "expired_password";
+ private static final String EXPIRED_MESSAGE = "Your password has expired";
+ private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
+ /**
+ * Constructs an {@code ExtendedRedirectServerAuthenticationFailureHandler} with
+ * the default redirection location.
+ *
+ * @param location the base URI for authentication failure redirection
+ */
public ExtendedRedirectServerAuthenticationFailureHandler(String location) {
super(location);
Assert.notNull(location, "location cannot be null");
this.location = URI.create(location);
}
+ /**
+ * Handles authentication failures by determining the specific cause and
+ * redirecting accordingly.
+ *
+ * @param webFilterExchange the current web exchange
+ * @param exception the exception that caused authentication failure
+ * @return a {@link Mono} signaling completion after the redirect
+ */
@Override
public Mono onAuthenticationFailure(WebFilterExchange webFilterExchange, AuthenticationException exception) {
this.location = URI.create("login?error");
@@ -37,5 +87,4 @@ public Mono onAuthenticationFailure(WebFilterExchange webFilterExchange, A
}
return this.redirectStrategy.sendRedirect(webFilterExchange.getExchange(), this.location);
}
-
}
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 e08a6bad..471f4895 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/GatewaySecurityConfiguration.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/GatewaySecurityConfiguration.java
@@ -41,16 +41,18 @@
import lombok.extern.slf4j.Slf4j;
/**
- * {@link Configuration} to initialize the Gateway's
- * {@link SecurityWebFilterChain} during application start up, such as
- * establishing path based access rules, configuring authentication providers,
- * etc.
+ * Configuration for the security settings in geOrchestra Gateway.
*
- * Note this configuration does very little by itself. Instead, it relies on
- * available beans implementing the {@link ServerHttpSecurityCustomizer}
- * extension point to tweak the {@link ServerHttpSecurity} as appropriate in a
- * decoupled way.
- *
+ * This configuration initializes the {@link SecurityWebFilterChain}, handling
+ * authentication, authorization, and security policies.
+ *
+ *
+ *
+ * Instead of defining all security settings directly, this configuration relies
+ * on {@link ServerHttpSecurityCustomizer} implementations to allow decoupled
+ * and extensible security customization.
+ *
+ *
* @see ServerHttpSecurityCustomizer
*/
@Configuration(proxyBeanMethods = false)
@@ -65,24 +67,26 @@ public class GatewaySecurityConfiguration {
private @Value("${georchestra.gateway.logoutUrl:/?logout}") String georchestraLogoutUrl;
/**
- * Relies on available {@link ServerHttpSecurityCustomizer} extensions to
- * configure the different aspects of the {@link ServerHttpSecurity} used to
- * {@link ServerHttpSecurity#build build} the {@link SecurityWebFilterChain}.
- *
- * Disables appending default response headers as far as the regular
- * Spring-Security is concerned. This way, we let Spring Cloud Gateway control
- * their behavior. Otherwise the config property
- * {@literal spring.cloud.gateway.filter.secure-headers.disable: x-frame-options}
- * has no effect.
+ * Configures security settings for the gateway using available customizers.
*
- * Note also {@literal spring.cloud.gateway.default-filters} must contain the
- * {@literal SecureHeaders} filter.
+ * This method:
+ *
+ * Disables CSRF protection (expected to be handled by proxied web
+ * apps).
+ * Disables default security response headers to allow Spring Cloud Gateway
+ * to manage them.
+ * Applies a custom access denied handler.
+ * Sets up form-based login handling.
+ * Applies all available {@link ServerHttpSecurityCustomizer} extensions in
+ * order.
+ * Configures logout handling, using an OIDC logout handler if
+ * available.
+ *
+ *
+ *
*
- * Finally, note
- * {@literal spring.cloud.gateway.default-filters: x-frame-options} won't
- * prevent downstream services so provide their own header.
- *
- * The following are the default headers suppressed here:
+ * The following response headers are disabled by default:
+ *
*
*
*
@@ -95,6 +99,12 @@ public class GatewaySecurityConfiguration {
* X-XSS-Protection: 1; mode=block
*
*
+ *
+ * @param http the {@link ServerHttpSecurity} instance
+ * @param customizers the list of available {@link ServerHttpSecurityCustomizer}
+ * implementations
+ * @return the configured {@link SecurityWebFilterChain}
+ * @throws Exception if an error occurs during configuration
*/
@Bean
SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
@@ -102,14 +112,8 @@ SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
log.info("Initializing security filter chain...");
- // disable CSRF protection, considering it will be managed
- // by proxified webapps, not the gateway.
http.csrf().disable();
-
- // disable default response headers. See comment in the method's javadoc
http.headers().disable();
-
- // custom handling for forbidden error
http.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler());
http.formLogin()
@@ -126,31 +130,58 @@ SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
RedirectServerLogoutSuccessHandler defaultRedirect = new RedirectServerLogoutSuccessHandler();
defaultRedirect.setLogoutSuccessUrl(URI.create(georchestraLogoutUrl));
- LogoutSpec logoutUrl = http.formLogin().loginPage("/login").and().logout()
+ LogoutSpec logoutSpec = http.formLogin().loginPage("/login").and().logout()
.requiresLogout(ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/logout"))
.logoutSuccessHandler(oidcLogoutSuccessHandler != null ? oidcLogoutSuccessHandler : defaultRedirect);
- return logoutUrl.and().build();
+ return logoutSpec.and().build();
}
+ /**
+ * Sorts and returns the list of custom security configurations.
+ *
+ * @param customizers the list of security customizers
+ * @return a sorted stream of {@link ServerHttpSecurityCustomizer} instances
+ */
private Stream sortedCustomizers(List customizers) {
return customizers.stream().sorted((c1, c2) -> Integer.compare(c1.getOrder(), c2.getOrder()));
}
+ /**
+ * Creates a {@link GeorchestraUserMapper} to resolve user identities using the
+ * configured resolvers and customizers.
+ *
+ * @param resolvers the list of user resolvers
+ * @param customizers the list of user customizers
+ * @return an instance of {@link GeorchestraUserMapper}
+ */
@Bean
GeorchestraUserMapper georchestraUserResolver(List resolvers,
List customizers) {
return new GeorchestraUserMapper(resolvers, customizers);
}
+ /**
+ * Creates a global filter that resolves authenticated geOrchestra users in the
+ * request lifecycle.
+ *
+ * @param resolver the {@link GeorchestraUserMapper} used to resolve users
+ * @return an instance of {@link ResolveGeorchestraUserGlobalFilter}
+ */
@Bean
ResolveGeorchestraUserGlobalFilter resolveGeorchestraUserGlobalFilter(GeorchestraUserMapper resolver) {
return new ResolveGeorchestraUserGlobalFilter(resolver);
}
/**
- * Extension to make {@link GeorchestraUserMapper} append user roles based on
- * {@link GatewayConfigProperties#getRolesMappings()}
+ * Registers a custom user role mapping extension.
+ *
+ * This extension updates user roles based on the configured mappings in
+ * {@link GatewayConfigProperties#getRolesMappings()}.
+ *
+ *
+ * @param config the gateway configuration properties
+ * @return an instance of {@link RolesMappingsUserCustomizer}
*/
@Bean
RolesMappingsUserCustomizer rolesMappingsUserCustomizer(GatewayConfigProperties config) {
@@ -158,5 +189,4 @@ RolesMappingsUserCustomizer rolesMappingsUserCustomizer(GatewayConfigProperties
log.info("Creating {}", RolesMappingsUserCustomizer.class.getSimpleName());
return new RolesMappingsUserCustomizer(rolesMappings);
}
-
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraGatewaySecurityConfigProperties.java b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraGatewaySecurityConfigProperties.java
index c2fcaaf9..ba57899c 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraGatewaySecurityConfigProperties.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraGatewaySecurityConfigProperties.java
@@ -40,13 +40,15 @@
import lombok.experimental.Accessors;
/**
- * Config properties, usually loaded from georchestra datadir's
- * {@literal default.properties}.
+ * Configuration properties for geOrchestra Gateway security settings, typically
+ * loaded from the geOrchestra data directory's {@literal default.properties}
+ * file.
*
- * e.g.:
+ * Example configuration:
+ *
*
*
- *{@code
+ * {@code
* ldapHost=localhost
* ldapPort=389
* ldapScheme=ldap
@@ -64,130 +66,166 @@
@ConfigurationProperties(prefix = "georchestra.gateway.security")
public class GeorchestraGatewaySecurityConfigProperties implements Validator {
+ /**
+ * Flag indicating whether non-existing users should be created in LDAP
+ * automatically.
+ */
private boolean createNonExistingUsersInLDAP = true;
+ /**
+ * Default organization assigned to users when no specific organization is set.
+ */
private String defaultOrganization = "";
+ /**
+ * LDAP server configurations mapped by their respective names.
+ */
@Valid
private Map ldap = Map.of();
+ /**
+ * Represents a configured LDAP server.
+ */
@Generated
public static @Data class Server {
+ /**
+ * Indicates whether this LDAP configuration is enabled.
+ */
boolean enabled;
/**
- * Whether the LDAP authentication source shall use georchestra-specific
+ * Whether the LDAP authentication source shall use geOrchestra-specific
* extensions. For example, when using the default OpenLDAP database with
- * additional user identity information
+ * additional user identity information.
*/
boolean extended;
+ /**
+ * URL of the LDAP server.
+ */
private String url;
/**
- * Flag indicating the LDAP authentication end point is an Active Directory
- * service
+ * Flag indicating if the LDAP authentication endpoint is an Active Directory
+ * service.
*/
private boolean activeDirectory;
/**
- * Base DN of the LDAP directory Base Distinguished Name of the LDAP directory.
- * Also named root or suffix, see
- * http://www.zytrax.com/books/ldap/apd/index.html#base
- *
- * For example, georchestra's default baseDn is dc=georchestra,dc=org
+ * Base Distinguished Name (DN) of the LDAP directory. This represents the root
+ * suffix. Example: {@code dc=georchestra,dc=org}.
*/
private String baseDn;
/**
- * How to extract user information. Only searchFilter is used if activeDirectory
- * is true
+ * Configuration for extracting user information. When {@code activeDirectory}
+ * is {@code true}, only {@code searchFilter} is used.
*/
private Users users;
/**
- * How to extract role information, un-used for Active Directory
+ * Configuration for extracting role information. This setting is unused for
+ * Active Directory.
*/
private Roles roles;
/**
- * How to extract Organization information, only used for OpenLDAP if extended =
- * true
+ * Configuration for extracting organization information. Used only for OpenLDAP
+ * when {@code extended} is {@code true}.
*/
private Organizations orgs;
/**
- * The user distinguished name (principal) to use for getting authenticated
- * contexts (optional).
+ * Distinguished Name (DN) of the administrator user used for LDAP
+ * authentication operations.
*/
private String adminDn;
/**
- * The password (credentials) to use for getting authenticated contexts
- * (optional).
+ * Password for the administrator user used for LDAP authentication operations.
*/
private String adminPassword;
}
+ /**
+ * Configuration for user-related LDAP attributes.
+ */
@Generated
public static @Data @Accessors(chain = true) class Users {
/**
- * Users RDN Relative distinguished name of the "users" LDAP organization unit.
- * E.g. if the complete name (or DN) is ou=users,dc=georchestra,dc=org, the RDN
- * is ou=users.
+ * Relative Distinguished Name (RDN) of the organizational unit containing
+ * users. Example: If the full DN is {@code ou=users,dc=georchestra,dc=org}, the
+ * RDN is {@code ou=users}.
*/
private String rdn;
/**
- * Users search filter, e.g. (uid={0}) for OpenLDAP, and
- * (&(objectClass=user)(userPrincipalName={0})) for ActiveDirectory
+ * LDAP search filter to find users. Example: {@code (uid={0})} for OpenLDAP or
+ * {@code (&(objectClass=user)(userPrincipalName={0}))} for Active Directory.
*/
private String searchFilter;
/**
- * Specifies the attributes that will be returned as part of the search.
- *
- * null indicates that all attributes will be returned. An empty array indicates
- * no attributes are returned.
+ * Specifies the LDAP attributes to be returned in search results. {@code null}
+ * indicates all attributes will be returned. An empty array means no attributes
+ * will be returned.
*/
private @Setter String[] returningAttributes;
}
+ /**
+ * Configuration for role-related LDAP attributes.
+ */
@Generated
public static @Data @Accessors(chain = true) class Roles {
/**
- * Roles RDN Relative distinguished name of the "roles" LDAP organization unit.
- * E.g. if the complete name (or DN) is ou=roles,dc=georchestra,dc=org, the RDN
- * is ou=roles.
+ * Relative Distinguished Name (RDN) of the organizational unit containing
+ * roles. Example: If the full DN is {@code ou=roles,dc=georchestra,dc=org}, the
+ * RDN is {@code ou=roles}.
*/
private String rdn;
/**
- * Roles search filter. e.g. (member={0})
+ * LDAP search filter used to determine role membership. Example:
+ * {@code (member={0})}.
*/
private String searchFilter;
}
+ /**
+ * Configuration for organization-related LDAP attributes.
+ */
@Generated
public static @Data @Accessors(chain = true) class Organizations {
/**
- * Organizations search base. Default: ou=orgs
+ * Relative Distinguished Name (RDN) of the organizational unit containing
+ * organizations. Default value: {@code ou=orgs}.
*/
private String rdn = "ou=orgs";
/**
- * Pending organizations search base. Default: ou=pendingorgs
+ * Relative Distinguished Name (RDN) of the organizational unit containing
+ * pending organizations. Default value: {@code ou=pendingorgs}.
*/
private String pendingRdn = "ou=pendingorgs";
}
+ /**
+ * {@inheritDoc}
+ */
public @Override boolean supports(Class> clazz) {
return GeorchestraGatewaySecurityConfigProperties.class.equals(clazz);
}
+ /**
+ * Validates the LDAP configuration properties.
+ *
+ * @param target the instance to validate
+ * @param errors the validation errors
+ */
@Override
public void validate(Object target, Errors errors) {
GeorchestraGatewaySecurityConfigProperties config = (GeorchestraGatewaySecurityConfigProperties) target;
@@ -199,26 +237,35 @@ public void validate(Object target, Errors errors) {
ldapConfig.forEach((name, serverConfig) -> validations.validate(name, serverConfig, errors));
}
+ /**
+ * Retrieves the list of enabled simple (non-extended) LDAP configurations.
+ *
+ * @return a list of basic {@link LdapServerConfig} instances.
+ */
public List simpleEnabled() {
LdapConfigBuilder builder = new LdapConfigBuilder();
- return entries()//
- .filter(e -> e.getValue().isEnabled())//
- .filter(e -> !e.getValue().isExtended())//
+ return entries().filter(e -> e.getValue().isEnabled()).filter(e -> !e.getValue().isExtended())
.map(e -> builder.asBasicLdapConfig(e.getKey(), e.getValue())).toList();
}
+ /**
+ * Retrieves the list of enabled extended LDAP configurations.
+ *
+ * @return a list of {@link ExtendedLdapConfig} instances.
+ */
public List extendedEnabled() {
LdapConfigBuilder builder = new LdapConfigBuilder();
- return entries()//
- .filter(e -> e.getValue().isEnabled())//
- .filter(e -> !e.getValue().isActiveDirectory())//
- .filter(e -> e.getValue().isExtended())//
- .map(e -> builder.asExtendedLdapConfig(e.getKey(), e.getValue()))//
+ return entries().filter(e -> e.getValue().isEnabled()).filter(e -> !e.getValue().isActiveDirectory())
+ .filter(e -> e.getValue().isExtended()).map(e -> builder.asExtendedLdapConfig(e.getKey(), e.getValue()))
.toList();
}
+ /**
+ * Retrieves a stream of LDAP configuration entries.
+ *
+ * @return a stream of LDAP server entries.
+ */
private Stream> entries() {
return ldap == null ? Stream.empty() : ldap.entrySet().stream();
}
-
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserCustomizerExtension.java b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserCustomizerExtension.java
index 55ccb052..a0cda39c 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserCustomizerExtension.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserCustomizerExtension.java
@@ -26,15 +26,29 @@
import org.springframework.security.core.Authentication;
/**
- * Extension point to customize the state of a {@link GeorchestraUser} once it
- * was obtained from an authentication provider by means of a
- * {@link GeorchestraUserMapperExtension}.
- *
+ * Extension point to customize the {@link GeorchestraUser} after it has been
+ * resolved from an authentication provider.
+ *
+ * This interface allows modifying the {@link GeorchestraUser} instance based on
+ * authentication details, such as applying additional role mappings, setting
+ * organization attributes, or enriching the user with external metadata.
+ *
+ *
+ * Implementations are executed in the order defined by {@link #getOrder()}.
+ *
+ *
* @see GeorchestraUserMapper
+ * @see GeorchestraUserMapperExtension
*/
public interface GeorchestraUserCustomizerExtension
extends Ordered, BiFunction {
+ /**
+ * Defines the execution order of this extension when multiple customizers are
+ * available.
+ *
+ * @return the order in which this customizer is applied, defaults to {@code 0}.
+ */
default int getOrder() {
return 0;
}
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 0c266990..8adc3f19 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapper.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapper.java
@@ -21,71 +21,105 @@
import java.util.List;
import java.util.Optional;
-import org.georchestra.gateway.model.GeorchestraUsers;
import org.georchestra.gateway.security.exceptions.DuplicatedEmailFoundException;
import org.georchestra.security.model.GeorchestraUser;
-import org.springframework.core.Ordered;
import org.springframework.security.core.Authentication;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
/**
- * Aids {@link ResolveGeorchestraUserGlobalFilter} in resolving the
- * {@link GeorchestraUser} from the current request's {@link Authentication}
- * token.
+ * Resolves a {@link GeorchestraUser} from an {@link Authentication} token by
+ * delegating to available {@link GeorchestraUserMapperExtension}
+ * implementations.
*
- * Relies on the provided {@link GeorchestraUserMapperExtension}s to map an
- * {@link Authentication} to a {@link GeorchestraUsers}, and on
- * {@link GeorchestraUserCustomizerExtension} to apply additional user
- * customizations once resolved from {@link Authentication} to
- * {@link GeorchestraUser}.
+ * This class acts as an abstraction layer that allows multiple authentication
+ * strategies to provide user resolution mechanisms, such as LDAP, OAuth2, or
+ * custom authentication providers.
+ *
*
- * {@literal GeorchestraUserMapperExtension} beans specialize in mapping auth
- * tokens for specific authentication sources (e.g. LDAP, OAuth2, OAuth2+OpenID,
- * etc).
+ * Once a user is successfully resolved, any registered
+ * {@link GeorchestraUserCustomizerExtension} implementations are applied in
+ * order to modify or enrich the user attributes.
+ *
*
- * {@literal GeorchestraUserCustomizerExtension} beans specialize in applying
- * any additional customization to the {@link GeorchestraUser} object after it
- * has been extracted from the {@link Authentication} created by the actual
- * authentication provider.
+ * This component is primarily used by
+ * {@link ResolveGeorchestraUserGlobalFilter} to extract user details from
+ * authentication tokens in the request lifecycle.
+ *
*
* @see GeorchestraUserMapperExtension
* @see GeorchestraUserCustomizerExtension
+ * @see ResolveGeorchestraUserGlobalFilter
*/
@RequiredArgsConstructor
public class GeorchestraUserMapper {
/**
- * {@link Ordered ordered} list of user mapper extensions.
+ * Ordered list of user mapper extensions responsible for resolving a
+ * {@link GeorchestraUser} from an {@link Authentication} token.
*/
private final @NonNull List resolvers;
+ /**
+ * Ordered list of user customizer extensions that apply modifications to a
+ * resolved {@link GeorchestraUser}.
+ */
private final @NonNull List customizers;
+ /**
+ * Default constructor for use when no resolvers or customizers are provided.
+ */
GeorchestraUserMapper() {
this(List.of(), List.of());
}
+ /**
+ * Constructor for initializing only with user resolvers.
+ *
+ * @param resolvers the list of {@link GeorchestraUserMapperExtension} instances
+ */
GeorchestraUserMapper(List resolvers) {
this(resolvers, List.of());
}
/**
- * @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.
+ * Attempts to resolve a {@link GeorchestraUser} from the provided
+ * authentication token.
+ *
+ * Each {@link GeorchestraUserMapperExtension} is queried in order until one
+ * successfully resolves a user. If no extension handles the authentication
+ * token, an empty result is returned.
+ *
+ *
+ * If a user is resolved, it is then processed through all registered
+ * {@link GeorchestraUserCustomizerExtension} instances in order.
+ *
+ *
+ * @param authToken the authentication token to resolve
+ * @return an optional {@link GeorchestraUser} if resolution is successful
+ * @throws DuplicatedEmailFoundException if multiple users with the same email
+ * are found
*/
public Optional resolve(@NonNull Authentication authToken) throws DuplicatedEmailFoundException {
- return resolvers.stream()//
- .map(resolver -> resolver.resolve(authToken))//
- .filter(Optional::isPresent)//
- .map(Optional::orElseThrow)//
- .map(mapped -> customize(authToken, mapped)).findFirst();
+ return resolvers.stream().map(resolver -> resolver.resolve(authToken)).filter(Optional::isPresent)
+ .map(Optional::orElseThrow).map(mapped -> customize(authToken, mapped)).findFirst();
}
+ /**
+ * Applies registered {@link GeorchestraUserCustomizerExtension} instances to
+ * the resolved user.
+ *
+ * This allows for modifications such as role mappings, attribute enrichment, or
+ * other custom transformations based on the authentication context.
+ *
+ *
+ * @param authToken the authentication token associated with the user
+ * @param mapped the resolved {@link GeorchestraUser} instance
+ * @return the customized {@link GeorchestraUser} after all modifications are
+ * applied
+ * @throws DuplicatedEmailFoundException if an issue occurs during customization
+ */
private GeorchestraUser customize(@NonNull Authentication authToken, GeorchestraUser mapped)
throws DuplicatedEmailFoundException {
GeorchestraUser customized = mapped;
@@ -94,4 +128,4 @@ private GeorchestraUser customize(@NonNull Authentication authToken, Georchestra
}
return customized;
}
-}
\ 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 17f074b9..4f507ce6 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapperExtension.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapperExtension.java
@@ -16,7 +16,6 @@
* 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;
@@ -26,24 +25,55 @@
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.
+ * Defines an extension point for mapping authentication tokens to
+ * {@link GeorchestraUser} instances.
+ *
+ * This interface allows different authentication mechanisms (e.g., LDAP,
+ * OAuth2, OpenID Connect) to provide their own strategy for extracting user
+ * details from an {@link Authentication} token.
+ *
+ *
+ * Implementations of this interface are queried by
+ * {@link GeorchestraUserMapper} to determine whether they can handle the given
+ * authentication token. If a suitable implementation is found, it returns a
+ * non-empty {@link GeorchestraUser}.
+ *
+ *
*
- * 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.
+ * Beans of this type are {@link Ordered}, meaning multiple resolvers can be
+ * defined with explicit ordering to prioritize certain authentication sources
+ * over others.
+ *
+ *
+ * @see GeorchestraUserMapper
*/
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.
+ * Attempts to map an {@link Authentication} token to a {@link GeorchestraUser}.
+ *
+ * If this implementation can extract user details from the provided
+ * authentication token, it should return a populated {@link GeorchestraUser}.
+ * Otherwise, it should return {@link Optional#empty()} to allow other resolvers
+ * to handle the token.
+ *
+ *
+ * @param authToken the authentication token representing the user's credentials
+ * @return an optional {@link GeorchestraUser} if this resolver can handle the
+ * authentication token
*/
Optional resolve(Authentication authToken);
+ /**
+ * Defines the order in which this resolver should be executed relative to other
+ * {@link GeorchestraUserMapperExtension} implementations.
+ *
+ * A lower value indicates higher priority.
+ *
+ *
+ * @return {@code 0} as the default order. Implementations can override this if
+ * needed.
+ */
default int getOrder() {
return 0;
}
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 1d27f56e..8537d99c 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ResolveGeorchestraUserGlobalFilter.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ResolveGeorchestraUserGlobalFilter.java
@@ -21,7 +21,6 @@
import java.net.URI;
import org.georchestra.gateway.model.GeorchestraOrganizations;
-import org.georchestra.gateway.model.GeorchestraTargetConfig;
import org.georchestra.gateway.model.GeorchestraUsers;
import org.georchestra.gateway.security.exceptions.DuplicatedEmailFoundException;
import org.georchestra.gateway.security.ldap.extended.ExtendedGeorchestraUser;
@@ -46,13 +45,27 @@
/**
* 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 a server web exchange filter chain execution.
+ * retrieved} during subsequent 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.
+ * This filter ensures that each request has access to the authenticated user,
+ * which can be used to populate security-related headers (e.g.,
+ * {@literal sec-*} headers) when forwarding requests to backend services.
+ *
*
+ *
+ * If the resolved {@link GeorchestraUser} is an instance of
+ * {@link ExtendedGeorchestraUser}, this filter also extracts the associated
+ * {@link Organization} and makes it available for downstream processing.
+ *
+ *
+ *
+ * If a {@link DuplicatedEmailFoundException} occurs, the user is redirected to
+ * the login page with an error flag, and the session is invalidated.
+ *
+ *
* @see GeorchestraUserMapper
+ * @see GeorchestraUsers
+ * @see GeorchestraOrganizations
*/
@RequiredArgsConstructor
@Slf4j(topic = "org.georchestra.gateway.security")
@@ -62,49 +75,81 @@ public class ResolveGeorchestraUserGlobalFilter implements GlobalFilter, Ordered
private final @NonNull GeorchestraUserMapper resolver;
- private ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
+ private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
- private static String DUPLICATE_ACCOUNT = "duplicate_account";
+ private static final String DUPLICATE_ACCOUNT_ERROR = "duplicate_account";
/**
- * @return a lower precedence than {@link RouteToRequestUrlFilter}'s, in order
- * to make sure the matched {@link Route} has been set as a
- * {@link ServerWebExchange#getAttributes attribute} when
- * {@link #filter} is called.
+ * Defines the order in which this filter executes relative to other
+ * {@link GlobalFilter} implementations.
+ *
+ * It runs right after {@link RouteToRequestUrlFilter} to ensure that the
+ * matched {@link Route} has been determined before resolving the user.
+ *
+ *
+ * @return filter execution order
*/
- public @Override int getOrder() {
+ @Override
+ public int getOrder() {
return ORDER;
}
/**
- * Resolves the matched {@link Route} and its corresponding
- * {@link GeorchestraTargetConfig}, if possible, and proceeds with the filter
- * chain.
+ * Resolves the authenticated {@link GeorchestraUser} from the request context
+ * and stores it for downstream processing.
+ *
+ * If an {@link ExtendedGeorchestraUser} is found, the associated
+ * {@link Organization} is also extracted and stored.
+ *
+ *
+ * If a {@link DuplicatedEmailFoundException} is encountered, the user is
+ * redirected to the login page with an error message, and the session is
+ * invalidated.
+ *
+ *
+ * @param exchange the current server exchange
+ * @param chain the filter chain
+ * @return a {@link Mono} that completes when processing is finished
+ */
+ @Override
+ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
+ return exchange.getPrincipal()
+ .doOnNext(principal -> log.debug("Resolving user from {}", principal.getClass().getName()))
+ .filter(Authentication.class::isInstance).map(Authentication.class::cast).map(resolver::resolve)
+ .map(user -> storeUserAndOrganization(exchange, user.orElse(null))).defaultIfEmpty(exchange)
+ .flatMap(chain::filter)
+ .onErrorResume(DuplicatedEmailFoundException.class, error -> handleDuplicateEmailError(exchange));
+ }
+
+ /**
+ * Stores the resolved {@link GeorchestraUser} and its associated
+ * {@link Organization} (if applicable) in the exchange attributes.
+ *
+ * @param exchange the current server exchange
+ * @param user the resolved user, or {@code null} if none found
+ * @return the updated server exchange
*/
- public @Override Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
- return exchange.getPrincipal()//
- .doOnNext(p -> log.debug("resolving user from {}", p.getClass().getName()))//
- .filter(Authentication.class::isInstance)//
- .map(Authentication.class::cast)//
- .map(resolver::resolve)//
- .map(user -> {
- GeorchestraUser usr = user.orElse(null);
- GeorchestraUsers.store(exchange, usr);
- if (usr != null && usr instanceof ExtendedGeorchestraUser) {
- ExtendedGeorchestraUser eu = (ExtendedGeorchestraUser) usr;
- Organization org = eu.getOrg();
- if (org != null) {
- GeorchestraOrganizations.store(exchange, org);
- }
- }
- return exchange;
- })//
- .defaultIfEmpty(exchange)//
- .flatMap(chain::filter)//
- .onErrorResume(DuplicatedEmailFoundException.class,
- exp -> this.redirectStrategy
- .sendRedirect(exchange, URI.create("/login?error=" + DUPLICATE_ACCOUNT))
- .then(exchange.getSession().flatMap(WebSession::invalidate)));
+ private ServerWebExchange storeUserAndOrganization(ServerWebExchange exchange, GeorchestraUser user) {
+ GeorchestraUsers.store(exchange, user);
+
+ if (user instanceof ExtendedGeorchestraUser extendedUser) {
+ Organization org = extendedUser.getOrg();
+ if (org != null) {
+ GeorchestraOrganizations.store(exchange, org);
+ }
+ }
+ return exchange;
}
-}
\ No newline at end of file
+ /**
+ * Handles a {@link DuplicatedEmailFoundException} by redirecting the user to
+ * the login page with an error message and invalidating the session.
+ *
+ * @param exchange the current server exchange
+ * @return a {@link Mono} signaling the redirect operation
+ */
+ private Mono handleDuplicateEmailError(ServerWebExchange exchange) {
+ return redirectStrategy.sendRedirect(exchange, URI.create("/login?error=" + DUPLICATE_ACCOUNT_ERROR))
+ .then(exchange.getSession().flatMap(WebSession::invalidate));
+ }
+}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/RolesMappingsUserCustomizer.java b/gateway/src/main/java/org/georchestra/gateway/security/RolesMappingsUserCustomizer.java
index 9be15476..84f08e29 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/RolesMappingsUserCustomizer.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/RolesMappingsUserCustomizer.java
@@ -40,22 +40,70 @@
import lombok.extern.slf4j.Slf4j;
/**
- * Authenticated user customizer extension to expand the set of role names
- * assigned to a user by the actual authentication provider
+ * A {@link GeorchestraUserCustomizerExtension} that expands the set of role
+ * names assigned to a user by the authentication provider based on role mapping
+ * rules.
+ *
+ * This implementation allows assigning additional roles dynamically by defining
+ * mapping patterns in the gateway configuration.
+ *
+ *
+ *
+ * Role mappings are stored as a set of regular expression patterns. When a
+ * user's authenticated role matches a pattern, the corresponding mapped roles
+ * are added to the user's role set.
+ *
+ *
+ *
+ * Example role mapping configuration:
+ *
+ *
+ *
+ *
+ * rolesMappings:
+ * "ADMIN*": ["SUPERUSER"]
+ * "USER": ["READ_ONLY"]
+ *
+ *
+ *
+ *
+ * In the above example, any role starting with {@code ADMIN} will be assigned
+ * the {@code SUPERUSER} role, and users with the {@code USER} role will
+ * automatically receive the {@code READ_ONLY} role.
+ *
+ *
+ *
+ * This component also employs caching for performance optimization, avoiding
+ * redundant computations when resolving additional roles for a given
+ * authentication.
+ *
+ *
+ * @see GeorchestraUserMapper
*/
@Slf4j
public class RolesMappingsUserCustomizer implements GeorchestraUserCustomizerExtension {
+ /**
+ * Represents a role mapping rule, associating a regex-based pattern with a set
+ * of additional roles.
+ */
@RequiredArgsConstructor
private static class Matcher {
private final @NonNull Pattern pattern;
private final @NonNull @Getter List extraRoles;
+ /**
+ * Checks if the given role matches the pattern.
+ *
+ * @param role the role name to check
+ * @return {@code true} if the role matches the pattern, otherwise {@code false}
+ */
public boolean matches(String role) {
return pattern.matcher(role).matches();
}
- public @Override String toString() {
+ @Override
+ public String toString() {
return String.format("%s -> %s", pattern.pattern(), extraRoles);
}
}
@@ -65,48 +113,92 @@ public boolean matches(String role) {
private final Cache> byRoleNameCache = CacheBuilder.newBuilder().maximumSize(1_000).build();
+ /**
+ * Constructs an instance of {@link RolesMappingsUserCustomizer} with the
+ * provided role mappings.
+ *
+ * @param rolesMappings a map where keys represent role name patterns and values
+ * are lists of additional roles to be assigned when the
+ * pattern matches
+ */
public RolesMappingsUserCustomizer(@NonNull Map> rolesMappings) {
- this.rolesMappings = keysToRegularExpressions(rolesMappings);
+ this.rolesMappings = convertKeysToPatterns(rolesMappings);
}
- private @NonNull List keysToRegularExpressions(Map> mappings) {
- return mappings.entrySet()//
- .stream()//
- .map(e -> new Matcher(toPattern(e.getKey()), e.getValue()))//
- .peek(m -> log.info("Loaded role mapping {}", m))//
- .toList();
+ /**
+ * Converts role mapping keys into regex-based {@link Matcher} objects.
+ *
+ * @param mappings a map where each key is a role name pattern, and the
+ * corresponding value is a list of additional roles
+ * @return a list of compiled role matchers
+ */
+ private @NonNull List convertKeysToPatterns(Map> mappings) {
+ return mappings.entrySet().stream().map(entry -> new Matcher(compilePattern(entry.getKey()), entry.getValue()))
+ .peek(matcher -> log.info("Loaded role mapping {}", matcher)).toList();
}
- static Pattern toPattern(String role) {
- String regex = role.replace(".", "(\\.)").replace("*", "(.*)");
+ /**
+ * Converts a role mapping key into a regex pattern.
+ *
+ * Supports wildcard-based matching where:
+ *
+ *
+ * {@code *} is converted to {@code .*} (match any characters).
+ * {@code .} is escaped to match a literal period.
+ *
+ *
+ * @param role the role name pattern
+ * @return the compiled {@link Pattern}
+ */
+ static Pattern compilePattern(String role) {
+ String regex = role.replace(".", "\\.").replace("*", ".*");
return Pattern.compile(regex);
}
+ /**
+ * Applies additional role mappings to the authenticated user.
+ *
+ * This method scans the user's current roles, determines any additional roles
+ * based on predefined mappings, and updates the user object accordingly.
+ *
+ *
+ * @param authToken the original authentication token
+ * @param mappedUser the user retrieved from authentication
+ * @return the updated user with any additional roles assigned
+ */
@Override
- public GeorchestraUser apply(Authentication origAuthToken, GeorchestraUser mappedUser) {
-
+ public GeorchestraUser apply(Authentication authToken, GeorchestraUser mappedUser) {
Set additionalRoles = computeAdditionalRoles(mappedUser.getRoles());
+
if (!additionalRoles.isEmpty()) {
additionalRoles.addAll(mappedUser.getRoles());
- mappedUser.setRoles(new ArrayList<>(additionalRoles));// mutable
+ mappedUser.setRoles(new ArrayList<>(additionalRoles)); // Ensure mutability
}
+
return mappedUser;
}
/**
- * @param authenticatedRoles the role names extracted from the authentication
- * provider
- * @return the additional role names for the user
+ * Computes additional roles for the user based on their existing roles.
+ *
+ * @param authenticatedRoles the roles assigned by the authentication provider
+ * @return a set of additional roles derived from mapping rules
*/
private Set computeAdditionalRoles(List authenticatedRoles) {
final ConcurrentMap> cache = byRoleNameCache.asMap();
- return authenticatedRoles.stream().map(role -> cache.computeIfAbsent(role, this::computeAdditionalRoles))
+ return authenticatedRoles.stream().map(role -> cache.computeIfAbsent(role, this::resolveAdditionalRoles))
.flatMap(List::stream).collect(Collectors.toSet());
}
- private List computeAdditionalRoles(@NonNull String authenticatedRole) {
-
- List roles = rolesMappings.stream().filter(m -> m.matches(authenticatedRole))
+ /**
+ * Resolves additional roles for a given authenticated role by evaluating the
+ * configured role mappings.
+ *
+ * @param authenticatedRole the role assigned by the authentication provider
+ * @return a list of additional roles assigned based on mappings
+ */
+ private List resolveAdditionalRoles(@NonNull String authenticatedRole) {
+ List roles = rolesMappings.stream().filter(matcher -> matcher.matches(authenticatedRole))
.map(Matcher::getExtraRoles).flatMap(List::stream).toList();
log.info("Computed additional roles for {}: {}", authenticatedRole, roles);
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ServerHttpSecurityCustomizer.java b/gateway/src/main/java/org/georchestra/gateway/security/ServerHttpSecurityCustomizer.java
index 9ef6e6eb..5e257fde 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ServerHttpSecurityCustomizer.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ServerHttpSecurityCustomizer.java
@@ -23,31 +23,40 @@
import org.springframework.security.config.web.server.ServerHttpSecurity;
/**
- * Extension point to aid {@link GatewaySecurityConfiguration} in initializing
+ * Extension point to assist {@link GatewaySecurityConfiguration} in configuring
* the application security filter chain.
*
- * Spring beans of this type implement {@link Ordered}, and will be called in
- * sequence adhering to each bean's defined order.
+ * Implementations of this interface act as modular security configuration
+ * components that can modify the {@link ServerHttpSecurity} instance during
+ * application startup. These beans implement {@link Ordered}, ensuring they are
+ * applied in a predictable sequence based on their defined order.
+ *
*
- * This interface extends {@link Customizer Customizer}. The
- * {@link Customizer#customize customize(ServerHttpSecurity)} shall modify the
- * provided server HTTP security configuration bean in whatever way needed.
+ * This interface extends {@link Customizer} with {@code ServerHttpSecurity}.
+ * Implementations of the {@link Customizer#customize} method modify the
+ * provided {@link ServerHttpSecurity} configuration bean as required.
+ *
*/
public interface ServerHttpSecurityCustomizer extends Customizer, Ordered {
/**
- * @return user friendly extension name for logging purposes
+ * Returns a human-readable extension name for logging purposes.
+ *
+ * @return the fully qualified class name of the implementing class
*/
default String getName() {
return getClass().getCanonicalName();
}
/**
- * {@inheritDoc}
- *
- * @return {@code 0} as default order, implementations should override as needed
- * in case they need to apply their customizations to
- * {@link ServerHttpSecurity} in a specific order.
+ * Returns the execution order of this customizer.
+ *
+ * By default, it returns {@code 0}. Implementations should override this method
+ * if they need to apply their customizations in a specific order within the
+ * security configuration process.
+ *
+ *
+ * @return the order in which this customizer should be applied
* @see Ordered#HIGHEST_PRECEDENCE
* @see Ordered#LOWEST_PRECEDENCE
*/
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/accessrules/AccessRulesConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/security/accessrules/AccessRulesConfiguration.java
index b724dd04..4d2418a0 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/accessrules/AccessRulesConfiguration.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/accessrules/AccessRulesConfiguration.java
@@ -24,10 +24,36 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+/**
+ * Configures geOrchestra-specific access rules based on role-based security
+ * policies.
+ *
+ * This configuration registers the {@link AccessRulesCustomizer}, which applies
+ * role-based access rules to incoming requests based on the settings defined in
+ * {@link GatewayConfigProperties}.
+ *
+ *
+ *
+ * The rules are configured globally and can be overridden on a per-service
+ * basis via {@code georchestra.gateway.services.[service].access-rules}.
+ *
+ *
+ * @see AccessRulesCustomizer
+ * @see GatewayConfigProperties#getGlobalAccessRules()
+ * @see GatewayConfigProperties#getServices()
+ */
@Configuration
@EnableConfigurationProperties(GatewayConfigProperties.class)
public class AccessRulesConfiguration {
+ /**
+ * Registers the {@link AccessRulesCustomizer} bean to enforce role-based access
+ * rules.
+ *
+ * @param config the gateway configuration properties
+ * @param userMapper the user identity resolver for extracting user roles
+ * @return an instance of {@link AccessRulesCustomizer}
+ */
@Bean
AccessRulesCustomizer georchestraAccessRulesCustomizer(GatewayConfigProperties config,
GeorchestraUserMapper userMapper) {
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 cb23843c..d52440e1 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
@@ -37,13 +37,17 @@
import lombok.extern.slf4j.Slf4j;
/**
- * {@link ServerHttpSecurityCustomizer} to apply {@link RoleBasedAccessRule ROLE
- * based access rules} at startup.
+ * {@link ServerHttpSecurityCustomizer} responsible for applying
+ * {@link RoleBasedAccessRule role-based access rules} at application startup.
*
- * The access rules are configured as
- * {@link GatewayConfigProperties#getGlobalAccessRules() global rules}, and
- * overridden if needed on a per-service basis from
- * {@link GatewayConfigProperties#getServices()}.
+ * The access rules can be configured as:
+ *
+ * {@link GatewayConfigProperties#getGlobalAccessRules() Global rules},
+ * which apply to all services.
+ * Service-specific rules defined in
+ * {@link GatewayConfigProperties#getServices()}, which override the global
+ * rules for particular services.
+ *
*
* @see RoleBasedAccessRule
* @see GatewayConfigProperties#getGlobalAccessRules()
@@ -62,9 +66,8 @@ public void customize(ServerHttpSecurity http) {
AuthorizeExchangeSpec authorizeExchange = http.authorizeExchange();
- // apply service-specific rules before global rules, order matters, and
- // otherwise global path matches would be applied before service ones.
-
+ // Apply service-specific rules before global rules.
+ // This ensures that service-specific paths take precedence over general rules.
config.getServices().forEach((name, service) -> {
log.info("Applying access rules for backend service '{}' at {}", name, service.getTarget());
apply(name, authorizeExchange, service.getAccessRules());
@@ -74,6 +77,13 @@ public void customize(ServerHttpSecurity http) {
apply("global", authorizeExchange, config.getGlobalAccessRules());
}
+ /**
+ * Applies a set of access rules to the provided {@link AuthorizeExchangeSpec}.
+ *
+ * @param serviceName the name of the service being configured
+ * @param authorizeExchange the authorization configuration object
+ * @param accessRules the access rules to apply
+ */
private void apply(String serviceName, AuthorizeExchangeSpec authorizeExchange,
List accessRules) {
if (accessRules == null || accessRules.isEmpty()) {
@@ -85,6 +95,36 @@ private void apply(String serviceName, AuthorizeExchangeSpec authorizeExchange,
}
}
+ /**
+ * Applies a {@link RoleBasedAccessRule} to the provided
+ * {@link AuthorizeExchangeSpec}, determining how access should be granted or
+ * restricted for specific URL patterns.
+ *
+ * This method evaluates the given access rule and configures the security
+ * filter chain accordingly by applying one of the following strategies:
+ *
+ *
+ * If {@link RoleBasedAccessRule#isForbidden()} is {@code true}, access is
+ * completely denied.
+ * If {@link RoleBasedAccessRule#isAnonymous()} is {@code true}, the URLs
+ * are publicly accessible.
+ * If {@link RoleBasedAccessRule#getAllowedRoles()} is empty, access is
+ * granted to any authenticated user.
+ * Otherwise, access is restricted to users with at least one of the
+ * specified roles.
+ *
+ *
+ * The URL patterns to which the rule applies are derived from
+ * {@link RoleBasedAccessRule#getInterceptUrl()}.
+ *
+ *
+ * @param authorizeExchange the authorization configuration object where rules
+ * are applied
+ * @param rule the access rule defining the URL patterns and access
+ * conditions
+ * @throws NullPointerException if the rule or its intercept URLs are null
+ * @throws IllegalArgumentException if the rule does not define any URL patterns
+ */
@VisibleForTesting
void apply(AuthorizeExchangeSpec authorizeExchange, RoleBasedAccessRule rule) {
final List antPatterns = resolveAntPatterns(rule);
@@ -92,6 +132,7 @@ void apply(AuthorizeExchangeSpec authorizeExchange, RoleBasedAccessRule rule) {
final boolean anonymous = rule.isAnonymous();
final List allowedRoles = rule.getAllowedRoles() == null ? List.of() : rule.getAllowedRoles();
Access access = authorizeExchange(authorizeExchange, antPatterns);
+
if (forbidden) {
log.debug("Denying access to everyone for {}", antPatterns);
denyAll(access);
@@ -108,49 +149,93 @@ void apply(AuthorizeExchangeSpec authorizeExchange, RoleBasedAccessRule rule) {
}
}
+ /**
+ * Resolves the Ant-style URL patterns for a given access rule.
+ *
+ * @param rule the access rule containing the URL patterns
+ * @return the list of resolved URL patterns
+ */
private List resolveAntPatterns(RoleBasedAccessRule rule) {
List antPatterns = rule.getInterceptUrl();
Objects.requireNonNull(antPatterns, "intercept-urls is null");
antPatterns.forEach(Objects::requireNonNull);
- if (antPatterns.isEmpty())
+ if (antPatterns.isEmpty()) {
throw new IllegalArgumentException("No ant-pattern(s) defined for rule " + rule);
- antPatterns.forEach(Objects::requireNonNull);
+ }
return antPatterns;
}
+ /**
+ * Configures URL-based authorization for a given set of patterns.
+ *
+ * @param authorizeExchange the security configuration object
+ * @param antPatterns the URL patterns to authorize
+ * @return the access configuration for the specified patterns
+ */
@VisibleForTesting
Access authorizeExchange(AuthorizeExchangeSpec authorizeExchange, List antPatterns) {
return authorizeExchange.pathMatchers(antPatterns.toArray(String[]::new));
}
+ /**
+ * Resolves the role names, ensuring they have the required prefix.
+ *
+ * @param antPatterns the URL patterns being configured
+ * @param allowedRoles the roles that should be granted access
+ * @return the list of role names with appropriate prefixes
+ */
private List resolveRoles(List antPatterns, List allowedRoles) {
return allowedRoles.stream().map(this::ensureRolePrefix).toList();
}
+ /**
+ * Requires that the user be authenticated to access the configured path.
+ *
+ * @param access the access configuration object
+ */
@VisibleForTesting
void requireAuthenticatedUser(Access access) {
access.authenticated();
}
+ /**
+ * Grants access only if the user has at least one of the specified roles.
+ *
+ * @param access the access configuration object
+ * @param roles the list of roles required for 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));
}
+ /**
+ * Grants unrestricted access to the configured path.
+ *
+ * @param access the access configuration object
+ */
@VisibleForTesting
void permitAll(Access access) {
access.permitAll();
}
+ /**
+ * Denies access to all users for the configured path.
+ *
+ * @param access the access configuration object
+ */
@VisibleForTesting
void denyAll(Access access) {
access.denyAll();
}
+ /**
+ * Ensures that the given role name has the required {@code ROLE_} prefix.
+ *
+ * @param roleName the role name to check
+ * @return the role name with the {@code ROLE_} prefix if it was missing
+ */
private String ensureRolePrefix(@NonNull String roleName) {
return roleName.startsWith("ROLE_") ? roleName : ("ROLE_" + roleName);
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/exceptions/DuplicatedEmailFoundException.java b/gateway/src/main/java/org/georchestra/gateway/security/exceptions/DuplicatedEmailFoundException.java
index 3e55b423..4aab159b 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/exceptions/DuplicatedEmailFoundException.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/exceptions/DuplicatedEmailFoundException.java
@@ -18,13 +18,31 @@
*/
package org.georchestra.gateway.security.exceptions;
+/**
+ * Exception thrown when multiple user accounts are found with the same email
+ * address.
+ *
+ * This exception is used to indicate a conflict in user identity resolution,
+ * typically occurring during authentication or user synchronization processes.
+ *
+ */
@SuppressWarnings("serial")
public class DuplicatedEmailFoundException extends RuntimeException {
+ /**
+ * Constructs a new {@code DuplicatedEmailFoundException} with the specified
+ * detail message.
+ *
+ * @param message the detail message
+ */
public DuplicatedEmailFoundException(String message) {
super(message);
}
+ /**
+ * Constructs a new {@code DuplicatedEmailFoundException} without a detail
+ * message.
+ */
public DuplicatedEmailFoundException() {
}
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/exceptions/DuplicatedUsernameFoundException.java b/gateway/src/main/java/org/georchestra/gateway/security/exceptions/DuplicatedUsernameFoundException.java
index 4ec969fa..4353539f 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/exceptions/DuplicatedUsernameFoundException.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/exceptions/DuplicatedUsernameFoundException.java
@@ -18,13 +18,31 @@
*/
package org.georchestra.gateway.security.exceptions;
+/**
+ * Exception thrown when multiple user accounts are found with the same
+ * username.
+ *
+ * This exception is used to indicate a conflict in user identity resolution,
+ * typically occurring during authentication or user synchronization processes.
+ *
+ */
@SuppressWarnings("serial")
public class DuplicatedUsernameFoundException extends RuntimeException {
+ /**
+ * Constructs a new {@code DuplicatedUsernameFoundException} with the specified
+ * detail message.
+ *
+ * @param message the detail message
+ */
public DuplicatedUsernameFoundException(String message) {
super(message);
}
+ /**
+ * Constructs a new {@code DuplicatedUsernameFoundException} without a detail
+ * message.
+ */
public DuplicatedUsernameFoundException() {
}
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/AuthenticationProviderDecorator.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/AuthenticationProviderDecorator.java
index a2bb2941..0d45934a 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/AuthenticationProviderDecorator.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/AuthenticationProviderDecorator.java
@@ -26,19 +26,48 @@
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
+/**
+ * Abstract decorator for {@link AuthenticationProvider} implementations.
+ *
+ * This class delegates authentication and support checks to another
+ * {@link AuthenticationProvider}, allowing additional processing in subclasses
+ * without modifying the original authentication logic.
+ *
+ *
+ * @see AuthenticationProvider
+ */
@RequiredArgsConstructor
public abstract class AuthenticationProviderDecorator implements AuthenticationProvider {
private final @NonNull AuthenticationProvider delegate;
+ /**
+ * Determines whether this {@link AuthenticationProvider} supports the specified
+ * authentication class.
+ *
+ * @param authentication the authentication class to check
+ * @return {@code true} if the provider supports the given authentication type,
+ * otherwise {@code false}
+ */
@Override
public boolean supports(Class> authentication) {
return delegate.supports(authentication);
}
+ /**
+ * Authenticates the given {@link Authentication} request.
+ *
+ * This method delegates authentication to the underlying
+ * {@link AuthenticationProvider}.
+ *
+ *
+ * @param authentication the authentication request
+ * @return a fully authenticated object, or {@code null} if authentication was
+ * unsuccessful
+ * @throws AuthenticationException if authentication fails
+ */
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
return delegate.authenticate(authentication);
}
-
-}
\ No newline at end of file
+}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapAuthenticationConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapAuthenticationConfiguration.java
index 78b41c37..3e21c465 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapAuthenticationConfiguration.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapAuthenticationConfiguration.java
@@ -37,39 +37,42 @@
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.context.SecurityContext;
-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
+ * {@link ServerHttpSecurityCustomizer} to enable LDAP-based authentication and
* authorization across multiple LDAP databases.
*
- * This configuration sets up the required beans for spring-based LDAP
+ * This configuration sets up the required beans for Spring-based LDAP
* authentication and authorization, using
* {@link GeorchestraGatewaySecurityConfigProperties} to get the
* {@link GeorchestraGatewaySecurityConfigProperties#getUrl() connection URL}
* and the {@link GeorchestraGatewaySecurityConfigProperties#getBaseDn() base
* DN}.
+ *
*
- * As a result, the {@link ServerHttpSecurity} will have HTTP-Basic
- * authentication enabled and {@link ServerHttpSecurity#formLogin() form login}
- * set up.
+ * As a result, the {@link ServerHttpSecurity} will have HTTP Basic
+ * authentication enabled, as well as {@link ServerHttpSecurity#formLogin() form
+ * login}.
+ *
*
- * 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.
+ * Upon successful authentication, an {@link Authentication} instance will be
+ * set in the {@link org.springframework.security.core.context.SecurityContext
+ * SecurityContext} with an
+ * {@link org.springframework.security.ldap.userdetails.LdapUserDetails
+ * LdapUserDetails} as the principal and roles extracted from LDAP as
+ * authorities.
+ *
*
- * 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 ExtendedLdapAuthenticationConfiguration} for further details.
- *
+ * However, depending on the configured gateway routes, this may not be enough
+ * information to convey geOrchestra-specific HTTP request headers to backend
+ * services. See {@link ExtendedLdapAuthenticationConfiguration} for further
+ * details.
+ *
+ *
* @see GeorchestraGatewaySecurityConfigProperties
* @see BasicLdapAuthenticationConfiguration
* @see ExtendedLdapAuthenticationConfiguration
@@ -82,26 +85,63 @@
@Slf4j(topic = "org.georchestra.gateway.security.ldap")
public class LdapAuthenticationConfiguration {
+ /**
+ * Enables HTTP Basic authentication and form login for LDAP authentication.
+ */
public static final class LDAPAuthenticationCustomizer implements ServerHttpSecurityCustomizer {
+ /**
+ * Configures HTTP Basic authentication and form login.
+ *
+ * @param http the {@link ServerHttpSecurity} instance
+ */
public @Override void customize(ServerHttpSecurity http) {
log.info("Enabling HTTP Basic authentication support for LDAP");
http.httpBasic().and().formLogin();
}
}
+ /**
+ * Registers an LDAP authentication customizer to enable HTTP Basic and form
+ * login.
+ *
+ * @return a {@link ServerHttpSecurityCustomizer} for LDAP authentication
+ */
@Bean
ServerHttpSecurityCustomizer ldapHttpBasicLoginFormEnablerExtension() {
return new LDAPAuthenticationCustomizer();
}
+ /**
+ * Creates an {@link AuthenticationWebFilter} for LDAP authentication.
+ *
+ * This filter is triggered when requests match the {@code /auth/login} path.
+ *
+ *
+ * @param ldapAuthenticationManager the {@link ReactiveAuthenticationManager}
+ * for LDAP authentication
+ * @return an {@link AuthenticationWebFilter} configured for LDAP authentication
+ */
@Bean
AuthenticationWebFilter ldapAuthenticationWebFilter(ReactiveAuthenticationManager ldapAuthenticationManager) {
-
AuthenticationWebFilter ldapAuthFilter = new AuthenticationWebFilter(ldapAuthenticationManager);
ldapAuthFilter.setRequiresAuthenticationMatcher(ServerWebExchangeMatchers.pathMatchers("/auth/login"));
return ldapAuthFilter;
}
+ /**
+ * Creates an LDAP authentication manager that combines multiple authentication
+ * providers.
+ *
+ * This manager supports both basic and extended LDAP authentication providers.
+ * If no providers are available, {@code null} is returned.
+ *
+ *
+ * @param basic a list of {@link BasicLdapAuthenticationProvider} instances
+ * @param extended a list of {@link GeorchestraLdapAuthenticationProvider}
+ * instances
+ * @return a {@link ReactiveAuthenticationManager} if providers are available,
+ * otherwise {@code null}
+ */
@Bean
ReactiveAuthenticationManager ldapAuthenticationManager(List basic,
List extended) {
@@ -109,8 +149,11 @@ ReactiveAuthenticationManager ldapAuthenticationManager(List flattened = Stream.concat(basic.stream(), extended.stream())
.map(AuthenticationProvider.class::cast).toList();
- if (flattened.isEmpty())
+ if (flattened.isEmpty()) {
+ log.warn("No LDAP authentication providers configured.");
return null;
+ }
+
ProviderManager providerManager = new ProviderManager(flattened);
return new ReactiveAuthenticationManagerAdapter(providerManager);
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapConfigBuilder.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapConfigBuilder.java
index b286de8a..73de5185 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapConfigBuilder.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapConfigBuilder.java
@@ -30,55 +30,79 @@
import lombok.extern.slf4j.Slf4j;
/**
+ * Helper class to construct LDAP configuration objects for both basic and
+ * extended LDAP authentication mechanisms.
+ *
+ * This class is responsible for converting {@link Server} configuration objects
+ * into {@link LdapServerConfig} (basic LDAP) and {@link ExtendedLdapConfig}
+ * (extended LDAP) configurations.
+ *
*/
@Slf4j
public class LdapConfigBuilder {
+ /**
+ * Builds a {@link LdapServerConfig} for basic LDAP authentication.
+ *
+ * @param name the name of the LDAP configuration
+ * @param config the {@link Server} configuration containing LDAP settings
+ * @return a fully initialized {@link LdapServerConfig} instance
+ */
public LdapServerConfig asBasicLdapConfig(String name, Server config) {
String searchFilter = usersSearchFilter(name, config);
- return LdapServerConfig.builder()//
- .name(name)//
- .enabled(config.isEnabled())//
- .activeDirectory(config.isActiveDirectory())//
- .url(config.getUrl())//
- .baseDn(config.getBaseDn())//
- .usersRdn(config.getUsers().getRdn())//
- .usersSearchFilter(searchFilter)//
- .returningAttributes(config.getUsers().getReturningAttributes())//
- .rolesRdn(config.getRoles().getRdn())//
- .rolesSearchFilter(config.getRoles().getSearchFilter())//
- .adminDn(toOptional(config.getAdminDn()))//
+ return LdapServerConfig.builder().name(name).enabled(config.isEnabled())
+ .activeDirectory(config.isActiveDirectory()).url(config.getUrl()).baseDn(config.getBaseDn())
+ .usersRdn(config.getUsers().getRdn()).usersSearchFilter(searchFilter)
+ .returningAttributes(config.getUsers().getReturningAttributes()).rolesRdn(config.getRoles().getRdn())
+ .rolesSearchFilter(config.getRoles().getSearchFilter()).adminDn(toOptional(config.getAdminDn()))
.adminPassword(toOptional(config.getAdminPassword())).build();
}
+ /**
+ * Builds an {@link ExtendedLdapConfig} for extended LDAP authentication.
+ *
+ * @param name the name of the LDAP configuration
+ * @param config the {@link Server} configuration containing LDAP settings
+ * @return a fully initialized {@link ExtendedLdapConfig} instance
+ */
public ExtendedLdapConfig asExtendedLdapConfig(String name, Server config) {
String searchFilter = usersSearchFilter(name, config);
- return ExtendedLdapConfig.builder()//
- .name(name)//
- .enabled(config.isEnabled())//
- .url(config.getUrl())//
- .baseDn(config.getBaseDn())//
- .usersRdn(config.getUsers().getRdn())//
- .usersSearchFilter(searchFilter)//
- .returningAttributes(config.getUsers().getReturningAttributes())//
- .rolesRdn(config.getRoles().getRdn())//
- .rolesSearchFilter(config.getRoles().getSearchFilter())//
- .orgsRdn(config.getOrgs().getRdn())//
- .pendingOrgsRdn(config.getOrgs().getPendingRdn())//
- .adminDn(toOptional(config.getAdminDn()))//
- .adminPassword(toOptional(config.getAdminPassword()))//
- .build();
+ return ExtendedLdapConfig.builder().name(name).enabled(config.isEnabled()).url(config.getUrl())
+ .baseDn(config.getBaseDn()).usersRdn(config.getUsers().getRdn()).usersSearchFilter(searchFilter)
+ .returningAttributes(config.getUsers().getReturningAttributes()).rolesRdn(config.getRoles().getRdn())
+ .rolesSearchFilter(config.getRoles().getSearchFilter()).orgsRdn(config.getOrgs().getRdn())
+ .pendingOrgsRdn(config.getOrgs().getPendingRdn()).adminDn(toOptional(config.getAdminDn()))
+ .adminPassword(toOptional(config.getAdminPassword())).build();
}
+ /**
+ * Determines the user search filter for LDAP authentication.
+ *
+ * If no search filter is explicitly defined and the LDAP server is an Active
+ * Directory instance, the default Active Directory search filter is used.
+ *
+ *
+ * @param name the name of the LDAP configuration
+ * @param config the LDAP server configuration
+ * @return the user search filter string
+ */
private String usersSearchFilter(String name, Server config) {
String searchFilter = config.getUsers().getSearchFilter();
if (!StringUtils.hasText(searchFilter) && config.isActiveDirectory()) {
searchFilter = LdapServerConfig.DEFAULT_ACTIVE_DIRECTORY_USER_SEARCH_FILTER;
- log.info("Using default search filter '{}' for AD config {}", searchFilter, name);
+ log.info("Using default search filter '{}' for Active Directory configuration: {}", searchFilter, name);
}
return searchFilter;
}
+ /**
+ * Converts a string value to an {@link Optional}, returning an empty value if
+ * the string is null or empty.
+ *
+ * @param value the input string
+ * @return an {@link Optional} containing the string if it is not empty,
+ * otherwise empty
+ */
private Optional toOptional(String value) {
return ofNullable(StringUtils.hasText(value) ? value : null);
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapConfigPropertiesValidations.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapConfigPropertiesValidations.java
index 618bde88..e8937ca6 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapConfigPropertiesValidations.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapConfigPropertiesValidations.java
@@ -26,21 +26,53 @@
import lombok.extern.slf4j.Slf4j;
+/**
+ * Validator for LDAP configuration properties.
+ *
+ * This class ensures that necessary LDAP configuration fields are correctly
+ * defined based on the type of LDAP authentication used.
+ *
+ *
+ *
+ * Validation rules include:
+ *
+ *
+ * Ensuring required properties such as URL and base DN are provided.
+ * Applying additional validation rules for extended LDAP
+ * configurations.
+ * Ensuring Active Directory configurations do not include unnecessary
+ * properties.
+ *
+ */
@Slf4j(topic = "org.georchestra.gateway.security.ldap")
public class LdapConfigPropertiesValidations {
+ /**
+ * Validates an LDAP configuration.
+ *
+ * @param name the LDAP configuration name
+ * @param config the {@link Server} configuration containing LDAP settings
+ * @param errors the {@link Errors} object for capturing validation errors
+ */
public void validate(String name, Server config, Errors errors) {
if (!config.isEnabled()) {
- log.debug("ignoring validation of LDAP config {}, enabled = false", name);
+ log.debug("Ignoring validation of LDAP config '{}', enabled = false", name);
return;
}
+
+ // Ensure the LDAP URL is defined
final String url = format("ldap.[%s].url", name);
- rejectIfEmptyOrWhitespace(errors, url, "", "LDAP url is required (e.g.: ldap://my.ldap.com:389)");
+ rejectIfEmptyOrWhitespace(errors, url, "", "LDAP URL is required (e.g., ldap://my.ldap.com:389)");
+ // Validate base LDAP configuration
validateSimpleLdap(name, config, errors);
+
+ // Validate geOrchestra-specific extensions if enabled
if (config.isExtended()) {
validateGeorchestraExtensions(name, config, errors);
}
+
+ // Apply specific validation rules for Active Directory configurations
if (config.isActiveDirectory()) {
validateActiveDirectory(name, config, errors);
} else {
@@ -48,39 +80,76 @@ public void validate(String name, Server config, Errors errors) {
}
}
+ /**
+ * Validates essential LDAP properties for a standard LDAP configuration.
+ *
+ * @param name the LDAP configuration name
+ * @param config the LDAP server configuration
+ * @param errors the validation error object
+ */
private void validateSimpleLdap(String name, Server config, Errors errors) {
rejectIfEmptyOrWhitespace(errors, format("ldap.[%s].baseDn", name), "",
- "LDAP base DN is required. e.g.: dc=georchestra,dc=org");
+ "LDAP base DN is required (e.g., dc=georchestra,dc=org)");
rejectIfEmptyOrWhitespace(errors, format("ldap.[%s].users.rdn", name), "",
- "LDAP users RDN (Relative Distinguished Name) is required. e.g.: ou=users,dc=georchestra,dc=org");
+ "LDAP users RDN (Relative Distinguished Name) is required (e.g., ou=users,dc=georchestra,dc=org)");
rejectIfEmptyOrWhitespace(errors, format("ldap.[%s].roles.rdn", name), "",
- "Roles Relative distinguished name is required. e.g.: ou=roles");
+ "Roles Relative Distinguished Name is required (e.g., ou=roles)");
rejectIfEmptyOrWhitespace(errors, format("ldap.[%s].roles.searchFilter", name), "",
- "Roles searchFilter is required. e.g.: (member={0})");
+ "Roles search filter is required (e.g., (member={0}))");
}
+ /**
+ * Ensures that the user search filter is mandatory for non-Active Directory
+ * configurations.
+ *
+ * @param name the LDAP configuration name
+ * @param errors the validation error object
+ */
private void validateUsersSearchFilterMandatory(String name, Errors errors) {
rejectIfEmptyOrWhitespace(errors, format("ldap.[%s].users.searchFilter", name), "",
- "LDAP users searchFilter is required for regular LDAP configs. e.g.: (uid={0}), and optional for Active Directory. e.g.: (&(objectClass=user)(userPrincipalName={0}))");
+ "LDAP users search filter is required for standard LDAP configurations (e.g., (uid={0})), "
+ + "but optional for Active Directory (e.g., (&(objectClass=user)(userPrincipalName={0})))");
}
+ /**
+ * Validates geOrchestra-specific LDAP extensions.
+ *
+ * @param name the LDAP configuration name
+ * @param config the LDAP server configuration
+ * @param errors the validation error object
+ */
private void validateGeorchestraExtensions(String name, Server config, Errors errors) {
rejectIfEmptyOrWhitespace(errors, format("ldap.[%s].orgs.rdn", name), "",
- "Organizations search base RDN is required if extended is true. e.g.: ou=orgs");
+ "Organizations search base RDN is required if 'extended' is true (e.g., ou=orgs)");
}
+ /**
+ * Ensures that Active Directory configurations do not contain unused
+ * properties.
+ *
+ * @param name the LDAP configuration name
+ * @param config the LDAP server configuration
+ * @param errors the validation error object
+ */
private void validateActiveDirectory(String name, Server config, Errors errors) {
warnUnusedByActiveDirectory(name, "orgs", config.getOrgs());
}
+ /**
+ * Logs a warning if an Active Directory configuration contains an unused
+ * property.
+ *
+ * @param name the LDAP configuration name
+ * @param property the property name
+ * @param value the property value
+ */
private void warnUnusedByActiveDirectory(String name, String property, Object value) {
if (value != null) {
- log.warn(
- "Found config property org.georchestra.gateway.security.ldap.{}.{} but it's not used by Active Directory",
- name, property);
+ log.warn("Found config property 'org.georchestra.gateway.security.ldap.{}.{}', "
+ + "but it is not used by Active Directory configurations.", name, property);
}
}
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/NoPasswordLdapUserDetailsMapper.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/NoPasswordLdapUserDetailsMapper.java
index d80b4ad2..1cf54493 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/NoPasswordLdapUserDetailsMapper.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/NoPasswordLdapUserDetailsMapper.java
@@ -1,9 +1,43 @@
+/*
+ * 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 org.springframework.security.ldap.userdetails.LdapUserDetailsMapper;
+/**
+ * Custom implementation of {@link LdapUserDetailsMapper} that prevents storing
+ * passwords in the security context.
+ *
+ * This class overrides the default password mapping behavior to always return
+ * {@code null}, ensuring that passwords retrieved from LDAP are not retained in
+ * memory.
+ *
+ */
public class NoPasswordLdapUserDetailsMapper extends LdapUserDetailsMapper {
+ /**
+ * Overrides the default password mapping to always return {@code null},
+ * ensuring that the user's password is never stored in the security context.
+ *
+ * @param passwordValue the original password value from LDAP
+ * @return always {@code null}
+ */
@Override
protected String mapPassword(Object passwordValue) {
return null;
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticatedUserMapper.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticatedUserMapper.java
index 8d4c3936..ef5255b0 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticatedUserMapper.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticatedUserMapper.java
@@ -36,43 +36,72 @@
import lombok.RequiredArgsConstructor;
/**
- * {@link GeorchestraUserMapperExtension} that maps generic LDAP-authenticated
- * token to {@link GeorchestraUser} by calling
- * {@link UsersApi#findByUsername(String)}, with the authentication token's
- * principal name as argument.
+ * Maps a generic LDAP-authenticated {@link Authentication} token to a
+ * {@link GeorchestraUser}.
+ *
+ * This implementation extracts user details from an
+ * {@link LdapUserDetails}-based authentication and maps them to a
+ * {@link GeorchestraUser}. It retrieves:
+ *
+ * Username from {@link LdapUserDetails#getUsername()}
+ * Roles from {@link Authentication#getAuthorities()}
+ * Additional attributes (first name, telephone, description) if available
+ * from a {@link Person} instance.
+ *
+ *
+ *
+ *
+ * This mapper does not interact with {@link UsersApi}, unlike other
+ * implementations.
+ *
*/
@RequiredArgsConstructor
public class BasicLdapAuthenticatedUserMapper implements GeorchestraUserMapperExtension {
+ /**
+ * Attempts to resolve a {@link GeorchestraUser} from the provided
+ * authentication token.
+ *
+ * @param authToken the authentication token to process
+ * @return an {@link Optional} containing the mapped {@link GeorchestraUser}, or
+ * empty if the token does not match the expected type
+ */
@Override
public Optional resolve(Authentication authToken) {
- return Optional.ofNullable(authToken)//
- .filter(UsernamePasswordAuthenticationToken.class::isInstance)
- .map(UsernamePasswordAuthenticationToken.class::cast)//
- .filter(token -> token.getPrincipal() instanceof LdapUserDetails)//
- .flatMap(this::map);
+ return Optional.ofNullable(authToken).filter(UsernamePasswordAuthenticationToken.class::isInstance)
+ .map(UsernamePasswordAuthenticationToken.class::cast)
+ .filter(token -> token.getPrincipal() instanceof LdapUserDetails).flatMap(this::map);
}
+ /**
+ * Maps an LDAP-authenticated user to a {@link GeorchestraUser}.
+ *
+ * @param token the authentication token containing LDAP user details
+ * @return an {@link Optional} containing the mapped {@link GeorchestraUser}
+ */
Optional map(UsernamePasswordAuthenticationToken token) {
- final LdapUserDetails principal = (LdapUserDetails) token.getPrincipal();
- final String username = principal.getUsername();
+ LdapUserDetails principal = (LdapUserDetails) token.getPrincipal();
+ String username = principal.getUsername();
List roles = resolveRoles(token.getAuthorities());
GeorchestraUser user = new GeorchestraUser();
user.setUsername(username);
- user.setRoles(new ArrayList<>(roles));//mutable
+ user.setRoles(new ArrayList<>(roles)); // Ensure roles are mutable
if (principal instanceof Person person) {
- String description = person.getDescription();
- String givenName = person.getGivenName();
- String telephoneNumber = person.getTelephoneNumber();
- user.setNotes(description);
- user.setFirstName(givenName);
- user.setTelephoneNumber(telephoneNumber);
+ user.setFirstName(person.getGivenName());
+ user.setTelephoneNumber(person.getTelephoneNumber());
+ user.setNotes(person.getDescription());
}
return Optional.of(user);
}
+ /**
+ * Extracts role names from the authentication token's authorities.
+ *
+ * @param authorities the granted authorities assigned to the user
+ * @return a list of role names
+ */
protected List resolveRoles(Collection extends GrantedAuthority> authorities) {
return authorities.stream().map(GrantedAuthority::getAuthority).toList();
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticationConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticationConfiguration.java
index db097cbf..0093087c 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticationConfiguration.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticationConfiguration.java
@@ -21,45 +21,45 @@
import java.util.List;
import org.georchestra.gateway.security.GeorchestraGatewaySecurityConfigProperties;
-import org.georchestra.gateway.security.ServerHttpSecurityCustomizer;
import org.georchestra.gateway.security.ldap.extended.ExtendedLdapAuthenticationConfiguration;
import org.springframework.beans.factory.BeanCreationException;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
-import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.ldap.authentication.LdapAuthenticationProvider;
import org.springframework.security.ldap.userdetails.LdapUserDetails;
import lombok.extern.slf4j.Slf4j;
/**
- * {@link ServerHttpSecurityCustomizer} to enable LDAP based authentication and
- * authorization across multiple LDAP databases.
+ * Configures LDAP-based authentication and authorization for geOrchestra
+ * Gateway.
*
- * This configuration sets up the required beans for spring-based LDAP
- * authentication and authorization, using
- * {@link GeorchestraGatewaySecurityConfigProperties} to get the
- * {@link GeorchestraGatewaySecurityConfigProperties#getUrl() connection URL}
- * and the {@link GeorchestraGatewaySecurityConfigProperties#getBaseDn() base
- * DN}.
+ * This configuration:
+ *
+ * Loads LDAP server configurations from
+ * {@link GeorchestraGatewaySecurityConfigProperties}.
+ * Registers {@link BasicLdapAuthenticationProvider} instances for each
+ * enabled LDAP server.
+ * Provides a {@link BasicLdapAuthenticatedUserMapper} to convert LDAP
+ * authentication data into a geOrchestra user.
+ *
+ *
*
- * As a result, the {@link ServerHttpSecurity} will have HTTP-Basic
- * authentication enabled and {@link ServerHttpSecurity#formLogin() form login}
- * set up.
+ * Authenticated users will have:
+ *
+ * An {@link LdapUserDetails} principal extracted from their LDAP
+ * authentication.
+ * Roles assigned based on the LDAP group mappings.
+ * A security context populated with an {@link Authentication} object.
+ *
+ *
*
- * 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 ExtendedLdapAuthenticationConfiguration} for further details.
+ * This configuration primarily supports standard LDAP authentication. For
+ * geOrchestra-specific LDAP features (e.g., organizations, additional
+ * attributes), refer to {@link ExtendedLdapAuthenticationConfiguration}.
+ *
*
* @see ExtendedLdapAuthenticationConfiguration
* @see GeorchestraGatewaySecurityConfigProperties
@@ -69,39 +69,63 @@
@Slf4j(topic = "org.georchestra.gateway.security.ldap.basic")
public class BasicLdapAuthenticationConfiguration {
+ /**
+ * Provides an LDAP user mapper for basic authentication configurations.
+ *
+ * @param enabledConfigs the list of enabled simple LDAP configurations
+ * @return a {@link BasicLdapAuthenticatedUserMapper} instance or {@code null}
+ * if no LDAP configurations are enabled
+ */
@Bean
BasicLdapAuthenticatedUserMapper ldapAuthenticatedUserMapper(List enabledConfigs) {
return enabledConfigs.isEmpty() ? null : new BasicLdapAuthenticatedUserMapper();
}
+ /**
+ * Retrieves the list of enabled simple (non-extended) LDAP configurations.
+ *
+ * @param config the security configuration properties
+ * @return a list of enabled {@link LdapServerConfig} instances
+ */
@Bean
List enabledSimpleLdapConfigs(GeorchestraGatewaySecurityConfigProperties config) {
return config.simpleEnabled();
}
+ /**
+ * Creates a list of LDAP authentication providers based on the enabled LDAP
+ * configurations.
+ *
+ * @param configs the list of enabled LDAP configurations
+ * @return a list of {@link BasicLdapAuthenticationProvider} instances
+ */
@Bean
List ldapAuthenticationProviders(List configs) {
return configs.stream().map(this::createLdapProvider).toList();
}
+ /**
+ * Creates an {@link BasicLdapAuthenticationProvider} for a given LDAP
+ * configuration.
+ *
+ * @param config the LDAP server configuration
+ * @return an initialized {@link BasicLdapAuthenticationProvider} instance
+ * @throws BeanCreationException if an error occurs during provider creation
+ */
private BasicLdapAuthenticationProvider createLdapProvider(LdapServerConfig config) {
- log.info("Creating LDAP AuthenticationProvider {} with URL {}", config.getName(), config.getUrl());
+ log.info("Creating LDAP AuthenticationProvider '{}' with URL {}", config.getName(), config.getUrl());
try {
- LdapAuthenticationProvider provider = new LdapAuthenticatorProviderBuilder()//
- .url(config.getUrl())//
- .baseDn(config.getBaseDn())//
- .userSearchBase(config.getUsersRdn())//
- .userSearchFilter(config.getUsersSearchFilter())//
- .rolesSearchBase(config.getRolesRdn())//
- .rolesSearchFilter(config.getRolesSearchFilter())//
- .adminDn(config.getAdminDn().orElse(null))//
- .adminPassword(config.getAdminPassword().orElse(null))//
+ LdapAuthenticationProvider provider = new LdapAuthenticatorProviderBuilder().url(config.getUrl())
+ .baseDn(config.getBaseDn()).userSearchBase(config.getUsersRdn())
+ .userSearchFilter(config.getUsersSearchFilter()).rolesSearchBase(config.getRolesRdn())
+ .rolesSearchFilter(config.getRolesSearchFilter()).adminDn(config.getAdminDn().orElse(null))
+ .adminPassword(config.getAdminPassword().orElse(null))
.returningAttributes(config.getReturningAttributes()).build();
return new BasicLdapAuthenticationProvider(config.getName(), provider);
} catch (RuntimeException e) {
- throw new BeanCreationException(
- "Error creating LDAP Authentication Provider for config " + config + ": " + e.getMessage(), e);
+ throw new BeanCreationException("Error creating LDAP Authentication Provider for config " + config.getName()
+ + ": " + e.getMessage(), e);
}
}
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticationProvider.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticationProvider.java
index 0b87a664..9eb566c8 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticationProvider.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticationProvider.java
@@ -27,32 +27,73 @@
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
+/**
+ * Decorates an {@link AuthenticationProvider} for basic LDAP authentication,
+ * adding logging and monitoring capabilities.
+ *
+ * This provider wraps a standard {@link AuthenticationProvider} to:
+ *
+ * Log authentication attempts, successes, and failures for a specific LDAP
+ * configuration.
+ * Provide better traceability for multi-LDAP environments.
+ *
+ *
+ *
+ * Example usage:
+ *
+ *
+ *
+ * {
+ * @code
+ * AuthenticationProvider ldapProvider = new BasicLdapAuthenticationProvider("ldap1", delegateProvider);
+ * }
+ *
+ */
@Slf4j(topic = "org.georchestra.gateway.security.ldap")
public class BasicLdapAuthenticationProvider extends AuthenticationProviderDecorator {
private final @NonNull String configName;
+ /**
+ * Constructs a new {@code BasicLdapAuthenticationProvider} that decorates the
+ * given delegate.
+ *
+ * @param configName the name of the LDAP configuration (used for logging and
+ * identification)
+ * @param delegate the actual {@link AuthenticationProvider} handling the
+ * authentication
+ */
public BasicLdapAuthenticationProvider(@NonNull String configName, @NonNull AuthenticationProvider delegate) {
super(delegate);
this.configName = configName;
}
+ /**
+ * Attempts to authenticate a user against the configured LDAP server.
+ *
+ * Logs authentication attempts, successes, and failures.
+ *
+ *
+ * @param authentication the authentication request object
+ * @return the authenticated {@link Authentication} object if authentication is
+ * successful
+ * @throws AuthenticationException if authentication fails
+ */
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
- log.debug("Attempting to authenticate user {} against {} LDAP", authentication.getName(), configName);
+ log.debug("Attempting to authenticate user '{}' against '{}' LDAP", authentication.getName(), configName);
try {
Authentication auth = super.authenticate(authentication);
- log.debug("Authenticated {} from {} with roles {}", auth.getName(), configName, auth.getAuthorities());
+ log.debug("Authenticated '{}' from '{}' with roles {}", auth.getName(), configName, auth.getAuthorities());
return auth;
} catch (AuthenticationException e) {
if (log.isDebugEnabled()) {
- log.info("Authentication of {} against {} LDAP failed", authentication.getName(), configName, e);
+ log.info("Authentication of '{}' against '{}' LDAP failed", authentication.getName(), configName, e);
} else {
- log.info("Authentication of {} against {} LDAP failed: {}", authentication.getName(), configName,
+ log.info("Authentication of '{}' against '{}' LDAP failed: {}", authentication.getName(), configName,
e.getMessage());
}
throw e;
}
}
-
}
\ No newline at end of file
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/LdapAuthenticatorProviderBuilder.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/LdapAuthenticatorProviderBuilder.java
index f6bbd97c..c46f97d6 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/LdapAuthenticatorProviderBuilder.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/LdapAuthenticatorProviderBuilder.java
@@ -35,6 +35,32 @@
import lombok.experimental.Accessors;
/**
+ * Builder for creating an {@link ExtendedLdapAuthenticationProvider} instance
+ * with a configurable LDAP authentication setup.
+ *
+ * This builder allows setting:
+ *
+ * LDAP connection properties (URL, base DN, admin credentials)
+ * User search configuration (search base, filter, returning
+ * attributes)
+ * Role resolution configuration (group search base and filter)
+ * Integration with an optional {@link AccountDao} for user account
+ * management
+ *
+ *
+ *
+ *
+ * Example usage:
+ *
+ *
+ *
+ * {
+ * @code
+ * LdapAuthenticationProvider provider = new LdapAuthenticatorProviderBuilder().url("ldap://example.com")
+ * .baseDn("dc=example,dc=com").userSearchBase("ou=users").userSearchFilter("(uid={0})")
+ * .rolesSearchBase("ou=groups").rolesSearchFilter("(member={0})").build();
+ * }
+ *
*/
@Accessors(chain = true, fluent = true)
public class LdapAuthenticatorProviderBuilder {
@@ -53,34 +79,49 @@ public class LdapAuthenticatorProviderBuilder {
private @Setter AccountDao accountDao;
- // null = all atts, empty == none
+ /**
+ * Attributes to be retrieved when querying LDAP for user details.
+ *
+ * A {@code null} value retrieves all attributes, while an empty array retrieves
+ * none.
+ *
+ */
private @Setter String[] returningAttributes = null;
+ /**
+ * Builds and returns an {@link ExtendedLdapAuthenticationProvider} based on the
+ * configured settings.
+ *
+ * @return an LDAP authentication provider
+ * @throws NullPointerException if required fields are not set
+ */
public ExtendedLdapAuthenticationProvider build() {
- requireNonNull(url, "url is not set");
- requireNonNull(baseDn, "baseDn is not set");
- requireNonNull(userSearchBase, "userSearchBase is not set");
- requireNonNull(userSearchFilter, "userSearchFilter is not set");
- requireNonNull(rolesSearchBase, "rolesSearchBase is not set");
- requireNonNull(rolesSearchFilter, "rolesSearchFilter is not set");
-
- final ExtendedPasswordPolicyAwareContextSource source = contextSource();
- final BindAuthenticator authenticator = ldapAuthenticator(source);
- final DefaultLdapAuthoritiesPopulator rolesPopulator = ldapAuthoritiesPopulator(source);
+ requireNonNull(url, "LDAP URL is not set");
+ requireNonNull(baseDn, "Base DN is not set");
+ requireNonNull(userSearchBase, "User search base is not set");
+ requireNonNull(userSearchFilter, "User search filter is not set");
+ requireNonNull(rolesSearchBase, "Roles search base is not set");
+ requireNonNull(rolesSearchFilter, "Roles search filter is not set");
+
+ final ExtendedPasswordPolicyAwareContextSource contextSource = createContextSource();
+ final BindAuthenticator authenticator = createLdapAuthenticator(contextSource);
+ final DefaultLdapAuthoritiesPopulator rolesPopulator = createLdapAuthoritiesPopulator(contextSource);
+
ExtendedLdapAuthenticationProvider provider = new ExtendedLdapAuthenticationProvider(authenticator,
rolesPopulator);
-
- final GrantedAuthoritiesMapper rolesMapper = ldapAuthoritiesMapper();
- provider.setAuthoritiesMapper(rolesMapper);
+ provider.setAuthoritiesMapper(createAuthoritiesMapper());
provider.setUserDetailsContextMapper(new NoPasswordLdapUserDetailsMapper());
provider.setAccountDao(accountDao);
+
return provider;
}
- private BindAuthenticator ldapAuthenticator(BaseLdapPathContextSource contextSource) {
+ /**
+ * Creates and configures the LDAP authenticator.
+ */
+ private BindAuthenticator createLdapAuthenticator(BaseLdapPathContextSource contextSource) {
FilterBasedLdapUserSearch search = new FilterBasedLdapUserSearch(userSearchBase, userSearchFilter,
contextSource);
-
search.setReturningAttributes(returningAttributes);
BindAuthenticator authenticator = new BindAuthenticator(contextSource);
@@ -89,7 +130,10 @@ private BindAuthenticator ldapAuthenticator(BaseLdapPathContextSource contextSou
return authenticator;
}
- private ExtendedPasswordPolicyAwareContextSource contextSource() {
+ /**
+ * Creates and configures the LDAP context source for authentication.
+ */
+ private ExtendedPasswordPolicyAwareContextSource createContextSource() {
ExtendedPasswordPolicyAwareContextSource context = new ExtendedPasswordPolicyAwareContextSource(url);
context.setBase(baseDn);
if (adminDn != null) {
@@ -100,11 +144,18 @@ private ExtendedPasswordPolicyAwareContextSource contextSource() {
return context;
}
- private GrantedAuthoritiesMapper ldapAuthoritiesMapper() {
+ /**
+ * Creates a default authority mapper to convert LDAP roles into Spring Security
+ * authorities.
+ */
+ private GrantedAuthoritiesMapper createAuthoritiesMapper() {
return new SimpleAuthorityMapper();
}
- private DefaultLdapAuthoritiesPopulator ldapAuthoritiesPopulator(BaseLdapPathContextSource contextSource) {
+ /**
+ * Creates and configures the LDAP role populator.
+ */
+ private DefaultLdapAuthoritiesPopulator createLdapAuthoritiesPopulator(BaseLdapPathContextSource contextSource) {
DefaultLdapAuthoritiesPopulator authoritiesPopulator = new DefaultLdapAuthoritiesPopulator(contextSource,
rolesSearchBase);
authoritiesPopulator.setGroupSearchFilter(rolesSearchFilter);
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/LdapServerConfig.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/LdapServerConfig.java
index 1179b9e7..688328e9 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/LdapServerConfig.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/LdapServerConfig.java
@@ -16,7 +16,6 @@
* You should have received a copy of the GNU General Public License along with
* geOrchestra. If not, see .
*/
-
package org.georchestra.gateway.security.ldap.basic;
import java.util.Optional;
@@ -26,26 +25,130 @@
import lombok.NonNull;
import lombok.Value;
+/**
+ * Configuration object representing the LDAP server settings used for user
+ * authentication and role retrieval.
+ *
+ * This class defines essential connection parameters and search configurations
+ * for LDAP authentication.
+ *
+ *
+ *
+ * Features:
+ *
+ * Supports both standard LDAP and Active Directory.
+ * Allows specifying search filters and base DNs for users and roles.
+ * Supports optional admin credentials for LDAP queries.
+ * Provides a default Active Directory search filter.
+ *
+ *
+ *
+ * Example usage:
+ *
+ *
+ * {
+ * @code
+ * LdapServerConfig config = LdapServerConfig.builder().name("default").enabled(true).activeDirectory(false)
+ * .url("ldap://example.com").baseDn("dc=example,dc=com").usersRdn("ou=users")
+ * .usersSearchFilter("(uid={0})").rolesRdn("ou=roles").rolesSearchFilter("(member={0})")
+ * .adminDn(Optional.of("cn=admin,dc=example,dc=com")).adminPassword(Optional.of("secret")).build();
+ * }
+ *
+ */
@Value
@Builder
@Generated
public class LdapServerConfig {
+
+ /**
+ * Default search filter for Active Directory user lookup.
+ */
public static final String DEFAULT_ACTIVE_DIRECTORY_USER_SEARCH_FILTER = "(&(objectClass=user)(userPrincipalName={0}))";
+ /**
+ * Logical name for identifying the LDAP configuration.
+ */
private @NonNull String name;
+
+ /**
+ * Flag indicating if this LDAP configuration is enabled.
+ */
private boolean enabled;
+
+ /**
+ * Indicates whether the LDAP server is an Active Directory instance.
+ */
private boolean activeDirectory;
+ /**
+ * LDAP server URL, including protocol and port (e.g.,
+ * "ldap://ldap.example.com:389").
+ */
private @NonNull String url;
+
+ /**
+ * Base Distinguished Name (DN) for the LDAP directory.
+ *
+ * This is the root DN where searches for users and roles begin. Example:
+ * {@code dc=example,dc=com}.
+ *
+ */
private @NonNull String baseDn;
+ /**
+ * Relative Distinguished Name (RDN) for user entries within the directory.
+ *
+ * Example: {@code ou=users}.
+ *
+ */
private @NonNull String usersRdn;
+
+ /**
+ * LDAP search filter for locating user entries.
+ *
+ * Example:
+ *
+ * OpenLDAP: {@code (uid={0})}
+ * Active Directory:
+ * {@code (&(objectClass=user)(userPrincipalName={0}))}
+ *
+ *
+ */
private @NonNull String usersSearchFilter;
+
+ /**
+ * Relative Distinguished Name (RDN) for role entries within the directory.
+ *
+ * Example: {@code ou=roles}.
+ *
+ */
private @NonNull String rolesRdn;
+
+ /**
+ * LDAP search filter for retrieving user roles.
+ *
+ * Example: {@code (member={0})}.
+ *
+ */
private @NonNull String rolesSearchFilter;
- // null = all atts, empty == none
+
+ /**
+ * Attributes to retrieve when searching for user details.
+ *
+ * A {@code null} value means all attributes are retrieved, while an empty array
+ * means none are returned.
+ *
+ */
private String[] returningAttributes;
+ /**
+ * Optional distinguished name (DN) for an LDAP administrator account used for
+ * privileged queries.
+ */
private @NonNull Optional adminDn;
+
+ /**
+ * Optional password for the administrator account used for privileged queries.
+ */
private @NonNull Optional adminPassword;
-}
\ No newline at end of file
+}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/DemultiplexingUsersApi.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/DemultiplexingUsersApi.java
index 177f9685..dfaea626 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/DemultiplexingUsersApi.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/DemultiplexingUsersApi.java
@@ -16,7 +16,6 @@
* You should have received a copy of the GNU General Public License along with
* geOrchestra. If not, see .
*/
-
package org.georchestra.gateway.security.ldap.extended;
import java.util.HashSet;
@@ -36,74 +35,142 @@
import lombok.RequiredArgsConstructor;
/**
- * Demultiplexer to call the appropriate {@link UsersApi} based on the
- * authentication's service name, as provided by
- * {@link GeorchestraUserNamePasswordAuthenticationToken#getConfigName()},
- * matching a configured LDAP database through the configuration properties
- * {@code georchestra.gateway.security..*}.
+ * A service responsible for selecting the appropriate {@link UsersApi} based on
+ * the authentication's originating LDAP configuration, ensuring user lookups
+ * occur in the correct LDAP database.
+ *
+ * This class provides methods to:
+ *
+ * Retrieve user details based on username, ensuring queries are made to the
+ * LDAP service where authentication originated.
+ * Resolve the user's organization information using the corresponding
+ * {@link OrganizationsApi}.
+ * Handle OAuth2-based user identification.
+ *
+ *
+ *
*
- * Ensures {@link GeorchestraLdapAuthenticatedUserMapper} queries the same LDAP
- * database the authentication object was created from, avoiding the need to
- * disambiguate if two configured LDAP databases have accounts with the same
- * {@literal username}.
+ * The mapping between LDAP configuration names and their corresponding APIs is
+ * established through configuration properties following the pattern:
+ * {@code georchestra.gateway.security..*}.
+ *
+ *
+ * Example usage:
+ *
+ *
+ * {
+ * @code
+ * Optional user = demultiplexer.findByUsername("ldap-service-1", "jdoe");
+ * }
+ *
+ *
+ * @see GeorchestraUser
+ * @see ExtendedGeorchestraUser
+ * @see UsersApi
+ * @see OrganizationsApi
*/
@RequiredArgsConstructor
public class DemultiplexingUsersApi {
+ /**
+ * Mapping between service names and their corresponding {@link UsersApi}
+ * instances.
+ */
private final @NonNull Map usersByConfigName;
+
+ /**
+ * Mapping between service names and their corresponding
+ * {@link OrganizationsApi} instances.
+ */
private final @NonNull Map orgsByConfigName;
+ /**
+ * Retrieves the set of configured service names.
+ *
+ * @return a set containing all registered LDAP service names.
+ */
public @VisibleForTesting Set getTargetNames() {
return new HashSet<>(usersByConfigName.keySet());
}
/**
+ * Finds a user by username within a specific LDAP service.
*
- * @param serviceName the configured LDAP service name, as from the
- * configuration properties
- * {@code georchestra.gateway.security.}
- * @param username the user name to query the service's {@link UsersApi} with
- *
- * @return the {@link GeorchestraUser} returned by the service's
- * {@link UsersApi}, or {@link Optional#empty() empty} if not found
+ * @param serviceName the LDAP service configuration name.
+ * @param username the username to search for.
+ * @return an {@link Optional} containing the {@link ExtendedGeorchestraUser},
+ * or empty if the user is not found.
+ * @throws NullPointerException if no {@link UsersApi} is registered for the
+ * given service.
*/
public Optional findByUsername(@NonNull String serviceName, @NonNull String username) {
- UsersApi usersApi = usersByConfigName.get(serviceName);
- Objects.requireNonNull(usersApi, () -> "No UsersApi found for config named " + serviceName);
- Optional user = usersApi.findByUsername(username);
+ UsersApi usersApi = Objects.requireNonNull(usersByConfigName.get(serviceName),
+ () -> "No UsersApi found for config named " + serviceName);
- return extend(serviceName, user);
+ Optional user = usersApi.findByUsername(username);
+ return extendUserWithOrganization(serviceName, user);
}
+ /**
+ * Finds a user by username in the first registered LDAP service.
+ *
+ * This method is useful when only one LDAP service is expected, but may not be
+ * reliable when multiple LDAP configurations exist.
+ *
+ *
+ * @param username the username to search for.
+ * @return an {@link Optional} containing the {@link ExtendedGeorchestraUser},
+ * or empty if the user is not found.
+ */
public Optional findByUsername(@NonNull String username) {
- // TODO: iterates over every possible geOrchestra LDAP being registered ?
- // I would expect to have generally only one geOrchestra (extended) LDAP
- // configured,
- // so the first extended LDAP should do.
- String serviceName = usersByConfigName.keySet().stream().findFirst().get();
- UsersApi usersApi = usersByConfigName.get(serviceName);
- Optional user = usersApi.findByUsername(username);
-
- return extend(serviceName, user);
+ return usersByConfigName.keySet().stream().findFirst()
+ .flatMap(serviceName -> findByUsername(serviceName, username));
}
+ /**
+ * Finds a user by their OAuth2 provider and unique identifier.
+ *
+ * This method attempts to match an OAuth2-authenticated user within the first
+ * registered LDAP service.
+ *
+ *
+ * @param oauth2Provider the OAuth2 provider name (e.g., "google", "github").
+ * @param oauth2Uid the unique identifier for the user within the OAuth2
+ * provider.
+ * @return an {@link Optional} containing the {@link ExtendedGeorchestraUser},
+ * or empty if the user is not found.
+ * @throws NullPointerException if no {@link UsersApi} is registered for the
+ * selected service.
+ */
public Optional findByOAuth2Uid(@NonNull String oauth2Provider,
@NonNull String oauth2Uid) {
- String serviceName = usersByConfigName.keySet().stream().findFirst().get();
- UsersApi usersApi = usersByConfigName.get(serviceName);
- Objects.requireNonNull(usersApi, () -> "No UsersApi found for config named " + serviceName);
- Optional user = usersApi.findByOAuth2Uid(oauth2Provider, oauth2Uid);
+ return usersByConfigName.keySet().stream().findFirst().flatMap(serviceName -> {
+ UsersApi usersApi = Objects.requireNonNull(usersByConfigName.get(serviceName),
+ () -> "No UsersApi found for config named " + serviceName);
- return extend(serviceName, user);
+ Optional user = usersApi.findByOAuth2Uid(oauth2Provider, oauth2Uid);
+ return extendUserWithOrganization(serviceName, user);
+ });
}
- private Optional extend(String serviceName, Optional user) {
- OrganizationsApi orgsApi = orgsByConfigName.get(serviceName);
- Objects.requireNonNull(orgsApi, () -> "No OrganizationsApi found for config named " + serviceName);
+ /**
+ * Extends a {@link GeorchestraUser} by attaching its corresponding organization
+ * details.
+ *
+ * @param serviceName the LDAP service configuration name.
+ * @param user the resolved user, if present.
+ * @return an {@link Optional} containing the {@link ExtendedGeorchestraUser}
+ * with organization details.
+ * @throws NullPointerException if no {@link OrganizationsApi} is registered for
+ * the given service.
+ */
+ private Optional extendUserWithOrganization(String serviceName,
+ Optional user) {
+ OrganizationsApi orgsApi = Objects.requireNonNull(orgsByConfigName.get(serviceName),
+ () -> "No OrganizationsApi found for config named " + serviceName);
Organization org = user.map(GeorchestraUser::getOrganization).flatMap(orgsApi::findByShortName).orElse(null);
return user.map(ExtendedGeorchestraUser::new).map(u -> u.setOrg(org));
}
-
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedGeorchestraUser.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedGeorchestraUser.java
index 1e3ecb13..2ab2fc99 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedGeorchestraUser.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedGeorchestraUser.java
@@ -1,3 +1,21 @@
+/*
+ * 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.extended;
import org.georchestra.security.model.GeorchestraUser;
@@ -13,19 +31,70 @@
import lombok.experimental.Delegate;
/**
- * {@link GeorchestraUser} with resolved {@link #getOrg() Organization}
+ * An extended version of {@link GeorchestraUser} that includes an associated
+ * {@link Organization}.
+ *
+ * This class wraps an existing {@link GeorchestraUser} instance while adding an
+ * {@link #org} property, which represents the user's resolved organization.
+ * This is useful for systems where user information is stored separately from
+ * organizational details.
+ *
+ *
+ * Example Usage:
+ *
+ *
+ * {
+ * @code
+ * GeorchestraUser baseUser = new GeorchestraUser();
+ * baseUser.setUsername("jdoe");
+ *
+ * Organization org = new Organization();
+ * org.setName("GeoOrg");
+ *
+ * ExtendedGeorchestraUser extendedUser = new ExtendedGeorchestraUser(baseUser);
+ * extendedUser.setOrg(org);
+ *
+ * System.out.println(extendedUser.getUsername()); // Inherited from GeorchestraUser
+ * System.out.println(extendedUser.getOrg().getName()); // "GeoOrg"
+ * }
+ *
+ *
+ * Key Features:
+ *
+ * Delegates all calls to the wrapped {@link GeorchestraUser} instance.
+ * Allows seamless access to user properties while adding an
+ * {@link Organization} field.
+ * Uses {@link JsonIgnore} to prevent unnecessary serialization of sensitive
+ * fields.
+ * Overrides {@link #equals(Object)} and {@link #hashCode()} to ensure
+ * consistent equality checks.
+ *
*/
@SuppressWarnings("serial")
@RequiredArgsConstructor
@Accessors(chain = true)
public class ExtendedGeorchestraUser extends GeorchestraUser {
+ /**
+ * The underlying {@link GeorchestraUser} instance, which is fully delegated.
+ */
@JsonIgnore
private final @NonNull @Delegate GeorchestraUser user;
+ /**
+ * The organization associated with this user.
+ */
@JsonIgnore
private @Getter @Setter Organization org;
+ /**
+ * Compares this user to another object based on the properties of the
+ * underlying {@link GeorchestraUser}.
+ *
+ * @param o the object to compare against
+ * @return {@code true} if the object is a {@link GeorchestraUser} and has the
+ * same properties, otherwise {@code false}
+ */
public @Override boolean equals(Object o) {
if (!(o instanceof GeorchestraUser)) {
return false;
@@ -33,6 +102,11 @@ public class ExtendedGeorchestraUser extends GeorchestraUser {
return super.equals(o);
}
+ /**
+ * Computes the hash code based on the underlying {@link GeorchestraUser}.
+ *
+ * @return the hash code value for this user
+ */
public @Override int hashCode() {
return super.hashCode();
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapAuthenticationConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapAuthenticationConfiguration.java
index 7a1b39b5..01a795a7 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapAuthenticationConfiguration.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapAuthenticationConfiguration.java
@@ -1,5 +1,3 @@
-package org.georchestra.gateway.security.ldap.extended;
-
/*
* Copyright (C) 2022 by the geOrchestra PSC
*
@@ -19,6 +17,8 @@
* geOrchestra. If not, see .
*/
+package org.georchestra.gateway.security.ldap.extended;
+
import static java.util.Objects.requireNonNull;
import java.util.Collections;
@@ -57,33 +57,77 @@
import lombok.extern.slf4j.Slf4j;
/**
- * Sets up a {@link GeorchestraUserMapperExtension} that knows how to map an
- * authentication credentials given by a
- * {@link GeorchestraUserNamePasswordAuthenticationToken} 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}.
+ * Configures authentication against an extended LDAP directory, supporting
+ * geOrchestra-specific attributes such as organizations and roles.
+ *
+ * This configuration provides a {@link GeorchestraUserMapperExtension} to
+ * transform an authenticated {@link LdapUserDetails} into a
+ * {@link GeorchestraUser}, leveraging geOrchestra's LDAP-based user management
+ * APIs.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(GeorchestraGatewaySecurityConfigProperties.class)
@Slf4j(topic = "org.georchestra.gateway.security.ldap.extended")
public class ExtendedLdapAuthenticationConfiguration {
+ /**
+ * Registers a user mapper that resolves LDAP-authenticated users to
+ * {@link GeorchestraUser}.
+ *
+ * @param users The {@link DemultiplexingUsersApi} used to look up users in
+ * different LDAP directories.
+ * @return A {@link GeorchestraLdapAuthenticatedUserMapper} instance if LDAP
+ * authentication is enabled; otherwise, returns {@code null}.
+ */
@Bean
GeorchestraLdapAuthenticatedUserMapper georchestraLdapAuthenticatedUserMapper(DemultiplexingUsersApi users) {
return users.getTargetNames().isEmpty() ? null : new GeorchestraLdapAuthenticatedUserMapper(users);
}
+ /**
+ * Retrieves the list of enabled extended LDAP configurations.
+ *
+ * @param config The global security configuration properties.
+ * @return A list of enabled extended LDAP configurations.
+ */
@Bean
List enabledExtendedLdapConfigs(GeorchestraGatewaySecurityConfigProperties config) {
return config.extendedEnabled();
}
+ /**
+ * Creates authentication providers for each enabled extended LDAP
+ * configuration.
+ *
+ * @param configs A list of enabled extended LDAP configurations.
+ * @return A list of configured {@link GeorchestraLdapAuthenticationProvider}
+ * instances.
+ */
@Bean
List extendedLdapAuthenticationProviders(List configs) {
return configs.stream().map(this::createLdapProvider).toList();
}
+ /**
+ * Creates a {@link GeorchestraLdapAuthenticationProvider} for the given
+ * {@link ExtendedLdapConfig} by setting up the necessary LDAP authentication
+ * and authorization mechanisms.
+ *
+ * This method initializes an {@link LdapTemplate} and an {@link AccountDao}
+ * based on the given LDAP configuration. It then builds an
+ * {@link ExtendedLdapAuthenticationProvider} using an
+ * {@link LdapAuthenticatorProviderBuilder}, setting up the authentication
+ * provider with user and role search filters, as well as optional admin
+ * credentials if provided.
+ *
+ *
+ * @param config The {@link ExtendedLdapConfig} defining the LDAP connection
+ * details and search configurations.
+ * @return A configured {@link GeorchestraLdapAuthenticationProvider} for
+ * handling authentication against the specified LDAP server.
+ * @throws IllegalStateException if an error occurs while creating the LDAP
+ * authentication provider.
+ */
private GeorchestraLdapAuthenticationProvider createLdapProvider(ExtendedLdapConfig config) {
log.info("Creating extended LDAP AuthenticationProvider {} at {}", config.getName(), config.getUrl());
@@ -103,10 +147,17 @@ private GeorchestraLdapAuthenticationProvider createLdapProvider(ExtendedLdapCon
.returningAttributes(config.getReturningAttributes()).accountDao(accountsDao).build();
return new GeorchestraLdapAuthenticationProvider(config.getName(), delegate);
} catch (Exception e) {
- throw new RuntimeException(e);
+ throw new IllegalStateException(e);
}
}
+ /**
+ * Registers a {@link DemultiplexingUsersApi} that routes user API calls to the
+ * appropriate LDAP instance based on configuration.
+ *
+ * @param configs The list of extended LDAP configurations.
+ * @return A {@link DemultiplexingUsersApi} instance.
+ */
@Bean
DemultiplexingUsersApi demultiplexingUsersApi(List configs) {
Map usersByConfigName = new HashMap<>();
@@ -132,7 +183,7 @@ DemultiplexingUsersApi demultiplexingUsersApi(List configs)
//////////////////////////////////////////////
private OrganizationsApi createOrgsApi(ExtendedLdapConfig ldapConfig, LdapTemplate ldapTemplate,
- AccountDao accountsDao) throws Exception {
+ AccountDao accountsDao) {
OrganizationsApiImpl impl = new OrganizationsApiImpl();
OrgsDaoImpl orgsDao = new OrgsDaoImpl();
orgsDao.setLdapTemplate(ldapTemplate);
@@ -145,12 +196,11 @@ private OrganizationsApi createOrgsApi(ExtendedLdapConfig ldapConfig, LdapTempla
return impl;
}
- private UsersApi createUsersApi(ExtendedLdapConfig ldapConfig, LdapTemplate ldapTemplate, AccountDao accountsDao)
- throws Exception {
+ private UsersApi createUsersApi(ExtendedLdapConfig ldapConfig, LdapTemplate ldapTemplate, AccountDao accountsDao) {
final RoleDao roleDao = roleDao(ldapTemplate, ldapConfig, accountsDao);
final UserMapper ldapUserMapper = createUserMapper(roleDao);
- UserRule userRule = ldapUserRule(ldapConfig);
+ UserRule userRule = ldapUserRule();
UsersApiImpl impl = new UsersApiImpl();
impl.setAccountsDao(accountsDao);
@@ -190,9 +240,7 @@ private AccountDao accountsDao(LdapTemplate ldapTemplate, ExtendedLdapConfig lda
impl.setBasePath(baseDn);
impl.setUserSearchBaseDN(userSearchBaseDN);
impl.setRoleSearchBaseDN(roleSearchBaseDN);
- if (pendingUsersSearchBaseDN != null) {
- impl.setPendingUserSearchBaseDN(pendingUsersSearchBaseDN);
- }
+ impl.setPendingUserSearchBaseDN(pendingUsersSearchBaseDN);
String orgSearchBaseDN = ldapConfig.getOrgsRdn();
requireNonNull(orgSearchBaseDN);
@@ -213,7 +261,7 @@ private RoleDao roleDao(LdapTemplate ldapTemplate, ExtendedLdapConfig ldapConfig
impl.setLdapTemplate(ldapTemplate);
impl.setRoleSearchBaseDN(rolesRdn);
impl.setAccountDao(accountDao);
- impl.setRoles(ldapProtectedRoles(ldapConfig));
+ impl.setRoles(ldapProtectedRoles());
return impl;
}
@@ -225,17 +273,11 @@ private OrgsDao orgsDao(LdapTemplate ldapTemplate, Server ldapConfig) {
impl.setOrgSearchBaseDN(ldapConfig.getOrgs().getRdn());
final String pendingOrgSearchBaseDN = "ou=pendingorgs";
-
- // not needed here, only console cares, we shouldn't allow to authenticate
- // pending users, should we?
impl.setPendingOrgSearchBaseDN(pendingOrgSearchBaseDN);
- // not needed here, only console's OrgsController cares about this, right?
- // final String orgTypes = "Association,Company,NGO,Individual,Other";
- // impl.setOrgTypeValues(orgTypes);
return impl;
}
- private UserRule ldapUserRule(ExtendedLdapConfig ldapConfig) {
+ private UserRule ldapUserRule() {
// we can't possibly try to delete a protected user, so no need to configure
// them
List protectedUsers = Collections.emptyList();
@@ -244,7 +286,7 @@ private UserRule ldapUserRule(ExtendedLdapConfig ldapConfig) {
return rule;
}
- private RoleProtected ldapProtectedRoles(ExtendedLdapConfig ldapConfig) {
+ private RoleProtected ldapProtectedRoles() {
// protected roles are used by the console service to avoid deleting them. This
// application will never try to do so, so we don't care about configuring them
List protectedRoles = List.of();
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapAuthenticationProvider.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapAuthenticationProvider.java
index 921e2262..634e2076 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapAuthenticationProvider.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapAuthenticationProvider.java
@@ -1,3 +1,21 @@
+/*
+ * 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.extended;
import org.georchestra.ds.DataServiceException;
@@ -16,31 +34,71 @@
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
+/**
+ * Custom LDAP authentication provider that extends
+ * {@link LdapAuthenticationProvider} to support user lookups by email and
+ * additional account resolution logic.
+ *
+ * This provider first attempts to resolve the authenticated user's email to a
+ * corresponding LDAP account via {@link AccountDao}. If a match is found,
+ * authentication proceeds using the associated UID instead of the provided
+ * email.
+ *
+ * This approach ensures that users can log in with their email addresses while
+ * maintaining compatibility with LDAP-based user identification.
+ */
public class ExtendedLdapAuthenticationProvider extends LdapAuthenticationProvider {
private AccountDao accountDao;
+ /**
+ * Constructs an {@link ExtendedLdapAuthenticationProvider} using the specified
+ * {@link LdapAuthenticator} and {@link LdapAuthoritiesPopulator}.
+ *
+ * @param authenticator the {@link LdapAuthenticator} used for
+ * authentication
+ * @param authoritiesPopulator the {@link LdapAuthoritiesPopulator} used to load
+ * user authorities
+ */
public ExtendedLdapAuthenticationProvider(LdapAuthenticator authenticator,
LdapAuthoritiesPopulator authoritiesPopulator) {
super(authenticator, authoritiesPopulator);
}
+ /**
+ * Sets the {@link AccountDao} used to resolve accounts by email.
+ *
+ * @param accountDao the {@link AccountDao} instance
+ */
public void setAccountDao(AccountDao accountDao) {
this.accountDao = accountDao;
}
+ /**
+ * Authenticates a user by first attempting to resolve the account via email
+ * lookup, then delegating to the parent class for authentication against LDAP.
+ *
+ * @param authentication the authentication request object
+ * @return an authenticated {@link Authentication} instance if successful
+ * @throws AuthenticationException if authentication fails
+ */
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> this.messages.getMessage("LdapAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));
+
UsernamePasswordAuthenticationToken userToken = (UsernamePasswordAuthenticationToken) authentication;
Account account = null;
+
try {
account = accountDao.findByEmail(userToken.getName());
- } catch (DataServiceException e) {
- } catch (NameNotFoundException e) {
+ } catch (DataServiceException | NameNotFoundException ignored) {
+ // Swallow exceptions and proceed with normal authentication if account is not
+ // found
}
+
+ // If an account was found, replace the authentication token with its UID
if (account != null) {
userToken = new UsernamePasswordAuthenticationToken(account.getUid(), userToken.getCredentials());
}
@@ -56,7 +114,10 @@ public Authentication authenticate(Authentication authentication) throws Authent
throw new BadCredentialsException(
this.messages.getMessage("AbstractLdapAuthenticationProvider.emptyPassword", "Empty Password"));
}
+
Assert.notNull(password, "Null password was supplied in authentication token");
+
+ // Perform LDAP authentication
DirContextOperations userData = doAuthentication(userToken);
UserDetails user = this.userDetailsContextMapper.mapUserFromContext(userData, username,
loadUserAuthorities(userData, authentication.getName(), (String) authentication.getCredentials()));
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapConfig.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapConfig.java
index eb65060f..0b7dd49c 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapConfig.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapConfig.java
@@ -16,7 +16,6 @@
* You should have received a copy of the GNU General Public License along with
* geOrchestra. If not, see .
*/
-
package org.georchestra.gateway.security.ldap.extended;
import java.util.Optional;
@@ -27,27 +26,93 @@
import lombok.NonNull;
import lombok.Value;
+/**
+ * Configuration properties for an extended LDAP authentication source.
+ *
+ * This class represents the settings required to connect and authenticate
+ * against an extended geOrchestra LDAP directory, including user and role
+ * search configurations, as well as optional administrator credentials.
+ *
+ *
+ *
+ * Extended LDAP configurations include additional organization-related fields
+ * used for enhanced user management.
+ *
+ */
@Value
@Builder
@Generated
public class ExtendedLdapConfig {
+
+ /**
+ * The unique identifier for this LDAP configuration.
+ */
private @NonNull String name;
+
+ /**
+ * Flag indicating whether this LDAP configuration is enabled.
+ */
private boolean enabled;
+
+ /**
+ * The LDAP server URL.
+ */
private @NonNull String url;
+
+ /**
+ * The base distinguished name (DN) of the LDAP directory.
+ */
private @NonNull String baseDn;
+ /**
+ * The relative distinguished name (RDN) of the user entries.
+ */
private @NonNull String usersRdn;
+
+ /**
+ * The search filter used to find user entries in the LDAP directory.
+ */
private @NonNull String usersSearchFilter;
+
+ /**
+ * The relative distinguished name (RDN) of the role entries.
+ */
private @NonNull String rolesRdn;
+
+ /**
+ * The search filter used to find role entries in the LDAP directory.
+ */
private @NonNull String rolesSearchFilter;
- // null = all atts, empty == none
+
+ /**
+ * The attributes to be retrieved for users.
+ *
+ * A {@code null} value indicates all attributes should be returned, while an
+ * empty array means no attributes will be returned.
+ *
+ */
private String[] returningAttributes;
+ /**
+ * Optional administrator distinguished name (DN) for performing privileged
+ * operations.
+ */
@Default
private @NonNull Optional adminDn = Optional.empty();
+
+ /**
+ * Optional administrator password for privileged operations.
+ */
@Default
private @NonNull Optional adminPassword = Optional.empty();
+ /**
+ * The relative distinguished name (RDN) of the organization entries.
+ */
private @NonNull String orgsRdn;
+
+ /**
+ * The relative distinguished name (RDN) of the pending organization entries.
+ */
private @NonNull String pendingOrgsRdn;
-}
\ No newline at end of file
+}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedPasswordPolicyAwareContextSource.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedPasswordPolicyAwareContextSource.java
index 740ddbcd..e55f5f38 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedPasswordPolicyAwareContextSource.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedPasswordPolicyAwareContextSource.java
@@ -1,3 +1,21 @@
+/*
+ * 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.extended;
import javax.naming.Context;
@@ -13,37 +31,78 @@
import org.springframework.security.ldap.ppolicy.PasswordPolicyException;
import org.springframework.security.ldap.ppolicy.PasswordPolicyResponseControl;
+/**
+ * Extended version of {@link PasswordPolicyAwareContextSource} that enforces
+ * LDAP password policy controls when binding as a user.
+ *
+ * This implementation first binds as the manager user before attempting to
+ * rebind as the actual principal, allowing for password policy controls to be
+ * applied correctly.
+ *
+ * If the password policy control response indicates an error (e.g., password
+ * expired, account locked), a {@link PasswordPolicyException} is thrown.
+ */
public class ExtendedPasswordPolicyAwareContextSource extends PasswordPolicyAwareContextSource {
+ /**
+ * Constructs an {@code ExtendedPasswordPolicyAwareContextSource} with the given
+ * LDAP provider URL.
+ *
+ * @param providerUrl the LDAP provider URL
+ */
public ExtendedPasswordPolicyAwareContextSource(String providerUrl) {
super(providerUrl);
}
+ /**
+ * Obtains an LDAP {@link DirContext} for the specified principal and
+ * credentials, enforcing LDAP password policy controls.
+ *
+ * If binding as the configured manager user (admin DN), it delegates directly
+ * to {@link PasswordPolicyAwareContextSource#getContext(String, String)}.
+ * Otherwise, it first binds as the manager user before reconnecting as the
+ * specified principal to ensure password policy controls are properly applied.
+ *
+ * @param principal the distinguished name (DN) of the user to authenticate
+ * @param credentials the user's credentials
+ * @return a bound {@link DirContext} for the authenticated user
+ * @throws PasswordPolicyException if the LDAP server enforces password policy
+ * restrictions
+ */
@Override
public DirContext getContext(String principal, String credentials) throws PasswordPolicyException {
- if (principal.equals(this.userDn)) {
+ final String userdn = getUserDn();
+ if (principal.equals(userdn)) {
return super.getContext(principal, credentials);
}
- this.logger.trace(LogMessage.format("Binding as %s, prior to reconnect as user %s", this.userDn, principal));
- // First bind as manager user before rebinding as the specific principal.
- LdapContext ctx = (LdapContext) super.getContext(this.userDn, this.password);
+
+ this.logger.trace(LogMessage.format("Binding as %s, prior to reconnect as user %s", userdn, principal));
+
+ // Bind as the manager user before re-binding as the specific principal
+ LdapContext ctx = (LdapContext) super.getContext(userdn, getPassword());
Control[] rctls = { new PasswordPolicyControl(false) };
+
try {
ctx.addToEnvironment(Context.SECURITY_PRINCIPAL, principal);
ctx.addToEnvironment(Context.SECURITY_CREDENTIALS, credentials);
ctx.reconnect(rctls);
} catch (javax.naming.NamingException ex) {
PasswordPolicyResponseControl ctrl = PasswordPolicyControlExtractor.extractControl(ctx);
+
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Failed to bind with %s", ctrl), ex);
}
+
LdapUtils.closeContext(ctx);
+
if (ctrl != null && ctrl.getErrorStatus() != null) {
throw new PasswordPolicyException(ctrl.getErrorStatus());
}
+
throw LdapUtils.convertLdapException(ex);
}
+
this.logger.debug(LogMessage.of(() -> "Bound with " + PasswordPolicyControlExtractor.extractControl(ctx)));
return ctx;
}
-}
\ No newline at end of file
+}
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 58dae5ff..4ed99043 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
@@ -16,7 +16,6 @@
* You should have received a copy of the GNU General Public License along with
* geOrchestra. If not, see .
*/
-
package org.georchestra.gateway.security.ldap.extended;
import java.util.ArrayList;
@@ -37,14 +36,19 @@
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.
+ * Maps LDAP-authenticated tokens to {@link GeorchestraUser} instances by
+ * retrieving user details from the configured {@link UsersApi}.
+ *
+ * This implementation specifically handles instances of
+ * {@link GeorchestraUserNamePasswordAuthenticationToken}, using its
+ * {@link GeorchestraUserNamePasswordAuthenticationToken#getConfigName()
+ * configuration name} to resolve users from the correct LDAP database.
+ *
*
- * Resolves only {@link GeorchestraUserNamePasswordAuthenticationToken}, using
- * its {@link GeorchestraUserNamePasswordAuthenticationToken#getConfigName()
- * configName} to disambiguate amongst different configured LDAP databases.
- *
+ * Additionally, this class ensures role name consistency by normalizing
+ * mismatched prefixes between LDAP authorities and geOrchestra roles.
+ *
+ *
* @see DemultiplexingUsersApi
*/
@RequiredArgsConstructor
@@ -54,13 +58,19 @@ class GeorchestraLdapAuthenticatedUserMapper implements GeorchestraUserMapperExt
@Override
public Optional resolve(Authentication authToken) {
- return Optional.ofNullable(authToken)//
- .filter(GeorchestraUserNamePasswordAuthenticationToken.class::isInstance)
- .map(GeorchestraUserNamePasswordAuthenticationToken.class::cast)//
- .filter(token -> token.getPrincipal() instanceof LdapUserDetails)//
- .flatMap(this::map);
+ return Optional.ofNullable(authToken).filter(GeorchestraUserNamePasswordAuthenticationToken.class::isInstance)
+ .map(GeorchestraUserNamePasswordAuthenticationToken.class::cast)
+ .filter(token -> token.getPrincipal() instanceof LdapUserDetails).flatMap(this::map);
}
+ /**
+ * Retrieves user details from the appropriate LDAP database based on the
+ * authentication token's configuration name.
+ *
+ * @param token the LDAP authentication token
+ * @return an {@link Optional} containing the resolved {@link GeorchestraUser},
+ * or empty if no user was found
+ */
Optional map(GeorchestraUserNamePasswordAuthenticationToken token) {
final LdapUserDetails principal = (LdapUserDetails) token.getPrincipal();
final String ldapConfigName = token.getConfigName();
@@ -70,13 +80,23 @@ Optional map(GeorchestraUserNamePasswordAuthenticationToken tok
return user.map(u -> fixPrefixedRoleNames(u, token));
}
+ /**
+ * Ensures that role names are properly prefixed with "ROLE_" for consistency
+ * between LDAP and geOrchestra role management.
+ *
+ * Also updates LDAP password expiration details in the user object.
+ *
+ *
+ * @param user the resolved user object
+ * @param token the authentication token containing authorities
+ * @return the updated {@link GeorchestraUser} with normalized roles
+ */
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
+ // Ensure consistent role naming by normalizing both authorities and user roles
Stream authorityRoleNames = token.getAuthorities().stream()
.filter(SimpleGrantedAuthority.class::isInstance).map(GrantedAuthority::getAuthority)
.map(this::normalize);
@@ -84,7 +104,9 @@ private GeorchestraUser fixPrefixedRoleNames(GeorchestraUser user,
Stream userRoles = user.getRoles().stream().map(this::normalize);
List roles = Stream.concat(authorityRoleNames, userRoles).distinct().toList();
- user.setRoles(new ArrayList<>(roles));// mutable
+ user.setRoles(new ArrayList<>(roles));
+
+ // Set LDAP password expiration warnings if applicable
if (principal.getTimeBeforeExpiration() < Integer.MAX_VALUE) {
user.setLdapWarn(true);
user.setLdapRemainingDays(String.valueOf(principal.getTimeBeforeExpiration() / (60 * 60 * 24)));
@@ -95,6 +117,12 @@ private GeorchestraUser fixPrefixedRoleNames(GeorchestraUser user,
return user;
}
+ /**
+ * Normalizes role names by ensuring they start with "ROLE_".
+ *
+ * @param role the original role name
+ * @return the normalized role name
+ */
private String normalize(String role) {
return role.startsWith("ROLE_") ? role : "ROLE_" + role;
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraLdapAuthenticationProvider.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraLdapAuthenticationProvider.java
index 61457575..2a0b9001 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraLdapAuthenticationProvider.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraLdapAuthenticationProvider.java
@@ -16,7 +16,6 @@
* You should have received a copy of the GNU General Public License along with
* geOrchestra. If not, see .
*/
-
package org.georchestra.gateway.security.ldap.extended;
import org.georchestra.gateway.security.ldap.AuthenticationProviderDecorator;
@@ -27,16 +26,59 @@
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
+/**
+ * LDAP authentication provider for geOrchestra with extended user management
+ * capabilities.
+ *
+ * This provider wraps an existing {@link AuthenticationProvider} and enhances
+ * it with additional behavior specific to geOrchestra. It ensures that
+ * authenticated users are associated with the correct LDAP configuration and
+ * returns a {@link GeorchestraUserNamePasswordAuthenticationToken} upon
+ * successful authentication.
+ *
+ *
+ *
+ * Performance Consideration:
+ *
+ *
+ * Under heavy load, the LDAP server may be overwhelmed and start failing
+ * authentication requests. To mitigate this, a Caffeine cache with a short TTL
+ * could be introduced to handle concurrency and avoid redundant authentication
+ * attempts when multiple requests arrive simultaneously.
+ *
+ */
@Slf4j(topic = "org.georchestra.gateway.security.ldap.extended")
public class GeorchestraLdapAuthenticationProvider extends AuthenticationProviderDecorator {
private final @NonNull String configName;
+ /**
+ * Constructs a new {@code GeorchestraLdapAuthenticationProvider} that wraps a
+ * delegate authentication provider.
+ *
+ * @param configName the name of the LDAP configuration associated with this
+ * provider
+ * @param delegate the actual LDAP authentication provider being decorated
+ */
public GeorchestraLdapAuthenticationProvider(@NonNull String configName, @NonNull AuthenticationProvider delegate) {
super(delegate);
this.configName = configName;
}
+ /**
+ * Attempts to authenticate a user against the configured LDAP authentication
+ * provider.
+ *
+ * If authentication succeeds, it wraps the authentication result in a
+ * {@link GeorchestraUserNamePasswordAuthenticationToken}, ensuring that the
+ * user's authentication is correctly associated with the configured LDAP
+ * instance.
+ *
+ *
+ * @param authentication the authentication request object
+ * @return the authenticated token, or {@code null} if authentication fails
+ * @throws AuthenticationException if authentication is unsuccessful
+ */
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
log.debug("Attempting to authenticate user {} against {} extended LDAP", authentication.getName(), configName);
@@ -55,5 +97,4 @@ public Authentication authenticate(Authentication authentication) throws Authent
throw e;
}
}
-
-}
\ No newline at end of file
+}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraUserNamePasswordAuthenticationToken.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraUserNamePasswordAuthenticationToken.java
index 28d8220b..504031a4 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraUserNamePasswordAuthenticationToken.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraUserNamePasswordAuthenticationToken.java
@@ -16,7 +16,6 @@
* You should have received a copy of the GNU General Public License along with
* geOrchestra. If not, see .
*/
-
package org.georchestra.gateway.security.ldap.extended;
import java.util.Collection;
@@ -30,48 +29,105 @@
import lombok.RequiredArgsConstructor;
/**
- * A specialized {@link Authentication} object for Georchestra extensions aware
- * LDAP databases, such as the default OpenLDAP schema, where {@link UsersApi}
- * can be used to fetch additional user identity information.
+ * A specialized {@link Authentication} implementation for Georchestra that
+ * associates authentication details with a specific LDAP configuration.
+ *
+ * This class is designed for use with Georchestra-aware LDAP databases, such as
+ * the default OpenLDAP schema, where {@link UsersApi} can be used to fetch
+ * additional user identity information.
+ *
+ *
+ * It acts as a wrapper around an existing {@link Authentication} instance,
+ * ensuring that authentication context remains associated with the correct LDAP
+ * configuration.
+ *
*/
@RequiredArgsConstructor
public class GeorchestraUserNamePasswordAuthenticationToken implements Authentication {
private static final long serialVersionUID = 1L;
+ /**
+ * The name of the LDAP configuration associated with this authentication.
+ */
private final @NonNull @Getter String configName;
+
+ /**
+ * The original authentication instance being wrapped.
+ */
private final @NonNull Authentication orig;
+ /**
+ * Returns the name of the authenticated principal.
+ *
+ * @return the authenticated user's name
+ */
@Override
public String getName() {
return orig.getName();
}
+ /**
+ * Returns the authorities granted to the authenticated user.
+ *
+ * @return a collection of granted authorities
+ */
@Override
public Collection extends GrantedAuthority> getAuthorities() {
return orig.getAuthorities();
}
+ /**
+ * Returns the credentials (e.g., password) used for authentication.
+ *
+ * This method always returns {@code null} as passwords should not be stored or
+ * exposed beyond the authentication process.
+ *
+ *
+ * @return {@code null} (credentials are not stored)
+ */
@Override
public Object getCredentials() {
return null;
}
+ /**
+ * Returns additional details about the authenticated request.
+ *
+ * @return the authentication details object
+ */
@Override
public Object getDetails() {
return orig.getDetails();
}
+ /**
+ * Returns the authenticated principal, typically a user object or username.
+ *
+ * @return the authenticated principal
+ */
@Override
public Object getPrincipal() {
return orig.getPrincipal();
}
+ /**
+ * Indicates whether the authentication is currently valid.
+ *
+ * @return {@code true} if authenticated, otherwise {@code false}
+ */
@Override
public boolean isAuthenticated() {
return orig.isAuthenticated();
}
+ /**
+ * Sets the authentication status of the user.
+ *
+ * @param isAuthenticated {@code true} to mark as authenticated, {@code false}
+ * otherwise
+ * @throws IllegalArgumentException if setting authentication is not supported
+ */
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
orig.setAuthenticated(isAuthenticated);
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/ExtendedOAuth2ClientProperties.java b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/ExtendedOAuth2ClientProperties.java
index 70790ee0..a06d43a2 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/ExtendedOAuth2ClientProperties.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/ExtendedOAuth2ClientProperties.java
@@ -21,32 +21,91 @@
import java.util.HashMap;
import java.util.Map;
-import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
+/**
+ * Extended version of {@link OAuth2ClientProperties} to support additional
+ * OAuth2 provider configurations, specifically adding an end session URI for
+ * logout handling.
+ *
+ * This class allows defining extra properties under the
+ * `spring.security.oauth2.client` namespace, enabling seamless integration with
+ * OAuth2 providers that support session termination via a dedicated logout
+ * endpoint.
+ *
+ *
+ *
+ * Example configuration:
+ *
+ *
+ *
+ * {@code
+ * spring:
+ * security:
+ * oauth2:
+ * client:
+ * provider:
+ * keycloak:
+ * issuer-uri: https://keycloak.example.com/realms/myrealm
+ * authorization-uri: https://keycloak.example.com/auth
+ * token-uri: https://keycloak.example.com/token
+ * end-session-uri: https://keycloak.example.com/logout
+ * }
+ *
+ *
+ *
+ * This allows retrieving the end session URI via:
+ *
+ *
+ *
+ * {
+ * @code
+ * String logoutUrl = extendedOAuth2ClientProperties.getProvider().get("keycloak").getEndSessionUri();
+ * }
+ *
+ *
+ * @see OAuth2ClientProperties
+ */
@ConfigurationProperties(prefix = "spring.security.oauth2.client")
-public class ExtendedOAuth2ClientProperties implements InitializingBean {
+public class ExtendedOAuth2ClientProperties {
private final Map provider = new HashMap<>();
+ /**
+ * Retrieves the map of configured OAuth2 providers.
+ *
+ * @return a map where the key is the provider name, and the value contains its
+ * configuration.
+ */
public Map getProvider() {
return this.provider;
}
+ /**
+ * Represents an extended OAuth2 provider configuration, adding support for an
+ * end session URI to handle provider-specific logout functionality.
+ */
public static class Provider extends OAuth2ClientProperties.Provider {
+
private String endSessionUri;
+ /**
+ * Retrieves the provider's end session URI, used for logging out the user.
+ *
+ * @return the end session URI, or {@code null} if not configured.
+ */
public String getEndSessionUri() {
return this.endSessionUri;
}
+ /**
+ * Sets the provider's end session URI.
+ *
+ * @param endSessionUri the logout endpoint of the OAuth2 provider.
+ */
public void setEndSessionUri(String endSessionUri) {
this.endSessionUri = endSessionUri;
}
}
-
- @Override
- public void afterPropertiesSet() {
- }
}
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 491bd377..df1f99e4 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
@@ -38,9 +38,7 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
-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.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.WebClientReactiveAuthorizationCodeTokenResponseClient;
@@ -64,6 +62,13 @@
import reactor.netty.http.client.HttpClient;
import reactor.netty.transport.ProxyProvider;
+/**
+ * OAuth2 security configuration for geOrchestra's Gateway.
+ *
+ * This configuration enables OAuth2 authentication, OpenID Connect integration,
+ * and HTTP proxy support for OAuth2 clients. It includes support for OAuth2
+ * login, JWT decoding, role mapping, and customized logout handling.
+ */
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({ OAuth2ProxyConfigProperties.class, OpenIdConnectCustomClaimsConfigProperties.class,
GeorchestraGatewaySecurityConfigProperties.class, ExtendedOAuth2ClientProperties.class })
@@ -72,14 +77,28 @@ public class OAuth2Configuration {
private @Value("${georchestra.gateway.logoutUrl:/?logout}") String georchestraLogoutUrl;
+ /**
+ * Customizer for enabling OAuth2 authentication in the Spring Security filter
+ * chain.
+ */
public static final class OAuth2AuthenticationCustomizer implements ServerHttpSecurityCustomizer {
-
- public @Override void customize(ServerHttpSecurity http) {
+ @Override
+ public void customize(ServerHttpSecurity http) {
log.info("Enabling authentication support using an OAuth 2.0 and/or OpenID Connect 1.0 Provider");
http.oauth2Login();
}
}
+ /**
+ * Configures the OIDC logout handler to handle end-session requests properly.
+ *
+ * @param clientRegistrationRepository The repository of registered OAuth2
+ * clients.
+ * @param properties The extended OAuth2 client properties
+ * including logout endpoints.
+ * @return A configured {@link ServerLogoutSuccessHandler} that initiates OIDC
+ * logout.
+ */
@Bean
@Profile("!test")
ServerLogoutSuccessHandler oidcLogoutSuccessHandler(
@@ -100,23 +119,41 @@ ServerLogoutSuccessHandler oidcLogoutSuccessHandler(
}
});
- OidcClientInitiatedServerLogoutSuccessHandler oidcLogoutSuccessHandler = new OidcClientInitiatedServerLogoutSuccessHandler(
+ OidcClientInitiatedServerLogoutSuccessHandler logoutHandler = new OidcClientInitiatedServerLogoutSuccessHandler(
clientRegistrationRepository);
- oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}/login?logout");
- oidcLogoutSuccessHandler.setLogoutSuccessUrl(URI.create(georchestraLogoutUrl));
- return oidcLogoutSuccessHandler;
+ logoutHandler.setPostLogoutRedirectUri("{baseUrl}/login?logout");
+ logoutHandler.setLogoutSuccessUrl(URI.create(georchestraLogoutUrl));
+ return logoutHandler;
}
+ /**
+ * Registers a Spring Security customizer to enable OAuth2 login.
+ *
+ * @return A {@link ServerHttpSecurityCustomizer} instance.
+ */
@Bean
ServerHttpSecurityCustomizer oauth2LoginEnablingCustomizer() {
return new OAuth2AuthenticationCustomizer();
}
+ /**
+ * Provides a default OAuth2 user mapper for mapping authentication tokens to
+ * geOrchestra users.
+ *
+ * @return An instance of {@link OAuth2UserMapper}.
+ */
@Bean
OAuth2UserMapper oAuth2GeorchestraUserUserMapper() {
return new OAuth2UserMapper();
}
+ /**
+ * Provides a custom OpenID Connect user mapper for processing non-standard
+ * claims.
+ *
+ * @param nonStandardClaimsConfig Configuration for custom OIDC claims.
+ * @return An instance of {@link OpenIdConnectUserMapper}.
+ */
@Bean
OpenIdConnectUserMapper openIdConnectGeorchestraUserUserMapper(
OpenIdConnectCustomClaimsConfigProperties nonStandardClaimsConfig) {
@@ -124,31 +161,28 @@ OpenIdConnectUserMapper openIdConnectGeorchestraUserUserMapper(
}
/**
- * Configures the OAuth2 client to use the HTTP proxy if enabled, by means of
- * {@linkplain #oauth2WebClient}
- *
- * {@link OAuth2LoginSpec ServerHttpSecurity$OAuth2LoginSpec#createDefault()}
- * will return a {@link ReactiveAuthenticationManager} by first looking up a
- * {@link ReactiveOAuth2AccessTokenResponseClient
- * ReactiveOAuth2AccessTokenResponseClient}
- * in the application context, and creating a default one if none is found.
- *
- * We provide such bean here to have it configured with an {@link WebClient HTTP
- * client} that will use the {@link OAuth2ProxyConfigProperties configured} HTTP
- * proxy.
+ * Configures the OAuth2 access token response client to support an HTTP proxy
+ * if enabled.
+ *
+ * @param oauth2WebClient The WebClient configured for OAuth2 communication.
+ * @return A configured instance of
+ * {@link ReactiveOAuth2AccessTokenResponseClient}.
*/
@Bean
ReactiveOAuth2AccessTokenResponseClient reactiveOAuth2AccessTokenResponseClient(
@Qualifier("oauth2WebClient") WebClient oauth2WebClient) {
-
WebClientReactiveAuthorizationCodeTokenResponseClient client = new WebClientReactiveAuthorizationCodeTokenResponseClient();
client.setWebClient(oauth2WebClient);
return client;
}
/**
- * Custom JWT decoder factory to use the web client that can be set up to go
- * through an HTTP proxy
+ * Creates a JWT decoder factory that supports OAuth2 authentication and an
+ * optional HTTP proxy.
+ *
+ * @param oauth2WebClient The WebClient used to fetch JWT keys if needed.
+ * @return A {@link ReactiveJwtDecoderFactory} configured for OAuth2
+ * authentication.
*/
@Bean
ReactiveJwtDecoderFactory idTokenDecoderFactory(
@@ -174,18 +208,31 @@ ReactiveJwtDecoderFactory idTokenDecoderFactory(
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);
+ throw new BadJwtException("Failed to decode the JWT token", exception);
}
};
}
- // Some IDPs return claims with null value but Spring does not handle them
+ /**
+ * Removes null claims from JWT tokens to avoid Spring OAuth2 processing issues.
+ */
private Map removeNullClaims(Map claims) {
return claims.entrySet().stream().filter(entry -> entry.getValue() != null)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
+ /**
+ * Provides a default implementation of {@link DefaultReactiveOAuth2UserService}
+ * for handling OAuth2 user authentication.
+ *
+ * This service is responsible for retrieving user details from the OAuth2
+ * provider and processing user information. The configured {@link WebClient} is
+ * used to make requests to the provider's user info endpoint, allowing it to
+ * support an HTTP proxy if configured.
+ *
+ * @param oauth2WebClient The WebClient instance configured for OAuth2 requests.
+ * @return A configured instance of {@link DefaultReactiveOAuth2UserService}.
+ */
@Bean
DefaultReactiveOAuth2UserService reactiveOAuth2UserService(
@Qualifier("oauth2WebClient") WebClient oauth2WebClient) {
@@ -195,6 +242,18 @@ DefaultReactiveOAuth2UserService reactiveOAuth2UserService(
return service;
}
+ /**
+ * Provides a customized {@link OidcReactiveOAuth2UserService} for handling
+ * OpenID Connect (OIDC) authentication.
+ *
+ * This service extends the default OAuth2 user service to support OIDC-specific
+ * claims and processing. It delegates OAuth2 authentication to the provided
+ * {@link DefaultReactiveOAuth2UserService}.
+ *
+ * @param oauth2Delegate The default OAuth2 user service used for retrieving
+ * user information.
+ * @return A configured instance of {@link OidcReactiveOAuth2UserService}.
+ */
@Bean
OidcReactiveOAuth2UserService oidcReactiveOAuth2UserService(DefaultReactiveOAuth2UserService oauth2Delegate) {
OidcReactiveOAuth2UserService oidUserService = new OidcReactiveOAuth2UserService();
@@ -203,39 +262,24 @@ OidcReactiveOAuth2UserService oidcReactiveOAuth2UserService(DefaultReactiveOAuth
}
/**
- * {@link WebClient} to use when performing HTTP POST requests to the OAuth2
- * service providers, that can be configured to use an HTTP proxy through the
- * {@link OAuth2ProxyConfigProperties} configuration properties.
+ * Configures a WebClient for OAuth2 authentication requests, supporting HTTP
+ * proxy settings if enabled.
*
- * @param proxyConfig defines the HTTP proxy settings specific for the OAuth2
- * client. If not
- * {@link OAuth2ProxyConfigProperties#isEnabled() enabled},
- * the {@code WebClient} will use the proxy configured
- * through System properties ({@literal http(s).proxyHost}
- * and {@literal http(s).proxyPort}), if any.
+ * @param proxyConfig The proxy configuration properties.
+ * @return A configured {@link WebClient} instance.
*/
@Bean("oauth2WebClient")
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 -> proxyPassword));
+ log.info("OAuth2 client will use HTTP proxy {}:{}", proxyConfig.getHost(), proxyConfig.getPort());
+ httpClient = httpClient.proxy(proxy -> proxy.type(ProxyProvider.Proxy.HTTP).host(proxyConfig.getHost())
+ .port(proxyConfig.getPort()).username(proxyConfig.getUsername())
+ .password(user -> proxyConfig.getPassword()));
} else {
- log.info("Oauth2 client will use HTTP proxy from System properties if provided");
+ log.info("OAuth2 client will use system-defined HTTP proxy settings if available.");
httpClient = httpClient.proxyWithSystemProperties();
}
- ReactorClientHttpConnector conn = new ReactorClientHttpConnector(httpClient);
-
- return WebClient.builder().clientConnector(conn).build();
+ return WebClient.builder().clientConnector(new ReactorClientHttpConnector(httpClient)).build();
}
-
}
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
index b8c57132..a3317ea4 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2ProxyConfigProperties.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2ProxyConfigProperties.java
@@ -22,11 +22,59 @@
import lombok.Data;
+/**
+ * Configuration properties for the OAuth2 client HTTP proxy.
+ *
+ * This configuration allows the OAuth2 client to use a proxy when communicating
+ * with the authentication provider, which can be useful in environments with
+ * restricted network access.
+ *
+ *
+ *
+ * Example configuration in {@code application.yml}:
+ *
+ *
+ *
+ *
+ * georchestra:
+ * gateway:
+ * security:
+ * oauth2:
+ * proxy:
+ * enabled: true
+ * host: proxy.example.com
+ * port: 8080
+ * username: proxyuser
+ * password: proxypass
+ *
+ *
+ */
@ConfigurationProperties(prefix = "georchestra.gateway.security.oauth2.proxy")
-public @Data class OAuth2ProxyConfigProperties {
+@Data
+public class OAuth2ProxyConfigProperties {
+
+ /**
+ * Whether the OAuth2 client should use an HTTP proxy.
+ */
private boolean enabled;
+
+ /**
+ * The proxy host address (e.g., {@code proxy.example.com}).
+ */
private String host;
+
+ /**
+ * The proxy port number.
+ */
private Integer port;
+
+ /**
+ * The optional proxy username for authentication.
+ */
private String username;
+
+ /**
+ * The optional proxy password for authentication.
+ */
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 a8ae3e79..5fdf56d9 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
@@ -16,7 +16,6 @@
* 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.util.ArrayList;
@@ -40,38 +39,55 @@
/**
* Maps {@link OAuth2AuthenticationToken} to {@link GeorchestraUser}.
*
+ * This class extracts user information from an OAuth2 authentication token and
+ * maps it to a {@link GeorchestraUser}. The mapping process follows these
+ * rules:
+ *
*
- * The {@link OAuth2User principal}'s {@literal login}
- * {@link OAuth2User#getAttributes() attribute} is used with preference to the
- * {@link OAuth2AuthenticationToken#getName() name} if provided, to set the
- * {@link GeorchestraUser#setUsername(String) username}, since the name is
- * usually an external sytem's numeric identifier that's not really appropriate
- * for a username.
- * The user's {@link GeorchestraUser#setEmail(String) email} is obtained
- * from the {@literal email} {@link OAuth2User#getAttributes() attribute}, if
- * present.
- * The user's {@link GeorchestraUser#setRoles(List) roles} are derived from
- * the {@link GrantedAuthority granted authorities} in the
- * {@link OAuth2User#getAuthorities()}, removing those that start with
- * {@literal ROLE_SCOPE_} or {@code SCOPE_}.
+ * The {@link OAuth2User principal}'s {@code login} attribute is used with
+ * preference over {@link OAuth2AuthenticationToken#getName()} if available, as
+ * the latter often contains an external system's numeric identifier.
+ * The user's email is extracted from the {@code email} attribute, if
+ * present.
+ * Roles are derived from the granted authorities but exclude any that start
+ * with {@code ROLE_SCOPE_} or {@code SCOPE_}.
*
*/
@Slf4j(topic = "org.georchestra.gateway.security.oauth2")
public class OAuth2UserMapper implements GeorchestraUserMapperExtension {
+ /**
+ * Attempts to resolve an OAuth2 authentication token into a
+ * {@link GeorchestraUser}.
+ *
+ * @param authToken The authentication token to resolve.
+ * @return An {@link Optional} containing the mapped user if the token is valid,
+ * or {@link Optional#empty()} if it cannot be mapped.
+ */
@Override
public Optional resolve(Authentication authToken) {
- return Optional.ofNullable(authToken)//
- .filter(OAuth2AuthenticationToken.class::isInstance)//
- .map(OAuth2AuthenticationToken.class::cast)//
- .filter(tokenFilter())//
- .flatMap(this::map);
+ return Optional.ofNullable(authToken).filter(OAuth2AuthenticationToken.class::isInstance)
+ .map(OAuth2AuthenticationToken.class::cast).filter(tokenFilter()).flatMap(this::map);
}
+ /**
+ * Provides a predicate to filter which OAuth2 tokens should be processed.
+ *
+ * The default implementation accepts all tokens.
+ *
+ *
+ * @return A {@link Predicate} for filtering authentication tokens.
+ */
protected Predicate tokenFilter() {
return token -> true;
}
+ /**
+ * Maps an {@link OAuth2AuthenticationToken} to a {@link GeorchestraUser}.
+ *
+ * @param token The OAuth2 authentication token.
+ * @return An {@link Optional} containing the mapped {@link GeorchestraUser}.
+ */
protected Optional map(OAuth2AuthenticationToken token) {
logger().debug("Mapping {} authentication token from provider {}",
token.getPrincipal().getClass().getSimpleName(), token.getAuthorizedClientRegistrationId());
@@ -82,23 +98,29 @@ protected Optional map(OAuth2AuthenticationToken token) {
user.setOAuth2Uid(token.getName());
Map attributes = oAuth2User.getAttributes();
-
List roles = resolveRoles(oAuth2User.getAuthorities());
String userName = token.getName();
String login = (String) attributes.get("login");
/*
- * plain Oauth2 authentication user names are usually a number. The 'login'
- * attribute usually carries over a more meaningful name, so use it in
- * preference of userName if provided
+ * Plain OAuth2 authentication user names are often numeric identifiers. The
+ * 'login' attribute typically contains a more meaningful name, so it is used in
+ * preference to the username if available.
*/
apply(user::setUsername, login, userName);
apply(user::setEmail, (String) attributes.get("email"));
- user.setRoles(new ArrayList<>(roles));// mutable
+ user.setRoles(new ArrayList<>(roles)); // mutable
return Optional.of(user);
}
+ /**
+ * Resolves roles from granted authorities while excluding OAuth2 scope-related
+ * authorities.
+ *
+ * @param authorities The collection of granted authorities.
+ * @return A list of resolved role names.
+ */
protected List resolveRoles(Collection extends GrantedAuthority> authorities) {
return authorities.stream().map(GrantedAuthority::getAuthority).filter(scope -> {
if (scope.startsWith("ROLE_SCOPE_") || scope.startsWith("SCOPE_")) {
@@ -109,15 +131,26 @@ protected List resolveRoles(Collection extends GrantedAuthority> autho
}).toList();
}
+ /**
+ * Applies the first non-null candidate value to the specified setter function.
+ *
+ * @param setter The setter function to apply.
+ * @param candidates A varargs list of candidate values.
+ */
protected void apply(Consumer setter, String... candidates) {
for (String candidateValue : candidates) {
- if (null != candidateValue) {
+ if (candidateValue != null) {
setter.accept(candidateValue);
break;
}
}
}
+ /**
+ * Provides access to the class logger.
+ *
+ * @return The logger instance.
+ */
protected Logger logger() {
return log;
}
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
index 22346d33..741dd4e7 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectCustomClaimsConfigProperties.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectCustomClaimsConfigProperties.java
@@ -39,6 +39,14 @@
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
+/**
+ * Configuration properties for extracting custom OpenID Connect (OIDC) claims.
+ *
+ * This class allows configuring how user information such as ID, roles, and
+ * organization details are extracted from an OAuth2/OIDC authentication token
+ * using JSONPath expressions.
+ *
+ */
@ConfigurationProperties(prefix = "georchestra.gateway.security.oidc.claims")
@Slf4j(topic = "org.georchestra.gateway.security.oauth2")
public @Data class OpenIdConnectCustomClaimsConfigProperties {
@@ -47,50 +55,90 @@
private RolesMapping roles = new RolesMapping();
private JsonPathExtractor organization = new JsonPathExtractor();
+ /**
+ * Retrieves the JSONPath extractor configuration for extracting the user ID.
+ *
+ * @return an {@link Optional} containing the {@link JsonPathExtractor} for ID
+ * extraction.
+ */
public Optional id() {
return Optional.ofNullable(id);
}
+ /**
+ * Retrieves the configuration for role mapping.
+ *
+ * @return an {@link Optional} containing the {@link RolesMapping}
+ * configuration.
+ */
public Optional roles() {
return Optional.ofNullable(roles);
}
+ /**
+ * Retrieves the JSONPath extractor configuration for extracting the
+ * organization.
+ *
+ * @return an {@link Optional} containing the {@link JsonPathExtractor} for
+ * organization extraction.
+ */
public Optional organization() {
return Optional.ofNullable(organization);
}
+ /**
+ * Configuration for extracting roles from OIDC claims.
+ *
+ * This class defines transformation rules for role extraction, including case
+ * formatting, normalization, and whether extracted roles should replace or
+ * append to existing ones.
+ *
+ */
@Accessors(chain = true)
public static @Data class RolesMapping {
private JsonPathExtractor json = new JsonPathExtractor();
/**
- * Whether to return mapped role names as upper-case
+ * Whether to return mapped role names in uppercase.
*/
private boolean uppercase = true;
/**
- * Whether to remove special characters and replace spaces by underscores
+ * Whether to normalize role names by removing special characters and replacing
+ * spaces with 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).
+ * Whether to append the extracted roles to existing roles (true), or replace
+ * them (false).
*/
private boolean append = true;
+ /**
+ * Retrieves the JSONPath extractor for roles.
+ *
+ * @return an {@link Optional} containing the {@link JsonPathExtractor} for role
+ * extraction.
+ */
public Optional json() {
return Optional.ofNullable(json);
}
+ /**
+ * Extracts and applies roles from the provided claims to the given user.
+ *
+ * @param claims The OIDC claims from which roles should be extracted.
+ * @param target The {@link GeorchestraUser} to which the roles should be
+ * applied.
+ */
public void apply(Map claims, GeorchestraUser target) {
-
json().ifPresent(oidcClaimsConfig -> {
List rawValues = oidcClaimsConfig.extract(claims);
- List oidcRoles = rawValues.stream().map(this::applyTransforms)
- // make sure the resulting list is mutable, Stream.toList() is not
- .toList();
+ List oidcRoles = rawValues.stream().map(this::applyTransforms).toList(); // Ensure the resulting
+ // list is mutable
+
if (oidcRoles.isEmpty()) {
return;
}
@@ -101,6 +149,12 @@ public void apply(Map claims, GeorchestraUser target) {
});
}
+ /**
+ * Applies configured transformations to a role value.
+ *
+ * @param value The original role value.
+ * @return The transformed role value.
+ */
private String applyTransforms(String value) {
String result = uppercase ? value.toUpperCase() : value;
if (normalize) {
@@ -109,32 +163,44 @@ private String applyTransforms(String value) {
return result;
}
+ /**
+ * Normalizes a role string by:
+ *
+ * Applying Unicode Normalization (NFC form).
+ * Removing diacritical marks (accents).
+ * Replacing whitespace with underscores.
+ * Removing special characters.
+ *
+ *
+ * @param value The original role string.
+ * @return The normalized role string.
+ */
public String normalize(@NonNull String value) {
- // apply Unicode Normalization (NFC: a + ◌̂ = â) (see
- // https://www.unicode.org/reports/tr15/)
+ // Apply Unicode Normalization (NFC: a + ◌̂ = â)
String normalized = Normalizer.normalize(value, Form.NFC);
- // remove unicode accents and diacritics
+ // Remove diacritical marks
normalized = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
- // replace all whitespace groups by a single underscore
- normalized = value.replaceAll("\\s+", "_");
+ // Replace all whitespace with underscores
+ normalized = normalized.replaceAll("\\s+", "_");
- // remove remaining characters like parenthesis, commas, etc
- normalized = normalized.replaceAll("[^a-zA-Z0-9_]", "");
- return normalized;
+ // Remove remaining special characters
+ return normalized.replaceAll("[^a-zA-Z0-9_]", "");
}
}
+ /**
+ * Extracts values from OIDC claims using JSONPath expressions.
+ */
@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.
+ * List of JSONPath expressions to extract values from OIDC claims.
*
- * For example, if the claims map contains a JSON object under the
- * {@literal groups_json} key with the value
+ * Example:
+ *
*
*
* {@code
@@ -146,31 +212,39 @@ public String normalize(@NonNull String value) {
* "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}.
+ * The JSONPath expression `$.groups_json[0][0].name` extracts the first group
+ * name, while `$.groups_json..['name']` extracts all group names into a list.
*/
private List path = new ArrayList<>();
/**
- * @param claims
- * @return
+ * Extracts values from the provided OIDC claims using the configured JSONPath
+ * expressions.
+ *
+ * @param claims The OIDC claims map.
+ * @return A list of extracted values.
*/
public @NonNull List extract(@NonNull Map claims) {
- return this.path.stream()//
- .map(jsonPathExpression -> this.extract(jsonPathExpression, claims))//
- .flatMap(List::stream)//
- .toList();
+ return this.path.stream().map(jsonPathExpression -> this.extract(jsonPathExpression, claims))
+ .flatMap(List::stream).toList();
}
+ /**
+ * Extracts values from the given claims using a single JSONPath expression.
+ *
+ * @param jsonPathExpression The JSONPath expression.
+ * @param claims The claims map.
+ * @return A list of extracted values.
+ */
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
@@ -180,28 +254,32 @@ private List extract(final String jsonPathExpression, Map list = (matched instanceof List) ? (List>) matched : List.of(matched);
- return IntStream.range(0, list.size())//
- .mapToObj(list::get)//
- .filter(Objects::nonNull)//
- .map(value -> validateValueIsString(jsonPathExpression, value))//
- .toList();
+ return IntStream.range(0, list.size()).mapToObj(list::get).filter(Objects::nonNull)
+ .map(value -> validateValueIsString(jsonPathExpression, value)).toList();
}
- private String validateValueIsString(final String jsonPathExpression, @NonNull Object v) {
- if (v instanceof String val)
+ /**
+ * Ensures that extracted values are of type {@link String}.
+ *
+ * @param jsonPathExpression The JSONPath expression used.
+ * @param value The extracted value.
+ * @return The extracted value as a string.
+ * @throws IllegalStateException If the extracted value is not a string.
+ */
+ private String validateValueIsString(final String jsonPathExpression, @NonNull Object value) {
+ if (value instanceof String val) {
return val;
-
- 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);
-
+ }
+ throw new IllegalStateException(String.format(
+ "The JSONPath expression %s evaluates to %s instead of String. Value: %s",
+ jsonPathExpression, value.getClass().getCanonicalName(), value));
}
}
-}
+}
\ No newline at end of file
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 0b8c4397..6b3da0e6 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
@@ -16,7 +16,6 @@
* 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.util.List;
@@ -46,88 +45,83 @@
import lombok.extern.slf4j.Slf4j;
/**
- * Maps an OpenID authenticated {@link OidcUser user} to a
+ * Maps an OpenID Connect (OIDC) authenticated {@link OidcUser} to a
* {@link GeorchestraUser}.
*
- * {@link StandardClaimAccessor standard claims} map as follow:
+ * The mapping follows OpenID Connect standard claims:
*
- * {@link StandardClaimAccessor#getSubject() subject} to
- * {@link GeorchestraUser#getId() id}
- * {@link StandardClaimAccessor#getPreferredUsername preferredUsername} or
- * {@link StandardClaimAccessor#getEmail email} to
- * {@link GeorchestraUser#setUsername username}, in that order of precedence.
- * {@link StandardClaimAccessor#getGivenName givenName} to
- * {@link GeorchestraUser#setFirstName firstName}
- * {@link StandardClaimAccessor#getEmail email} to
- * {@link GeorchestraUser#setEmail email}
- * {@link StandardClaimAccessor#getPhoneNumber phoneNumber} to
- * {@link GeorchestraUser#setTelephoneNumber telephoneNumber}
- * {@link AddressStandardClaim#getFormatted address.formatted} to
- * {@link GeorchestraUser#setPostalAddress postalAddress}
+ * {@link StandardClaimAccessor#getSubject() subject} →
+ * {@link GeorchestraUser#getId() id}
+ * {@link StandardClaimAccessor#getPreferredUsername() preferredUsername} or
+ * {@link StandardClaimAccessor#getEmail() email} →
+ * {@link GeorchestraUser#setUsername(String) username}
+ * {@link StandardClaimAccessor#getGivenName() givenName} →
+ * {@link GeorchestraUser#setFirstName(String) firstName}
+ * {@link StandardClaimAccessor#getFamilyName() familyName} →
+ * {@link GeorchestraUser#setLastName(String) lastName}
+ * {@link StandardClaimAccessor#getEmail() email} →
+ * {@link GeorchestraUser#setEmail(String) email}
+ * {@link StandardClaimAccessor#getPhoneNumber() phoneNumber} →
+ * {@link GeorchestraUser#setTelephoneNumber(String) telephoneNumber}
+ * {@link AddressStandardClaim#getFormatted() address.formatted} →
+ * {@link GeorchestraUser#setPostalAddress(String) postalAddress}
*
+ *
*
- * Non-standard claims can be used to set {@link GeorchestraUser#setRoles roles}
- * and {@link GeorchestraUser#setOrganization organization} short name by
- * externalized configuration of
- * {@link OpenIdConnectCustomClaimsConfigProperties}, using a JSONPath
- * expression with the {@link OidcUser#getClaims()} {@code Map}
- * as root object.
- *
- * For example, if the OpenID Connect token contains the following claims:
+ * Non-standard claims can be mapped to {@link GeorchestraUser#setRoles(List)
+ * roles} and {@link GeorchestraUser#setOrganization(String) organization} via
+ * {@link OpenIdConnectCustomClaimsConfigProperties} using JSONPath expressions.
+ *
+ *
+ * Example Configuration If the OpenID Connect token contains the
+ * following claims:
*
*
- *
- * { ...,
- * "groups_json": [[{"name":"GDI Planer"}],[{"name":"GDI Editor"}]],
- * "PartyOrganisationID": "6007280321",
- * ...
- * }
- *
+ * {
+ * "groups_json": [[{"name":"GDI Planer"}],[{"name":"GDI Editor"}]],
+ * "PartyOrganisationID": "6007280321"
+ * }
*
*
- * the following configuration in {@literal application.yml} (or other included
- * configuration file):
+ * The following configuration in {@code application.yml}:
*
*
- * {@code
- * georchestra:
- * gateway:
- * security:
- * oidc:
- * claims:
+ * georchestra:
+ * gateway:
+ * security:
+ * oidc:
+ * claims:
* organization.path: "$.PartyOrganisationID"
* roles.path: "$.groups_json..['name']"
- * }
*
*
- * will assign {@literal "6007280321"} to
- * {@link GeorchestraUser#setOrganization(String)}, and append
- * {@literal ["ROLE_GDI_PLANER", "ROLE_GDI_EDITOR"]} to
- * {@link GeorchestraUser#setRoles(List)}.
- *
- * Additional, some control can be applied over how to map strings resolved from
- * the roles JSONPath expression to internal role names through the following
- * config properties:
+ * Will:
+ *
+ * Assign {@code "6007280321"} to
+ * {@link GeorchestraUser#setOrganization(String)}
+ * Append {@code ["ROLE_GDI_PLANER", "ROLE_GDI_EDITOR"]} to
+ * {@link GeorchestraUser#setRoles(List)}
+ *
+ *
+ * Role Mapping Customization Additional customization for role name
+ * formatting:
*
*
- * {@code
- * georchestra.gateway.security.oidc.claims.roles:
+ * georchestra.gateway.security.oidc.claims.roles:
* path: "$.groups_json..['name']"
* uppercase: true
* normalize: true
* append: true
- * }
*
*
- * With the following meanings:
+ * Where:
*
- * {@code uppercase}: Whether to return mapped role names as upper-case.
- * Defaults to {@code true}.
- * {@code normalize}: Whether to remove special characters and replace
- * spaces by underscores. Defaults to {@code true}.
- * {@code append}: Whether to append the resolved role names to the roles
- * given by the OAuth2 authentication. (true), or replace them (false). Defaults
- * to {@code true}.
+ * {@code uppercase}: Convert role names to uppercase (default:
+ * {@code true}).
+ * {@code normalize}: Remove special characters and replace spaces with
+ * underscores (default: {@code true}).
+ * {@code append}: Append roles to those provided by the authentication,
+ * rather than replacing them (default: {@code true}).
*
*/
@RequiredArgsConstructor
@@ -137,15 +131,35 @@ public class OpenIdConnectUserMapper extends OAuth2UserMapper {
private final @NonNull OpenIdConnectCustomClaimsConfigProperties nonStandardClaimsConfig;
+ /**
+ * Filters authentication tokens to process only {@link OidcUser}-based
+ * authentication.
+ *
+ * @return Predicate that checks if the principal is an instance of
+ * {@link OidcUser}.
+ */
protected @Override Predicate tokenFilter() {
return token -> token.getPrincipal() instanceof OidcUser;
}
+ /**
+ * Ensures this mapper runs before the generic {@link OAuth2UserMapper}.
+ *
+ * @return {@link Ordered#HIGHEST_PRECEDENCE} to prioritize this mapper.
+ */
public @Override int getOrder() {
- // be evaluated before OAuth2AuthenticationTokenUserMapper
return Ordered.HIGHEST_PRECEDENCE;
}
+ /**
+ * Maps an OpenID Connect (OIDC) authenticated user to a
+ * {@link GeorchestraUser}.
+ *
+ * @param token The {@link OAuth2AuthenticationToken} containing the
+ * authentication information.
+ * @return An {@link Optional} containing the mapped {@link GeorchestraUser}, or
+ * empty if mapping fails.
+ */
protected @Override Optional map(OAuth2AuthenticationToken token) {
GeorchestraUser user = super.map(token).orElseGet(GeorchestraUser::new);
OidcUser oidcUser = (OidcUser) token.getPrincipal();
@@ -162,50 +176,53 @@ public class OpenIdConnectUserMapper extends OAuth2UserMapper {
}
/**
- * @param claims OpenId Connect merged claims from {@link OidcUserInfo} and
- * {@link OidcIdToken}
- * @param target
+ * Applies non-standard claims to the {@link GeorchestraUser} based on
+ * {@link OpenIdConnectCustomClaimsConfigProperties}.
+ *
+ * @param claims OpenID Connect claims extracted from {@link OidcUserInfo} and
+ * {@link OidcIdToken}.
+ * @param target The {@link GeorchestraUser} to update.
*/
@VisibleForTesting
void applyNonStandardClaims(Map claims, GeorchestraUser target) {
- nonStandardClaimsConfig.id().map(jsonEvaluator -> jsonEvaluator.extract(claims))//
- .map(List::stream)//
- .flatMap(Stream::findFirst)//
- .ifPresent(target::setId);
+ nonStandardClaimsConfig.id().map(jsonEvaluator -> jsonEvaluator.extract(claims)).map(List::stream)
+ .flatMap(Stream::findFirst).ifPresent(target::setId);
nonStandardClaimsConfig.roles().ifPresent(rolesMapper -> rolesMapper.apply(claims, target));
- nonStandardClaimsConfig.organization().map(jsonEvaluator -> jsonEvaluator.extract(claims))//
- .map(List::stream)//
- .flatMap(Stream::findFirst)//
- .ifPresent(target::setOrganization);
+
+ nonStandardClaimsConfig.organization().map(jsonEvaluator -> jsonEvaluator.extract(claims)).map(List::stream)
+ .flatMap(Stream::findFirst).ifPresent(target::setOrganization);
}
+ /**
+ * Applies standard OpenID Connect claims to a {@link GeorchestraUser}.
+ *
+ * @param standardClaims The OIDC standard claims.
+ * @param target The user to populate with standard claim values.
+ */
@VisibleForTesting
void applyStandardClaims(StandardClaimAccessor standardClaims, GeorchestraUser target) {
- String subjectId = standardClaims.getSubject();
- String preferredUsername = standardClaims.getPreferredUsername();
- String givenName = standardClaims.getGivenName();
- String familyName = standardClaims.getFamilyName();
-
- String email = standardClaims.getEmail();
- String phoneNumber = standardClaims.getPhoneNumber();
+ apply(target::setId, standardClaims.getSubject());
+ apply(target::setUsername, standardClaims.getPreferredUsername(), standardClaims.getSubject());
+ apply(target::setFirstName, standardClaims.getGivenName());
+ apply(target::setLastName, standardClaims.getFamilyName());
+ apply(target::setEmail, standardClaims.getEmail());
+ apply(target::setTelephoneNumber, standardClaims.getPhoneNumber());
AddressStandardClaim address = standardClaims.getAddress();
- String formattedAddress = address == null ? null : address.getFormatted();
-
- apply(target::setId, subjectId);
- apply(target::setUsername, preferredUsername, subjectId);
- apply(target::setFirstName, givenName);
- apply(target::setLastName, familyName);
- apply(target::setEmail, email);
- apply(target::setTelephoneNumber, phoneNumber);
- apply(target::setPostalAddress, formattedAddress);
+ apply(target::setPostalAddress, address == null ? null : address.getFormatted());
}
- protected void apply(Consumer setter, String... alternativesInOrderOfPreference) {
- Stream.of(alternativesInOrderOfPreference).filter(Objects::nonNull).findFirst()//
- .ifPresent(setter::accept);
+ /**
+ * Applies the first non-null value from the provided alternatives to the
+ * setter.
+ *
+ * @param setter The setter method to apply the value to.
+ * @param alternatives The list of potential values in order of preference.
+ */
+ protected void apply(Consumer setter, String... alternatives) {
+ Stream.of(alternatives).filter(Objects::nonNull).findFirst().ifPresent(setter::accept);
}
protected @Override Logger logger() {
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/preauth/HeaderPreAuthenticationConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/security/preauth/HeaderPreAuthenticationConfiguration.java
index e7ce5298..ed99201f 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/preauth/HeaderPreAuthenticationConfiguration.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/preauth/HeaderPreAuthenticationConfiguration.java
@@ -26,48 +26,88 @@
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
/**
- * {@link Configuration @Configuration} to enable request headers
- * pre-authentication.
+ * Configuration class for enabling request-header-based pre-authentication.
*
+ * This setup allows authentication to be performed via HTTP request headers,
+ * typically injected by a trusted reverse proxy or identity provider.
+ * Authentication is only considered valid if the
+ * {@code sec-georchestra-preauthenticated} header is present and set to
+ * {@code true}.
+ *
+ *
+ * Authentication Flow:
*
- * NOTE {@literal preauth-roles} is NOT mandatory, and the pre-authenticated
- * user will only have the {@literal ROLE_USER} role when {@code preauth-roles}
- * is not provided.
- * {@link PreauthenticatedUserMapperExtension} maps the
- * {@link PreAuthenticatedAuthenticationToken} to a {@link GeorchestraUser} when
- * {@link GeorchestraUserMapper#resolve(org.springframework.security.core.Authentication)
- * GeorchestraUserMapper.resolve(Authentication)} requests it.
+ *
+ * Expected Headers: The following HTTP headers can be used for
+ * authentication:
+ *
+ * {@code preauth-username} - Username of the authenticated user.
+ * {@code preauth-firstname} - User's first name.
+ * {@code preauth-lastname} - User's last name.
+ * {@code preauth-org} - Organization name.
+ * {@code preauth-email} - Email address of the user.
+ * {@code preauth-roles} - (Optional) Comma-separated list of user
+ * roles.
*
+ *
+ * Note: If {@code preauth-roles} is not provided, the user will only be
+ * assigned the default role {@code ROLE_USER}.
+ *
+ *
+ * Example Configuration:
*
+ *
+ * {@code
+ * georchestra:
+ * gateway:
+ * security:
+ * header-authentication:
+ * enabled: true
+ * }
+ *
*/
@Configuration
@EnableConfigurationProperties(HeaderPreauthConfigProperties.class)
public class HeaderPreAuthenticationConfiguration {
+ /**
+ * Registers a security customizer that enables authentication based on
+ * pre-authentication headers.
+ *
+ * @return a {@link PreauthGatewaySecurityCustomizer} bean
+ */
@Bean
PreauthGatewaySecurityCustomizer preauthGatewaySecurityCustomizer() {
return new PreauthGatewaySecurityCustomizer();
}
+ /**
+ * Registers a mapper that converts a
+ * {@link PreAuthenticatedAuthenticationToken} into a {@link GeorchestraUser}
+ * instance.
+ *
+ * @return a {@link PreauthenticatedUserMapperExtension} bean
+ */
@Bean
PreauthenticatedUserMapperExtension preauthenticatedUserMapperExtension() {
return new PreauthenticatedUserMapperExtension();
}
-
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/preauth/HeaderPreauthConfigProperties.java b/gateway/src/main/java/org/georchestra/gateway/security/preauth/HeaderPreauthConfigProperties.java
index b4f78efb..a5223c48 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/preauth/HeaderPreauthConfigProperties.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/preauth/HeaderPreauthConfigProperties.java
@@ -24,23 +24,55 @@
import lombok.Generated;
/**
- * Model object representing the externalized configuration properties used to
- * set up request headers based pre-authentication.
+ * Configuration properties for enabling and configuring request-header-based
+ * pre-authentication.
+ *
+ * When enabled, authentication can be performed by sending a special header
+ * ({@code sec-georchestra-preauthenticated}) set to {@code true}, along with
+ * additional user identity details in the following request headers:
+ *
+ * {@code preauth-username} - The username of the pre-authenticated
+ * user.
+ * {@code preauth-firstname} - The first name of the user.
+ * {@code preauth-lastname} - The last name of the user.
+ * {@code preauth-org} - The organization of the user.
+ * {@code preauth-email} - The user's email address.
+ * {@code preauth-roles} - A comma-separated list of roles assigned to the
+ * user.
+ *
+ *
+ * This mechanism allows an external authentication system (e.g., a reverse
+ * proxy or another identity provider) to inject user identity information into
+ * requests without requiring direct authentication within the application.
+ *
+ *
+ * Example configuration in {@code application.yml}:
+ *
+ *
+ * {@code
+ * georchestra:
+ * gateway:
+ * security:
+ * header-authentication:
+ * enabled: true
+ * }
+ *
*/
@Data
@Generated
@ConfigurationProperties(HeaderPreauthConfigProperties.PROPERTY_BASE)
public class HeaderPreauthConfigProperties {
+ /** Base property prefix for header authentication settings. */
static final String PROPERTY_BASE = "georchestra.gateway.security.header-authentication";
+ /** Property key for enabling header-based pre-authentication. */
public static final String ENABLED_PROPERTY = PROPERTY_BASE + ".enabled";
/**
- * If enabled, pre-authentication is enabled and can be performed by passing
- * true to the sec-georchestra-preauthenticated request header, and user details
- * through the following request headers: preauth-username, preauth-firstname,
- * preauth-lastname, preauth-org, preauth-email, preauth-roles
+ * Whether header-based pre-authentication is enabled.
+ *
+ * When {@code true}, authentication via request headers is allowed.
*/
private boolean enabled = false;
}
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthAuthenticationManager.java b/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthAuthenticationManager.java
index 340b4091..36ac8562 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthAuthenticationManager.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthAuthenticationManager.java
@@ -37,6 +37,49 @@
import reactor.core.publisher.Mono;
+/**
+ * A {@link ReactiveAuthenticationManager} and
+ * {@link ServerAuthenticationConverter} implementation that enables
+ * pre-authentication based on HTTP request headers.
+ *
+ * This authentication mechanism is designed for use with a trusted reverse
+ * proxy or an identity provider that injects pre-authenticated user details
+ * into HTTP headers. If the {@code sec-georchestra-preauthenticated} header is
+ * set to {@code true}, this manager extracts user details and creates a
+ * {@link PreAuthenticatedAuthenticationToken}.
+ *
+ *
+ * Authentication Flow:
+ *
+ * Checks for the presence of the {@code sec-georchestra-preauthenticated}
+ * header.
+ * If present, extracts user details from pre-authentication headers.
+ * Creates a {@link PreAuthenticatedAuthenticationToken} with the extracted
+ * details.
+ * Returns the token for authentication.
+ *
+ *
+ * Expected Headers: The following request headers are parsed for
+ * authentication:
+ *
+ * {@code preauth-username} - (Required) Username of the authenticated
+ * user.
+ * {@code preauth-email} - (Optional) User's email address.
+ * {@code preauth-firstname} - (Optional) First name of the user.
+ * {@code preauth-lastname} - (Optional) Last name of the user.
+ * {@code preauth-org} - (Optional) Organization name.
+ * {@code preauth-roles} - (Optional) Comma-separated list of user
+ * roles.
+ * {@code preauth-provider} - (Optional) External authentication
+ * provider.
+ * {@code preauth-provider-id} - (Optional) Identifier from the external
+ * provider.
+ *
+ *
+ * Note: If {@code preauth-roles} is not provided, the user is assigned
+ * the default role {@code ROLE_USER}.
+ *
+ */
public class PreauthAuthenticationManager implements ReactiveAuthenticationManager, ServerAuthenticationConverter {
public static final String PREAUTH_HEADER_NAME = "sec-georchestra-preauthenticated";
@@ -51,8 +94,13 @@ public class PreauthAuthenticationManager implements ReactiveAuthenticationManag
public static final String PREAUTH_PROVIDER_ID = "preauth-provider-id";
/**
- * @return {@code Mono.empty()} if the pre-auth request headers are not
- * provided,
+ * Converts an incoming request into a
+ * {@link PreAuthenticatedAuthenticationToken} if the request contains valid
+ * pre-authentication headers.
+ *
+ * @param exchange the {@link ServerWebExchange} representing the request
+ * @return a {@link Mono} containing the authentication token, or an empty
+ * {@link Mono} if not pre-authenticated
*/
@Override
public Mono convert(ServerWebExchange exchange) {
@@ -69,23 +117,51 @@ public Mono convert(ServerWebExchange exchange) {
return Mono.empty();
}
+ /**
+ * Extracts all pre-authentication headers from the request and returns them as
+ * a map.
+ *
+ * @param headers the HTTP request headers
+ * @return a map containing pre-authentication header values
+ */
private Map extract(HttpHeaders headers) {
return headers.toSingleValueMap().entrySet().stream()
.filter(e -> e.getKey().toLowerCase().startsWith("preauth-"))
.collect(Collectors.toMap(e -> e.getKey().toLowerCase(), Map.Entry::getValue));
}
+ /**
+ * Authenticates a previously converted
+ * {@link PreAuthenticatedAuthenticationToken}.
+ *
+ * @param authentication the authentication token
+ * @return a {@link Mono} containing the authenticated token
+ */
@Override
public Mono authenticate(Authentication authentication) {
return Mono.just(authentication);
}
+ /**
+ * Checks whether a request is pre-authenticated based on the presence of the
+ * {@code sec-georchestra-preauthenticated} header.
+ *
+ * @param exchange the server web exchange containing the request
+ * @return {@code true} if the request is pre-authenticated, otherwise
+ * {@code false}
+ */
public static boolean isPreAuthenticated(ServerWebExchange exchange) {
HttpHeaders requestHeaders = exchange.getRequest().getHeaders();
final String preAuthHeader = requestHeaders.getFirst(PREAUTH_HEADER_NAME);
return Boolean.parseBoolean(preAuthHeader);
}
+ /**
+ * Maps extracted request headers into a {@link GeorchestraUser} object.
+ *
+ * @param requestHeaders a map of extracted request headers
+ * @return a {@link GeorchestraUser} instance populated with the extracted data
+ */
public static GeorchestraUser map(Map requestHeaders) {
String username = SecurityHeaders.decode(requestHeaders.get(PREAUTH_USERNAME));
String email = SecurityHeaders.decode(requestHeaders.get(PREAUTH_EMAIL));
@@ -108,13 +184,19 @@ public static GeorchestraUser map(Map requestHeaders) {
user.setFirstName(firstName);
user.setLastName(lastName);
user.setOrganization(org);
- user.setRoles(new ArrayList<>(roleNames));// mutable
+ user.setRoles(new ArrayList<>(roleNames)); // mutable list
user.setOAuth2Provider(provider);
user.setOAuth2Uid(providerId);
- // TODO rename oauth2 fields to a more generic name : externalProvider ?
+ // TODO: Consider renaming OAuth2-related fields to a more generic
+ // "externalProvider"
return user;
}
+ /**
+ * Removes pre-authentication headers from a given set of mutable HTTP headers.
+ *
+ * @param mutableHeaders the mutable {@link HttpHeaders} object to clean up
+ */
public void removePreauthHeaders(HttpHeaders mutableHeaders) {
mutableHeaders.remove(PREAUTH_HEADER_NAME);
mutableHeaders.remove(PREAUTH_USERNAME);
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthGatewaySecurityCustomizer.java b/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthGatewaySecurityCustomizer.java
index 33bf62c1..bcf97eb5 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthGatewaySecurityCustomizer.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthGatewaySecurityCustomizer.java
@@ -30,25 +30,91 @@
import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Mono;
+/**
+ * Customizes {@link ServerHttpSecurity} to enable pre-authentication based on
+ * HTTP request headers.
+ *
+ * This security customizer sets up an authentication filter that extracts user
+ * information from specific headers. If valid pre-authentication headers are
+ * found, authentication is performed and an authenticated user is established
+ * in the security context.
+ *
+ *
+ * Customization Steps:
+ *
+ * Creates a {@link PreauthAuthenticationManager} to handle
+ * authentication.
+ * Registers an {@link AuthenticationWebFilter} to authenticate requests
+ * with pre-auth headers.
+ * Registers a {@link RemovePreauthHeadersWebFilter} to strip pre-auth
+ * headers from downstream requests, preventing them from being misused by
+ * backend services.
+ *
+ *
+ *
+ * The authentication process is initiated if the
+ * {@code sec-georchestra-preauthenticated} header is present.
+ *
+ */
public class PreauthGatewaySecurityCustomizer implements ServerHttpSecurityCustomizer {
+ /**
+ * Configures {@link ServerHttpSecurity} to add the pre-authentication filters.
+ *
+ * This method does the following:
+ *
+ * Creates an {@link AuthenticationWebFilter} with a
+ * {@link PreauthAuthenticationManager}.
+ * Sets the authentication converter to extract credentials from HTTP
+ * headers.
+ * Adds the authentication filter as the first filter in the security filter
+ * chain.
+ * Adds a post-processing filter to remove pre-authentication headers before
+ * passing the request to downstream services.
+ *
+ *
+ * @param http the {@link ServerHttpSecurity} instance to configure.
+ */
@SuppressWarnings("deprecation")
@Override
public void customize(ServerHttpSecurity http) {
PreauthAuthenticationManager authenticationManager = new PreauthAuthenticationManager();
AuthenticationWebFilter headerFilter = new AuthenticationWebFilter(authenticationManager);
- // return Mono.empty() if preauth headers not provided
+ // Set the authentication converter to extract credentials from headers
headerFilter.setAuthenticationConverter(authenticationManager::convert);
+
+ // Add authentication filter at the beginning of the security filter chain
http.addFilterAt(headerFilter, SecurityWebFiltersOrder.FIRST);
+
+ // Add a filter at the end of the chain to remove pre-auth headers before
+ // forwarding the request
http.addFilterAt(new RemovePreauthHeadersWebFilter(authenticationManager), SecurityWebFiltersOrder.LAST);
}
+ /**
+ * A {@link WebFilter} that removes pre-authentication headers from the request
+ * before passing it to the next filter in the chain.
+ *
+ * This ensures that backend services do not see or rely on the
+ * pre-authentication headers, which could otherwise be misused.
+ *
+ */
@RequiredArgsConstructor
static class RemovePreauthHeadersWebFilter implements WebFilter {
private final PreauthAuthenticationManager manager;
+ /**
+ * Filters incoming requests by removing pre-authentication headers before
+ * continuing the chain.
+ *
+ * @param exchange the {@link ServerWebExchange} representing the HTTP request
+ * and response.
+ * @param chain the {@link WebFilterChain} to delegate further request
+ * processing.
+ * @return a {@link Mono} indicating when request processing is complete.
+ */
@Override
public Mono filter(ServerWebExchange exchange, WebFilterChain chain) {
ServerHttpRequest request = exchange.getRequest().mutate().headers(manager::removePreauthHeaders).build();
diff --git a/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthenticatedUserMapperExtension.java b/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthenticatedUserMapperExtension.java
index 944cdfb9..9a7f4802 100644
--- a/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthenticatedUserMapperExtension.java
+++ b/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthenticatedUserMapperExtension.java
@@ -26,16 +26,47 @@
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
+/**
+ * A {@link GeorchestraUserMapperExtension} implementation that maps a
+ * {@link PreAuthenticatedAuthenticationToken} to a {@link GeorchestraUser}.
+ *
+ * This class extracts user details from the credentials of the authentication
+ * token, which are expected to be a {@link Map} containing pre-authenticated
+ * user attributes.
+ *
+ *
+ * Mapping Logic:
+ *
+ * Verifies that the provided authentication token is a
+ * {@link PreAuthenticatedAuthenticationToken}.
+ * Extracts the credentials from the token, expecting them to be a
+ * {@link Map}.
+ * Uses {@link PreauthAuthenticationManager#map(Map)} to convert the
+ * extracted attributes into a {@link GeorchestraUser}.
+ *
+ *
+ * If the token does not meet these conditions, an empty {@link Optional} is
+ * returned.
+ *
+ */
public class PreauthenticatedUserMapperExtension implements GeorchestraUserMapperExtension {
+ /**
+ * Resolves a {@link GeorchestraUser} from a pre-authenticated authentication
+ * token.
+ *
+ * @param authToken the authentication token to resolve
+ * @return an {@link Optional} containing the mapped {@link GeorchestraUser}, or
+ * empty if resolution fails
+ */
@Override
public Optional resolve(Authentication authToken) {
return Optional.ofNullable(authToken)//
- .filter(PreAuthenticatedAuthenticationToken.class::isInstance)
+ .filter(PreAuthenticatedAuthenticationToken.class::isInstance) // Ensure token type
.map(PreAuthenticatedAuthenticationToken.class::cast)//
- .map(PreAuthenticatedAuthenticationToken::getCredentials)//
- .filter(Map.class::isInstance)//
- .map(Map.class::cast).map(PreauthAuthenticationManager::map);
+ .map(PreAuthenticatedAuthenticationToken::getCredentials) // Extract credentials
+ .filter(Map.class::isInstance) // Ensure credentials are a Map
+ .map(Map.class::cast)//
+ .map(PreauthAuthenticationManager::map); // Convert to GeorchestraUser
}
-
}
diff --git a/gateway/src/main/java/org/geoserver/cloud/gateway/filter/GlobalUriFilter.java b/gateway/src/main/java/org/geoserver/cloud/gateway/filter/GlobalUriFilter.java
index 2cb23bef..5cac3926 100644
--- a/gateway/src/main/java/org/geoserver/cloud/gateway/filter/GlobalUriFilter.java
+++ b/gateway/src/main/java/org/geoserver/cloud/gateway/filter/GlobalUriFilter.java
@@ -20,36 +20,76 @@
import reactor.core.publisher.Mono;
/**
- * See gateway's issue #2065
- * "Double Encoded URLs"
+ * Global filter to correct double-encoded URLs in Spring Cloud Gateway.
+ *
+ * This filter addresses issue #2065 ,
+ * where request URIs may be unintentionally double-encoded during request
+ * processing.
+ *
+ * When a request URI is already encoded (i.e., it contains percent-encoded
+ * characters), this filter ensures that it is not further re-encoded by the
+ * {@link ReactiveLoadBalancerClientFilter}.
+ *
+ * The filter does the following:
+ *
+ * Checks if the incoming request URI is already encoded.
+ * Retrieves the original {@link Route} for the request.
+ * Overrides the incorrectly re-encoded URI by merging the original request
+ * URI with the target load-balanced service URL.
+ *
+ *
+ * @see ReactiveLoadBalancerClientFilter
*/
@Component
public class GlobalUriFilter implements GlobalFilter, Ordered {
+ /**
+ * Intercepts requests to check for double-encoded URIs and fixes them before
+ * further processing.
+ *
+ * @param exchange the {@link ServerWebExchange} containing request and response
+ * details
+ * @param chain the {@link GatewayFilterChain} to continue request processing
+ * @return a {@link Mono} indicating when request processing is complete
+ */
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
URI incomingUri = exchange.getRequest().getURI();
if (isUriEncoded(incomingUri)) {
- // Get the original Gateway route (contains the service's original host)
+ // Retrieve the route associated with the request
Route route = exchange.getAttribute(GATEWAY_ROUTE_ATTR);
if (route == null) {
return chain.filter(exchange);
}
- // Save it as the outgoing URI to call the service, and override the "wrongly"
- // double encoded URI in ReactiveLoadBalancerClientFilter
- // LoadBalancerUriTools::containsEncodedParts
- // double encoded URI again
+ // Retrieve the load-balanced service URI (computed by
+ // ReactiveLoadBalancerClientFilter)
URI balanceUrl = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR);
+
+ // Construct the corrected URI to prevent double encoding
URI mergedUri = createUri(incomingUri, balanceUrl);
+
+ // Override the wrongly encoded URI in the exchange attributes
exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, mergedUri);
}
return chain.filter(exchange);
}
+ /**
+ * Creates a correctly formatted URI by merging the incoming request URI with
+ * the load-balanced service URL.
+ *
+ * This method ensures that the original request's query parameters and path
+ * remain intact while applying the proper host and scheme from the load
+ * balancer.
+ *
+ * @param incomingUri the original request URI
+ * @param balanceUrl the load-balanced target service URI
+ * @return a corrected {@link URI} with proper encoding and formatting
+ */
private URI createUri(URI incomingUri, URI balanceUrl) {
final var port = balanceUrl.getPort() != -1 ? ":" + balanceUrl.getPort() : "";
final var rawPath = balanceUrl.getRawPath() != null ? balanceUrl.getRawPath() : "";
@@ -57,12 +97,29 @@ private URI createUri(URI incomingUri, URI balanceUrl) {
return URI.create(balanceUrl.getScheme() + "://" + balanceUrl.getHost() + port + rawPath + query);
}
+ /**
+ * Checks if a URI is already encoded by looking for percent-encoded characters
+ * in the path or query.
+ *
+ * @param uri the {@link URI} to check
+ * @return {@code true} if the URI contains percent-encoded characters,
+ * otherwise {@code false}
+ */
private static boolean isUriEncoded(URI uri) {
return (uri.getRawQuery() != null && uri.getRawQuery().contains("%"))
|| (uri.getRawPath() != null && uri.getRawPath().contains("%"));
}
- // order after ReactiveLoadBalancerClientFilter
+ /**
+ * Defines the order of execution for this filter.
+ *
+ * This filter runs immediately after the
+ * {@link ReactiveLoadBalancerClientFilter} to correct double-encoded URIs
+ * before they are processed further.
+ *
+ * @return the filter execution order, which is one position after
+ * {@link ReactiveLoadBalancerClientFilter#LOAD_BALANCER_CLIENT_FILTER_ORDER}
+ */
@Override
public int getOrder() {
return ReactiveLoadBalancerClientFilter.LOAD_BALANCER_CLIENT_FILTER_ORDER + 1;
diff --git a/gateway/src/main/java/org/geoserver/cloud/gateway/filter/RouteProfileGatewayFilterFactory.java b/gateway/src/main/java/org/geoserver/cloud/gateway/filter/RouteProfileGatewayFilterFactory.java
index e354243a..ccd2cd9f 100644
--- a/gateway/src/main/java/org/geoserver/cloud/gateway/filter/RouteProfileGatewayFilterFactory.java
+++ b/gateway/src/main/java/org/geoserver/cloud/gateway/filter/RouteProfileGatewayFilterFactory.java
@@ -28,7 +28,18 @@
import lombok.experimental.Accessors;
import reactor.core.publisher.Mono;
-/** Allows to enable routes only if a given spring profile is enabled */
+/**
+ * A Gateway filter factory that conditionally enables or disables routes based
+ * on the presence or absence of a specified Spring profile.
+ *
+ * This filter is useful for dynamically controlling route availability
+ * depending on the application's active profiles. If the configured profile is
+ * active, the request is allowed to proceed; otherwise, the request is rejected
+ * with the specified HTTP status code.
+ *
+ * Profiles can be negated using the {@code !} prefix. If a profile is prefixed
+ * with {@code !}, the route will be disabled if the profile is active.
+ */
public class RouteProfileGatewayFilterFactory
extends AbstractGatewayFilterFactory {
@@ -52,6 +63,14 @@ public GatewayFilter apply(Config config) {
return new RouteProfileGatewayFilter(environment, config);
}
+ /**
+ * A filter that conditionally allows requests based on the presence of a
+ * specified Spring profile.
+ *
+ * If the required profile is active, the request proceeds. If the profile is
+ * negated (e.g., {@code !profileName}), the request is blocked if the profile
+ * is active.
+ */
@RequiredArgsConstructor
private static class RouteProfileGatewayFilter implements GatewayFilter {
@@ -86,20 +105,29 @@ public String toString() {
}
}
+ /**
+ * Configuration class for {@link RouteProfileGatewayFilterFactory}.
+ *
+ * Defines the profile condition and HTTP status code to return if the condition
+ * is not met.
+ */
@Data
@Accessors(chain = true)
@Validated
public static class Config {
/**
- * Profiles key, indicates which profiles must be enabled to allow the request
- * to proceed
+ * The profile name that must be active for the request to proceed.
+ *
+ * If prefixed with {@code !}, the request is blocked if the profile is active.
*/
public static final String PROFILE_KEY = "profile";
/**
- * Status code key. HTTP status code to return when the request is not allowed
- * to proceed because the required profiles are not active
+ * The HTTP status code to return when the request is blocked due to profile
+ * conditions.
+ *
+ * Defaults to {@link HttpStatus#NOT_FOUND} (404).
*/
public static final String HTTPSTATUS_KEY = "statusCode";
diff --git a/gateway/src/main/java/org/geoserver/cloud/gateway/filter/StripBasePathGatewayFilterFactory.java b/gateway/src/main/java/org/geoserver/cloud/gateway/filter/StripBasePathGatewayFilterFactory.java
index 3157e3c9..84a9040f 100644
--- a/gateway/src/main/java/org/geoserver/cloud/gateway/filter/StripBasePathGatewayFilterFactory.java
+++ b/gateway/src/main/java/org/geoserver/cloud/gateway/filter/StripBasePathGatewayFilterFactory.java
@@ -18,9 +18,24 @@
import lombok.Data;
/**
- * See gateway's issue #1759
- * "Webflux base path does not work with Path predicates"
+ * A {@link GatewayFilter} factory that strips a base path prefix from the
+ * incoming request URI.
+ *
+ * This filter is useful in scenarios where requests contain a base path that
+ * needs to be removed for downstream processing. The base path is specified in
+ * the {@link PrefixConfig} and must meet the following conditions:
+ *
+ * The prefix must start with a '/' character.
+ * The prefix must not end with a '/' unless it is exactly '/'.
+ *
+ *
+ * This filter works by calculating how many segments of the URI need to be
+ * removed based on the configured prefix. If the prefix is found in the request
+ * URI, it is stripped before the request is forwarded.
+ *
+ * For more details, see issue
+ * #1759
*/
public class StripBasePathGatewayFilterFactory
extends AbstractGatewayFilterFactory {
@@ -31,11 +46,17 @@ public StripBasePathGatewayFilterFactory() {
super(PrefixConfig.class);
}
+ /**
+ * {@inheritDoc}
+ */
@Override
public List shortcutFieldOrder() {
return List.of("prefix");
}
+ /**
+ * {@inheritDoc}
+ */
@Override
public GatewayFilter apply(PrefixConfig config) {
config.checkPreconditions();
@@ -44,46 +65,84 @@ public GatewayFilter apply(PrefixConfig config) {
final String basePath = config.getPrefix();
final String path = request.getURI().getRawPath();
- // if (basePath.equals(path)) {
- // return chain.filter(exchange);
- // }
+ // Calculate how many parts of the path to strip based on the base path
final int partsToRemove = resolvePartsToStrip(basePath, path);
if (partsToRemove == 0) {
- return chain.filter(exchange);
+ return chain.filter(exchange); // No base path to strip, continue with the chain
}
+
+ // Create and apply the StripPrefix filter with the correct number of parts to
+ // remove
GatewayFilter stripFilter = stripPrefix.apply(newStripPrefixConfig(partsToRemove));
return stripFilter.filter(exchange, chain);
};
}
+ /**
+ * Creates a new configuration for the {@link StripPrefixGatewayFilterFactory}
+ * with the specified number of parts to remove from the URI.
+ *
+ * @param partsToRemove the number of URI path segments to strip
+ * @return a new {@link Config} for the StripPrefix filter
+ */
private Config newStripPrefixConfig(int partsToRemove) {
Config config = stripPrefix.newConfig();
config.setParts(partsToRemove);
return config;
}
+ /**
+ * Resolves the number of URI path segments to strip based on the base path and
+ * the incoming request URI.
+ *
+ * @param basePath the base path to strip
+ * @param requestPath the incoming request path
+ * @return the number of path segments to strip
+ */
private int resolvePartsToStrip(String basePath, String requestPath) {
- if (null == basePath)
- return 0;
+ if (null == basePath) {
+ return 0; // No prefix to strip
+ }
if (!requestPath.startsWith(basePath)) {
- return 0;
+ return 0; // Base path is not part of the request URI
}
+
final int basePathSteps = StringUtils.countOccurrencesOf(basePath, "/");
boolean isRoot = basePath.equals(requestPath);
- return isRoot ? basePathSteps - 1 : basePathSteps;
+ return isRoot ? basePathSteps - 1 : basePathSteps; // Calculate how many parts to remove
}
- public static @Data class PrefixConfig {
+ /**
+ * Configuration class for the {@link StripBasePathGatewayFilterFactory}.
+ *
+ * Defines the prefix to be stripped from the incoming URI. The prefix must meet
+ * specific constraints as follows:
+ *
+ * It must start with '/'.
+ * If it is not '/', it must not end with '/'.
+ *
+ */
+ @Data
+ public static class PrefixConfig {
+
private String prefix;
+ /**
+ * Validates the preconditions for the {@link PrefixConfig}.
+ *
+ * Ensures that the prefix:
+ *
+ * Starts with '/'.
+ * If not '/', does not end with '/'.
+ *
+ */
public void checkPreconditions() {
final String prefix = getPrefix();
- // requireNonNull(prefix, "StripBasePath prefix can't be null");
+ // Ensure the prefix is valid
if (prefix != null) {
checkArgument(prefix.startsWith("/"), "StripBasePath prefix must start with /");
-
checkArgument("/".equals(prefix) || !prefix.endsWith("/"), "StripBasePath prefix must not end with /");
}
}
diff --git a/gateway/src/main/java/org/geoserver/cloud/gateway/predicate/RegExpQueryRoutePredicateFactory.java b/gateway/src/main/java/org/geoserver/cloud/gateway/predicate/RegExpQueryRoutePredicateFactory.java
index 5d762c4e..649f7630 100644
--- a/gateway/src/main/java/org/geoserver/cloud/gateway/predicate/RegExpQueryRoutePredicateFactory.java
+++ b/gateway/src/main/java/org/geoserver/cloud/gateway/predicate/RegExpQueryRoutePredicateFactory.java
@@ -69,23 +69,51 @@ public class RegExpQueryRoutePredicateFactory
/** HTTP request query parameter value regexp key. */
public static final String VALUE_KEY = "valueRegexp";
+ /**
+ * Constructs a new instance of {@link RegExpQueryRoutePredicateFactory}.
+ */
public RegExpQueryRoutePredicateFactory() {
super(Config.class);
}
- public @Override List shortcutFieldOrder() {
+ /**
+ * Returns the order of the shortcut fields.
+ *
+ * @return the field order list.
+ */
+ @Override
+ public List shortcutFieldOrder() {
return Arrays.asList(PARAM_KEY, VALUE_KEY);
}
- public @Override Predicate apply(Config config) {
+ /**
+ * Applies the given configuration to create a {@link GatewayPredicate}.
+ *
+ * @param config the configuration to apply.
+ * @return a {@link GatewayPredicate} based on the provided config.
+ */
+ @Override
+ public Predicate apply(Config config) {
return new RegExpQueryRoutePredicate(config);
}
+ /**
+ * A {@link GatewayPredicate} implementation for matching query parameters based
+ * on regular expressions.
+ */
@RequiredArgsConstructor
private static class RegExpQueryRoutePredicate implements GatewayPredicate {
private final @NonNull Config config;
- public @Override boolean test(ServerWebExchange exchange) {
+ /**
+ * Tests if the given exchange matches the predicate based on the configured
+ * regular expressions.
+ *
+ * @param exchange the exchange to test.
+ * @return true if the predicate matches the exchange, false otherwise.
+ */
+ @Override
+ public boolean test(ServerWebExchange exchange) {
final String paramRegexp = config.getParamRegexp();
final String valueRegexp = config.getValueRegexp();
@@ -98,31 +126,59 @@ private static class RegExpQueryRoutePredicate implements GatewayPredicate {
return paramNameMatches && paramValueMatches(paramName.get(), valueRegexp, exchange);
}
- public @Override String toString() {
+ /**
+ * Provides a string representation of this predicate.
+ *
+ * @return a string describing this predicate.
+ */
+ @Override
+ public String toString() {
return String.format("Query: param regexp='%s' value regexp='%s'", config.getParamRegexp(),
config.getValueRegexp());
}
}
+ /**
+ * Finds the first query parameter that matches the provided regular expression.
+ *
+ * @param regex the regular expression to match the parameter name.
+ * @param exchange the exchange containing the request.
+ * @return an optional containing the parameter name if a match is found, or
+ * empty otherwise.
+ */
static Optional findParameterName(@NonNull String regex, ServerWebExchange exchange) {
Set parameterNames = exchange.getRequest().getQueryParams().keySet();
return parameterNames.stream().filter(name -> name.matches(regex)).findFirst();
}
+ /**
+ * Checks if the value of the query parameter matches the provided regular
+ * expression.
+ *
+ * @param paramName the name of the parameter.
+ * @param valueRegEx the regular expression to match the parameter value.
+ * @param exchange the exchange containing the request.
+ * @return true if a matching value is found, false otherwise.
+ */
static boolean paramValueMatches(@NonNull String paramName, @NonNull String valueRegEx,
ServerWebExchange exchange) {
List values = exchange.getRequest().getQueryParams().get(paramName);
return values != null && values.stream().anyMatch(v -> v != null && v.matches(valueRegEx));
}
+ /**
+ * Configuration class for the {@link RegExpQueryRoutePredicateFactory}.
+ */
@Data
@Accessors(chain = true)
@Validated
public static class Config {
+ /** The regular expression for the query parameter name. */
@NotEmpty
private String paramRegexp;
+ /** The regular expression for the query parameter value (optional). */
private String valueRegexp;
}
}
diff --git a/gateway/src/test/java/org/georchestra/gateway/security/RolesMappingsUserCustomizerTest.java b/gateway/src/test/java/org/georchestra/gateway/security/RolesMappingsUserCustomizerTest.java
index ac2587e6..0c685626 100644
--- a/gateway/src/test/java/org/georchestra/gateway/security/RolesMappingsUserCustomizerTest.java
+++ b/gateway/src/test/java/org/georchestra/gateway/security/RolesMappingsUserCustomizerTest.java
@@ -60,11 +60,11 @@ private void addConfig(String role, String... additionalRoles) {
void constructorCreatesValidPatterns() {
Pattern pattern;
- pattern = RolesMappingsUserCustomizer.toPattern("ROLE.GDI.USER");
+ pattern = RolesMappingsUserCustomizer.compilePattern("ROLE.GDI.USER");
assertTrue(pattern.matcher("ROLE.GDI.USER").matches());
assertFalse(pattern.matcher("ROLE.GDI_USER").matches());
- pattern = RolesMappingsUserCustomizer.toPattern("ROLE.*.*.ADMIN");
+ pattern = RolesMappingsUserCustomizer.compilePattern("ROLE.*.*.ADMIN");
assertTrue(pattern.matcher("ROLE.GDI.GS.ADMIN").matches());
assertFalse(pattern.matcher("ROLE.GDI.GS.USER").matches());
}
diff --git a/gateway/src/test/java/org/georchestra/gateway/security/ldap/LdapConfigPropertiesValidationsTest.java b/gateway/src/test/java/org/georchestra/gateway/security/ldap/LdapConfigPropertiesValidationsTest.java
index bdece7fa..7a0a182d 100644
--- a/gateway/src/test/java/org/georchestra/gateway/security/ldap/LdapConfigPropertiesValidationsTest.java
+++ b/gateway/src/test/java/org/georchestra/gateway/security/ldap/LdapConfigPropertiesValidationsTest.java
@@ -86,7 +86,7 @@ void validates_common_url_is_mandatory_if_enabled() {
, "georchestra.gateway.security.ldap.basic2.enabled: false" //
, "georchestra.gateway.security.ldap.basic2.url:" //
).run(context -> {
- assertThat(context).getFailure().hasStackTraceContaining("LDAP url is required")
+ assertThat(context).getFailure().hasStackTraceContaining("LDAP URL is required")
.hasStackTraceContaining("ldap.[basic1].url").hasStackTraceContaining("ldap.[extended1].url")
.hasStackTraceContaining("ldap.[ad1].url");
});
@@ -161,7 +161,7 @@ void validates_basic_and_extended_users_searchFilter_is_mandatory() {
, "georchestra.gateway.security.ldap.extended1.users.searchFilter: " //
).run(context -> {
assertThat(context).getFailure()//
- .hasStackTraceContaining("LDAP users searchFilter is required for regular LDAP configs")//
+ .hasStackTraceContaining("LDAP users search filter is required for standard LDAP configurations")//
.hasStackTraceContaining("ldap.[ldap1].users.searchFilter")//
.hasStackTraceContaining("ldap.[extended1].users.searchFilter");
});
@@ -187,7 +187,7 @@ void validates_basic_and_extended_roles_rdn_is_mandatory() {
, "georchestra.gateway.security.ldap.extended1.roles.rdn: " //
).run(context -> {
assertThat(context).getFailure()//
- .hasStackTraceContaining("Roles Relative distinguished name is required")//
+ .hasStackTraceContaining("Roles Relative Distinguished Name is required")//
.hasStackTraceContaining("ldap.[ldap1].roles.rdn")//
.hasStackTraceContaining("ldap.[extended1].roles.rdn");
});
@@ -215,7 +215,7 @@ void validates_basic_and_extended_roles_searchFilter_is_mandatory() {
, "georchestra.gateway.security.ldap.extended1.roles.searchFilter: "//
).run(context -> {
assertThat(context).getFailure()//
- .hasStackTraceContaining("Roles searchFilter is required")//
+ .hasStackTraceContaining("Roles search filter is required")//
.hasStackTraceContaining("ldap.[ldap1].roles.searchFilter")//
.hasStackTraceContaining("ldap.[extended1].roles.searchFilter");
});
@@ -235,7 +235,7 @@ void validates_extended_orgs_rdn_is_mandatory() {
, "georchestra.gateway.security.ldap.extended1.orgs.rdn: " //
).run(context -> {
assertThat(context).getFailure()//
- .hasStackTraceContaining("Organizations search base RDN is required if extended is true")//
+ .hasStackTraceContaining("Organizations search base RDN is required if 'extended' is true")//
.hasStackTraceContaining("ldap.[extended1].orgs.rdn");
});
}