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

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


The following commit(s) were added to refs/heads/master by this push:
     new 108aa9c446 [ENHANCEMENT] Add a simpler password mechanism to webadmin 
(#2759)
108aa9c446 is described below

commit 108aa9c446eb7e4f5d6c837228423f47d0aba0fd
Author: Benoit TELLIER <btell...@linagora.com>
AuthorDate: Fri Jun 27 15:22:13 2025 +0200

    [ENHANCEMENT] Add a simpler password mechanism to webadmin (#2759)
---
 .../servers/partials/configure/webadmin.adoc       |  23 +++++
 .../james/modules/server/WebAdminServerModule.java |   7 +-
 .../james/webadmin/WebAdminConfiguration.java      |  23 ++++-
 .../webadmin/authentication/PasswordFilter.java    |  62 +++++++++++++
 .../authentication/PasswordFilterTest.java         | 102 +++++++++++++++++++++
 5 files changed, 214 insertions(+), 3 deletions(-)

diff --git a/docs/modules/servers/partials/configure/webadmin.adoc 
b/docs/modules/servers/partials/configure/webadmin.adoc
index 6a9a6fb79c..dc429c142a 100644
--- a/docs/modules/servers/partials/configure/webadmin.adoc
+++ b/docs/modules/servers/partials/configure/webadmin.adoc
@@ -39,6 +39,29 @@ to get some examples and hints.
 | cors.origin
 | Specify ths CORS origin (default: null)
 
+| password
+| Uses a configured static value for authentication. It relies on the Password 
header.
+It supports several passwords, configured as a coma separated list.
+
+....
+password=secretA,secretB,secretC
+....
+
+Will allow request with
+
+....
+Password: secretA
+Password: secretB
+....
+
+But deny
+
+....
+Password: secretD
+....
+
+As well as request without the password header.
+
 | jwt.enable
 | Allow JSON Web Token as an authentication mechanism (default: false)
 
diff --git 
a/server/container/guice/protocols/webadmin/src/main/java/org/apache/james/modules/server/WebAdminServerModule.java
 
b/server/container/guice/protocols/webadmin/src/main/java/org/apache/james/modules/server/WebAdminServerModule.java
index 7cce21a3d5..9b9dcca561 100644
--- 
a/server/container/guice/protocols/webadmin/src/main/java/org/apache/james/modules/server/WebAdminServerModule.java
+++ 
b/server/container/guice/protocols/webadmin/src/main/java/org/apache/james/modules/server/WebAdminServerModule.java
@@ -59,6 +59,7 @@ import org.apache.james.webadmin.WebAdminServer;
 import org.apache.james.webadmin.authentication.AuthenticationFilter;
 import org.apache.james.webadmin.authentication.JwtFilter;
 import org.apache.james.webadmin.authentication.NoAuthenticationFilter;
+import org.apache.james.webadmin.authentication.PasswordFilter;
 import org.apache.james.webadmin.dto.DTOModuleInjections;
 import org.apache.james.webadmin.mdc.RequestLogger;
 import org.apache.james.webadmin.utils.JsonTransformer;
@@ -154,6 +155,7 @@ public class WebAdminServerModule extends AbstractModule {
                     
Optional.ofNullable(configurationFile.getString("jwt.publickeypem.url", null))))
                 
.maxThreadCount(Optional.ofNullable(configurationFile.getInteger("maxThreadCount",
 null)))
                 
.minThreadCount(Optional.ofNullable(configurationFile.getInteger("minThreadCount",
 null)))
+                
.password(Optional.ofNullable(configurationFile.getString("password", null)))
                 .build();
         } catch (FileNotFoundException e) {
             LOGGER.info("No webadmin.properties file. Disabling WebAdmin 
interface.");
@@ -182,13 +184,16 @@ public class WebAdminServerModule extends AbstractModule {
     @Provides
     @Singleton
     public AuthenticationFilter 
providesAuthenticationFilter(PropertiesProvider propertiesProvider,
+                                                             
WebAdminConfiguration webAdminConfiguration,
                                                              
@Named("webadmin") JwtTokenVerifier.Factory jwtTokenVerifier) throws Exception {
         try {
             Configuration configurationFile = 
propertiesProvider.getConfiguration("webadmin");
             if (configurationFile.getBoolean("jwt.enabled", 
DEFAULT_JWT_DISABLED)) {
                 return new JwtFilter(jwtTokenVerifier);
             }
-            return new NoAuthenticationFilter();
+            return webAdminConfiguration.getPassword()
+                .<AuthenticationFilter>map(PasswordFilter::new)
+                .orElse(new NoAuthenticationFilter());
         } catch (FileNotFoundException e) {
             return new NoAuthenticationFilter();
         }
diff --git 
a/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/WebAdminConfiguration.java
 
b/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/WebAdminConfiguration.java
index 45965abeb9..820a8cc826 100644
--- 
a/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/WebAdminConfiguration.java
+++ 
b/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/WebAdminConfiguration.java
@@ -57,6 +57,7 @@ public class WebAdminConfiguration {
         private Optional<TlsConfiguration> tlsConfiguration = Optional.empty();
         private Optional<String> urlCORSOrigin = Optional.empty();
         private Optional<String> host = Optional.empty();
+        private Optional<String> password = Optional.empty();
         private ImmutableList.Builder<String> additionalRoutes = 
ImmutableList.builder();
         private Optional<String> jwtPublicKey = Optional.empty();
         private Optional<Integer> maxThreadCount = Optional.empty();
@@ -123,6 +124,16 @@ public class WebAdminConfiguration {
             return this;
         }
 
+        public Builder password(String password) {
+            this.password = Optional.ofNullable(password);
+            return this;
+        }
+
+        public Builder password(Optional<String> password) {
+            this.password = password;
+            return this;
+        }
+
         public Builder additionalRoute(String additionalRoute) {
             this.additionalRoutes.add(additionalRoute);
             return this;
@@ -155,6 +166,7 @@ public class WebAdminConfiguration {
                 host.orElse(DEFAULT_HOST),
                 additionalRoutes.build(),
                 jwtPublicKey,
+                password,
                 maxThreadCount,
                 minThreadCount);
         }
@@ -168,12 +180,13 @@ public class WebAdminConfiguration {
     private final String host;
     private final List<String> additionalRoutes;
     private final Optional<String> jwtPublicKey;
+    private final Optional<String> password;
     private final Optional<Integer> maxThreadCount;
     private final Optional<Integer> minThreadCount;
 
     @VisibleForTesting
     WebAdminConfiguration(boolean enabled, Optional<PortSupplier> port, 
Optional<TlsConfiguration> tlsConfiguration,
-                          boolean enableCORS, String urlCORSOrigin, String 
host, List<String> additionalRoutes, Optional<String> jwtPublicKey, 
Optional<Integer> maxThreadCount, Optional<Integer> minThreadCount) {
+                          boolean enableCORS, String urlCORSOrigin, String 
host, List<String> additionalRoutes, Optional<String> jwtPublicKey, 
Optional<String> password, Optional<Integer> maxThreadCount, Optional<Integer> 
minThreadCount) {
         this.enabled = enabled;
         this.port = port;
         this.tlsConfiguration = tlsConfiguration;
@@ -182,6 +195,7 @@ public class WebAdminConfiguration {
         this.host = host;
         this.additionalRoutes = additionalRoutes;
         this.jwtPublicKey = jwtPublicKey;
+        this.password = password;
         this.maxThreadCount = maxThreadCount;
         this.minThreadCount = minThreadCount;
     }
@@ -230,6 +244,10 @@ public class WebAdminConfiguration {
         return host;
     }
 
+    public Optional<String> getPassword() {
+        return password;
+    }
+
     @Override
     public final boolean equals(Object o) {
         if (o instanceof WebAdminConfiguration) {
@@ -242,6 +260,7 @@ public class WebAdminConfiguration {
                 && Objects.equals(this.jwtPublicKey, that.jwtPublicKey)
                 && Objects.equals(this.urlCORSOrigin, that.urlCORSOrigin)
                 && Objects.equals(this.host, that.host)
+                && Objects.equals(this.password, that.password)
                 && Objects.equals(this.additionalRoutes, that.additionalRoutes)
                 && Objects.equals(this.minThreadCount, that.minThreadCount)
                 && Objects.equals(this.maxThreadCount, that.maxThreadCount);
@@ -251,6 +270,6 @@ public class WebAdminConfiguration {
 
     @Override
     public final int hashCode() {
-        return Objects.hash(enabled, port, tlsConfiguration, enableCORS, 
jwtPublicKey, urlCORSOrigin, host, additionalRoutes, minThreadCount, 
maxThreadCount);
+        return Objects.hash(enabled, port, tlsConfiguration, enableCORS, 
jwtPublicKey, urlCORSOrigin, host, additionalRoutes, minThreadCount, 
maxThreadCount, password);
     }
 }
diff --git 
a/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/authentication/PasswordFilter.java
 
b/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/authentication/PasswordFilter.java
new file mode 100644
index 0000000000..1c8ae700f5
--- /dev/null
+++ 
b/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/authentication/PasswordFilter.java
@@ -0,0 +1,62 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.webadmin.authentication;
+
+import static spark.Spark.halt;
+
+import java.util.List;
+import java.util.Optional;
+
+import jakarta.inject.Inject;
+
+import org.eclipse.jetty.http.HttpStatus;
+
+import com.google.common.base.Splitter;
+
+import spark.Request;
+import spark.Response;
+
+public class PasswordFilter implements AuthenticationFilter {
+    public static final String PASSWORD = "Password";
+    public static final String OPTIONS = "OPTIONS";
+
+    private final List<String> passwords;
+
+    @Inject
+    public PasswordFilter(String passwordString) {
+        this.passwords = Splitter.on(',')
+            .splitToList(passwordString);
+    }
+
+    @Override
+    public void handle(Request request, Response response) throws Exception {
+        if (!request.requestMethod().equals(OPTIONS)) {
+            Optional<String> password = 
Optional.ofNullable(request.headers(PASSWORD));
+
+            if (!password.isPresent()) {
+                halt(HttpStatus.UNAUTHORIZED_401, "No Password header.");
+            }
+            if (!passwords.contains(password.get())) {
+                halt(HttpStatus.UNAUTHORIZED_401, "Wrong Password header.");
+            }
+        }
+    }
+
+}
diff --git 
a/server/protocols/webadmin/webadmin-core/src/test/java/org/apache/james/webadmin/authentication/PasswordFilterTest.java
 
b/server/protocols/webadmin/webadmin-core/src/test/java/org/apache/james/webadmin/authentication/PasswordFilterTest.java
new file mode 100644
index 0000000000..4a83babbdc
--- /dev/null
+++ 
b/server/protocols/webadmin/webadmin-core/src/test/java/org/apache/james/webadmin/authentication/PasswordFilterTest.java
@@ -0,0 +1,102 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.webadmin.authentication;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import com.google.common.collect.ImmutableSet;
+
+import spark.HaltException;
+import spark.Request;
+import spark.Response;
+
+class PasswordFilterTest {
+    private PasswordFilter testee;
+
+    @BeforeEach
+    void setUp() {
+        testee = new PasswordFilter("abc,def");
+    }
+
+    @Test
+    void handleShouldDoNothingOnOptions() throws Exception {
+        Request request = mock(Request.class);
+        //Ensure we don't take OPTIONS string from the constant pool
+        when(request.requestMethod()).thenReturn(new String("OPTIONS"));
+        Response response = mock(Response.class);
+
+        testee.handle(request, response);
+
+        verifyNoMoreInteractions(response);
+    }
+
+
+    @Test
+    void handleShouldRejectRequestWithoutHeaders() {
+        Request request = mock(Request.class);
+        when(request.requestMethod()).thenReturn("GET");
+        when(request.headers()).thenReturn(ImmutableSet.of());
+
+        assertThatThrownBy(() -> testee.handle(request, mock(Response.class)))
+            .isInstanceOf(HaltException.class)
+            .extracting(e -> HaltException.class.cast(e).statusCode())
+            .isEqualTo(401);
+    }
+
+    @Test
+    void handleShouldRejectWrongPassword() {
+        Request request = mock(Request.class);
+        when(request.requestMethod()).thenReturn("GET");
+        when(request.headers("Password")).thenReturn("ghi");
+
+        assertThatThrownBy(() -> testee.handle(request, mock(Response.class)))
+            .isInstanceOf(HaltException.class)
+            .extracting(e -> HaltException.class.cast(e).statusCode())
+            .isEqualTo(401);
+    }
+
+    @Test
+    void handleShouldRejectBothPassword() {
+        Request request = mock(Request.class);
+        when(request.requestMethod()).thenReturn("GET");
+        when(request.headers("Password")).thenReturn("abc,def");
+
+        assertThatThrownBy(() -> testee.handle(request, mock(Response.class)))
+            .isInstanceOf(HaltException.class)
+            .extracting(e -> HaltException.class.cast(e).statusCode())
+            .isEqualTo(401);
+    }
+
+    @Test
+    void handleShouldAcceptValidPassword() throws Exception {
+        Request request = mock(Request.class);
+        when(request.requestMethod()).thenReturn("GET");
+        when(request.requestMethod()).thenReturn("GET");
+        when(request.headers("Password")).thenReturn("abc");
+
+        testee.handle(request, mock(Response.class));
+    }
+}


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

Reply via email to