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