diff --git a/clear-elasticsearch.sh b/clear-elasticsearch.sh index 0d92e9da23..baa7606479 100755 --- a/clear-elasticsearch.sh +++ b/clear-elasticsearch.sh @@ -57,6 +57,8 @@ unset UNOMI_ELASTICSEARCH_SSL_ENABLE unset UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES # Also set by setup-elasticsearch.sh / setup-opensearch.sh unset UNOMI_DISTRIBUTION +unset UNOMI_ROOT_PASSWORD +unset UNOMI_HEALTHCHECK_PASSWORD unset _IS_SOURCED diff --git a/clear-opensearch.sh b/clear-opensearch.sh index 61b6c5a121..df4c9425b9 100755 --- a/clear-opensearch.sh +++ b/clear-opensearch.sh @@ -58,6 +58,8 @@ unset UNOMI_OPENSEARCH_SSL_ENABLE unset UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES # Also set by setup-opensearch.sh / setup-elasticsearch.sh unset UNOMI_DISTRIBUTION +unset UNOMI_ROOT_PASSWORD +unset UNOMI_HEALTHCHECK_PASSWORD unset _IS_SOURCED diff --git a/docker/README.md b/docker/README.md index c5ab6553d4..a5dc49bde9 100644 --- a/docker/README.md +++ b/docker/README.md @@ -30,9 +30,13 @@ required Unomi tarball. ## Launching docker-compose using Maven project -Unomi requires a search engine (ElasticSearch or OpenSearch) so it is recommended to run Unomi and the search engine using docker-compose: +Unomi requires a search engine (ElasticSearch or OpenSearch) so it is recommended to run Unomi and the search engine using docker-compose. + +Set admin and health passwords first (required; no known defaults are shipped): ``` +export UNOMI_ROOT_PASSWORD='choose-a-strong-password' +export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' mvn docker:start ``` @@ -72,6 +76,8 @@ For Unomi (with ElasticSearch): ```bash docker pull apache/unomi:3.1.0-SNAPSHOT docker run -d --name unomi --net unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \ + -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \ + -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \ -e UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 \ apache/unomi:3.1.0-SNAPSHOT ``` @@ -81,6 +87,8 @@ For Unomi (with OpenSearch): ```bash docker pull apache/unomi:3.1.0-SNAPSHOT docker run -d --name unomi --net unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \ + -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \ + -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \ -e UNOMI_DISTRIBUTION=unomi-distribution-opensearch \ -e UNOMI_OPENSEARCH_ADDRESSES=opensearch:9200 \ -e UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD} \ @@ -93,6 +101,8 @@ For ElasticSearch: ```bash docker run -d --name unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \ + -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \ + -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \ -e UNOMI_ELASTICSEARCH_ADDRESSES=host.docker.internal:9200 \ apache/unomi:3.1.0-SNAPSHOT ``` @@ -101,6 +111,8 @@ For OpenSearch: ```bash docker run -d --name unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \ + -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \ + -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \ -e UNOMI_DISTRIBUTION=unomi-distribution-opensearch \ -e UNOMI_OPENSEARCH_ADDRESSES=host.docker.internal:9200 \ -e UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD} \ @@ -112,6 +124,8 @@ Note: Linux doesn't support the host.docker.internal DNS lookup method yet, it s ## Environment Variables ### Common Variables +- `UNOMI_ROOT_PASSWORD`: Required admin (`karaf`) password — no known default +- `UNOMI_HEALTHCHECK_PASSWORD`: Required health-check (`health`) password — no known default - `UNOMI_AUTO_START`: Boolean to specify if unomi auto start with karaf (defaults to `true`) - `UNOMI_DISTRIBUTION`: Specifies the Unomi Distribution Feature to use (`unomi-distribution-elasticsearch` or `unomi-distribution-opensearch`, defaults to `unomi-distribution-elasticsearch`) @@ -133,7 +147,7 @@ Multi-tenancy requires a tenant before client endpoints such as `/cxs/context.js ```bash curl -X POST http://localhost:8181/cxs/tenants \ - --user karaf:karaf \ + --user "karaf:${UNOMI_ROOT_PASSWORD}" \ -H "Content-Type: application/json" \ -d '{"requestedId":"default","properties":{"name":"Default Tenant"}}' ``` diff --git a/docker/src/main/docker/docker-compose-build-es.yml b/docker/src/main/docker/docker-compose-build-es.yml index 03f265a7b8..34193ad30e 100644 --- a/docker/src/main/docker/docker-compose-build-es.yml +++ b/docker/src/main/docker/docker-compose-build-es.yml @@ -39,6 +39,8 @@ services: - UNOMI_AUTO_START=true - UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} # Debug settings - KARAF_DEBUG=${DEBUG:-false} - KARAF_DEBUG_PORT=${DEBUG_PORT:-5005} diff --git a/docker/src/main/docker/docker-compose-build-os.yml b/docker/src/main/docker/docker-compose-build-os.yml index 97d38cfe44..f5c8592d5d 100644 --- a/docker/src/main/docker/docker-compose-build-os.yml +++ b/docker/src/main/docker/docker-compose-build-os.yml @@ -100,6 +100,8 @@ services: - UNOMI_OPENSEARCH_ADDRESSES=opensearch-node1:9200 - UNOMI_OPENSEARCH_USERNAME=admin - UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD} + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} # Debug settings - KARAF_DEBUG=${DEBUG:-false} - KARAF_DEBUG_PORT=${DEBUG_PORT:-5005} diff --git a/docker/src/main/docker/docker-compose-cluster.yml b/docker/src/main/docker/docker-compose-cluster.yml index 75aed46288..fa1a3069e9 100644 --- a/docker/src/main/docker/docker-compose-cluster.yml +++ b/docker/src/main/docker/docker-compose-cluster.yml @@ -36,6 +36,8 @@ services: environment: - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 - UNOMI_CLUSTER_NODEID=unomi-3-node-1 + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} ports: - 8181:8181 - 9443:9443 @@ -58,6 +60,8 @@ services: environment: - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 - UNOMI_CLUSTER_NODEID=unomi-3-node-2 + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} ports: - 8182:8181 - 9444:9443 diff --git a/docker/src/main/docker/docker-compose-es.yml b/docker/src/main/docker/docker-compose-es.yml index edfada5911..a3d2946721 100644 --- a/docker/src/main/docker/docker-compose-es.yml +++ b/docker/src/main/docker/docker-compose-es.yml @@ -45,6 +45,9 @@ services: - UNOMI_AUTO_START=true - UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 + # Required admin password (no known default is shipped). Override via .env / shell. + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} # Debug settings - KARAF_DEBUG=${DEBUG:-false} - KARAF_DEBUG_PORT=${DEBUG_PORT:-5005} diff --git a/docker/src/main/docker/docker-compose-os.yml b/docker/src/main/docker/docker-compose-os.yml index 579d3e2bc5..a37babf5bb 100644 --- a/docker/src/main/docker/docker-compose-os.yml +++ b/docker/src/main/docker/docker-compose-os.yml @@ -84,6 +84,9 @@ services: - UNOMI_OPENSEARCH_ADDRESSES=opensearch-node1:9200 - UNOMI_OPENSEARCH_USERNAME=admin - UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD} + # Required admin password (no known default is shipped). Override via .env / shell. + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} # Debug settings - KARAF_DEBUG=${DEBUG:-false} - KARAF_DEBUG_PORT=${DEBUG_PORT:-5005} diff --git a/docker/src/main/docker/entrypoint.sh b/docker/src/main/docker/entrypoint.sh index 1cfc684564..e805537b4b 100755 --- a/docker/src/main/docker/entrypoint.sh +++ b/docker/src/main/docker/entrypoint.sh @@ -34,6 +34,47 @@ export KARAF_OPTS="-Dunomi.autoStart=${UNOMI_AUTO_START} -Dunomi.distribution=${ echo "KARAF_OPTS: $KARAF_OPTS" +# Refuse to start without admin/health passwords. An unset password is not "no account": it +# expands to the empty string, which Karaf's PropertiesLoginModule accepts as a valid password. +# This is the gate for container launches: exiting here means the container fails to start rather +# than booting with an administrator account that accepts an empty password. +check_required_password() { + # $1 env var name, $2 property name, $3 skip flag name + eval _value=\"\${$1}\" + eval _skip=\"\${$3}\" + + [ -n "${_value}" ] && return 0 + + if [ "${_skip}" = "true" ]; then + cat >&2 <&2 <org.apache.karaf.jaas.boot provided + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + diff --git a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckHttpContext.java b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckHttpContext.java index 8e9331a619..85f83e5a21 100644 --- a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckHttpContext.java +++ b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckHttpContext.java @@ -32,6 +32,7 @@ import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.util.Base64; /** @@ -41,6 +42,8 @@ public class HealthCheckHttpContext implements HttpContext { private static final Logger LOGGER = LoggerFactory.getLogger(HealthCheckHttpContext.class.getName()); + private static final String BASIC_PREFIX = "Basic "; + private final String realm; public HealthCheckHttpContext(String realm) { @@ -67,20 +70,34 @@ public boolean handleSecurity(HttpServletRequest req, HttpServletResponse res) t protected boolean authenticated(HttpServletRequest request) { request.setAttribute(AUTHENTICATION_TYPE, HttpServletRequest.BASIC_AUTH); - String authzHeader = request.getHeader("Authorization"); - String usernameAndPassword = new String(Base64.getDecoder().decode(authzHeader.substring(6).getBytes())); - String[] parts = usernameAndPassword.split(":"); + String[] parts = extractBasicCredentials(request.getHeader("Authorization")); + if (parts == null) { + LOGGER.debug("Malformed Basic credentials, refusing access"); + return false; + } + final String user = parts[0]; + final String password = parts[1]; + + // An unset org.apache.unomi.healthcheck.password resolves to the empty string, which + // PropertiesLoginModule then accepts as this account's password (UNOMI-974). This endpoint + // authenticates against the karaf realm directly rather than through the REST + // AuthenticationFilter, so it needs its own refusal: it stays reachable on launch paths the + // startup guards in bin/setenv and the Docker entrypoint cannot cover, notably karaf.bat. + if (password.isEmpty()) { + LOGGER.warn("Rejecting health check Basic authentication with an empty password"); + return false; + } - LOGGER.debug("Authenticating user {}", parts[0]); + LOGGER.debug("Authenticating user {}", user); try { //We use JAAS for authentication and authorization but it could be done using UserAdmin OSGI service LOGGER.debug("Creating Login Context for realm {}", realm); LoginContext loginContext = new LoginContext(realm, callbacks -> { for (Callback callback : callbacks) { if (callback instanceof NameCallback) { - ((NameCallback) callback).setName(parts[0]); + ((NameCallback) callback).setName(user); } else if (callback instanceof PasswordCallback) { - ((PasswordCallback) callback).setPassword(parts[1].toCharArray()); + ((PasswordCallback) callback).setPassword(password.toCharArray()); } else { throw new UnsupportedCallbackException(callback); } @@ -106,6 +123,44 @@ protected boolean authenticated(HttpServletRequest request) { return false; } + /** + * Decodes a Basic {@code Authorization} header into {user, password}, or {@code null} when it is + * missing, not Basic, undecodable, or carries no {@code ':'} separator. + *

+ * The split is bounded to two parts on purpose. {@code split(":")} discards trailing empty + * strings, so {@code "health:"} yielded a single element and blew up on {@code parts[1]}, while + * {@code "health::x"} yielded {@code ["health", "", "x"]} — an empty password that was + * handed straight to JAAS. Bounding it keeps the RFC 7617 rule that the password is everything + * after the first colon, and makes the emptiness check in {@link #authenticated} meaningful. + *

+ * The scheme is matched case-insensitively per RFC 7235 §2.1. The previous implementation did a + * blind {@code substring(6)} with no prefix check at all, so it accepted {@code "basic "}; a + * case-sensitive check here would have quietly started rejecting those clients. + *

+ * Neither returned element is ever {@code null}: {@link String#split(String, int)} only ever + * produces non-null substrings, and a result that is not exactly two elements is rejected above. + * Package-private for {@code HealthCheckHttpContextBlankPasswordTest}, which pins every one of + * these cases. + */ + String[] extractBasicCredentials(String authzHeader) { + if (authzHeader == null + || authzHeader.length() < BASIC_PREFIX.length() + || !authzHeader.regionMatches(true, 0, BASIC_PREFIX, 0, BASIC_PREFIX.length())) { + return null; + } + try { + String decoded = new String(Base64.getDecoder().decode(authzHeader.substring(BASIC_PREFIX.length()).trim()), + StandardCharsets.UTF_8); + String[] parts = decoded.split(":", 2); + return parts.length == 2 ? parts : null; + } catch (IllegalArgumentException e) { + // Undecodable base64. Deliberately not logged at error: this is attacker-controlled input + // and a malformed header is a client error, not a server fault. + LOGGER.debug("Could not decode Basic credentials"); + return null; + } + } + public URL getResource(String s) { return null; } diff --git a/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/servlet/HealthCheckHttpContextBlankPasswordTest.java b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/servlet/HealthCheckHttpContextBlankPasswordTest.java new file mode 100644 index 0000000000..e8fa6823b8 --- /dev/null +++ b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/servlet/HealthCheckHttpContextBlankPasswordTest.java @@ -0,0 +1,308 @@ +/* + * 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.unomi.healthcheck.servlet; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.NameCallback; +import javax.security.auth.callback.PasswordCallback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.login.AppConfigurationEntry; +import javax.security.auth.login.Configuration; +import javax.security.auth.login.LoginException; +import javax.security.auth.spi.LoginModule; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * An unset {@code org.apache.unomi.healthcheck.password} resolves to the empty string, which + * {@code PropertiesLoginModule} accepts as this account's password (UNOMI-974). {@code /health/check} + * authenticates against the karaf realm directly rather than through the REST + * {@code AuthenticationFilter}, so it carries its own refusal — this covers it. + *

+ * The realm stubbed here accepts any credential. That is the point: a test against a + * rejecting realm would pass whether or not the guard exists, since both answer "not authenticated". + * Only an always-succeeding realm can distinguish "refused before JAAS" from "JAAS said no". + */ +class HealthCheckHttpContextBlankPasswordTest { + + private static final String REALM = "karaf"; + + private Configuration previousConfiguration; + private HealthCheckHttpContext context; + + @BeforeEach + void setUp() { + previousConfiguration = Configuration.getConfiguration(); + Configuration.setConfiguration(new AlwaysSucceedingConfiguration()); + AlwaysSucceedingLoginModule.reset(); + context = new HealthCheckHttpContext(REALM); + } + + @AfterEach + void tearDown() { + Configuration.setConfiguration(previousConfiguration); + } + + @Test + void blankPasswordIsRefused() { + assertFalse(context.authenticated(requestWith("health:"))); + } + + @Test + void blankUserAndPasswordIsRefused() { + assertFalse(context.authenticated(requestWith(":"))); + } + + /** + * Regression test for the real bypass, and the reason this asserts on the captured password + * rather than on the return value: {@code split(":")} discards trailing empty strings, so + * {@code "health::x"} decoded to {@code ["health", "", "x"]} and {@code parts[1]} was the + * empty string, handed to JAAS as the password. Because the stubbed realm accepts + * anything, that bypass still returned "authenticated" — only inspecting what JAAS was actually + * given can tell the two apart. Bounding the split to two parts makes the password everything + * after the first colon ({@code ":x"} here), per RFC 7617. + */ + @Test + void extraColonsDoNotCollapseIntoABlankPassword() { + assertTrue(context.authenticated(requestWith("health::x"))); + + assertEquals("health", AlwaysSucceedingLoginModule.lastUser); + assertEquals(":x", AlwaysSucceedingLoginModule.lastPassword); + } + + /** Control: an ordinary credential still reaches the realm, unaltered. */ + @Test + void nonBlankPasswordReachesJaas() { + assertTrue(context.authenticated(requestWith("health:a-strong-password"))); + + assertEquals("health", AlwaysSucceedingLoginModule.lastUser); + assertEquals("a-strong-password", AlwaysSucceedingLoginModule.lastPassword); + } + + /** A password of spaces is a real (if terrible) password, not the blank-resolution failure. */ + @Test + void whitespacePasswordIsNotTreatedAsBlank() { + assertTrue(context.authenticated(requestWith("health: "))); + + assertEquals(" ", AlwaysSucceedingLoginModule.lastPassword); + } + + /** The guard must refuse before JAAS is consulted at all, not rely on the realm to say no. */ + @Test + void blankPasswordNeverReachesJaas() { + assertFalse(context.authenticated(requestWith("health:"))); + + assertNull(AlwaysSucceedingLoginModule.lastUser); + assertNull(AlwaysSucceedingLoginModule.lastPassword); + } + + @Test + void malformedHeadersAreRefused() { + assertFalse(context.authenticated(requestWith("no-colon-at-all"))); + assertFalse(context.authenticated(requestWithRawHeader("Basic not-base64!!"))); + assertFalse(context.authenticated(requestWithRawHeader("Bearer some-token"))); + assertFalse(context.authenticated(requestWithRawHeader(null))); + + assertNull(AlwaysSucceedingLoginModule.lastUser, "no malformed header may reach JAAS"); + } + + // ------------------------------------------------------- extractBasicCredentials, exhaustively + + /** + * Every header shape that yields no usable credential. All must produce {@code null} rather than + * throwing: this runs on unauthenticated, attacker-controlled input, and the original + * implementation threw out of the servlet (a 500) on several of these. + */ + @ParameterizedTest(name = "[{index}] rejected: {0}") + @MethodSource("unusableHeaders") + void extractBasicCredentials_returnsNullFor(String description, String header) { + assertNull(context.extractBasicCredentials(header), description); + } + + static Stream unusableHeaders() { + return Stream.of( + Arguments.of("null header", null), + Arguments.of("empty header", ""), + Arguments.of("shorter than the scheme", "Bas"), + Arguments.of("scheme with no trailing space", "Basic"), + Arguments.of("a different scheme", "Bearer some-token"), + Arguments.of("scheme only, nothing to decode", "Basic "), + Arguments.of("not valid base64", "Basic not-base64!!"), + Arguments.of("valid base64, no colon separator", "Basic " + b64("nocolon")), + Arguments.of("valid base64, empty payload", "Basic " + b64(""))); + } + + /** + * Every header shape that yields a credential, and exactly what it decodes to. Pins the RFC 7617 + * rule (password is everything after the first colon) and the case-insensitive scheme + * match of RFC 7235 §2.1 — the previous blind {@code substring(6)} accepted {@code "basic "}, + * so tightening the prefix check had to preserve that. + */ + @ParameterizedTest(name = "[{index}] {0}") + @MethodSource("usableHeaders") + void extractBasicCredentials_decodes(String description, String header, String user, String password) { + String[] parts = context.extractBasicCredentials(header); + + assertNotNull(parts, description); + assertEquals(2, parts.length); + assertEquals(user, parts[0], description); + assertEquals(password, parts[1], description); + } + + static Stream usableHeaders() { + return Stream.of( + Arguments.of("ordinary credential", "Basic " + b64("health:s3cret"), "health", "s3cret"), + Arguments.of("empty password", "Basic " + b64("health:"), "health", ""), + Arguments.of("empty user", "Basic " + b64(":s3cret"), "", "s3cret"), + Arguments.of("both empty", "Basic " + b64(":"), "", ""), + Arguments.of("password is a lone colon", "Basic " + b64("health::"), "health", ":"), + Arguments.of("password starts with a colon", "Basic " + b64("health::x"), "health", ":x"), + Arguments.of("password contains colons", "Basic " + b64("health:pa:ss:wd"), "health", "pa:ss:wd"), + Arguments.of("password is whitespace", "Basic " + b64("health: "), "health", " "), + Arguments.of("lowercase scheme", "basic " + b64("health:s3cret"), "health", "s3cret"), + Arguments.of("mixed-case scheme", "BaSiC " + b64("health:s3cret"), "health", "s3cret"), + Arguments.of("padded base64", "Basic " + b64("health:s3cret") + " ", "health", "s3cret"), + Arguments.of("non-ASCII password", "Basic " + b64("health:pässwörd"), "health", "pässwörd")); + } + + /** + * Answers the question directly: can {@code parts[1]} be {@code null}, making the + * {@code password.isEmpty()} guard throw? It cannot. {@link String#split(String, int)} only ever + * produces non-null substrings, and any result that is not exactly two elements is rejected + * before it is returned — so both elements are always non-null when a caller gets an array. + */ + @ParameterizedTest + @MethodSource("usableHeaders") + void extractBasicCredentials_neverReturnsNullElements(String description, String header, String user, + String password) { + String[] parts = context.extractBasicCredentials(header); + + assertNotNull(parts[0], description); + assertNotNull(parts[1], description); + } + + private static String b64(String raw) { + return Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + + private HttpServletRequest requestWith(String credentials) { + return requestWithRawHeader("Basic " + b64(credentials)); + } + + private HttpServletRequest requestWithRawHeader(String authorization) { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader("Authorization")).thenReturn(authorization); + return request; + } + + /** Minimal JAAS setup so {@code new LoginContext(realm, ...)} succeeds without a real Karaf realm. */ + private static class AlwaysSucceedingConfiguration extends Configuration { + @Override + public AppConfigurationEntry[] getAppConfigurationEntry(String name) { + if (!REALM.equals(name)) { + return null; + } + return new AppConfigurationEntry[]{ + new AppConfigurationEntry( + AlwaysSucceedingLoginModule.class.getName(), + AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, + new HashMap<>()) + }; + } + } + + public static class AlwaysSucceedingLoginModule implements LoginModule { + + /** + * What the realm was actually handed, so a test can distinguish "refused before JAAS" from + * "JAAS was called with a password the parser mangled". Static because JAAS instantiates the + * module reflectively, leaving no handle on the instance; {@link #reset()} runs before each + * test, and these tests are not parallelised. + */ + static String lastUser; + static String lastPassword; + + static void reset() { + lastUser = null; + lastPassword = null; + } + + private Subject subject; + private CallbackHandler callbackHandler; + + @Override + public void initialize(Subject subject, CallbackHandler callbackHandler, Map sharedState, + Map options) { + this.subject = subject; + this.callbackHandler = callbackHandler; + } + + @Override + public boolean login() throws LoginException { + try { + NameCallback nameCallback = new NameCallback("name"); + PasswordCallback passwordCallback = new PasswordCallback("password", false); + callbackHandler.handle(new Callback[]{nameCallback, passwordCallback}); + lastUser = nameCallback.getName(); + lastPassword = passwordCallback.getPassword() == null + ? null : new String(passwordCallback.getPassword()); + } catch (IOException | UnsupportedCallbackException e) { + throw new LoginException(e.getMessage()); + } + return true; + } + + @Override + public boolean commit() { + return true; + } + + @Override + public boolean abort() { + return true; + } + + @Override + public boolean logout() { + subject.getPrincipals().clear(); + return true; + } + } +} diff --git a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java index 441449e42c..c391033ce6 100644 --- a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java +++ b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java @@ -155,6 +155,17 @@ private boolean isAuthenticatedUser(HttpServletRequest req) { String username = usernameAndPassword.substring(0, userNameIndex); String password = usernameAndPassword.substring(userNameIndex + 1); + // An unset org.apache.unomi.security.root.password resolves to the empty string, which + // PropertiesLoginModule then accepts as the shipped administrator's password (UNOMI-974). + // This servlet authenticates against the karaf realm directly rather than through the REST + // AuthenticationFilter, so it needs its own refusal: it stays reachable on launch paths the + // startup guards in bin/setenv and the Docker entrypoint cannot cover, notably karaf.bat. + // Checked ahead of the API key lookup too — an empty private key is never a valid one. + if (password.isEmpty()) { + LOG.warn("Rejecting Basic authentication with an empty password"); + return false; + } + // First try API key authentication if (username.length() > 0) { Tenant tenant = tenantService.getTenantByApiKey(password, ApiKey.ApiKeyType.PRIVATE); diff --git a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java index e64acd0085..1023741cd3 100644 --- a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java +++ b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java @@ -138,6 +138,41 @@ void validate_withoutAuthorizationHeader_isRejected() throws IOException { verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED); } + /** + * An unset {@code org.apache.unomi.security.root.password} resolves to the empty string, which + * {@code PropertiesLoginModule} accepts as the shipped administrator's password (UNOMI-974). + * This servlet logs in against the karaf realm directly, outside the REST + * {@code AuthenticationFilter}, so it carries its own refusal. + *

+ * The realm stubbed here accepts any credential, so this only passes if the empty + * password is refused before JAAS is ever consulted — asserting on the 401 alone would prove + * nothing, since a rejecting realm answers 401 too. + */ + @Test + void validate_withBlankPassword_isRejectedBeforeReachingJaas() throws IOException { + when(request.getHeader("Authorization")) + .thenReturn("Basic " + Base64.getEncoder().encodeToString("karaf:".getBytes())); + + boolean authenticated = validator.validate(null, null, request, response); + + assertFalse(authenticated); + verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED); + verify(securityService, never()).setCurrentSubject(any()); + verify(executionContextManager, never()).setCurrentContext(any()); + } + + /** Control: a non-blank credential still reaches the realm and is accepted by it. */ + @Test + void validate_withNonBlankPassword_reachesJaas() throws IOException { + when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH); + when(tenantService.getTenantByApiKey(any(), eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null); + + boolean authenticated = validator.validate(null, null, request, response); + + assertTrue(authenticated); + verify(response, never()).sendError(any(Integer.class)); + } + /** * Minimal JAAS configuration that makes {@code new LoginContext("karaf", ...)} succeed * without requiring a real Karaf realm, so the post-login branches under test can run diff --git a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java index 5ec6385bad..1940d3c79f 100644 --- a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java @@ -132,7 +132,10 @@ public abstract class BaseIT extends KarafTestSupport { protected static final ContentType JSON_CONTENT_TYPE = ContentType.create("application/json"); protected static final String BASE_URL = "http://localhost"; protected static final String BASIC_AUTH_USER_NAME = "karaf"; + /** Explicit IT password — package no longer ships a known default ({@code UNOMI_ROOT_PASSWORD}). */ protected static final String BASIC_AUTH_PASSWORD = "karaf"; + protected static final String HEALTHCHECK_AUTH_USER_NAME = "health"; + protected static final String HEALTHCHECK_AUTH_PASSWORD = "health"; protected static final int REQUEST_TIMEOUT = 60000; protected static final int DEFAULT_TRYING_TIMEOUT = 1000; protected static final int DEFAULT_TRYING_TRIES = 10; @@ -671,6 +674,9 @@ public Option[] config() { editConfigurationFilePut("etc/system.properties", SEARCH_ENGINE_PROPERTY, searchEngine), editConfigurationFilePut("etc/system.properties", PERSISTENCE_PROVIDER_PROPERTY, searchEngine), editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.migration.tenant.id", TEST_TENANT_ID), + // Explicit test credentials (package no longer ships a known default password). + editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.security.root.password", BASIC_AUTH_PASSWORD), + editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.healthcheck.password", HEALTHCHECK_AUTH_PASSWORD), // Default scheduler.thread.poolSize (5) is sized for the near-instant in-memory unit-test // double, not a real ES/OS backend. Under real refresh/write latency, the checker, task // executions, and lease-renewal heartbeats (see scheduler.adoc) compete for the same small diff --git a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java index 305c2f38e8..f7179a7f42 100644 --- a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java @@ -54,8 +54,6 @@ public class HealthCheckIT extends BaseIT { private final static Logger LOGGER = LoggerFactory.getLogger(HealthCheckIT.class); - protected static final String HEALTHCHECK_AUTH_USER_NAME = "health"; - protected static final String HEALTHCHECK_AUTH_PASSWORD = "health"; protected static final String HEALTHCHECK_ENDPOINT = "/health/check"; @Test @@ -132,6 +130,15 @@ private void assertHealthCheckLive(List response) { } } + @Test + public void testHealthCheck_wrongPasswordRejected() throws Exception { + final HttpGet httpGet = new HttpGet(getFullUrl(HEALTHCHECK_ENDPOINT)); + try (CloseableHttpResponse response = executeHttpRequest( + httpGet, AuthType.CUSTOM_BASIC, HEALTHCHECK_AUTH_USER_NAME, "wrong-password")) { + Assert.assertEquals(401, response.getStatusLine().getStatusCode()); + } + } + protected T get(final String url, TypeReference typeReference) { CloseableHttpResponse response = null; try { diff --git a/itests/src/test/java/org/apache/unomi/itests/TenantIT.java b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java index a9ef9a5952..9cce847074 100644 --- a/itests/src/test/java/org/apache/unomi/itests/TenantIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java @@ -190,7 +190,7 @@ public void testTenantEndpointAuthentication() throws Exception { // Create test tenant for API key tests BasicCredentialsProvider adminCredsProvider = new BasicCredentialsProvider(); - adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); try (CloseableHttpClient adminClient = HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build()) { Map requestBody = new HashMap<>(); @@ -273,7 +273,7 @@ public void testPublicEndpointAuthentication() throws Exception { // Test with JAAS auth (should succeed) — use a fresh request to avoid carrying X-Unomi-Api-Key from previous step BasicCredentialsProvider adminCredsProvider = new BasicCredentialsProvider(); - adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); try (CloseableHttpClient adminClient = HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build(); CloseableHttpResponse response = adminClient.execute(new HttpGet(getFullUrl("/context.json?sessionId=" + sessionId)))) { Assert.assertEquals("JAAS auth should grant access to public endpoints", 200, response.getStatusLine().getStatusCode()); @@ -325,7 +325,7 @@ public void testPrivateEndpointAuthentication() throws Exception { // Test with JAAS auth (should succeed) — use a fresh request to avoid carrying Authorization from previous step BasicCredentialsProvider adminCredsProvider = new BasicCredentialsProvider(); - adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); try (CloseableHttpClient adminClient = HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build(); CloseableHttpResponse response = adminClient.execute(new HttpGet(getFullUrl("/cxs/profiles/count")))) { Assert.assertEquals("JAAS auth should grant access to private endpoints", 200, response.getStatusLine().getStatusCode()); @@ -378,7 +378,7 @@ public void testApiKeyAuthentication() throws Exception { // Test with JAAS authentication (should succeed) getRequest = new HttpGet(getFullUrl("/cxs/profiles/count")); - getRequest.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString(("karaf:karaf").getBytes())); + getRequest.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString((BASIC_AUTH_USER_NAME + ":" + BASIC_AUTH_PASSWORD).getBytes())); try (CloseableHttpResponse response = executeHttpRequest(getRequest, AuthType.JAAS_ADMIN)) { Assert.assertEquals("JAAS authentication should grant access to private endpoints", 200, response.getStatusLine().getStatusCode()); } diff --git a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java index cce6c54847..f27089368c 100644 --- a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java @@ -225,7 +225,7 @@ private void testV3ModeBehavior() throws Exception { request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); BasicCredentialsProvider credsProvider = new BasicCredentialsProvider(); - credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); RequestConfig requestConfig = RequestConfig.custom() .setAuthenticationEnabled(true) @@ -283,7 +283,7 @@ private void testV2ModeBehavior() throws Exception { HttpGet getRequest = new HttpGet(getFullUrl("/cxs/profiles/" + TEST_PROFILE_ID)); BasicCredentialsProvider credsProvider = new BasicCredentialsProvider(); - credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); RequestConfig requestConfig = RequestConfig.custom() .setAuthenticationEnabled(true) diff --git a/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java b/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java index 1ff2201092..34db0381e0 100644 --- a/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java @@ -72,7 +72,7 @@ protected CloseableHttpResponse postAnonymous(final String resource) throws Exce } /** - * Performs a GraphQL POST request with JAAS admin authentication (karaf:karaf). + * Performs a GraphQL POST request with JAAS admin authentication ({@link #BASIC_AUTH_USER_NAME}/{@link #BASIC_AUTH_PASSWORD}). * This is equivalent to AuthType.JAAS_ADMIN. * * @param resource The resource path to the GraphQL query/mutation diff --git a/itests/src/test/resources/etc/users.properties b/itests/src/test/resources/etc/users.properties index 377fe6a160..538906c38c 100644 --- a/itests/src/test/resources/etc/users.properties +++ b/itests/src/test/resources/etc/users.properties @@ -29,6 +29,6 @@ # and modifiable via the JAAS command group. These users reside in a JAAS domain # with the name "karaf". # -karaf = ${org.apache.unomi.security.root.password:-karaf},_g_:admingroup -health = ${org.apache.unomi.healthcheck.password:-health},health +karaf = ${org.apache.unomi.security.root.password},_g_:admingroup +health = ${org.apache.unomi.healthcheck.password},health _g_\:admingroup = group,admin,manager,viewer,systembundles,ssh,ROLE_UNOMI_ADMIN,ROLE_UNOMI_TENANT_USER,ROLE_UNOMI_TENANT_ADMIN diff --git a/manual/src/main/asciidoc/5-min-quickstart.adoc b/manual/src/main/asciidoc/5-min-quickstart.adoc index c42a9404a7..9966b275ad 100644 --- a/manual/src/main/asciidoc/5-min-quickstart.adoc +++ b/manual/src/main/asciidoc/5-min-quickstart.adoc @@ -19,6 +19,16 @@ Begin by creating a `docker-compose.yml` file. You can choose between Elasticsea ==== Option 1: Using Elasticsearch +Export the passwords first. The compose files read them from your shell, and the `curl` commands +further down use the same variables, so setting them once here keeps both consistent. Unomi refuses +to start if they are unset. + +[source,bash] +---- +export UNOMI_ROOT_PASSWORD='choose-a-strong-password' +export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' +---- + [source,yaml] ---- version: '3.8' @@ -35,6 +45,8 @@ services: environment: - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 - UNOMI_THIRDPARTY_PROVIDER1_IPADDRESSES=0.0.0.0/0,::1,127.0.0.1 + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD} ports: - 8181:8181 - 9443:9443 @@ -84,6 +96,8 @@ services: - UNOMI_OPENSEARCH_SSL_ENABLE=true - UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES=true - UNOMI_HEALTHCHECK_PROVIDERS=cluster,opensearch,unomi,persistence + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD} ports: - 8181:8181 - 9443:9443 @@ -104,7 +118,7 @@ Once Unomi is running, create a tenant, then **regenerate** API keys and save th [source,bash] ---- curl -X POST http://localhost:8181/cxs/tenants \ - --user karaf:karaf \ + --user "karaf:${UNOMI_ROOT_PASSWORD}" \ -H "Content-Type: application/json" \ -d '{ "requestedId": "default", @@ -114,13 +128,13 @@ curl -X POST http://localhost:8181/cxs/tenants \ } }' -curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user karaf:karaf -curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user karaf:karaf +curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user "karaf:${UNOMI_ROOT_PASSWORD}" +curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user "karaf:${UNOMI_ROOT_PASSWORD}" ---- Use the public API key in `X-Unomi-Api-Key` for `/cxs/context.json` requests. See <<_multitenancy,Multi-tenancy>>. -Try accessing https://localhost:9443/cxs/cluster with username/password: karaf/karaf . You might get a certificate warning in your browser, just accept it despite the warning it is safe. +Try accessing https://localhost:9443/cxs/cluster with username/password: `karaf` / your `UNOMI_ROOT_PASSWORD` . You might get a certificate warning in your browser, just accept it despite the warning it is safe. === Quick Start manually @@ -159,6 +173,14 @@ discovery.type: single-node 5) Download Apache Unomi here : https://unomi.apache.org/download.html +5b) Before starting Karaf, export required passwords (Unomi will refuse to start without them): + +[source,bash] +---- +export UNOMI_ROOT_PASSWORD='choose-a-strong-password' +export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' +---- + 6) Start it using : `./bin/karaf` 7) Start the Apache Unomi packages using: @@ -173,14 +195,14 @@ which determines which set of features and bundles are installed and started. A 8) Wait for startup to complete -9) Try accessing https://localhost:9443/cxs/cluster with username/password: `karaf/karaf` . You might get a certificate warning in your browser, just accept it despite the warning it is safe. +9) Try accessing https://localhost:9443/cxs/cluster with username/password: `karaf` / your `UNOMI_ROOT_PASSWORD` . You might get a certificate warning in your browser, just accept it despite the warning it is safe. 10) Create a tenant that will own all your data, then regenerate keys and store `plainTextKey`: [source,bash] ---- curl -X POST http://localhost:8181/cxs/tenants \ - --user karaf:karaf \ + --user "karaf:${UNOMI_ROOT_PASSWORD}" \ -H "Content-Type: application/json" \ -d '{ "requestedId": "default", @@ -190,8 +212,8 @@ curl -X POST http://localhost:8181/cxs/tenants \ } }' -curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user karaf:karaf -curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user karaf:karaf +curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user "karaf:${UNOMI_ROOT_PASSWORD}" +curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user "karaf:${UNOMI_ROOT_PASSWORD}" ---- Save the `plainTextKey` values from the key-creation responses — you'll need them for API calls. diff --git a/manual/src/main/asciidoc/building-and-deploying.adoc b/manual/src/main/asciidoc/building-and-deploying.adoc index dc15533260..f4599cf900 100644 --- a/manual/src/main/asciidoc/building-and-deploying.adoc +++ b/manual/src/main/asciidoc/building-and-deploying.adoc @@ -339,6 +339,15 @@ The "package" sub-project generates a pre-configured Apache Karaf installation t Simply uncompress the package/target/unomi-VERSION.tar.gz (for Linux or Mac OS X) or package/target/unomi-VERSION.zip (for Windows) archive into the directory of your choice. +Apache Unomi ships no default administrator password, and `bin/karaf` refuses to start until one is +set, so export both passwords first: + +[source] +---- +export UNOMI_ROOT_PASSWORD='choose-a-strong-password' +export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' +---- + You can then start the server simply by using the command on UNIX/Linux/MacOS X : [source] @@ -350,9 +359,16 @@ or on Windows shell : [source] ---- +set UNOMI_ROOT_PASSWORD=choose-a-strong-password +set UNOMI_HEALTHCHECK_PASSWORD=choose-a-strong-health-password bin\karaf.bat ---- +WARNING: On Windows, `karaf.bat` does not check the exit code of `setenv.bat`, so a missing password +is reported but startup continues anyway with an account whose password is empty. Set both variables +before launching, and verify with `curl -i -u "karaf:" http://localhost:8181/cxs/tenants`, which must +return 401. + You will then need to launch (only on the first Karaf start) the Apache Unomi packages using the following Apache Karaf shell command: @@ -397,7 +413,7 @@ Create a new $MY_KARAF_HOME/etc/org.apache.cxf.osgi.cfg file and put the followi ---- If all went smoothly, you should be able to access the context script here : http://localhost:8181/cxs/cluster[http://localhost:8181/cxs/cluster] . - You should be able to login with karaf / karaf and see basic server information. If not something went wrong during the install. + You should be able to login as `karaf` with the password you set in `UNOMI_ROOT_PASSWORD` and see basic server information. If not something went wrong during the install. ==== Installing GraphViz for Manual Generation diff --git a/manual/src/main/asciidoc/configuration.adoc b/manual/src/main/asciidoc/configuration.adoc index b76b2abb2e..5f51a05613 100644 --- a/manual/src/main/asciidoc/configuration.adoc +++ b/manual/src/main/asciidoc/configuration.adoc @@ -248,7 +248,10 @@ At the end, you should have about 4 million entries in the geonames index. === REST API Security The Apache Unomi Context Server REST API is protected using JAAS authentication and using Basic or Digest HTTP auth. -By default, the login/password for the REST API full administrative access is "karaf/karaf". +You must set an admin password via `UNOMI_ROOT_PASSWORD` (or `org.apache.unomi.security.root.password`) +and a health-check password via `UNOMI_HEALTHCHECK_PASSWORD` (or `org.apache.unomi.healthcheck.password`); +Unomi does not ship known defaults. +The default JAAS user name is `karaf`. The generated package is also configured with a default SSL certificate. You can change it by following these steps : @@ -267,8 +270,9 @@ org.ops4j.pax.web.ssl.keypassword=${env:UNOMI_SSL_KEYPASSWORD:-changeme} You should now have SSL setup on Karaf with your certificate, and you can test it by trying to access it on port 9443. -Changing the default Karaf password can be done by modifying the `org.apache.unomi.security.root.password` in the -`$MY_KARAF_HOME/etc/unomi.custom.system.properties` file +Changing the Karaf admin password is done by setting `UNOMI_ROOT_PASSWORD` or by modifying +`org.apache.unomi.security.root.password` in the +`$MY_KARAF_HOME/etc/unomi.custom.system.properties` file. Unomi does not ship a known default password. === Tenant Management and API Access @@ -278,14 +282,14 @@ Apache Unomi supports multi-tenancy, allowing multiple organizations to use the IMPORTANT: All tenant management operations (create, list, update, delete, API key management) are restricted to administrators only and require JAAS authentication. These endpoints cannot be accessed using tenant API keys. -To manage tenants, you need administrator access to Unomi (default credentials: karaf/karaf). You can manage tenants using either the REST API or the Karaf shell commands: +To manage tenants, you need administrator access to Unomi (`karaf` / your `UNOMI_ROOT_PASSWORD`). You can manage tenants using either the REST API or the Karaf shell commands: Using REST API (requires admin credentials): [source,bash] ---- # Create a new tenant (JAAS auth required) curl -X POST "http://localhost:8181/cxs/tenants" \ - -u karaf:karaf \ + -u "karaf:$UNOMI_ROOT_PASSWORD" \ -H "Content-Type: application/json" \ -d '{ "requestedId": "mytenant", @@ -318,17 +322,17 @@ curl -X POST "http://localhost:8181/cxs/tenants" \ # List all tenants (JAAS auth required) curl -X GET "http://localhost:8181/cxs/tenants" \ - -u karaf:karaf \ + -u "karaf:$UNOMI_ROOT_PASSWORD" \ -H "Accept: application/json" # Get tenant details (JAAS auth required) curl -X GET "http://localhost:8181/cxs/tenants/mytenant" \ - -u karaf:karaf \ + -u "karaf:$UNOMI_ROOT_PASSWORD" \ -H "Accept: application/json" # Delete a tenant (JAAS auth required) curl -X DELETE "http://localhost:8181/cxs/tenants/mytenant" \ - -u karaf:karaf + -u "karaf:$UNOMI_ROOT_PASSWORD" ---- Using Karaf shell (requires admin access to Karaf console). See <<_shell_commands,Shell commands>> for full syntax: @@ -362,9 +366,9 @@ unomi:crud read tenant -i mytenant # Obtain plaintext (store immediately; it is not persisted) curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PUBLIC" \ - -u karaf:karaf + -u "karaf:$UNOMI_ROOT_PASSWORD" curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PRIVATE" \ - -u karaf:karaf + -u "karaf:$UNOMI_ROOT_PASSWORD" # Response example: { @@ -382,7 +386,7 @@ To generate new API keys (requires admin access): ---- # Using REST API (JAAS auth required). Replaces any existing key of the same type. curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PUBLIC&validityDays=30" \ - -u karaf:karaf + -u "karaf:$UNOMI_ROOT_PASSWORD" # Response (HTTP 200 OK) — store plainTextKey immediately: { @@ -442,7 +446,7 @@ curl -X POST "http://localhost:8181/cxs/profiles/search" \ [source,bash] ---- curl -X GET "http://localhost:8181/cxs/tenants" \ - --user "karaf:karaf" \ + --user "karaf:$UNOMI_ROOT_PASSWORD" \ -H "Accept: application/json" ---- @@ -817,10 +821,12 @@ Once the action has been created you need to submit it to Unomi (from the same f [source,bash] ---- curl -X POST 'http://localhost:8181/cxs/groovyActions' \ ---user "TENANT_ID:PRIVATE_KEY" \ +--user "karaf:${UNOMI_ROOT_PASSWORD}" \ --form 'file=@helloWorldGroovyAction.groovy' ---- +NOTE: Groovy Actions REST requires the system `ADMINISTRATOR` role (not a tenant private key). See <<_client_facing_hardening_3_1,client-facing hardening>>. + Important: A bug ( https://issues.apache.org/jira/browse/UNOMI-847[UNOMI-847] ) in Apache Unomi 2.5 and lower requires the filename of a Groovy file being submitted to be the same as the id of the Groovy action (as per the example above). Finally, register a rule to trigger execution of the groovy action: @@ -860,7 +866,7 @@ Once you're done with the Hello World! action, it can be deleted using the follo [source,bash] ---- curl -X DELETE 'http://localhost:8181/cxs/groovyActions/helloWorldGroovyAction' \ ---user "TENANT_ID:PRIVATE_KEY" +--user "karaf:${UNOMI_ROOT_PASSWORD}" ---- And the corresponding rule can be deleted using the following command: @@ -1078,7 +1084,7 @@ The `MergeProfilesOnPropertyAction` supports the following parameters: ==== Security considerations -IMPORTANT: Never trigger profile merges from unauthenticated operations such as form submissions or public-facing APIs. Always verify user identity before performing a merge. +IMPORTANT: Never trigger profile merges from unauthenticated or public-key-only operations such as form submissions or public-facing context events. Merging into another profile (or switching identity after a merge) requires a **trusted** caller — system administrator or tenant administrator (private key). Always verify user identity on your application server before emitting a merge-triggering event. See <<_client_facing_hardening_3_1,client-facing hardening>>. The following diagram highlights key security considerations: @@ -1178,23 +1184,29 @@ documentation for here : * https://karaf.apache.org/manual/latest/#_security_2[https://karaf.apache.org/manual/latest/#_security_2] -The default username/password is +You must set an admin password and a health-check password before using the REST API / health endpoints. +There are **no** known default passwords shipped with Unomi. -[source] +Set the environment variables `UNOMI_ROOT_PASSWORD` and `UNOMI_HEALTHCHECK_PASSWORD`, or set +`org.apache.unomi.security.root.password` and `org.apache.unomi.healthcheck.password` in +`$MY_KARAF_HOME/etc/unomi.custom.system.properties` (or `etc/custom.system.properties`). + +Example: + +[source,bash] ---- -karaf/karaf +export UNOMI_ROOT_PASSWORD='choose-a-strong-password' +export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' ---- -You should really change this default username/password as soon as possible. Changing the default Karaf password can be -done by modifying the `org.apache.unomi.security.root.password` in the `$MY_KARAF_HOME/etc/unomi.custom.system.properties` file - -Or if you want to also change the user name you could modify the following file : +The JAAS admin user name defaults to `karaf`. Authenticate with `karaf:$UNOMI_ROOT_PASSWORD`. +The health-check user name defaults to `health`. Authenticate with `health:$UNOMI_HEALTHCHECK_PASSWORD`. - $MY_KARAF_HOME/etc/users.properties +To change the admin user name, edit `$MY_KARAF_HOME/etc/users.properties` and also set: -But you will also need to change the following property in the $MY_KARAF_HOME/etc/unomi.custom.system.properties : + karaf.local.user = - karaf.local.user = karaf +in `unomi.custom.system.properties`. For your context servers, and for any standalone Elasticsearch nodes you will need to open the following ports for proper node-to-node communication : 9200 (Elasticsearch REST API), 9300 (Elasticsearch TCP transport) @@ -1744,7 +1756,9 @@ is up and running and can serve requests. The health check endpoint is available at the following URL: /health/check and returns a simple JSON response that includes all health check provider responses. Basic Http Authentication enforce security for the health check endpoint using the existing karaf realm. The user needs to have the specific role **health** -to access the endpoint. Users and roles can be configured in the etc/users.properties file. By default, a login/pass health/health is configured. +to access the endpoint. Users and roles can be configured in the etc/users.properties file. The shipped `health` user has no default password: its +password comes from `UNOMI_HEALTHCHECK_PASSWORD`, which must be set before starting (see <<_rest_api_security,REST API security>>). An empty password is +never accepted, on this endpoint or any other. Specific configuration is located in : org.apache.unomi.healthcheck.cfg Existing health checks are using configuration from that file, including authentication realm. diff --git a/manual/src/main/asciidoc/connectors/salesforce-connector.adoc b/manual/src/main/asciidoc/connectors/salesforce-connector.adoc index 9260397472..abe1763842 100644 --- a/manual/src/main/asciidoc/connectors/salesforce-connector.adoc +++ b/manual/src/main/asciidoc/connectors/salesforce-connector.adoc @@ -77,7 +77,7 @@ If this is not the case or you prefer to deploy using a KAR bundle, see the KAR + [source] ---- -ssh -p 8102 karaf@localhost (default password is karaf) +ssh -p 8102 karaf@localhost (the password is your configured UNOMI_ROOT_PASSWORD) ---- + . Deploy into Apache Unomi using the following commands from the Apache Karaf shell: @@ -111,8 +111,9 @@ The first URL will give you information about the version of the connectors, so plugin is properly deployed, started and the correct version. The second URL will actually make a request to the Salesforce REST API to retrieve the limits of the Salesforce API. + -Both URLs are password protected by the Apache Unomi (Karaf) password. You can find this user and password information -in the etc/users.properties file. +Both URLs are password protected by the Apache Unomi (Karaf) password: the `karaf` user and the +`UNOMI_ROOT_PASSWORD` you configured at startup. No default password is shipped, and +`etc/users.properties` only references the configured value rather than containing it. You can now use the connectors's defined actions in rules to push or pull data to/from the Salesforce CRM. You can find more information about rules in the <<_data_model_overview,Data Model>> and the <<_getting_started_with_unomi,Getting Started>> pages. @@ -152,7 +153,7 @@ mvn clean install + [source] ---- -ssh -p 8102 karaf@localhost (password by default is karaf) +ssh -p 8102 karaf@localhost (the password is your configured UNOMI_ROOT_PASSWORD) ---- + . Execute the following commands in the Karaf shell @@ -171,7 +172,7 @@ feature:install unomi-salesforce-connector-karaf-feature https://localhost:9443/cxs/sfdc/version ---- + -(if asked for a password it's the same karaf/karaf default) +(if asked for credentials, use the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`) ==== Using the Salesforce Workbench for testing REST API diff --git a/manual/src/main/asciidoc/getting-started.adoc b/manual/src/main/asciidoc/getting-started.adoc index 223cb38486..1351cbc955 100644 --- a/manual/src/main/asciidoc/getting-started.adoc +++ b/manual/src/main/asciidoc/getting-started.adoc @@ -50,6 +50,21 @@ Note for OpenSearch users: ==== Running Unomi +===== Set the admin and health passwords (required) + +Unomi does not ship known default passwords. Set both *before* starting the server +(otherwise startup fails with a clear error): + +[source,bash] +---- +export UNOMI_ROOT_PASSWORD='choose-a-strong-password' +export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' +---- + +Authenticate later with `karaf:$UNOMI_ROOT_PASSWORD`. See the configuration chapter if you prefer to set +`org.apache.unomi.security.root.password` / `org.apache.unomi.healthcheck.password` in +`etc/custom.system.properties` instead. + ===== Start Unomi Start Unomi according to the <<_five_minutes_quickstart,quick start with docker>> or by compiling using the @@ -67,14 +82,14 @@ Initializing profile service endpoint... Initializing cluster service endpoint... ---- -This indicates that all the Unomi services are started and ready to react to requests. +This indicates that all the Unomi services are started and ready to react to requests. -Before you can use the API, you need to create a tenant: +Create a tenant (using the password you set before startup): [source,bash] ---- curl -X POST http://localhost:8181/cxs/tenants \ - --user karaf:karaf \ + --user "karaf:${UNOMI_ROOT_PASSWORD}" \ -H "Content-Type: application/json" \ -d '{ "requestedId": "default", @@ -89,8 +104,8 @@ The tenant create response includes **masked** API key metadata only. Regenerate [source,bash] ---- -curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user karaf:karaf -curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user karaf:karaf +curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user "karaf:${UNOMI_ROOT_PASSWORD}" +curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user "karaf:${UNOMI_ROOT_PASSWORD}" ---- Store `plainTextKey` immediately — you'll need the public key for subsequent API calls. See <<_multitenancy,Multi-tenancy>> for authentication details. diff --git a/manual/src/main/asciidoc/graphql-examples.adoc b/manual/src/main/asciidoc/graphql-examples.adoc index 847a7ac99c..57656e60ac 100644 --- a/manual/src/main/asciidoc/graphql-examples.adoc +++ b/manual/src/main/asciidoc/graphql-examples.adoc @@ -171,13 +171,16 @@ To make this query work you need to supply authorization token in the `HTTP head [source,json] ---- { - "authorization": "Basic a2FyYWY6a2FyYWY=" + "authorization": "Basic BASE64_OF_KARAF_AND_ROOT_PASSWORD" } ---- -NOTE: GraphQL requests need authentication. For **mutations and administrative queries**, use HTTP Basic with JAAS (`karaf:karaf`) or `tenantId:privateApiKey`. For **public read** operations on the `cdp` root field, send `X-Unomi-Api-Key` with a tenant public API key. See <<_graphql_api,GraphQL API>> authentication. +Generate the Base64 value from your own credentials, for example with +`printf 'karaf:%s' "$UNOMI_ROOT_PASSWORD" | base64`. -When using curl, you can use the `--user` option instead of manually encoding credentials. For example, `--user karaf:karaf` or `--user TENANT_ID:PRIVATE_KEY` automatically handles Base64 encoding for Basic authentication. For public reads: +NOTE: GraphQL requests need authentication. For **mutations and administrative queries**, use HTTP Basic with JAAS (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`) or `tenantId:privateApiKey`. For **public read** operations on the `cdp` root field, send `X-Unomi-Api-Key` with a tenant public API key. See <<_graphql_api,GraphQL API>> authentication. + +When using curl, you can use the `--user` option instead of manually encoding credentials. For example, `--user "karaf:$UNOMI_ROOT_PASSWORD"` or `--user TENANT_ID:PRIVATE_KEY` automatically handles Base64 encoding for Basic authentication. For public reads: [source,bash] ---- diff --git a/manual/src/main/asciidoc/index.adoc b/manual/src/main/asciidoc/index.adoc index c14d4b5f46..36bf630d0d 100644 --- a/manual/src/main/asciidoc/index.adoc +++ b/manual/src/main/asciidoc/index.adoc @@ -181,7 +181,7 @@ UNOMI_HEALTHCHECK_ENABLED=true UNOMI_HEALTHCHECK_PROVIDERS=cluster,opensearch,unomi,persistence ---- -The endpoint is protected by the `health` role (default user `health` / `health`). Full provider configuration, sample JSON, and extension points are documented in the Configuration chapter: <<_health_check,Health check extension>>. +The endpoint is protected by the `health` role (user `health`, password from `UNOMI_HEALTHCHECK_PASSWORD` — no default is shipped). Full provider configuration, sample JSON, and extension points are documented in the Configuration chapter: <<_health_check,Health check extension>>. == Reference diff --git a/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc b/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc index 2d4a01440f..2730051554 100644 --- a/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc +++ b/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc @@ -16,7 +16,7 @@ The JSON schema endpoints are private, so the user has to be authenticated to manage the JSON schema in Unomi. -IMPORTANT: JSON schema endpoints require tenant authentication using Basic Auth with `tenantId:privateKey`. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`). +IMPORTANT: JSON schema endpoints require tenant authentication using Basic Auth with `tenantId:privateKey`. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`). ==== List existing schemas diff --git a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc index 18b69e2880..a041bf2d2e 100644 --- a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc +++ b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc @@ -34,7 +34,7 @@ The main change in 3.1 is the introduction of tenant-based authentication. The s |Aspect |Unomi 3.0 |Unomi 3.1 |Authentication Method -|System Administrator Authentication (karaf/karaf) +|System Administrator Authentication (configured admin password) |Tenant-based API Keys + System Administrator Authentication |Public API Endpoints @@ -46,8 +46,8 @@ The main change in 3.1 is the introduction of tenant-based authentication. The s |Tenant Authentication (tenantId/privateKey) OR System Administrator Authentication |Tenant Administration -|System Administrator Authentication (karaf/karaf) -|System Administrator Authentication (karaf/karaf) +|System Administrator Authentication +|System Administrator Authentication (configured `UNOMI_ROOT_PASSWORD`) |=== ==== API Key Types (3.1 Only) @@ -68,12 +68,12 @@ The main change in 3.1 is the introduction of tenant-based authentication. The s |Public API Key only |Administrative Operations -|System Admin (karaf/karaf) -|Tenant Auth (tenantId/privateKey) OR System Admin (karaf/karaf) +|System Admin (`karaf:$UNOMI_ROOT_PASSWORD`) +|Tenant Auth (tenantId/privateKey) OR System Admin (`karaf:$UNOMI_ROOT_PASSWORD`) |Tenant Administration (`/cxs/tenants`) -|System Admin (karaf/karaf) -|System Admin (karaf/karaf) +|System Admin (`karaf:$UNOMI_ROOT_PASSWORD`) +|System Admin (`karaf:$UNOMI_ROOT_PASSWORD`) |=== ==== Authentication Flow (3.1) @@ -84,7 +84,7 @@ The AuthenticationFilter in 3.1 follows this resolution order: 2. **Public endpoints** (e.g., `/context.json`): Requires public API key via `X-Unomi-Api-Key` header 3. **Private endpoints**: Tries tenant authentication first, then falls back to system administrator authentication: - **Tenant Authentication**: Basic Auth with `tenantId:privateKey` - - **System Administrator Authentication**: Basic Auth with `karaf:karaf` (or configured admin credentials) + - **System Administrator Authentication**: Basic Auth with `karaf:$UNOMI_ROOT_PASSWORD` (or configured admin credentials) ==== Code Examples @@ -94,7 +94,7 @@ The AuthenticationFilter in 3.1 follows this resolution order: ---- // Global system administrator authentication for all endpoints RestAssured.authentication = RestAssured.preemptive() - .basic("karaf", "karaf"); + .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")); // Context requests require no authentication RestAssured.given() @@ -124,14 +124,14 @@ given() // For private endpoints using system administrator authentication given() - .auth().preemptive().basic("karaf", "karaf") + .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")) .contentType(ContentType.JSON) .body(payload) .post("/cxs/profiles"); // For tenant administration (system admin only) given() - .auth().preemptive().basic("karaf", "karaf") + .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")) .contentType(ContentType.JSON) .body(tenantPayload) .post("/cxs/tenants"); @@ -164,7 +164,7 @@ public class UnomiConfiguration { public void init() { RestAssured.baseURI = baseUrl; RestAssured.authentication = RestAssured.preemptive() - .basic("karaf", "karaf"); + .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")); } // 3.1 Client @@ -210,10 +210,87 @@ When migrating to 3.1, you need to understand that: - Keep system administrator authentication as fallback for administrative operations - Continue using system administrator authentication for tenant administration -4. **No API Contract Changes** - - All endpoints remain the same - - Request/response payloads are unchanged - - Only authentication mechanism differs +4. **Related client contract changes (3.1 hardening)** + - Public `/context.json` and `/eventcollector` no longer treat body/query `profileId` as identity — see below + - Endpoint paths and most payload shapes remain the same; authentication and public profile-binding rules change + +[#_client_facing_hardening_3_1] +==== Client-facing hardening (profile cookie, passwords, privileged APIs) + +In addition to tenant API keys, Unomi 3.1 hardens several contracts that can break older clients and scripts. See also <<_how_profile_tracking_works,How profile tracking works>> and https://issues.apache.org/jira/browse/UNOMI-972[UNOMI-972]. + +**Why cookie-only for public profile identity?** On public context endpoints the profile id is a bearer token. Accepting a client-chosen body/query `profileId` let any public caller try to continue another visitor's context if they obtained that UUID. The cookie is issued by Unomi, sent automatically by the browser, and (with default `HttpOnly`) is not readable from page script — so public clients prove continuity with a server-issued bearer instead of an application-supplied id. Trusted callers may still override with body `profileId` when a backend intentionally selects a profile. Full rationale: <<_how_profile_tracking_works,How profile tracking works>> (section _Why the cookie is safer…_). + +[cols="1,2,2", options="header"] +|=== +|Area |Old common pattern |Required in 3.1 + +|Public profile identity +|Body/query `profileId` often selected the profile (sometimes ahead of the cookie) +|Public callers: **cookie only**. Body/query `profileId` is ignored. Trusted callers (tenant private key / system admin) may still override. + +|Session resume without cookie +|Sending only `sessionId` could switch onto that session's profile +|Public callers switch only if the cookie already owns the session; otherwise the session is detached for the request. + +|Profile cookie `HttpOnly` +|Often defaulted to `false` (JS could read `document.cookie`) +|Default **`true`**. Prefer `profileId` from the JSON response. Opt out with `org.apache.unomi.profile.cookie.httpOnly=false` only if you accept the tradeoff. + +|Admin / health passwords +|Known defaults such as `karaf` / `karaf` were common in samples +|Set `UNOMI_ROOT_PASSWORD` and `UNOMI_HEALTHCHECK_PASSWORD` before start (no shipped known defaults). + +|Groovy Actions / Router import-export REST +|Often callable with tenant private key +|Requires **system administrator** (`ADMINISTRATOR`), not tenant administrator. + +|Startup without a password +|Started with the shipped default +|`bin/karaf` and the Docker entrypoint refuse to start. On Windows startup continues — see the warning below. + +|`mergeProfilesOnProperty` / cross-profile `updateProperties` / `systemProperties.*` +|Sometimes driven from public context events +|Cross-profile merge/update and `systemProperties` writes require a trusted caller (system or tenant admin). Public callers may still update the **current** cookie-bound profile's normal properties when the event type allows it. +|=== + +[WARNING] +==== +**Windows: the startup check cannot stop the launcher.** + +On Linux and macOS, `bin/karaf` sources `bin/setenv` directly, so a missing password aborts startup. +The Docker entrypoint behaves the same way and the container exits. + +On Windows, `karaf.bat` runs `setenv.bat` with `call` and does not test the exit code, so the error +message is printed and **startup continues anyway**. This is a limitation of the Karaf launcher that +Unomi cannot work around without replacing `karaf.bat`. + +This matters because an unset password is not the same as a disabled account. `${env:UNOMI_ROOT_PASSWORD}` +resolves to the *empty string*, so `etc/users.properties` creates the `karaf` administrator with an +empty password that authenticates successfully. + +Windows operators must therefore treat the message as fatal and verify the password took effect +before exposing the instance — for example by confirming that an empty password is rejected: + +[source,bash] +---- +# Must return 401. A 200 means the account has a blank password. +curl -i -u "karaf:" http://localhost:8181/cxs/tenants +---- + +Unomi additionally refuses any REST call presenting Basic authentication with an empty password, so +the admin API is not reachable that way even if the server did start unconfigured. The Karaf SSH +console (port 8102) is not covered by that check. +==== + +===== Migration checklist for client applications + +* Browser / tracker clients: ensure cookies are sent (`withCredentials` / same-site setup); stop treating body `profileId` as authoritative for public calls. +* Headless or mobile public clients that stored a UUID and posted it only in the body: switch to sending the profile cookie, or call with a **private key** when you intentionally bind a profile. +* Login / merge flows: emit merge-triggering events from a trusted server-side caller after real authentication — not from the public key alone. +* Ops scripts and Docker: export both password env vars; replace `karaf:karaf` with `karaf:$UNOMI_ROOT_PASSWORD`. +* Windows deployments: confirm the passwords actually took effect after upgrading — the startup check warns but cannot halt `karaf.bat` (see the warning above). +* Automation that uploaded Groovy actions or managed Router import/export with a tenant private key: switch to system administrator credentials. ==== Benefits of Multi-Tenancy in 3.1 @@ -232,6 +309,7 @@ Before starting the migration, please ensure that: - You are currently running Apache Unomi 3.0 (or a later 3.0.x version) - You understand the multi-tenancy impact on your data model - You have a plan to update client applications to tenant API keys (or temporary <<_v2_compatibility_mode,V2 compatibility mode>> only if coming from 2.x) +- You have reviewed the <<_client_facing_hardening_3_1,client-facing hardening>> notes (cookie-only public profile binding, HttpOnly default, required passwords, privileged REST roles) - You know how to obtain plaintext API keys after upgrade (regenerate via `/cxs/tenants/{id}/apikeys`; create responses expose masked keys only) === Migration Process @@ -293,7 +371,7 @@ The fundamental difference between Unomi 3.0 and 3.1 is the introduction of **co - **3.0**: Single-tenant architecture with system administrator authentication for all operations - **3.1**: Multi-tenant architecture with complete data isolation and tenant-specific authentication -- **API Endpoints**: Identical between versions - no breaking changes to existing integrations +- **API Endpoints**: Paths remain the same; authentication and public profile-binding rules change (see client-facing hardening above) - **Data Model**: All entities (profiles, events, segments, rules, schemas) become tenant-specific in 3.1 - **Authentication**: New tenant-based authentication model with system administrator authentication as fallback diff --git a/manual/src/main/asciidoc/migrations/migrations.adoc b/manual/src/main/asciidoc/migrations/migrations.adoc index 9cbe001462..6faa3da16c 100644 --- a/manual/src/main/asciidoc/migrations/migrations.adoc +++ b/manual/src/main/asciidoc/migrations/migrations.adoc @@ -17,7 +17,7 @@ This section contains information and steps to migrate between major Unomi versi Use this decision guide to pick the right runbook: * **Unomi 2.x → 3.0** (platform): <<_migrate_from_2_x_to_3_0,Migrate from 2.x to 3.0>> (+ <<_migrate_from_elasticsearch_7_to_elasticsearch_9,ES7→ES9>> if needed) -* **Unomi 3.0 → 3.1** (tenants / API keys): <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> (`unomi:migrate`); optional <<_v2_compatibility_mode,V2 compatibility mode>> for 2.x clients +* **Unomi 3.0 → 3.1** (tenants / API keys / client hardening): <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> (`unomi:migrate`); optional <<_v2_compatibility_mode,V2 compatibility mode>> for 2.x clients; see also <<_client_facing_hardening_3_1,client-facing hardening>> * **Elasticsearch → OpenSearch** (same Unomi version, backend swap): <<_migrate_from_elasticsearch_to_opensearch,Migrate from Elasticsearch to OpenSearch>> (not `unomi:migrate`) [plantuml] diff --git a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc index 0614d523bf..6238209398 100644 --- a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc +++ b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc @@ -111,7 +111,7 @@ Your V2 clients should now work without any changes: ```java // V2-style authentication still works RestAssured.authentication = RestAssured.preemptive() - .basic("karaf", "karaf"); + .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")); // Context requests work without API keys RestAssured.given() @@ -137,7 +137,7 @@ Over time, gradually update your clients to use V3 authentication: ```java // This continues to work in V2 compatibility mode RestAssured.authentication = RestAssured.preemptive() - .basic("karaf", "karaf"); + .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")); RestAssured.given() .auth().none() @@ -260,7 +260,7 @@ The V2 third-party configuration supports dynamic updates: - Ensure the tenant exists and is accessible **Authentication errors**: -- Verify system administrator credentials (karaf/karaf) +- Verify system administrator credentials (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`) - Check that the server is running properly - Review logs for authentication errors diff --git a/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc b/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc index 4eb64ca280..fbf69a019f 100644 --- a/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc +++ b/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc @@ -40,7 +40,7 @@ This multi-tenancy support necessitates the authentication changes described bel |Aspect |Unomi V2 |Unomi 3.1 |Authentication Method -|System Administrator Authentication (karaf/karaf) +|System Administrator Authentication (`karaf` user) |Tenant-based API Keys + System Administrator Authentication |Public API Endpoints @@ -52,8 +52,8 @@ This multi-tenancy support necessitates the authentication changes described bel |Tenant Authentication (tenantId/privateKey) OR System Administrator Authentication |Tenant Administration -|System Administrator Authentication (karaf/karaf) -|System Administrator Authentication (karaf/karaf) +|System Administrator Authentication (`karaf` user) +|System Administrator Authentication (`karaf` user) |=== ===== API Key Types (V3 Only) @@ -74,12 +74,12 @@ V3 introduces two types of API keys per tenant: |Public API Key only |Administrative Operations -|System Admin (karaf/karaf) -|Tenant Auth (tenantId/privateKey) OR System Admin (karaf/karaf) +|System Admin (`karaf` user) +|Tenant Auth (tenantId/privateKey) OR System Admin (`karaf` user) |Tenant Administration (`/cxs/tenants`) -|System Admin (karaf/karaf) -|System Admin (karaf/karaf) +|System Admin (`karaf` user) +|System Admin (`karaf` user) |=== ==== Authentication Flow (V3) @@ -90,7 +90,7 @@ The AuthenticationFilter in V3 follows this resolution order: 2. **Public endpoints** (e.g., `/context.json`): Requires public API key via `X-Unomi-Api-Key` header 3. **Private endpoints**: Tries tenant authentication first, then falls back to system administrator authentication: - **Tenant Authentication**: Basic Auth with `tenantId:privateKey` - - **System Administrator Authentication**: Basic Auth with `karaf:karaf` (or configured admin credentials) + - **System Administrator Authentication**: Basic Auth with the `karaf` user and your configured `UNOMI_ROOT_PASSWORD` ==== Code Examples @@ -100,7 +100,7 @@ The AuthenticationFilter in V3 follows this resolution order: ---- // Global system administrator authentication for all endpoints RestAssured.authentication = RestAssured.preemptive() - .basic("karaf", "karaf"); + .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")); // Context requests require no authentication RestAssured.given() @@ -130,14 +130,14 @@ given() // For private endpoints using system administrator authentication given() - .auth().preemptive().basic("karaf", "karaf") + .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")) .contentType(ContentType.JSON) .body(payload) .post("/cxs/profiles"); // For tenant administration (system admin only) given() - .auth().preemptive().basic("karaf", "karaf") + .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")) .contentType(ContentType.JSON) .body(tenantPayload) .post("/cxs/tenants"); @@ -170,7 +170,7 @@ public class UnomiConfiguration { public void init() { RestAssured.baseURI = baseUrl; RestAssured.authentication = RestAssured.preemptive() - .basic("karaf", "karaf"); + .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")); } // V3 Client diff --git a/manual/src/main/asciidoc/recipes.adoc b/manual/src/main/asciidoc/recipes.adoc index 8f0f8c9ce7..7a0eef0df8 100644 --- a/manual/src/main/asciidoc/recipes.adoc +++ b/manual/src/main/asciidoc/recipes.adoc @@ -27,7 +27,7 @@ you might be tempted to modify them to fit your use case, which might result in The best approach during development is to enable Apache Unomi debug mode, which will provide you with more detailed logs about events processing. -The debug mode can be activated via the karaf SSH console (default credentials are karaf/karaf): +The debug mode can be activated via the karaf SSH console (user `karaf`, with the password you set in `UNOMI_ROOT_PASSWORD` — no default is shipped): [source] ---- diff --git a/manual/src/main/asciidoc/samples/login-sample.adoc b/manual/src/main/asciidoc/samples/login-sample.adoc index 0a33e23771..dc8a88d20c 100644 --- a/manual/src/main/asciidoc/samples/login-sample.adoc +++ b/manual/src/main/asciidoc/samples/login-sample.adoc @@ -30,7 +30,7 @@ Login into the Unomi Karaf SSH shell using something like this : [source] ---- -ssh -p 8102 karaf@localhost (default password is karaf) +ssh -p 8102 karaf@localhost (the password is the one you set in UNOMI_ROOT_PASSWORD; no default is shipped) ---- Install the login samples using the following command: diff --git a/manual/src/main/asciidoc/scheduler.adoc b/manual/src/main/asciidoc/scheduler.adoc index 786fdbc874..3783a767eb 100644 --- a/manual/src/main/asciidoc/scheduler.adoc +++ b/manual/src/main/asciidoc/scheduler.adoc @@ -645,14 +645,14 @@ NOTE: The REST API is for **monitoring and operations** (list, inspect, cancel, .List all tasks (paginated) [source,bash] ---- -curl -s -u karaf:karaf \ +curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \ "http://localhost:8181/cxs/tasks?offset=0&limit=20" ---- .Filter by status [source,bash] ---- -curl -s -u karaf:karaf \ +curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \ "http://localhost:8181/cxs/tasks?status=FAILED&limit=50" ---- @@ -661,7 +661,7 @@ Valid status values: `SCHEDULED`, `WAITING`, `RUNNING`, `COMPLETED`, `FAILED`, ` .Filter by task type [source,bash] ---- -curl -s -u karaf:karaf \ +curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \ "http://localhost:8181/cxs/tasks?type=cache-refresh-segment&limit=10" ---- @@ -689,7 +689,7 @@ curl -s -u karaf:karaf \ [source,bash] ---- -curl -s -u karaf:karaf \ +curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \ "http://localhost:8181/cxs/tasks/TASK_ID" ---- @@ -699,7 +699,7 @@ Cancellation stops future runs and marks the task `CANCELLED`. The task record r [source,bash] ---- -curl -s -u karaf:karaf -X DELETE \ +curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X DELETE \ "http://localhost:8181/cxs/tasks/TASK_ID" ---- @@ -710,11 +710,11 @@ Returns HTTP `204 No Content` on success. [source,bash] ---- # Retry keeping failure count -curl -s -u karaf:karaf -X POST \ +curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X POST \ "http://localhost:8181/cxs/tasks/TASK_ID/retry" # Retry and reset failure count to zero -curl -s -u karaf:karaf -X POST \ +curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X POST \ "http://localhost:8181/cxs/tasks/TASK_ID/retry?resetFailureCount=true" ---- @@ -724,7 +724,7 @@ Use when status is `CRASHED` and the executor supports checkpoint resume. [source,bash] ---- -curl -s -u karaf:karaf -X POST \ +curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X POST \ "http://localhost:8181/cxs/tasks/TASK_ID/resume" ---- diff --git a/manual/src/main/asciidoc/security.adoc b/manual/src/main/asciidoc/security.adoc index acfdd1b1fe..fb14fbec12 100644 --- a/manual/src/main/asciidoc/security.adoc +++ b/manual/src/main/asciidoc/security.adoc @@ -31,12 +31,14 @@ Unomi 3.1 authenticates every request so the server can resolve a **tenant** (or | Server-side integrations, admin UIs | Tenant administration (`/cxs/tenants`) -| JAAS system administrator (for example `karaf:karaf`) +| JAAS system administrator (for example `karaf:$UNOMI_ROOT_PASSWORD`) | Create tenants, rotate keys |=== Temporary exception: <<_v2_compatibility_mode,V2 compatibility mode>> allows public endpoints without API keys while migrating 2.x clients. +Public context callers must present the profile cookie as the profile bearer (body `profileId` is ignored for public callers). See <<_client_facing_hardening_3_1,client-facing hardening>> and <<_how_profile_tracking_works,How profile tracking works>>. + ===== Auth resolution sequence [plantuml] diff --git a/manual/src/main/asciidoc/shell-commands.adoc b/manual/src/main/asciidoc/shell-commands.adoc index 19a09dca6f..2a154d5d04 100644 --- a/manual/src/main/asciidoc/shell-commands.adoc +++ b/manual/src/main/asciidoc/shell-commands.adoc @@ -26,7 +26,7 @@ You can connect to the Apache Karaf SSH Shell using the following command: ssh -p 8102 karaf@localhost -The default username/password is karaf/karaf. You should change this as soon as possible by editing the `etc/users.properties` file. +Authenticate with the `karaf` user and the password from `UNOMI_ROOT_PASSWORD` (Unomi does not ship a known default password). Set the password before start; see <<_getting_started,Getting started>> and <<_client_facing_hardening_3_1,client-facing hardening>>. Once connected you can simply type in : diff --git a/manual/src/main/asciidoc/tutorial.adoc b/manual/src/main/asciidoc/tutorial.adoc index f381ce6d15..0d81767c3c 100644 --- a/manual/src/main/asciidoc/tutorial.adoc +++ b/manual/src/main/asciidoc/tutorial.adoc @@ -115,7 +115,7 @@ curl --location --request POST 'http://localhost:8181/cxs/scopes' \ }' ---- -NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`). The default `karaf:karaf` credentials should be changed as soon as possible by modifying the `etc/users.properties` file. +NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`). Unomi ships no default administrator password: set `UNOMI_ROOT_PASSWORD` (and `UNOMI_HEALTHCHECK_PASSWORD`) before starting the server, or it will refuse to start. ==== Using tracker in your own JavaScript projects @@ -210,7 +210,7 @@ Another (powerful) way to look at events is to use the SSH Console. You can conn ssh -p 8102 karaf@localhost ---- -Using the same username password (karaf:karaf) and then you can use command such as : +Using the same credentials (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`) and then you can use command such as : - `event-tail` to view in realtime the events as they come in (CTRL+C to stop) - `event-list` to view the latest events diff --git a/manual/src/main/asciidoc/useful-unomi-urls.adoc b/manual/src/main/asciidoc/useful-unomi-urls.adoc index 804c96e878..238ea423ad 100644 --- a/manual/src/main/asciidoc/useful-unomi-urls.adoc +++ b/manual/src/main/asciidoc/useful-unomi-urls.adoc @@ -132,7 +132,7 @@ where PROFILE_ID is a profile identifier. This will indeed retrieve all the even |/health/check |GET -|Health check JSON (role `health`, default user `health`/`health`). See <<_health_check,Health Check extension>>. +|Health check JSON (role `health`, user `health` with the password from `UNOMI_HEALTHCHECK_PASSWORD` — no default is shipped). See <<_health_check,Health Check extension>>. |/cxs/context.json?explain=true |POST diff --git a/manual/src/main/asciidoc/whats-new.adoc b/manual/src/main/asciidoc/whats-new.adoc index 4b34f3f906..d49febed16 100644 --- a/manual/src/main/asciidoc/whats-new.adoc +++ b/manual/src/main/asciidoc/whats-new.adoc @@ -21,9 +21,17 @@ Apache Unomi 3.1 builds on the 3.0 platform (Elasticsearch 9 client, Karaf 4.4, Complete tenant isolation for profiles, events, segments, rules, and schemas. Public endpoints (for example `/cxs/context.json`) require a tenant public API key; administrative work uses tenant private keys or system administrator credentials. * Operator guide: <<_multitenancy,Multi-tenancy>> -* Migration: <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> +* Migration: <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> (includes <<_client_facing_hardening_3_1,client-facing hardening>>) * Migrating from Unomi 2.x: <<_v2_compatibility_mode,V2 compatibility mode>> (`v2.compatibilitymode.enabled` in `org.apache.unomi.rest.authentication.cfg`) +==== Security hardening (credentials, profile binding, privileged APIs) + +Unomi 3.1 requires explicit admin and health-check passwords at startup, treats the profile cookie as the only public profile bearer on `/context.json` and `/eventcollector`, defaults the profile cookie to `HttpOnly`, restricts Groovy Actions and Router import/export REST to system administrator, and gates cross-profile merge / `updateProperties` / `systemProperties` writes to trusted callers. + +* Details and migration table: <<_client_facing_hardening_3_1,Client-facing hardening>> +* Profile tracking contract: <<_how_profile_tracking_works,How profile tracking works>> +* Jira: https://issues.apache.org/jira/browse/UNOMI-972[UNOMI-972] + ==== Cluster-aware task scheduler Built-in background job scheduler with persistence, cluster locks, recovery, REST API (`/cxs/tasks`), and Karaf shell commands. diff --git a/package/src/main/resources/bin/setenv b/package/src/main/resources/bin/setenv index e48561de4c..66fa7157de 100755 --- a/package/src/main/resources/bin/setenv +++ b/package/src/main/resources/bin/setenv @@ -50,6 +50,94 @@ MY_DIRNAME=`dirname $0` MY_KARAF_HOME=`cd "$MY_DIRNAME/.."; pwd` +# Fail fast when starting without admin/health passwords (no known defaults are shipped). +# +# An unset password does NOT resolve to "no account": ${env:...} with no value expands to the +# empty string, which Karaf's PropertiesLoginModule accepts as a valid (empty) password for the +# admin account. So "unset" and "blank" are the same failure, and both must be refused. +# +# This script is the gate for shell-launched Karaf; the Docker entrypoint covers containers. Any +# launcher that starts the JVM directly without sourcing this file (systemd units, Kubernetes +# command overrides, PaxExam) bypasses the check, so those deployments must set the passwords +# themselves. +_unomi_etc="${KARAF_ETC:-${MY_KARAF_HOME}/etc}" + +# Print the configured value of a property, searching custom.system.properties and the optional +# overlay it includes. Last assignment wins, comments ignored. Empty output means "not configured". +_unomi_configured_property() { + for _unomi_file in "${_unomi_etc}/custom.system.properties" "${_unomi_etc}/unomi.custom.system.properties"; do + [ -f "${_unomi_file}" ] || continue + sed -n "s|^[[:space:]]*$1[[:space:]]*=[[:space:]]*\(.*\)$|\1|p" "${_unomi_file}" + done | sed -e 's/[[:space:]]*$//' | grep -v '^$' | tail -n 1 +} + +# _unomi_require_password ENV_VAR_NAME PROPERTY_NAME SKIP_FLAG_NAME +# Returns 0 when a non-blank password is available, 1 when startup must be refused. +_unomi_require_password() { + eval _unomi_value=\"\${$1}\" + eval _unomi_skip=\"\${$3}\" + + [ -n "${_unomi_value}" ] && return 0 + + _unomi_configured=`_unomi_configured_property "$2"` + case "${_unomi_configured}" in + # An unresolved ${env:...} placeholder is not a configured password — it expands to empty. + *'${'*) _unomi_configured='' ;; + esac + [ -n "${_unomi_configured}" ] && return 0 + + if [ "${_unomi_skip}" = "true" ]; then + cat >&2 <&2 <before it reached JAAS. Asserting on the response status alone proves + * nothing here — every refusal path in this class ends in the same 401. + */ + AuthenticationFilter(RestAuthenticationConfig restAuthenticationConfig, + TenantService tenantService, + SecurityService securityService, + ExecutionContextManager executionContextManager, + JAASAuthenticationFilter jaasAuthenticationFilter) { this.restAuthenticationConfig = restAuthenticationConfig; this.tenantService = tenantService; this.securityService = securityService; this.executionContextManager = executionContextManager; + this.jaasAuthenticationFilter = jaasAuthenticationFilter; + } - // Build wrapped jaas filter - jaasAuthenticationFilter = new JAASAuthenticationFilter(); - jaasAuthenticationFilter.setRoleClassifier(ROLE_CLASSIFIER); - jaasAuthenticationFilter.setRoleClassifierType(ROLE_CLASSIFIER_TYPE); - jaasAuthenticationFilter.setContextName(CONTEXT_NAME); - jaasAuthenticationFilter.setRealmName(REALM_NAME); + private static JAASAuthenticationFilter buildJaasFilter() { + JAASAuthenticationFilter jaasFilter = new JAASAuthenticationFilter(); + jaasFilter.setRoleClassifier(ROLE_CLASSIFIER); + jaasFilter.setRoleClassifierType(ROLE_CLASSIFIER_TYPE); + jaasFilter.setContextName(CONTEXT_NAME); + jaasFilter.setRealmName(REALM_NAME); + return jaasFilter; } @Override @@ -133,6 +149,9 @@ public void filter(ContainerRequestContext requestContext) throws IOException { unauthorized(requestContext); return; } + if (rejectBlankBasicAuthPassword(requestContext, authHeader)) { + return; + } try { jaasAuthenticationFilter.filter(requestContext); @@ -192,6 +211,9 @@ public void filter(ContainerRequestContext requestContext) throws IOException { // For all other cases, try tenant private key first, then fall back to JAAS String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION); if (authHeader != null && authHeader.startsWith(BASIC_AUTH_PREFIX)) { + if (rejectBlankBasicAuthPassword(requestContext, authHeader)) { + return; + } // Try tenant private key authentication first String[] credentials = extractBasicAuthCredentials(authHeader); if (credentials != null && credentials.length == 2) { @@ -299,6 +321,9 @@ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, S // For private endpoints, require system administrator authentication (like V2) String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION); if (authHeader != null && authHeader.startsWith(BASIC_AUTH_PREFIX)) { + if (rejectBlankBasicAuthPassword(requestContext, authHeader)) { + return; + } try { jaasAuthenticationFilter.filter(requestContext); // JAASAuthenticationFilter handles credential failures internally (calls abortWith + returns normally). @@ -352,6 +377,45 @@ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, S unauthorized(requestContext); } + /** + * Rejects the request when the Basic credential about to be used carries an empty password. + *

+ * Defence in depth against a blank administrator password (UNOMI-972). If + * {@code org.apache.unomi.security.root.password} is unset, Karaf resolves it to the empty + * string and the shipped JAAS account authenticates with an empty password. {@code bin/karaf} + * and the Docker entrypoint refuse to start in that state, but they cannot cover every launcher + * (notably {@code karaf.bat}), so an empty credential is never accepted over REST either. + *

+ * Deliberately called at each point where a Basic credential is actually consumed rather than + * once at the top of {@link #filter}: anonymous traffic on public paths — and every path in V2 + * compatibility mode — ignores {@code Authorization} entirely, so rejecting up front would turn + * a stray or stale Basic header (a cached browser credential, an injecting proxy) into a 401 on + * a request that is supposed to succeed without authentication. + * + * @return {@code true} when the request has been aborted and the caller must return + */ + private boolean rejectBlankBasicAuthPassword(ContainerRequestContext requestContext, String authHeader) { + if (!hasBlankBasicAuthPassword(authHeader)) { + return false; + } + logger.warn("Rejecting Basic authentication with an empty password"); + unauthorized(requestContext); + return true; + } + + /** + * Whether a Basic {@code Authorization} header carries an empty password. A missing, malformed + * or non-Basic header is not treated as blank here — those are rejected by the normal + * authentication paths instead. + */ + boolean hasBlankBasicAuthPassword(String authHeader) { + if (authHeader == null || !authHeader.startsWith(BASIC_AUTH_PREFIX)) { + return false; + } + String[] credentials = extractBasicAuthCredentials(authHeader); + return credentials != null && credentials.length == 2 && credentials[1].isEmpty(); + } + private String[] extractBasicAuthCredentials(String authHeader) { try { String base64Credentials = authHeader.substring(BASIC_AUTH_PREFIX.length()).trim(); diff --git a/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java new file mode 100644 index 0000000000..016a457569 --- /dev/null +++ b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java @@ -0,0 +1,273 @@ +/* + * 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.unomi.rest.authentication; + +import org.apache.cxf.jaxrs.security.JAASAuthenticationFilter; +import org.apache.unomi.api.security.SecurityService; +import org.apache.unomi.api.services.ExecutionContextManager; +import org.apache.unomi.api.tenants.ApiKey; +import org.apache.unomi.api.tenants.TenantService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collections; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * A blank {@code UNOMI_ROOT_PASSWORD} leaves the shipped JAAS administrator with an empty password + * that {@code PropertiesLoginModule} accepts. {@code bin/karaf} and the Docker entrypoint refuse to + * start in that state, but they cannot cover every launcher, so the REST layer must never accept an + * empty credential either. + *

+ * Covers both the predicate and its wiring into {@link AuthenticationFilter#filter}: the check is + * applied where a Basic credential is consumed, and must NOT reject anonymous traffic on public + * paths that ignores {@code Authorization} altogether. + */ +class AuthenticationFilterBlankPasswordTest { + + private RestAuthenticationConfig restAuthenticationConfig; + private TenantService tenantService; + private JAASAuthenticationFilter jaasAuthenticationFilter; + private AuthenticationFilter filter; + + @BeforeEach + void setUp() { + restAuthenticationConfig = mock(RestAuthenticationConfig.class); + tenantService = mock(TenantService.class); + // Stubbed so the tests below can assert the credential never reached JAAS. Every refusal + // path in the filter answers 401, so the status alone cannot tell "refused for a blank + // password" apart from "JAAS rejected it" — only this can. + jaasAuthenticationFilter = mock(JAASAuthenticationFilter.class); + filter = new AuthenticationFilter( + restAuthenticationConfig, + tenantService, + mock(SecurityService.class), + mock(ExecutionContextManager.class), + jaasAuthenticationFilter); + } + + @Test + void emptyPasswordIsRejected() { + assertTrue(filter.hasBlankBasicAuthPassword(basic("karaf:"))); + } + + @Test + void emptyUserAndPasswordIsRejected() { + assertTrue(filter.hasBlankBasicAuthPassword(basic(":"))); + } + + @Test + void realPasswordIsAccepted() { + assertFalse(filter.hasBlankBasicAuthPassword(basic("karaf:a-strong-password"))); + } + + /** + * A password consisting of spaces is a real (if terrible) password, not the blank-resolution + * failure this guard exists for — leave it to the realm. + */ + @Test + void whitespacePasswordIsNotTreatedAsBlank() { + assertFalse(filter.hasBlankBasicAuthPassword(basic("karaf: "))); + } + + @Test + void missingOrNonBasicHeadersAreLeftToTheNormalPaths() { + assertFalse(filter.hasBlankBasicAuthPassword(null)); + assertFalse(filter.hasBlankBasicAuthPassword("Bearer some-token")); + } + + @Test + void malformedHeaderIsLeftToTheNormalPaths() { + assertFalse(filter.hasBlankBasicAuthPassword("Basic not-base64!!")); + // No colon at all: cannot be split into user and password. + assertFalse(filter.hasBlankBasicAuthPassword(basic("karaf"))); + } + + // ------------------------------------------------------------------ filter() wiring + + /** + * The predicate being correct is not enough: if the call site were dropped or moved after an + * earlier {@code return}, only this test would notice. + */ + @Test + void filterRejectsBlankPasswordOnAnAuthenticatedPath() throws IOException { + ContainerRequestContext requestContext = request("tenants", basic("karaf:")); + + filter.filter(requestContext); + + assertUnauthorizedWithoutReachingJaas(requestContext); + } + + /** Control: a non-blank credential on the same path must still be handed to JAAS to judge. */ + @Test + void filterPassesNonBlankPasswordToJaasOnAnAuthenticatedPath() throws IOException { + ContainerRequestContext requestContext = request("tenants", basic("karaf:a-strong-password")); + + filter.filter(requestContext); + + verify(jaasAuthenticationFilter).filter(requestContext); + } + + /** + * The ordinary V3 branch: not {@code tenants}, not a public path, V2 compatibility off. This is + * the route most private REST calls take, and it consumes the Basic credential at its own call + * site — with only the {@code tenants} and V2 tests above, deleting the guard here would leave + * the whole suite green. + */ + @Test + void filterRejectsBlankPasswordOnAPrivatePath() throws IOException { + when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList()); + ContainerRequestContext requestContext = request("profiles", basic("karaf:")); + + filter.filter(requestContext); + + assertUnauthorizedWithoutReachingJaas(requestContext); + } + + /** + * Control for the ordinary V3 branch: a non-blank credential must still be offered to the tenant + * private-key check and then to JAAS. + */ + @Test + void filterPassesNonBlankPasswordToJaasOnAPrivatePath() throws IOException { + when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList()); + ContainerRequestContext requestContext = request("profiles", basic("karaf:a-strong-password")); + + filter.filter(requestContext); + + verify(jaasAuthenticationFilter).filter(requestContext); + } + + /** + * V2 compatibility mode routes every request through {@link AuthenticationFilter}'s own + * private-endpoint branch, which consumes the Basic credential at a third, separate call site. + * Without this test that call site is unreachable from the suite: the other tests leave + * {@code isV2CompatibilityModeEnabled()} at the unstubbed Mockito {@code false}, so deleting + * the guard there would leave every test green. + */ + @Test + void filterRejectsBlankPasswordOnAPrivatePathInV2CompatibilityMode() throws IOException { + when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true); + when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList()); + ContainerRequestContext requestContext = request("profiles", basic("karaf:")); + + filter.filter(requestContext); + + assertUnauthorizedWithoutReachingJaas(requestContext); + } + + /** Control for the V2 branch: a non-blank credential must still reach JAAS there too. */ + @Test + void filterPassesNonBlankPasswordToJaasOnAPrivatePathInV2CompatibilityMode() throws IOException { + when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true); + when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList()); + ContainerRequestContext requestContext = request("profiles", basic("karaf:a-strong-password")); + + filter.filter(requestContext); + + verify(jaasAuthenticationFilter).filter(requestContext); + } + + /** + * A public path in V2 compatibility mode authenticates by default tenant, ignoring + * {@code Authorization} entirely — so a stray blank Basic header must not turn it into a 401. + */ + @Test + void filterDoesNotRejectAStrayBlankBasicHeaderOnAPublicPathInV2CompatibilityMode() throws IOException { + when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true); + when(restAuthenticationConfig.getPublicPathPatterns()) + .thenReturn(Collections.singletonList(Pattern.compile("POST context\\.json"))); + when(restAuthenticationConfig.getV2CompatibilityDefaultTenantId()).thenReturn("default"); + ContainerRequestContext requestContext = request("context.json", basic("someone:")); + + filter.filter(requestContext); + + verify(tenantService).getTenant("default"); + } + + private void assertUnauthorizedWithoutReachingJaas(ContainerRequestContext requestContext) throws IOException { + ArgumentCaptor aborted = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(aborted.capture()); + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), aborted.getValue().getStatus()); + verify(jaasAuthenticationFilter, never()).filter(any()); + } + + /** + * The check has to run where a Basic credential is consumed, not once at the top of + * {@code filter()}. Anonymous traffic carrying a stray Basic header — a stale cached browser + * credential, an injecting proxy — must still authenticate by API key on the public path. + *

+ * Asserted by observing that the public-path branch is still reached (the API key is looked up) + * rather than by asserting no abort: a public path whose API key does not resolve legitimately + * falls through and is refused for that unrelated reason, and authenticating one successfully + * needs a live CXF exchange, which is out of scope for a unit test. + */ + @Test + void filterConsultsThePublicPathBranchDespiteAStrayBlankBasicHeader() throws IOException { + when(restAuthenticationConfig.getPublicPathPatterns()) + .thenReturn(Collections.singletonList(Pattern.compile("POST context\\.json"))); + ContainerRequestContext requestContext = request("context.json", basic("someone:")); + + filter.filter(requestContext); + + verify(tenantService).getTenantByApiKey(any(), eq(ApiKey.ApiKeyType.PUBLIC)); + } + + /** A public path with no Authorization header at all must equally reach the API-key lookup. */ + @Test + void filterConsultsThePublicPathBranchForAnonymousRequests() throws IOException { + when(restAuthenticationConfig.getPublicPathPatterns()) + .thenReturn(Collections.singletonList(Pattern.compile("POST context\\.json"))); + ContainerRequestContext requestContext = request("context.json", null); + + filter.filter(requestContext); + + verify(tenantService).getTenantByApiKey(any(), eq(ApiKey.ApiKeyType.PUBLIC)); + } + + private ContainerRequestContext request(String path, String authHeader) { + ContainerRequestContext requestContext = mock(ContainerRequestContext.class); + UriInfo uriInfo = mock(UriInfo.class); + when(uriInfo.getPath()).thenReturn(path); + when(requestContext.getUriInfo()).thenReturn(uriInfo); + when(requestContext.getMethod()).thenReturn("POST"); + when(requestContext.getHeaderString(HttpHeaders.AUTHORIZATION)).thenReturn(authHeader); + return requestContext; + } + + private static String basic(String credentials) { + return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java b/rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java new file mode 100644 index 0000000000..639c0096bd --- /dev/null +++ b/rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java @@ -0,0 +1,418 @@ +/* + * 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.unomi.rest.config; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Ensures the distribution cannot start with a known or blank admin/health password. + *

+ * The launcher guards are executed here rather than grepped: a check whose text is present + * but whose condition never matches would otherwise pass silently, which is exactly how the Windows + * {@code KARAF_SCRIPT} quoting bug survived review. + */ +class ShippedAdminPasswordConfigTest { + + private static final String ROOT_PASSWORD_PROPERTY = "org.apache.unomi.security.root.password"; + private static final String HEALTHCHECK_PASSWORD_PROPERTY = "org.apache.unomi.healthcheck.password"; + + /** + * The two files this test executes. They double as the fingerprint of the repository root (see + * {@link #locateRepoRoot()}): a directory containing both is the Unomi checkout, not some nested + * copy or a same-named directory further up the filesystem. + */ + private static final String SETENV_PATH = "package/src/main/resources/bin/setenv"; + private static final String ENTRYPOINT_PATH = "docker/src/main/docker/entrypoint.sh"; + + /** First line of the guard to copy out of entrypoint.sh. */ + private static final String ENTRYPOINT_GUARD_START = "check_required_password()"; + /** Last line of the guard; the slice is meaningless unless this is actually reached. */ + private static final String ENTRYPOINT_GUARD_END = "UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK || exit 1"; + + /** Default stub launcher name, mirroring {@code bin/karaf}. */ + private static final String DEFAULT_KARAF_SCRIPT = "karaf"; + + private static final Path REPO_ROOT = locateRepoRoot(); + + // ---------------------------------------------------------------- shipped configuration + + @Test + void usersProperties_hasNoKnownDefaultPasswordFallback() throws Exception { + String content = Files.readString(repoFile("package/src/main/resources/etc/users.properties")); + + assertFalse(content.contains(":-karaf"), "users.properties must not default the karaf password to 'karaf'"); + assertFalse(content.contains(":-health"), "users.properties must not default the health password to 'health'"); + assertTrue(content.contains("${" + ROOT_PASSWORD_PROPERTY + "}")); + assertTrue(content.contains("${" + HEALTHCHECK_PASSWORD_PROPERTY + "}")); + } + + @Test + void customSystemProperties_requiresRootPasswordEnvWithoutKnownDefault() throws Exception { + List securityLines = Files.readAllLines(repoFile("package/src/main/resources/etc/custom.system.properties")) + .stream() + .filter(line -> line.contains(ROOT_PASSWORD_PROPERTY) || line.contains(HEALTHCHECK_PASSWORD_PROPERTY)) + .collect(Collectors.toList()); + + assertFalse(securityLines.isEmpty()); + for (String line : securityLines) { + assertFalse(line.contains(":-karaf"), "root password must not fall back to karaf: " + line); + assertFalse(line.contains(":-health"), "health password must not fall back to health: " + line); + } + } + + + // ---------------------------------------------------------------- bin/setenv, executed + + @Test + void setenv_refusesToStartWhenPasswordsMissing(@TempDir Path karafHome) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Result missing = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new HashMap<>()); + assertEquals(1, missing.exitCode, "setenv must refuse to start without passwords:\n" + missing.output); + assertFalse(missing.output.contains("LAUNCHED"), "the launcher must not be reached"); + assertTrue(missing.output.contains("UNOMI_ROOT_PASSWORD is not set"), missing.output); + } + + /** + * The outer {@code case "${KARAF_SCRIPT}"} in bin/setenv lists every shipped script that boots a + * server: {@code bin/karaf}, {@code bin/start} and {@code bin/karaf server} all reach + * {@code org.apache.karaf.main.Main}. Only {@code karaf} used to be exercised here, so narrowing + * that list to a single entry would have gone unnoticed - now each entry has its own test case. + */ + @ParameterizedTest(name = "KARAF_SCRIPT={0}") + @ValueSource(strings = {"karaf", "start", "server"}) + void setenv_refusesToStartForEveryServerStartingScript(String karafScript, @TempDir Path tempDir) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + Path karafHome = Files.createDirectories(tempDir.resolve(karafScript)); + installFakeKarafHome(karafHome, karafScript, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Result missing = runLauncher(karafHome, karafScript, new HashMap<>()); + assertEquals(1, missing.exitCode, + "bin/" + karafScript + " starts a server, so it must refuse to run without passwords:\n" + missing.output); + assertFalse(missing.output.contains("LAUNCHED"), "the launcher must not be reached for KARAF_SCRIPT=" + karafScript); + assertTrue(missing.output.contains("UNOMI_ROOT_PASSWORD is not set"), missing.output); + } + + /** + * The dedicated {@code bin/stop}, {@code bin/status}, {@code bin/client} and {@code bin/shell} + * scripts set {@code KARAF_SCRIPT} to their own name. None of them starts a server - they talk to + * an already running one - so the outer case must keep excluding them, otherwise stopping an + * instance would require the passwords used to start it. + */ + @ParameterizedTest(name = "KARAF_SCRIPT={0}") + @ValueSource(strings = {"stop", "status", "client", "shell"}) + void setenv_doesNotGuardScriptsThatOnlyTalkToARunningServer(String karafScript, @TempDir Path tempDir) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + Path karafHome = Files.createDirectories(tempDir.resolve(karafScript)); + installFakeKarafHome(karafHome, karafScript, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Result result = runLauncher(karafHome, karafScript, new HashMap<>()); + assertEquals(0, result.exitCode, "bin/" + karafScript + " must not require the passwords:\n" + result.output); + assertTrue(result.output.contains("LAUNCHED"), result.output); + } + + @Test + void setenv_startsWhenPasswordsProvided(@TempDir Path karafHome) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Map env = new HashMap<>(); + env.put("UNOMI_ROOT_PASSWORD", "a-strong-password"); + env.put("UNOMI_HEALTHCHECK_PASSWORD", "a-strong-health-password"); + + Result provided = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, env); + assertEquals(0, provided.exitCode, provided.output); + assertTrue(provided.output.contains("LAUNCHED"), provided.output); + } + + /** + * The escape hatch only suppresses the environment-variable check. Configuring the property + * directly is a supported way to start, and must not be reported as an error. + */ + @Test + void setenv_acceptsPasswordsConfiguredInPropertiesFile(@TempDir Path karafHome) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "configured-root", "configured-health"); + + Result configured = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new HashMap<>()); + assertEquals(0, configured.exitCode, configured.output); + assertTrue(configured.output.contains("LAUNCHED"), configured.output); + } + + /** + * Claiming the password is set elsewhere, while leaving it blank everywhere, is the dangerous + * case: it must warn rather than pass silently. + */ + @Test + void setenv_warnsWhenSkipFlagHidesABlankPassword(@TempDir Path karafHome) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Map env = new HashMap<>(); + env.put("UNOMI_SKIP_ROOT_PASSWORD_CHECK", "true"); + env.put("UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK", "true"); + + Result skipped = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, env); + assertEquals(0, skipped.exitCode, skipped.output); + assertTrue(skipped.output.contains("WARNING"), "a bypassed check must still warn:\n" + skipped.output); + } + + /** + * {@code bin/karaf stop} and {@code bin/karaf status} keep {@code KARAF_SCRIPT=karaf} but replace + * the main class with {@code Main.Stop} / {@code Main.Status}: no server is started, so no + * password is needed. Requiring one would make an instance impossible to shut down cleanly from a + * shell that no longer has the startup environment. + */ + @ParameterizedTest(name = "bin/karaf {0}") + @ValueSource(strings = {"stop", "status"}) + void setenv_doesNotBlockSubcommandsThatDoNotStartAServer(String subcommand, @TempDir Path tempDir) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + Path karafHome = Files.createDirectories(tempDir.resolve(subcommand)); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Result result = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new HashMap<>(), subcommand); + assertEquals(0, result.exitCode, "'karaf " + subcommand + "' must not require the passwords:\n" + result.output); + assertTrue(result.output.contains("LAUNCHED"), result.output); + } + + /** + * Regression guard for a real bug: the inner skip list once read {@code stop|status|client|shell}, + * which silently disabled the password check for {@code bin/karaf client} and + * {@code bin/karaf shell}. Those subcommands are not the {@code bin/client} / + * {@code bin/shell} remote consoles - the karaf script does not special-case them, so they fall + * through to {@code org.apache.karaf.main.Main} and boot a complete server, admin account + * included. The same is true of any argument the script does not recognise. Only {@code stop} and + * {@code status} may skip the check; everything else here must still be refused. + */ + @ParameterizedTest(name = "bin/karaf {0}") + @ValueSource(strings = {"client", "shell", "console", "--an-argument-karaf-does-not-know"}) + void setenv_stillGuardsSubcommandsThatBootAFullServer(String subcommand, @TempDir Path tempDir) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + Path karafHome = Files.createDirectories(tempDir.resolve(subcommand.replace("-", "_"))); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Result result = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new HashMap<>(), subcommand); + assertEquals(1, result.exitCode, + "'karaf " + subcommand + "' starts a full server, so it must refuse to run without passwords:\n" + result.output); + assertFalse(result.output.contains("LAUNCHED"), "the launcher must not be reached for 'karaf " + subcommand + "'"); + assertTrue(result.output.contains("UNOMI_ROOT_PASSWORD is not set"), result.output); + } + + // ---------------------------------------------------------------- docker entrypoint, executed + + @Test + void entrypoint_refusesToStartWhenPasswordsMissing(@TempDir Path workDir) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + Path guard = extractEntrypointPasswordGuard(workDir); + + assertEquals(1, runShell(guard, workDir, new HashMap<>()).exitCode, + "the Docker entrypoint must exit non-zero without passwords"); + + Map env = new HashMap<>(); + env.put("UNOMI_ROOT_PASSWORD", "a-strong-password"); + env.put("UNOMI_HEALTHCHECK_PASSWORD", "a-strong-health-password"); + Result provided = runShell(guard, workDir, env); + assertEquals(0, provided.exitCode, provided.output); + assertTrue(provided.output.contains("GUARD-PASSED"), provided.output); + } + + // ---------------------------------------------------------------- setenv.bat regression guard + + /** + * {@code karaf.bat} sets {@code KARAF_SCRIPT} with the quotes included in the value + * ({@code SET KARAF_SCRIPT="karaf.bat"}), so comparing {@code "%KARAF_SCRIPT%"} against + * {@code "karaf.bat"} never matches and the whole check is skipped. The quotes must be stripped + * before comparing. This cannot be executed on a POSIX CI machine, so assert the shape instead. + */ + @Test + void setenvBat_stripsQuotesBeforeComparingKarafScript() throws Exception { + String content = Files.readString(repoFile("package/src/main/resources/bin/setenv.bat")); + + assertTrue(content.contains("%KARAF_SCRIPT:\"=%"), + "setenv.bat must strip the quotes karaf.bat embeds in KARAF_SCRIPT"); + assertFalse(content.contains("\"%KARAF_SCRIPT%\"==\"karaf.bat\""), + "comparing the raw KARAF_SCRIPT against karaf.bat never matches"); + assertTrue(content.contains("UNOMI_ROOT_PASSWORD") && content.contains("UNOMI_HEALTHCHECK_PASSWORD"), + "setenv.bat must check both passwords"); + } + + // ---------------------------------------------------------------- helpers + + /** + * Finds the repository root by walking up from the working directory until an ancestor holds + * both shipped launcher scripts this test executes. Matching on a single relative path + * would let a nested checkout (or any unrelated directory that happens to contain a + * {@code package/} tree) win, and the test would then silently assert against the wrong sources. + */ + private static Path locateRepoRoot() { + Path start = Paths.get("").toAbsolutePath().normalize(); + for (Path candidate = start; candidate != null; candidate = candidate.getParent()) { + if (Files.isRegularFile(candidate.resolve(SETENV_PATH)) + && Files.isRegularFile(candidate.resolve(ENTRYPOINT_PATH))) { + return candidate; + } + } + throw new IllegalStateException("Could not locate the Unomi repository root from " + start + + " (looked for an ancestor containing both " + SETENV_PATH + " and " + ENTRYPOINT_PATH + ")"); + } + + /** + * Resolves a repository-relative path against the detected repository root, so the test works + * regardless of which module directory the build runs it from. + */ + private static Path repoFile(String relativePath) { + Path resolved = REPO_ROOT.resolve(relativePath); + if (!Files.exists(resolved)) { + throw new IllegalStateException("Could not locate " + relativePath + " under repository root " + REPO_ROOT); + } + return resolved; + } + + private static boolean hasPosixShell() { + return !System.getProperty("os.name", "").toLowerCase().contains("win"); + } + + /** + * Builds a throwaway Karaf layout containing the real {@code bin/setenv} plus a stub launcher + * that mimics how the shipped scripts source it. {@code karafScript} names the stub and the value + * it exports as {@code KARAF_SCRIPT}, so tests can reproduce {@code bin/karaf}, {@code bin/start}, + * {@code bin/stop}, ... rather than only ever exercising {@code karaf}. + */ + private static void installFakeKarafHome(Path karafHome, String karafScript, String rootPassword, String healthPassword) + throws IOException { + Path bin = Files.createDirectories(karafHome.resolve("bin")); + Path etc = Files.createDirectories(karafHome.resolve("etc")); + + Files.copy(repoFile(SETENV_PATH), bin.resolve("setenv")); + Files.write(etc.resolve("custom.system.properties"), + (ROOT_PASSWORD_PROPERTY + "=" + rootPassword + "\n" + + HEALTHCHECK_PASSWORD_PROPERTY + "=" + healthPassword + "\n").getBytes(StandardCharsets.UTF_8)); + + // Mirrors apache-karaf/bin/