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 2f500a7fd9f464498c5302780ce5500c234a3255 Author: Rene Cordier <[email protected]> AuthorDate: Fri Apr 10 15:29:17 2026 +0700 JAMES-4201 Implement granular password access control in PasswordFilter --- .../james/modules/server/WebAdminServerModule.java | 15 ++- .../webadmin/authentication/PasswordFilter.java | 100 ++++++++++++---- .../authentication/PasswordFilterTest.java | 127 ++++++++++++++++++++- 3 files changed, 216 insertions(+), 26 deletions(-) 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 bf42c4f701..000e6c9901 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 @@ -193,14 +193,23 @@ public class WebAdminServerModule extends AbstractModule { if (configurationFile.getBoolean("jwt.enabled", DEFAULT_JWT_DISABLED)) { return new JwtFilter(jwtTokenVerifier); } - return webAdminConfiguration.getPassword() - .<AuthenticationFilter>map(PasswordFilter::new) - .orElse(new NoAuthenticationFilter()); + if (isPasswordPresent(webAdminConfiguration)) { + return new PasswordFilter(webAdminConfiguration.getPassword(), + webAdminConfiguration.getReadOnlyPassword(), + webAdminConfiguration.getNoDeletePassword()); + } + return new NoAuthenticationFilter(); } catch (FileNotFoundException e) { return new NoAuthenticationFilter(); } } + private boolean isPasswordPresent(WebAdminConfiguration webAdminConfiguration) { + return webAdminConfiguration.getPassword().isPresent() + || webAdminConfiguration.getNoDeletePassword().isPresent() + || webAdminConfiguration.getReadOnlyPassword().isPresent(); + } + @Provides @Singleton @Named("webadmin") 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 index e324f75960..6c46db42ab 100644 --- 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 @@ -29,6 +29,7 @@ import jakarta.inject.Inject; import org.eclipse.jetty.http.HttpStatus; import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableList; import spark.Request; import spark.Response; @@ -39,12 +40,69 @@ public class PasswordFilter implements AuthenticationFilter { public static final String AUTHORIZATION_HEADER_PREFIX = "Bearer "; public static final String AUTHORIZATION_HEADER_NAME = "Authorization"; - private final List<String> passwords; + private static final String GET_METHOD = "GET"; + private static final String HEAD_METHOD = "HEAD"; + private static final String DELETE_METHOD = "DELETE"; + private final Optional<List<String>> passwords; + private final Optional<List<String>> readOnlyPasswords; + private final Optional<List<String>> noDeletePasswords; + + /** + * @param passwordString optional comma-separated list of full-access passwords + * @param readOnlyPasswordString optional comma-separated list of read-only passwords + * @param noDeletePasswordString optional comma-separated list of no-delete passwords + */ @Inject - public PasswordFilter(String passwordString) { - this.passwords = Splitter.on(',') - .splitToList(passwordString); + public PasswordFilter(Optional<String> passwordString, Optional<String> readOnlyPasswordString, Optional<String> noDeletePasswordString) { + this.passwords = splitOptionalPasswords(passwordString); + this.readOnlyPasswords = splitOptionalPasswords(readOnlyPasswordString); + this.noDeletePasswords = splitOptionalPasswords(noDeletePasswordString); + } + + private Optional<List<String>> splitOptionalPasswords(Optional<String> optionalPasswordString) { + return optionalPasswordString.map(this::splitPasswords); + } + + private List<String> splitPasswords(String passwordString) { + if (passwordString == null || passwordString.isEmpty()) { + return ImmutableList.of(); + } + return Splitter.on(',').splitToList(passwordString); + } + + private enum AccessLevel { + FULL, + NO_DELETE, + READ_ONLY, + NONE + } + + private AccessLevel getAccessLevel(String password) { + if (passwords.isPresent() && passwords.get().contains(password)) { + return AccessLevel.FULL; + } + if (noDeletePasswords.isPresent() && noDeletePasswords.get().contains(password)) { + return AccessLevel.NO_DELETE; + } + if (readOnlyPasswords.isPresent() && readOnlyPasswords.get().contains(password)) { + return AccessLevel.READ_ONLY; + } + return AccessLevel.NONE; + } + + private boolean isAccessAllowed(AccessLevel accessLevel, String httpMethod) { + switch (accessLevel) { + case FULL: + return true; + case NO_DELETE: + return !httpMethod.equals(DELETE_METHOD); + case READ_ONLY: + return httpMethod.equals(GET_METHOD) || httpMethod.equals(HEAD_METHOD); + case NONE: + default: + return false; + } } @Override @@ -53,22 +111,24 @@ public class PasswordFilter implements AuthenticationFilter { Optional<String> password = Optional.ofNullable(request.headers(PASSWORD)); Optional<String> authorization = Optional.ofNullable(request.headers(AUTHORIZATION_HEADER_NAME)); - if (!password.isPresent()) { - if (authorization.isPresent()) { - Optional<String> bearer = authorization - .filter(value -> value.startsWith(AUTHORIZATION_HEADER_PREFIX)) - .map(value -> value.substring(AUTHORIZATION_HEADER_PREFIX.length())); - - if (!bearer.filter(passwords::contains).isPresent()) { - halt(HttpStatus.UNAUTHORIZED_401, "Wrong Bearer."); - } - } else { - halt(HttpStatus.UNAUTHORIZED_401, "No Password header."); - } - } else { - if (!passwords.contains(password.get())) { - halt(HttpStatus.UNAUTHORIZED_401, "Wrong Password header."); - } + Optional<String> providedPassword = password + .or(() -> authorization + .filter(value -> value.startsWith(AUTHORIZATION_HEADER_PREFIX)) + .map(value -> value.substring(AUTHORIZATION_HEADER_PREFIX.length()))); + + if (providedPassword.isEmpty()) { + halt(HttpStatus.UNAUTHORIZED_401, "No Password in header."); + return; + } + + AccessLevel accessLevel = getAccessLevel(providedPassword.get()); + if (accessLevel == AccessLevel.NONE) { + halt(HttpStatus.UNAUTHORIZED_401, "Wrong password."); + return; + } + + if (!isAccessAllowed(accessLevel, request.requestMethod())) { + halt(HttpStatus.FORBIDDEN_403, "Insufficient permissions for this operation."); } } } 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 index 29667c7dee..b2295b44df 100644 --- 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 @@ -24,6 +24,8 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; +import java.util.Optional; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -38,7 +40,7 @@ class PasswordFilterTest { @BeforeEach void setUp() { - testee = new PasswordFilter("abc,def"); + testee = new PasswordFilter(Optional.of("abc,def"), Optional.of("readonly1,readonly2"), Optional.of("nodelete1,nodelete2")); } @Test @@ -105,7 +107,6 @@ class PasswordFilterTest { 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)); @@ -115,9 +116,129 @@ class PasswordFilterTest { void handleShouldAcceptValidPasswordWhenBearer() throws Exception { Request request = mock(Request.class); when(request.requestMethod()).thenReturn("GET"); - when(request.requestMethod()).thenReturn("GET"); when(request.headers("Authorization")).thenReturn("Bearer abc"); testee.handle(request, mock(Response.class)); } + + @Test + void handleShouldAcceptValidReadOnlyPasswordOnGet() throws Exception { + Request request = mock(Request.class); + when(request.requestMethod()).thenReturn("GET"); + when(request.headers("Password")).thenReturn("readonly1"); + + testee.handle(request, mock(Response.class)); + } + + @Test + void handleShouldRejectReadOnlyPasswordOnPost() { + Request request = mock(Request.class); + when(request.requestMethod()).thenReturn("POST"); + when(request.headers("Password")).thenReturn("readonly1"); + + assertThatThrownBy(() -> testee.handle(request, mock(Response.class))) + .isInstanceOf(HaltException.class) + .extracting(e -> HaltException.class.cast(e).statusCode()) + .isEqualTo(403); + } + + @Test + void handleShouldRejectReadOnlyPasswordOnDelete() { + Request request = mock(Request.class); + when(request.requestMethod()).thenReturn("DELETE"); + when(request.headers("Password")).thenReturn("readonly1"); + + assertThatThrownBy(() -> testee.handle(request, mock(Response.class))) + .isInstanceOf(HaltException.class) + .extracting(e -> HaltException.class.cast(e).statusCode()) + .isEqualTo(403); + } + + @Test + void handleShouldAcceptValidReadOnlyPasswordOnGetViaBearer() throws Exception { + Request request = mock(Request.class); + when(request.requestMethod()).thenReturn("GET"); + when(request.headers("Authorization")).thenReturn("Bearer readonly2"); + + testee.handle(request, mock(Response.class)); + } + + @Test + void handleShouldAcceptValidReadOnlyPasswordOnHeadViaBearer() throws Exception { + Request request = mock(Request.class); + when(request.requestMethod()).thenReturn("HEAD"); + when(request.headers("Authorization")).thenReturn("Bearer readonly2"); + + testee.handle(request, mock(Response.class)); + } + + @Test + void handleShouldAcceptValidNoDeletePasswordOnGet() throws Exception { + Request request = mock(Request.class); + when(request.requestMethod()).thenReturn("GET"); + when(request.headers("Password")).thenReturn("nodelete1"); + + testee.handle(request, mock(Response.class)); + } + + @Test + void handleShouldAcceptValidNoDeletePasswordOnPost() throws Exception { + Request request = mock(Request.class); + when(request.requestMethod()).thenReturn("POST"); + when(request.headers("Password")).thenReturn("nodelete1"); + + testee.handle(request, mock(Response.class)); + } + + @Test + void handleShouldRejectNoDeletePasswordOnDelete() { + Request request = mock(Request.class); + when(request.requestMethod()).thenReturn("DELETE"); + when(request.headers("Password")).thenReturn("nodelete1"); + + assertThatThrownBy(() -> testee.handle(request, mock(Response.class))) + .isInstanceOf(HaltException.class) + .extracting(e -> HaltException.class.cast(e).statusCode()) + .isEqualTo(403); + } + + @Test + void handleShouldAcceptValidNoDeletePasswordOnPut() throws Exception { + Request request = mock(Request.class); + when(request.requestMethod()).thenReturn("PUT"); + when(request.headers("Password")).thenReturn("nodelete2"); + + testee.handle(request, mock(Response.class)); + } + + @Test + void handleShouldAcceptFullPasswordOnDelete() throws Exception { + Request request = mock(Request.class); + when(request.requestMethod()).thenReturn("DELETE"); + when(request.headers("Password")).thenReturn("abc"); + + testee.handle(request, mock(Response.class)); + } + + @Test + void handleShouldAcceptFullPasswordOnPost() throws Exception { + Request request = mock(Request.class); + when(request.requestMethod()).thenReturn("POST"); + when(request.headers("Password")).thenReturn("def"); + + testee.handle(request, mock(Response.class)); + } + + @Test + void handleShouldRejectWhenNoConfiguredPasswords() { + PasswordFilter filterWithNulls = new PasswordFilter(Optional.empty(), Optional.empty(), Optional.empty()); + Request request = mock(Request.class); + when(request.requestMethod()).thenReturn("GET"); + when(request.headers("Password")).thenReturn("anypassword"); + + assertThatThrownBy(() -> filterWithNulls.handle(request, mock(Response.class))) + .isInstanceOf(HaltException.class) + .extracting(e -> HaltException.class.cast(e).statusCode()) + .isEqualTo(401); + } } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
