This is an automated email from the ASF dual-hosted git repository.

rcordier pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit 4a2bba0dadee4a49014b87ee5e72cee4ae4f73d0
Author: TungTV <vtt...@linagora.com>
AuthorDate: Mon Oct 14 15:32:52 2024 +0700

    JAMES-3680 XUserAuthenticationStrategy support secret checking (safer)
---
 docs/modules/servers/partials/configure/jmap.adoc  |   4 +
 examples/oidc/apisix/conf/apisix.yaml              |  22 ++++
 examples/oidc/docker-compose.yml                   |   2 +
 examples/oidc/james/jmap.properties                |   3 +-
 ...eJmapRFC8621AuthenticationStrategyContract.java | 111 +++++++++++++++++++++
 server/protocols/jmap/pom.xml                      |   5 +
 .../jmap/http/XUserAuthenticationStrategy.java     |  57 ++++++++++-
 src/site/xdoc/server/config-jmap.xml               |   3 +
 8 files changed, 201 insertions(+), 6 deletions(-)

diff --git a/docs/modules/servers/partials/configure/jmap.adoc 
b/docs/modules/servers/partials/configure/jmap.adoc
index 5dbfd83507..87bcfa21fb 100644
--- a/docs/modules/servers/partials/configure/jmap.adoc
+++ b/docs/modules/servers/partials/configure/jmap.adoc
@@ -99,6 +99,10 @@ This allows to prevent users from using some specific JMAP 
extensions.
 
 | set.max.size
 | Optional, default value is 500. The max number of items for /set methods.
+
+| authentication.strategy.rfc8621.xUser.secret
+| Optional. Disabled by default. Secret-value used to validate the 
X-User-Secret header when using the XUserAuthenticationStrategy. Use of this 
configuration property is highly advised.
+
 |===
 
 == Wire tapping
diff --git a/examples/oidc/apisix/conf/apisix.yaml 
b/examples/oidc/apisix/conf/apisix.yaml
index aadb10822c..fddfd4dc23 100644
--- a/examples/oidc/apisix/conf/apisix.yaml
+++ b/examples/oidc/apisix/conf/apisix.yaml
@@ -11,6 +11,9 @@ routes:
     plugins:
       proxy-rewrite:
         uri: /jmap
+        headers:
+          set:
+            X-User-Secret: ${{X_USER_SECRET}}
   -
     id: jmap_websocket
     uri: /oidc/jmap/ws
@@ -23,6 +26,9 @@ routes:
     plugins:
       proxy-rewrite:
         uri: /jmap/ws
+        headers:
+          set:
+            X-User-Secret: ${{X_USER_SECRET}}
   -
     id: jmap_session_oidc
     uri: /oidc/jmap/session
@@ -34,6 +40,9 @@ routes:
     plugins:
       proxy-rewrite:
         uri: /jmap/session
+        headers:
+          set:
+            X-User-Secret: ${{X_USER_SECRET}}
   -
     id: download
     uri: /oidc/download/*
@@ -48,6 +57,9 @@ routes:
         regex_uri:
           - "^/oidc/download/(.*)/(.*)"
           - "/download/$1/$2"
+        headers:
+          set:
+            X-User-Secret: ${{X_USER_SECRET}}
   -
     id: upload
     uri: /oidc/upload/*
@@ -61,6 +73,9 @@ routes:
         regex_uri:
           - "^/oidc/upload/(.*)"
           - "/upload/$1"
+        headers:
+          set:
+            X-User-Secret: ${{X_USER_SECRET}}
   -
     id: upload_draft
     uri: /oidc/upload
@@ -85,6 +100,9 @@ routes:
     plugins:
       proxy-rewrite:
         uri: /.well-known/webfinger
+      headers:
+        set:
+          X-User-Secret: ${{X_USER_SECRET}}
   -
     id: web_known_linagora_ecosystem
     uri: /oidc/.well-known/linagora-ecosystem
@@ -96,6 +114,9 @@ routes:
     plugins:
       proxy-rewrite:
         uri: /.well-known/linagora-ecosystem
+      headers:
+        set:
+          X-User-Secret: ${{X_USER_SECRET}}
   -
     id: web_known_jmap
     uri: /oidc/.well-known/jmap
@@ -116,6 +137,7 @@ routes:
         headers:
           set:
             Location: "/oidc/jmap/session"
+            X-User-Secret: ${{X_USER_SECRET}}
 
   #  Basic authentication endpoints
   - id: jmap_session_basic_auth
diff --git a/examples/oidc/docker-compose.yml b/examples/oidc/docker-compose.yml
index ade717c394..1e4be752b0 100644
--- a/examples/oidc/docker-compose.yml
+++ b/examples/oidc/docker-compose.yml
@@ -7,6 +7,8 @@ services:
     volumes:
       - ./apisix/conf/apisix.yaml:/usr/local/apisix/conf/apisix.yaml
       - ./apisix/conf/config.yaml:/usr/local/apisix/conf/config.yaml
+    environment:
+      - X_USER_SECRET=xusersecret123
     networks:
       - james
     ports:
diff --git a/examples/oidc/james/jmap.properties 
b/examples/oidc/james/jmap.properties
index 4755a08a60..ed68204f41 100644
--- a/examples/oidc/james/jmap.properties
+++ b/examples/oidc/james/jmap.properties
@@ -25,4 +25,5 @@ tls.secret=james72laBalle
 
 # If you want to specify authentication strategies for Jmap rfc-8621 version
 # For custom Authentication Strategy not inside package 
"org.apache.james.jmap.http", you have to specify its FQDN
-authentication.strategy.rfc8621=BasicAuthenticationStrategy,XUserAuthenticationStrategy
\ No newline at end of file
+authentication.strategy.rfc8621=BasicAuthenticationStrategy,XUserAuthenticationStrategy
+authentication.strategy.rfc8621.xUser.secret=xusersecret123
\ No newline at end of file
diff --git 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/custom/authentication/strategy/ModularizeJmapRFC8621AuthenticationStrategyContract.java
 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/custom/authentication/strategy/ModularizeJmapRFC8621AuthenticationStrategyContract.java
index 9c06b0bfe3..5dd126da9a 100644
--- 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/custom/authentication/strategy/ModularizeJmapRFC8621AuthenticationStrategyContract.java
+++ 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/custom/authentication/strategy/ModularizeJmapRFC8621AuthenticationStrategyContract.java
@@ -47,14 +47,22 @@ import java.util.Optional;
 import org.apache.james.GuiceJamesServer;
 import org.apache.james.jmap.JmapGuiceProbe;
 import org.apache.james.jmap.core.JmapRfc8621Configuration;
+import org.apache.james.jmap.http.XUserAuthenticationStrategy;
+import org.apache.james.mailbox.MailboxManager;
+import org.apache.james.user.api.UsersRepository;
 import org.apache.james.utils.DataProbeImpl;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+
 import io.restassured.RestAssured;
 import io.restassured.http.Header;
 
 public abstract class ModularizeJmapRFC8621AuthenticationStrategyContract {
+
     public static Optional<List<String>> ALLOW_AUTHENTICATION_STRATEGY = 
Optional.of(List.of(AllowAuthenticationStrategy.class.getCanonicalName()));
     public static Optional<List<String>> DENY_AUTHENTICATION_STRATEGY = 
Optional.of(List.of(DenyAuthenticationStrategy.class.getCanonicalName()));
     public static Optional<List<String>> DEFAULT_STRATEGIES = Optional.empty();
@@ -230,4 +238,107 @@ public abstract class 
ModularizeJmapRFC8621AuthenticationStrategyContract {
             .body("detail", equalTo("Failed Jwt verification"));
     }
 
+
+    @Test
+    public void 
givenXUserStrategyWhenMissingXUserSecretHeaderShouldFail(GuiceJamesServer 
server) throws Throwable {
+        // given a server with XUserAuthenticationStrategy
+        // The XUserAuthenticationStrategy is configured with a secret: 
"secret1"
+        setupJamesServerWithXUserStrategy(server, Optional.of("secret1"));
+
+        // when a request is made without the X-User-Secret header
+        // then the request should fail with a 401 status code
+        given()
+            .headers(getHeadersWith(new Header("X-User", BOB().asString())))
+            .body(ECHO_REQUEST_OBJECT())
+        .when()
+            .post()
+        .then()
+            .statusCode(SC_UNAUTHORIZED)
+            .body("status", equalTo(401))
+            .body("type", equalTo("about:blank"));
+    }
+
+    @Test
+    public void 
givenXUserStrategyWhenInvalidateXUserSecretHeaderShouldFail(GuiceJamesServer 
server) throws Throwable {
+        // given a server with XUserAuthenticationStrategy
+        // The XUserAuthenticationStrategy is configured with a secret: 
"secret1"
+        setupJamesServerWithXUserStrategy(server, Optional.of("secret1"));
+
+        // when a request is made with an invalid X-User-Secret header
+        // then the request should fail with a 401 status code
+        given()
+            .header(new Header("X-User", BOB().asString()))
+            .header(new Header("X-User-Secret", "invalid"))
+            .body(ECHO_REQUEST_OBJECT())
+        .when()
+            .post()
+        .then()
+            .statusCode(SC_UNAUTHORIZED)
+            .body("status", equalTo(401))
+            .body("type", equalTo("about:blank"));
+    }
+
+    @Test
+    public void 
givenXUserStrategyWhenValidateXUserSecretHeaderShouldSuccess(GuiceJamesServer 
server) throws Throwable {
+        // given a server with XUserAuthenticationStrategy
+        // The XUserAuthenticationStrategy is configured with a secret: 
"secret1"
+        String secret = "secret1";
+        setupJamesServerWithXUserStrategy(server, Optional.of(secret));
+
+        // when a request is made with an invalid X-User-Secret header
+        // then the request should fail with a 401 status code
+        given()
+            .header(new Header("X-User", BOB().asString()))
+            .header(new Header("X-User-Secret", secret))
+        .when()
+            .get("/session")
+        .then()
+            .statusCode(SC_OK)
+            .body("username", equalTo(BOB().asString()));
+    }
+
+    @Test
+    public void 
givenXUserStrategyWithAbsentXUserSecretWhenValidRequestShouldSuccess(GuiceJamesServer
 server) throws Throwable {
+        // given a server with XUserAuthenticationStrategy
+        // The XUserAuthenticationStrategy is configured with absent secret
+        setupJamesServerWithXUserStrategy(server, Optional.empty());
+
+        // when a request is made with an invalid X-User-Secret header
+        // then the request should fail with a 401 status code
+        given()
+            .header(new Header("X-User", BOB().asString()))
+            .when()
+        .get("/session")
+            .then()
+            .statusCode(SC_OK)
+            .body("username", equalTo(BOB().asString()));
+    }
+
+    private void setupJamesServerWithXUserStrategy(GuiceJamesServer server, 
Optional<String> xUserSecret) throws Exception {
+        jmapServer = server
+            .overrideWith(new AbstractModule() {
+                @Provides
+                @Singleton
+                public XUserAuthenticationStrategy 
provideXUserAuthenticationStrategy(UsersRepository usersRepository,
+                                                                               
       MailboxManager mailboxManager) {
+                    return new XUserAuthenticationStrategy(usersRepository, 
mailboxManager, xUserSecret);
+                }
+            })
+            .overrideWith(binder -> binder.bind(JmapRfc8621Configuration.class)
+                .toInstance(JmapRfc8621Configuration.LOCALHOST_CONFIGURATION()
+                    
.withAuthenticationStrategies(Optional.of(List.of(XUserAuthenticationStrategy.class.getCanonicalName())))));
+
+        jmapServer.start();
+
+        RestAssured.requestSpecification = jmapRequestSpecBuilder
+            
.setPort(jmapServer.getProbe(JmapGuiceProbe.class).getJmapPort().getValue())
+            .addHeader("Accept", "application/json; jmapVersion=rfc-8621")
+            .setBasePath(JMAP)
+            .build();
+
+        jmapServer.getProbe(DataProbeImpl.class)
+            .fluent()
+            .addDomain(DOMAIN().asString())
+            .addUser(BOB().asString(), BOB_PASSWORD());
+    }
 }
diff --git a/server/protocols/jmap/pom.xml b/server/protocols/jmap/pom.xml
index df20806d6e..2b1ae4ad5b 100644
--- a/server/protocols/jmap/pom.xml
+++ b/server/protocols/jmap/pom.xml
@@ -42,6 +42,11 @@
             <groupId>${james.groupId}</groupId>
             <artifactId>james-server-data-api</artifactId>
         </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
+            <artifactId>james-server-guice-common</artifactId>
+            <version>${project.version}</version>
+        </dependency>
         <dependency>
             <groupId>${james.groupId}</groupId>
             <artifactId>james-server-jwt</artifactId>
diff --git 
a/server/protocols/jmap/src/main/java/org/apache/james/jmap/http/XUserAuthenticationStrategy.java
 
b/server/protocols/jmap/src/main/java/org/apache/james/jmap/http/XUserAuthenticationStrategy.java
index 48f91ae445..7de77df0a1 100644
--- 
a/server/protocols/jmap/src/main/java/org/apache/james/jmap/http/XUserAuthenticationStrategy.java
+++ 
b/server/protocols/jmap/src/main/java/org/apache/james/jmap/http/XUserAuthenticationStrategy.java
@@ -19,15 +19,21 @@
 
 package org.apache.james.jmap.http;
 
+import java.io.FileNotFoundException;
 import java.util.Optional;
+import java.util.function.Function;
 
 import jakarta.inject.Inject;
 
+import org.apache.commons.configuration2.ex.ConfigurationException;
 import org.apache.james.core.Username;
 import org.apache.james.jmap.exceptions.UnauthorizedException;
 import org.apache.james.mailbox.MailboxManager;
 import org.apache.james.mailbox.MailboxSession;
 import org.apache.james.user.api.UsersRepository;
+import org.apache.james.utils.PropertiesProvider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import com.google.common.collect.ImmutableMap;
 
@@ -36,25 +42,52 @@ import reactor.netty.http.server.HttpServerRequest;
 
 public class XUserAuthenticationStrategy implements AuthenticationStrategy {
     private static final String X_USER_HEADER_NAME = "X-User";
+    private static final String X_USER_SECRET_HEADER_NAME = "X-User-Secret";
+    private static final String AUTHENTICATION_STRATEGY_XUSER_SECRET = 
"authentication.strategy.rfc8621.xUser.secret";
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(XUserAuthenticationStrategy.class);
     private static final AuthenticationChallenge X_USER_CHALLENGE = 
AuthenticationChallenge.of(
         AuthenticationScheme.of("XUserHeader"),
         ImmutableMap.of());
 
+    private static Optional<String> 
extractXUserSecretFromConfig(PropertiesProvider propertiesProvider) throws 
ConfigurationException {
+        try {
+            return 
Optional.ofNullable(propertiesProvider.getConfiguration("jmap"))
+                .map(config -> 
config.getString(AUTHENTICATION_STRATEGY_XUSER_SECRET, null));
+        } catch (FileNotFoundException e) {
+             LOGGER.warn("");
+            return Optional.empty();
+        }
+    }
+
     private final UsersRepository usersRepository;
     private final MailboxManager mailboxManager;
+    private final Function<HttpServerRequest, Optional<Username>> 
usernameExtractor;
 
     @Inject
-    public XUserAuthenticationStrategy(UsersRepository usersRepository, 
MailboxManager mailboxManager) {
+    public XUserAuthenticationStrategy(UsersRepository usersRepository,
+                                       MailboxManager mailboxManager,
+                                       PropertiesProvider configuration) 
throws ConfigurationException {
+        this(usersRepository, mailboxManager, 
extractXUserSecretFromConfig(configuration));
+    }
+
+    public XUserAuthenticationStrategy(UsersRepository usersRepository,
+                                       MailboxManager mailboxManager,
+                                       Optional<String> xUserSecret) {
         this.usersRepository = usersRepository;
         this.mailboxManager = mailboxManager;
+        this.usernameExtractor = xUserSecret
+            .map(this::createUsernameExtractorWithSecretValidation)
+            .orElseGet(() -> {
+                LOGGER.warn("No X-User-Secret value found. X-User header will 
be used without secret validation which can pose a security risk if an attacker 
gains access to the JMAP endpoint. " +
+                    "Secret validation can be set up via the 
authentication.strategy.rfc8621.xUser.secret jmap configuration property.");
+                return createUsernameExtractorWithoutSecretValidation();
+            });
     }
 
     @Override
     public Mono<MailboxSession> createMailboxSession(HttpServerRequest 
httpRequest) {
-        return 
Optional.ofNullable(httpRequest.requestHeaders().get(X_USER_HEADER_NAME))
-            .map(String::trim)
-            .filter(s -> !s.isEmpty())
-            .map(Username::of)
+        return usernameExtractor.apply(httpRequest)
             .map(this::createMailboxSession)
             .orElse(Mono.empty());
     }
@@ -69,4 +102,18 @@ public class XUserAuthenticationStrategy implements 
AuthenticationStrategy {
     public AuthenticationChallenge correspondingChallenge() {
         return X_USER_CHALLENGE;
     }
+
+    private Function<HttpServerRequest, Optional<Username>> 
createUsernameExtractorWithoutSecretValidation() {
+        return httpRequest -> 
Optional.ofNullable(httpRequest.requestHeaders().get(X_USER_HEADER_NAME))
+            .map(String::trim)
+            .filter(s -> !s.isEmpty())
+            .map(Username::of);
+    }
+
+    private Function<HttpServerRequest, Optional<Username>> 
createUsernameExtractorWithSecretValidation(String secret) {
+        return httpRequest -> 
createUsernameExtractorWithoutSecretValidation().apply(httpRequest)
+            .filter(username -> 
Optional.ofNullable(httpRequest.requestHeaders().get(X_USER_SECRET_HEADER_NAME))
+                .map(secret::equals)
+                .orElse(false));
+    }
 }
diff --git a/src/site/xdoc/server/config-jmap.xml 
b/src/site/xdoc/server/config-jmap.xml
index e8b70e6294..e1b6c5cf71 100644
--- a/src/site/xdoc/server/config-jmap.xml
+++ b/src/site/xdoc/server/config-jmap.xml
@@ -141,6 +141,9 @@
 
                     <dt><strong>set.max.size</strong></dt>
                     <dd>Optional, default value is 500. The max number of 
items for /set methods.</dd>
+
+                    
<dt><string>authentication.strategy.rfc8621.xUser.secret</string></dt>
+                    <dd>Optional. Disabled by default. Secret-value used to 
validate the X-User-Secret header when using the XUserAuthenticationStrategy. 
Use of this configuration property is highly advised.</dd>
                 </dl>
 
             </subsection>


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscr...@james.apache.org
For additional commands, e-mail: notifications-h...@james.apache.org

Reply via email to