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