diff --git a/CHANGELOG.md b/CHANGELOG.md index 83127c9d1c9..05c199a4e94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) ## [Unreleased] +### Security + + - Webadmin `password.generate`: generate a random password upon start up when none is configured, and log it. + **Breaking change**: this defaults to `true`, thus WebAdmin is no longer unauthenticated out of the box, existing + deployments included. Set `password.generate=false` in `webadmin.properties` to opt back into an unauthenticated + WebAdmin, or configure `password` to pin a stable secret. + This release brings the following significant changes: - Upgrade TCP protocols to Netty 4 diff --git a/docs/modules/servers/pages/15-minute-demo.adoc b/docs/modules/servers/pages/15-minute-demo.adoc index 00ab2246d3b..2223afb32ed 100644 --- a/docs/modules/servers/pages/15-minute-demo.adoc +++ b/docs/modules/servers/pages/15-minute-demo.adoc @@ -20,7 +20,7 @@ Run this command to create the `webadmin.properties` file: [source,bash] ---- -printf 'enabled=true\nport=8000\nhost=localhost' >> webadmin.properties +printf 'enabled=true\nport=8000\nhost=localhost\npassword.generate=false\n' >> webadmin.properties ---- Explanation: @@ -28,7 +28,14 @@ Explanation: * `enabled=true` instructs James to run the Admin API service * `port=8000` configures the Admin API to be made available via port 8000 * `host=localhost` configures the Admin API to respond on localhost + * `password.generate=false` disables WebAdmin authentication for this local demo +[NOTE] +==== +This demo explicitly disables WebAdmin password generation to keep the first-run experience simple. +By default, `password.generate` is enabled and James generates a random WebAdmin password at startup when no +`password` is explicitly configured. Do not disable WebAdmin authentication when exposing it beyond this local demo. +==== Now run the James demo server using this command: @@ -219,4 +226,3 @@ docker stop james ; docker rm james ---- That's all, folks! - diff --git a/examples/custom-webadmin-route/README.md b/examples/custom-webadmin-route/README.md index eba5a849d62..7f391315d94 100644 --- a/examples/custom-webadmin-route/README.md +++ b/examples/custom-webadmin-route/README.md @@ -48,12 +48,20 @@ enabled=true port=8000 host=localhost +# Disable WebAdmin authentication for this local customization demo. +# Password generation defaults to true when no password is explicitly configured. +password.generate=false + # List of fully qualified class names that should be exposed over webadmin # in addition to your product default routes. Routes needs to be located # within the classpath or in the ./extensions-jars folder. extensions.routes=org.apache.james.examples.RouteA ``` +This example explicitly disables WebAdmin password generation to keep the customization demo simple. By default, +`password.generate` is enabled and James generates a random WebAdmin password at startup when no `password` is +explicitly configured. Do not disable WebAdmin authentication when exposing it beyond this local demo. + Create a keystore (default password being `james72laBalle`): ``` @@ -76,4 +84,4 @@ You can play with `curl` utility with the resulting server: ``` $ curl -XGET http://172.17.0.2:8000/hello/a RouteA -``` \ No newline at end of file +``` diff --git a/examples/custom-webadmin-route/src/main/resources/webadmin.properties b/examples/custom-webadmin-route/src/main/resources/webadmin.properties index d19b4e5f2a2..db5f66a12d2 100644 --- a/examples/custom-webadmin-route/src/main/resources/webadmin.properties +++ b/examples/custom-webadmin-route/src/main/resources/webadmin.properties @@ -19,6 +19,10 @@ enabled=true port=8000 host=localhost +# Disable WebAdmin authentication for this local customization demo. +# Password generation defaults to true when no password is explicitly configured. +password.generate=false + # List of fully qualified class names that should be exposed over webadmin # in addition to your product default routes. Routes needs to be located # within the classpath or in the ./extensions-jars folder. diff --git a/server/apps/cassandra-app/docker-configuration/webadmin.properties b/server/apps/cassandra-app/docker-configuration/webadmin.properties index 5d72d99b744..3a8d67c93fc 100644 --- a/server/apps/cassandra-app/docker-configuration/webadmin.properties +++ b/server/apps/cassandra-app/docker-configuration/webadmin.properties @@ -51,4 +51,10 @@ https.enabled=false # List of fully qualified class names that should be exposed over webadmin # in addition to your product default routes. Routes needs to be located # within the classpath or in the ./extensions-jars folder. -#extensions.routes= \ No newline at end of file +#extensions.routes= + +# Generate a random password upon start up when no password is configured. +# The generated password is written in the logs, and changes upon each restart. +# Set it to false to run WebAdmin without any authentication. +# Defaults to true +password.generate=true diff --git a/server/apps/cassandra-app/sample-configuration/webadmin.properties b/server/apps/cassandra-app/sample-configuration/webadmin.properties index b97c71829c4..0727d221be7 100644 --- a/server/apps/cassandra-app/sample-configuration/webadmin.properties +++ b/server/apps/cassandra-app/sample-configuration/webadmin.properties @@ -52,4 +52,15 @@ https.enabled=false # List of fully qualified class names that should be exposed over webadmin # in addition to your product default routes. Routes needs to be located # within the classpath or in the ./extensions-jars folder. -#extensions.routes= \ No newline at end of file +#extensions.routes= + +# Password authentication settings + +# Generate a random password upon start up when no password is configured below. +# The generated password is written in the logs, and changes upon each restart. +# Set it to false to run WebAdmin without any authentication. +# Defaults to true +password.generate=true + +# Configure one or more passwords (comma separated) for WebAdmin authentication +#password=secret1,secret2 diff --git a/server/apps/distributed-app/docker-configuration/webadmin.properties b/server/apps/distributed-app/docker-configuration/webadmin.properties index 5d72d99b744..42c276ec184 100644 --- a/server/apps/distributed-app/docker-configuration/webadmin.properties +++ b/server/apps/distributed-app/docker-configuration/webadmin.properties @@ -51,4 +51,12 @@ https.enabled=false # List of fully qualified class names that should be exposed over webadmin # in addition to your product default routes. Routes needs to be located # within the classpath or in the ./extensions-jars folder. -#extensions.routes= \ No newline at end of file +#extensions.routes= + +# Password authentication settings + +# Generate a random password upon start up when no password is configured below. +# The generated password is written in the logs, and changes upon each restart. +# Set it to false to run WebAdmin without any authentication. +# Defaults to true +password.generate=true diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/benchmark/james-benchmark.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/benchmark/james-benchmark.adoc index 839c4296e77..a7bdf31f66d 100644 --- a/server/apps/distributed-app/docs/modules/ROOT/pages/benchmark/james-benchmark.adoc +++ b/server/apps/distributed-app/docs/modules/ROOT/pages/benchmark/james-benchmark.adoc @@ -55,6 +55,13 @@ chmod +x provision.sh sudo apt-get install postfix ---- +* Retrieve the generated WebAdmin password from the James startup logs and expose it to the provisioning script: +---- +export WEBADMIN_PASSWORD="replace-with-generated-webadmin-password" +---- + +Omit this variable only when WebAdmin password authentication is explicitly disabled. + * Run the provision script: ---- ./provision.sh @@ -98,4 +105,3 @@ A sample IMAP performance testing result (PlatformValidationSimulation): image::james-imap-base-performance.png[] If you get a IMAP performance far below this base performance, you should consider investigating for performance issues. - diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/benchmark/provision.sh b/server/apps/distributed-app/docs/modules/ROOT/pages/benchmark/provision.sh index 0f3c86e8f51..b5d528f9782 100755 --- a/server/apps/distributed-app/docs/modules/ROOT/pages/benchmark/provision.sh +++ b/server/apps/distributed-app/docs/modules/ROOT/pages/benchmark/provision.sh @@ -1,5 +1,7 @@ #!/bin/bash +set -eu + export WEBADMIN_BASE_URL="http://localhost:8000" export SMTP_URL="localhost:25" export DOMAIN_NAME="domain.org" @@ -7,32 +9,40 @@ export USERS_COUNT=10 export DUMMY_MAILBOXES_COUNT=10 export DUMMY_EMAILS_COUNT=100 +call_webadmin() { + if [ -n "${WEBADMIN_PASSWORD:-}" ]; then + curl --fail --header "Password: ${WEBADMIN_PASSWORD}" "$@" + else + curl --fail "$@" + fi +} + # Create domain -curl -X PUT ${WEBADMIN_BASE_URL}/domains/${DOMAIN_NAME} +call_webadmin -X PUT ${WEBADMIN_BASE_URL}/domains/${DOMAIN_NAME} for i in $(seq 1 $USERS_COUNT) do # Create user echo "Creating user $i" username=user${i}@$DOMAIN_NAME - curl -XPUT ${WEBADMIN_BASE_URL}/users/$username \ + call_webadmin -XPUT ${WEBADMIN_BASE_URL}/users/$username \ -d '{"password":"secret"}' \ -H "Content-Type: application/json" # Create mailboxes for each user echo "Creating user $i mailboxes" # Create some basic mailboxes - curl -XPUT ${WEBADMIN_BASE_URL}/users/${username}/mailboxes/INBOX - curl -XPUT ${WEBADMIN_BASE_URL}/users/${username}/mailboxes/Outbox - curl -XPUT ${WEBADMIN_BASE_URL}/users/${username}/mailboxes/Sent - curl -XPUT ${WEBADMIN_BASE_URL}/users/${username}/mailboxes/Draft - curl -XPUT ${WEBADMIN_BASE_URL}/users/${username}/mailboxes/Trash + call_webadmin -XPUT ${WEBADMIN_BASE_URL}/users/${username}/mailboxes/INBOX + call_webadmin -XPUT ${WEBADMIN_BASE_URL}/users/${username}/mailboxes/Outbox + call_webadmin -XPUT ${WEBADMIN_BASE_URL}/users/${username}/mailboxes/Sent + call_webadmin -XPUT ${WEBADMIN_BASE_URL}/users/${username}/mailboxes/Draft + call_webadmin -XPUT ${WEBADMIN_BASE_URL}/users/${username}/mailboxes/Trash # Create some other dummy mailboxes for j in $(seq 1 $DUMMY_MAILBOXES_COUNT) do dummyMailbox=MAILBOX${j} - curl -XPUT ${WEBADMIN_BASE_URL}/users/${username}/mailboxes/$dummyMailbox + call_webadmin -XPUT ${WEBADMIN_BASE_URL}/users/${username}/mailboxes/$dummyMailbox done done diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/webadmin.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/webadmin.adoc index 0fe6a777d82..49f7b42b536 100644 --- a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/webadmin.adoc +++ b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/webadmin.adoc @@ -4,9 +4,9 @@ The web administration supports for now the CRUD operations on the domains, the users, their mailboxes and their quotas, managing mail repositories, performing cassandra migrations, and much more, as described in the following sections. -*WARNING*: This API allows authentication only via the use of JWT. If not -configured with JWT, an administrator should ensure an attacker can not -use this API. +*WARNING*: This API supports authentication via a static password or JWT. If no +authentication mechanism is configured, an administrator should ensure an attacker +can not use this API. By the way, some endpoints are not filtered by authentication. Those endpoints are not related to data stored in James, for example: Swagger documentation & James health checks. @@ -35,6 +35,49 @@ to get some examples and hints. | cors.origin | Specify ths CORS origin (default: null) +| password.generate +| Generates a random password upon start up when no `password` is configured, allowing a secure setup without +hardcoded credentials (default: true). As such, WebAdmin is never unauthenticated unless explicitly asked for. + +.... +password.generate=false +.... + +The generated password is written in the logs upon start up: + +.... +WARN No WebAdmin password had been configured: a random one had been generated for this run. [...] +Generated WebAdmin password: 8Kj2mXqT4vZ... +.... + +Beware: the generated password changes upon each restart, and is exposed to whoever can read the logs. Configure +`password` explicitly for setups needing a stable secret, or set `password.generate=false` to opt back into an +unauthenticated WebAdmin. This option is ignored when `password` is configured, when `jwt.enabled` is true, and when +WebAdmin is disabled. + +| password +| Uses a configured static value for authentication. It relies on the `Password` header. +It supports several passwords, configured as a comma-separated list. + +.... +password=secretA,secretB,secretC +.... + +This allows requests with: + +.... +Password: secretA +Password: secretB +.... + +But denies: + +.... +Password: secretD +.... + +Requests without the `Password` header are denied as well. + | jwt.enable | Allow JSON Web Token as an authentication mechanism (default: false) @@ -97,4 +140,4 @@ The public key can be referenced as `jwt.publickeypem.url` of the `jmap.properti WebAdmin adds the value of `X-Real-IP` header as part of the logging MDC. -This allows for reverse proxies to cary other the IP address of the client down to the JMAP server for diagnostic purpose. \ No newline at end of file +This allows for reverse proxies to cary other the IP address of the client down to the JMAP server for diagnostic purpose. diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/run/run-docker.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/run/run-docker.adoc index 449ac3039d1..7d33238f5ec 100644 --- a/server/apps/distributed-app/docs/modules/ROOT/pages/run/run-docker.adoc +++ b/server/apps/distributed-app/docs/modules/ROOT/pages/run/run-docker.adoc @@ -82,7 +82,8 @@ Where : - HOSTNAME: is the hostname you want to give to your James container. This DNS entry will be used to send mail to your James server. -Webadmin port binding is restricted to loopback as users are not authenticated by default on webadmin server. Thus you should avoid exposing it in production. +Webadmin port binding is restricted to loopback. Webadmin is protected by a password randomly generated upon +each start up and written in the logs (see `password.generate`), yet you should avoid exposing it in production. Note that the above example assumes `127.0.0.1` is your loopback interface for convenience but you should change it if this is not the case on your machine. If you want to pass additional options to the underlying java command, you can configure a _JAVA_TOOL_OPTIONS_ env variable, for example add: diff --git a/server/apps/distributed-app/sample-configuration/webadmin.properties b/server/apps/distributed-app/sample-configuration/webadmin.properties index b97c71829c4..0727d221be7 100644 --- a/server/apps/distributed-app/sample-configuration/webadmin.properties +++ b/server/apps/distributed-app/sample-configuration/webadmin.properties @@ -52,4 +52,15 @@ https.enabled=false # List of fully qualified class names that should be exposed over webadmin # in addition to your product default routes. Routes needs to be located # within the classpath or in the ./extensions-jars folder. -#extensions.routes= \ No newline at end of file +#extensions.routes= + +# Password authentication settings + +# Generate a random password upon start up when no password is configured below. +# The generated password is written in the logs, and changes upon each restart. +# Set it to false to run WebAdmin without any authentication. +# Defaults to true +password.generate=true + +# Configure one or more passwords (comma separated) for WebAdmin authentication +#password=secret1,secret2 diff --git a/server/apps/distributed-pop3-app/docker-configuration/webadmin.properties b/server/apps/distributed-pop3-app/docker-configuration/webadmin.properties index 5d72d99b744..42c276ec184 100644 --- a/server/apps/distributed-pop3-app/docker-configuration/webadmin.properties +++ b/server/apps/distributed-pop3-app/docker-configuration/webadmin.properties @@ -51,4 +51,12 @@ https.enabled=false # List of fully qualified class names that should be exposed over webadmin # in addition to your product default routes. Routes needs to be located # within the classpath or in the ./extensions-jars folder. -#extensions.routes= \ No newline at end of file +#extensions.routes= + +# Password authentication settings + +# Generate a random password upon start up when no password is configured below. +# The generated password is written in the logs, and changes upon each restart. +# Set it to false to run WebAdmin without any authentication. +# Defaults to true +password.generate=true diff --git a/server/apps/distributed-pop3-app/sample-configuration/webadmin.properties b/server/apps/distributed-pop3-app/sample-configuration/webadmin.properties index b97c71829c4..0727d221be7 100644 --- a/server/apps/distributed-pop3-app/sample-configuration/webadmin.properties +++ b/server/apps/distributed-pop3-app/sample-configuration/webadmin.properties @@ -52,4 +52,15 @@ https.enabled=false # List of fully qualified class names that should be exposed over webadmin # in addition to your product default routes. Routes needs to be located # within the classpath or in the ./extensions-jars folder. -#extensions.routes= \ No newline at end of file +#extensions.routes= + +# Password authentication settings + +# Generate a random password upon start up when no password is configured below. +# The generated password is written in the logs, and changes upon each restart. +# Set it to false to run WebAdmin without any authentication. +# Defaults to true +password.generate=true + +# Configure one or more passwords (comma separated) for WebAdmin authentication +#password=secret1,secret2 diff --git a/server/apps/jpa-app/docker-configuration/webadmin.properties b/server/apps/jpa-app/docker-configuration/webadmin.properties index 5d72d99b744..42c276ec184 100644 --- a/server/apps/jpa-app/docker-configuration/webadmin.properties +++ b/server/apps/jpa-app/docker-configuration/webadmin.properties @@ -51,4 +51,12 @@ https.enabled=false # List of fully qualified class names that should be exposed over webadmin # in addition to your product default routes. Routes needs to be located # within the classpath or in the ./extensions-jars folder. -#extensions.routes= \ No newline at end of file +#extensions.routes= + +# Password authentication settings + +# Generate a random password upon start up when no password is configured below. +# The generated password is written in the logs, and changes upon each restart. +# Set it to false to run WebAdmin without any authentication. +# Defaults to true +password.generate=true diff --git a/server/apps/jpa-app/sample-configuration/webadmin.properties b/server/apps/jpa-app/sample-configuration/webadmin.properties index 5dc74740c55..c8e71f68424 100644 --- a/server/apps/jpa-app/sample-configuration/webadmin.properties +++ b/server/apps/jpa-app/sample-configuration/webadmin.properties @@ -46,4 +46,15 @@ https.enabled=false # List of fully qualified class names that should be exposed over webadmin # in addition to your product default routes. Routes needs to be located # within the classpath or in the ./extensions-jars folder. -#extensions.routes= \ No newline at end of file +#extensions.routes= + +# Password authentication settings + +# Generate a random password upon start up when no password is configured below. +# The generated password is written in the logs, and changes upon each restart. +# Set it to false to run WebAdmin without any authentication. +# Defaults to true +password.generate=true + +# Configure one or more passwords (comma separated) for WebAdmin authentication +#password=secret1,secret2 diff --git a/server/apps/jpa-smtp-app/docker-configuration/webadmin.properties b/server/apps/jpa-smtp-app/docker-configuration/webadmin.properties index 5d72d99b744..42c276ec184 100644 --- a/server/apps/jpa-smtp-app/docker-configuration/webadmin.properties +++ b/server/apps/jpa-smtp-app/docker-configuration/webadmin.properties @@ -51,4 +51,12 @@ https.enabled=false # List of fully qualified class names that should be exposed over webadmin # in addition to your product default routes. Routes needs to be located # within the classpath or in the ./extensions-jars folder. -#extensions.routes= \ No newline at end of file +#extensions.routes= + +# Password authentication settings + +# Generate a random password upon start up when no password is configured below. +# The generated password is written in the logs, and changes upon each restart. +# Set it to false to run WebAdmin without any authentication. +# Defaults to true +password.generate=true diff --git a/server/apps/jpa-smtp-app/sample-configuration/webadmin.properties b/server/apps/jpa-smtp-app/sample-configuration/webadmin.properties index 5dc74740c55..c8e71f68424 100644 --- a/server/apps/jpa-smtp-app/sample-configuration/webadmin.properties +++ b/server/apps/jpa-smtp-app/sample-configuration/webadmin.properties @@ -46,4 +46,15 @@ https.enabled=false # List of fully qualified class names that should be exposed over webadmin # in addition to your product default routes. Routes needs to be located # within the classpath or in the ./extensions-jars folder. -#extensions.routes= \ No newline at end of file +#extensions.routes= + +# Password authentication settings + +# Generate a random password upon start up when no password is configured below. +# The generated password is written in the logs, and changes upon each restart. +# Set it to false to run WebAdmin without any authentication. +# Defaults to true +password.generate=true + +# Configure one or more passwords (comma separated) for WebAdmin authentication +#password=secret1,secret2 diff --git a/server/apps/memory-app/docker-configuration/webadmin.properties b/server/apps/memory-app/docker-configuration/webadmin.properties index 5d72d99b744..42c276ec184 100644 --- a/server/apps/memory-app/docker-configuration/webadmin.properties +++ b/server/apps/memory-app/docker-configuration/webadmin.properties @@ -51,4 +51,12 @@ https.enabled=false # List of fully qualified class names that should be exposed over webadmin # in addition to your product default routes. Routes needs to be located # within the classpath or in the ./extensions-jars folder. -#extensions.routes= \ No newline at end of file +#extensions.routes= + +# Password authentication settings + +# Generate a random password upon start up when no password is configured below. +# The generated password is written in the logs, and changes upon each restart. +# Set it to false to run WebAdmin without any authentication. +# Defaults to true +password.generate=true diff --git a/server/apps/memory-app/sample-configuration/webadmin.properties b/server/apps/memory-app/sample-configuration/webadmin.properties index 3449eedc927..8227ddd7720 100644 --- a/server/apps/memory-app/sample-configuration/webadmin.properties +++ b/server/apps/memory-app/sample-configuration/webadmin.properties @@ -53,4 +53,15 @@ https.enabled=false # List of fully qualified class names that should be exposed over webadmin # in addition to your product default routes. Routes needs to be located # within the classpath or in the ./extensions-jars folder. -#extensions.routes= \ No newline at end of file +#extensions.routes= + +# Password authentication settings + +# Generate a random password upon start up when no password is configured below. +# The generated password is written in the logs, and changes upon each restart. +# Set it to false to run WebAdmin without any authentication. +# Defaults to true +password.generate=true + +# Configure one or more passwords (comma separated) for WebAdmin authentication +#password=secret1,secret2 diff --git a/server/apps/webadmin-cli/src/test/resources/webadmin.properties b/server/apps/webadmin-cli/src/test/resources/webadmin.properties index 3386a14238a..435136da303 100644 --- a/server/apps/webadmin-cli/src/test/resources/webadmin.properties +++ b/server/apps/webadmin-cli/src/test/resources/webadmin.properties @@ -22,4 +22,7 @@ enabled=true port=0 -host=127.0.0.1 \ No newline at end of file +host=127.0.0.1 + +# These tests exercise WebAdmin routes without authentication +password.generate=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 a050be10118..469a3d6f36f 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,8 @@ 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.authentication.PasswordGenerator; import org.apache.james.webadmin.dto.DTOModuleInjections; import org.apache.james.webadmin.mdc.RequestLogger; import org.apache.james.webadmin.utils.JsonTransformer; @@ -82,6 +84,7 @@ public class WebAdminServerModule extends AbstractModule { private static final boolean DEFAULT_JWT_DISABLED = false; private static final boolean DEFAULT_DISABLED = false; + private static final boolean DEFAULT_PASSWORD_GENERATION_ENABLED = true; private static final String DEFAULT_NO_CORS_ORIGIN = null; private static final boolean DEFAULT_CORS_DISABLED = false; private static final String DEFAULT_NO_KEYSTORE = null; @@ -141,9 +144,10 @@ public WebAdminConfiguration provideWebAdminConfiguration(FileSystem fileSystem, Configuration configurationFile = propertiesProvider.getConfiguration("webadmin"); List additionalRoutes = additionalRoutes(configurationFile); + boolean webAdminEnabled = configurationFile.getBoolean("enabled", DEFAULT_DISABLED); return WebAdminConfiguration.builder() - .enable(configurationFile.getBoolean("enabled", DEFAULT_DISABLED)) + .enable(webAdminEnabled) .port(port(configurationFile)) .tls(readHttpsConfiguration(configurationFile)) .enableCORS(configurationFile.getBoolean("cors.enable", DEFAULT_CORS_DISABLED)) @@ -154,6 +158,7 @@ public WebAdminConfiguration provideWebAdminConfiguration(FileSystem fileSystem, Optional.ofNullable(configurationFile.getString("jwt.publickeypem.url", null)))) .maxThreadCount(Optional.ofNullable(configurationFile.getInteger("maxThreadCount", null))) .minThreadCount(Optional.ofNullable(configurationFile.getInteger("minThreadCount", null))) + .password(password(configurationFile, webAdminEnabled)) .build(); } catch (FileNotFoundException e) { LOGGER.info("No webadmin.properties file. Disabling WebAdmin interface."); @@ -170,6 +175,33 @@ private PortSupplier port(Configuration configurationFile) { return new FixedPortSupplier(portNumber); } + @VisibleForTesting + Optional password(Configuration configurationFile, boolean webAdminEnabled) { + Optional configuredPassword = Optional.ofNullable(configurationFile.getString("password", null)); + + if (configuredPassword.isPresent()) { + return configuredPassword; + } + if (shouldGeneratePassword(configurationFile, webAdminEnabled)) { + return Optional.of(generateAndLogPassword()); + } + return Optional.empty(); + } + + private boolean shouldGeneratePassword(Configuration configurationFile, boolean webAdminEnabled) { + return configurationFile.getBoolean("password.generate", DEFAULT_PASSWORD_GENERATION_ENABLED) + && webAdminEnabled + && !configurationFile.getBoolean("jwt.enabled", DEFAULT_JWT_DISABLED); + } + + private String generateAndLogPassword() { + String password = PasswordGenerator.generate(); + LOGGER.warn("No WebAdmin password had been configured: a random one had been generated for this run. " + + "Supply it within the `Password` header of your WebAdmin requests, or pin it with the `password` entry " + + "of webadmin.properties. Generated WebAdmin password: {}", password); + return password; + } + @VisibleForTesting ImmutableList additionalRoutes(Configuration configurationFile) { return ImmutableList.copyOf(configurationFile.getStringArray("extensions.routes")); @@ -182,13 +214,16 @@ private Optional loadPublicKey(FileSystem fileSystem, Optional j @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() + .map(PasswordFilter::new) + .orElse(new NoAuthenticationFilter()); } catch (FileNotFoundException e) { return new NoAuthenticationFilter(); } diff --git a/server/container/guice/protocols/webadmin/src/test/java/org/apache/james/modules/server/WebAdminServerModuleTest.java b/server/container/guice/protocols/webadmin/src/test/java/org/apache/james/modules/server/WebAdminServerModuleTest.java index 7d81f0b423d..bbe213d2ed9 100644 --- a/server/container/guice/protocols/webadmin/src/test/java/org/apache/james/modules/server/WebAdminServerModuleTest.java +++ b/server/container/guice/protocols/webadmin/src/test/java/org/apache/james/modules/server/WebAdminServerModuleTest.java @@ -21,11 +21,18 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.util.Optional; + import org.apache.commons.configuration2.Configuration; +import org.apache.commons.configuration2.PropertiesConfiguration; import org.apache.james.utils.PropertiesProvider; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class WebAdminServerModuleTest { + private static final boolean WEBADMIN_ENABLED = true; + private static final boolean WEBADMIN_DISABLED = false; + @Test void shouldReturnEmptyWhenNoField() throws Exception { Configuration configuration = getConfiguration("webadmin-none"); @@ -61,4 +68,69 @@ void shouldReturnSeveralRoutes() throws Exception { private Configuration getConfiguration(String name) throws Exception { return PropertiesProvider.forTesting().getConfiguration(name); } + + @Nested + class PasswordGeneration { + @Test + void passwordShouldBeGeneratedByDefault() { + assertThat(new WebAdminServerModule().password(new PropertiesConfiguration(), WEBADMIN_ENABLED)) + .isNotEmpty(); + } + + @Test + void passwordShouldBeEmptyWhenGenerationIsDisabled() { + assertThat(new WebAdminServerModule().password(configuration("password.generate", false), WEBADMIN_ENABLED)) + .isEmpty(); + } + + @Test + void passwordShouldBeGeneratedWhenGenerationIsEnabled() { + assertThat(new WebAdminServerModule().password(configuration("password.generate", true), WEBADMIN_ENABLED)) + .isNotEmpty(); + } + + @Test + void generatedPasswordShouldNotContainThePasswordSeparator() { + assertThat(new WebAdminServerModule().password(configuration("password.generate", true), WEBADMIN_ENABLED)) + .hasValueSatisfying(password -> assertThat(password).doesNotContain(",")); + } + + @Test + void generatedPasswordsShouldBeRandom() { + Optional firstPassword = new WebAdminServerModule().password(configuration("password.generate", true), WEBADMIN_ENABLED); + Optional secondPassword = new WebAdminServerModule().password(configuration("password.generate", true), WEBADMIN_ENABLED); + + assertThat(firstPassword).isNotEqualTo(secondPassword); + } + + @Test + void configuredPasswordShouldTakePrecedenceOverGeneration() { + PropertiesConfiguration configuration = configuration("password.generate", true); + configuration.addProperty("password", "secret"); + + assertThat(new WebAdminServerModule().password(configuration, WEBADMIN_ENABLED)) + .contains("secret"); + } + + @Test + void passwordShouldNotBeGeneratedWhenWebAdminIsDisabled() { + assertThat(new WebAdminServerModule().password(configuration("password.generate", true), WEBADMIN_DISABLED)) + .isEmpty(); + } + + @Test + void passwordShouldNotBeGeneratedWhenJwtIsEnabled() { + PropertiesConfiguration configuration = configuration("password.generate", true); + configuration.addProperty("jwt.enabled", true); + + assertThat(new WebAdminServerModule().password(configuration, WEBADMIN_ENABLED)) + .isEmpty(); + } + + private PropertiesConfiguration configuration(String key, Object value) { + PropertiesConfiguration configuration = new PropertiesConfiguration(); + configuration.addProperty(key, value); + return configuration; + } + } } \ No newline at end of file diff --git a/server/protocols/webadmin-integration-test/distributed-webadmin-integration-test/src/test/resources/webadmin.properties b/server/protocols/webadmin-integration-test/distributed-webadmin-integration-test/src/test/resources/webadmin.properties index 78a176aabda..6602aa0f0a6 100644 --- a/server/protocols/webadmin-integration-test/distributed-webadmin-integration-test/src/test/resources/webadmin.properties +++ b/server/protocols/webadmin-integration-test/distributed-webadmin-integration-test/src/test/resources/webadmin.properties @@ -24,4 +24,7 @@ enabled=true port=0 host=127.0.0.1 -extensions.routes=org.apache.james.webadmin.dropwizard.MetricsRoutes \ No newline at end of file +extensions.routes=org.apache.james.webadmin.dropwizard.MetricsRoutes + +# These tests exercise WebAdmin routes without authentication +password.generate=false diff --git a/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/resources/webadmin.properties b/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/resources/webadmin.properties index 78a176aabda..6602aa0f0a6 100644 --- a/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/resources/webadmin.properties +++ b/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/resources/webadmin.properties @@ -24,4 +24,7 @@ enabled=true port=0 host=127.0.0.1 -extensions.routes=org.apache.james.webadmin.dropwizard.MetricsRoutes \ No newline at end of file +extensions.routes=org.apache.james.webadmin.dropwizard.MetricsRoutes + +# These tests exercise WebAdmin routes without authentication +password.generate=false 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 45965abeb90..820a8cc826e 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 static class Builder { private Optional tlsConfiguration = Optional.empty(); private Optional urlCORSOrigin = Optional.empty(); private Optional host = Optional.empty(); + private Optional password = Optional.empty(); private ImmutableList.Builder additionalRoutes = ImmutableList.builder(); private Optional jwtPublicKey = Optional.empty(); private Optional maxThreadCount = Optional.empty(); @@ -123,6 +124,16 @@ public Builder host(String host) { return this; } + public Builder password(String password) { + this.password = Optional.ofNullable(password); + return this; + } + + public Builder password(Optional password) { + this.password = password; + return this; + } + public Builder additionalRoute(String additionalRoute) { this.additionalRoutes.add(additionalRoute); return this; @@ -155,6 +166,7 @@ public WebAdminConfiguration build() { host.orElse(DEFAULT_HOST), additionalRoutes.build(), jwtPublicKey, + password, maxThreadCount, minThreadCount); } @@ -168,12 +180,13 @@ public WebAdminConfiguration build() { private final String host; private final List additionalRoutes; private final Optional jwtPublicKey; + private final Optional password; private final Optional maxThreadCount; private final Optional minThreadCount; @VisibleForTesting WebAdminConfiguration(boolean enabled, Optional port, Optional tlsConfiguration, - boolean enableCORS, String urlCORSOrigin, String host, List additionalRoutes, Optional jwtPublicKey, Optional maxThreadCount, Optional minThreadCount) { + boolean enableCORS, String urlCORSOrigin, String host, List additionalRoutes, Optional jwtPublicKey, Optional password, Optional maxThreadCount, Optional minThreadCount) { this.enabled = enabled; this.port = port; this.tlsConfiguration = tlsConfiguration; @@ -182,6 +195,7 @@ public WebAdminConfiguration build() { this.host = host; this.additionalRoutes = additionalRoutes; this.jwtPublicKey = jwtPublicKey; + this.password = password; this.maxThreadCount = maxThreadCount; this.minThreadCount = minThreadCount; } @@ -230,6 +244,10 @@ public String getHost() { return host; } + public Optional getPassword() { + return password; + } + @Override public final boolean equals(Object o) { if (o instanceof WebAdminConfiguration) { @@ -242,6 +260,7 @@ public final boolean equals(Object o) { && 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 final boolean equals(Object o) { @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 00000000000..e8d523fe7ea --- /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 javax.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 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 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/main/java/org/apache/james/webadmin/authentication/PasswordGenerator.java b/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/authentication/PasswordGenerator.java new file mode 100644 index 00000000000..da032ba61f5 --- /dev/null +++ b/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/authentication/PasswordGenerator.java @@ -0,0 +1,45 @@ +/**************************************************************** + * 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 java.security.SecureRandom; +import java.util.Base64; + +/** + * Generates ephemeral WebAdmin passwords, enabling a secure setup without hardcoded credentials. + * + * The base64 URL alphabet is used as it excludes the comma, which {@link PasswordFilter} relies on + * as a password separator. + */ +public class PasswordGenerator { + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final int ENTROPY_BYTE_COUNT = 32; + + public static String generate() { + byte[] entropy = new byte[ENTROPY_BYTE_COUNT]; + SECURE_RANDOM.nextBytes(entropy); + return ENCODER.encodeToString(entropy); + } + + private PasswordGenerator() { + + } +} 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 00000000000..4a83babbdcb --- /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)); + } +} diff --git a/third-party/rspamd/README.md b/third-party/rspamd/README.md index 6db4dac7e1b..cf898f39e94 100644 --- a/third-party/rspamd/README.md +++ b/third-party/rspamd/README.md @@ -89,8 +89,14 @@ If true `virusProcessor` and `rejectSpamProcessor` are honnered per user, at the - Declare the webadmin for Rspamd in `webadmin.properties` ``` +password.generate=false extensions.routes=org.apache.james.rspamd.route.FeedMessageRoute ``` + +The sample configuration explicitly disables WebAdmin password generation so its local example commands can remain +unauthenticated. Password generation is enabled by default when no `password` is explicitly configured. Do not disable +WebAdmin authentication when exposing it beyond this local customization sample. + How to use admin endpoint, see more at [Additional webadmin endpoints](README.md) - Docker compose file example: [docker-compose.yml](docker-compose.yml) or [docker-compose-distributed.yml](docker-compose-distributed.yml). @@ -204,4 +210,4 @@ The scheduled task will have the following type `FeedHamToRspamdTask` and the fo "timestamp": "2007-12-03T10:15:30Z", "type": "FeedHamToRspamdTask" } -``` \ No newline at end of file +``` diff --git a/third-party/rspamd/sample-configuration/webadmin.properties b/third-party/rspamd/sample-configuration/webadmin.properties index 953fc94dcb0..f237fc4dae6 100644 --- a/third-party/rspamd/sample-configuration/webadmin.properties +++ b/third-party/rspamd/sample-configuration/webadmin.properties @@ -19,7 +19,11 @@ enabled=true port=8000 host=0.0.0.0 +# Disable WebAdmin authentication for this local customization sample. +# Password generation defaults to true when no password is explicitly configured. +password.generate=false + # List of fully qualified class names that should be exposed over webadmin # in addition to your product default routes. Routes needs to be located # within the classpath or in the ./extensions-jars folder. -extensions.routes=org.apache.james.rspamd.route.FeedMessageRoute \ No newline at end of file +extensions.routes=org.apache.james.rspamd.route.FeedMessageRoute