This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch UNOMI-978-action-identity-ownership in repository https://gitbox.apache.org/repos/asf/unomi.git
commit 81504c79102de9b91c4ba0242a85a426730732b5 Author: Serge Huber <[email protected]> AuthorDate: Thu Aug 13 14:26:34 2026 +0200 UNOMI-978: Gate cross-profile merge and systemProperties writes on a trusted caller Two built-in actions operate on identity rather than on the calling profile's own data. MergeProfilesOnPropertyAction merges the current profile into another one selected by a property value, and UpdatePropertiesAction can write systemProperties, where trust-bearing state such as merge and identity markers is kept. Deciding that two profiles are the same person, or writing the markers that record it, is a claim about identity, and a claim about identity should come from a caller the server has established as entitled to make it. Both now require a trusted caller, behind a single isTrustedIdentityCaller() seam. Writes to a caller's own properties and same-profile merges are unchanged, and a server-side integration holding the tenant private key keeps both capabilities. A refused attempt is logged through the shared org.apache.unomi.api.utils.LogSanitizer, so a request-derived value cannot break out of its log record. The login sample is rewritten to demonstrate the pattern this leaves in place: the browser posts to the operator's own servlet, which holds the tenant private key and performs the merge server-side, rather than asking the visitor's browser to assert who it is. Integrations that performed the merge from the browser need to move that step behind their own server, which the sample now shows end to end. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> --- .../test/java/org/apache/unomi/itests/BasicIT.java | 29 +- .../org/apache/unomi/itests/ContextServletIT.java | 209 +++++++++++ .../org/apache/unomi/itests/ProfileMergeIT.java | 65 ++++ .../unomi/itests/PropertiesUpdateActionIT.java | 76 ++++ manual/src/main/asciidoc/samples/login-sample.adoc | 203 +++++++++-- .../actions/MergeProfilesOnPropertyAction.java | 32 ++ .../baseplugin/actions/UpdatePropertiesAction.java | 71 +++- .../resources/OSGI-INF/blueprint/blueprint.xml | 1 + .../actions/MergeProfilesOnPropertyActionTest.java | 136 +++++++ .../actions/UpdatePropertiesActionTest.java | 193 ++++++++++ samples/login-integration/pom.xml | 44 ++- samples/login-integration/setup.sh | 299 ++++++++++++++++ .../unomi/samples/login/LoginSampleResources.java | 37 ++ .../apache/unomi/samples/login/LoginServlet.java | 395 +++++++++++++++++++++ .../src/main/resources/static/index.html | 75 ++++ .../resources/static/javascript/login-example.js | 58 +++ .../src/main/webapp/WEB-INF/web.xml | 24 -- .../login-integration/src/main/webapp/index.html | 70 ---- .../src/main/webapp/javascript/login-example.js | 139 -------- .../unomi/samples/login/LoginServletTest.java | 353 ++++++++++++++++++ 20 files changed, 2233 insertions(+), 276 deletions(-) diff --git a/itests/src/test/java/org/apache/unomi/itests/BasicIT.java b/itests/src/test/java/org/apache/unomi/itests/BasicIT.java index bd650359b..ff4e12f06 100644 --- a/itests/src/test/java/org/apache/unomi/itests/BasicIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/BasicIT.java @@ -38,6 +38,8 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.ops4j.pax.exam.junit.PaxExam; + +import java.util.Base64; import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; import org.ops4j.pax.exam.spi.reactors.PerSuite; import org.slf4j.Logger; @@ -248,15 +250,36 @@ public class BasicIT extends BaseIT { loginEventPropertiesVisitor2.put(LAST_NAME, LAST_NAME_VISITOR_2); loginEventPropertiesVisitor2.put(EMAIL, EMAIL_VISITOR_2); - // Create login event with VISITOR_2 ContextRequest contextRequestLoginVisitor2 = getContextRequestWithLoginEvent(sourceSite, loginEventPropertiesVisitor2, EMAIL_VISITOR_2, SESSION_ID_4); + + // Public API key must not switch identity / merge into another profile on a shared cookie. + HttpPost publicSwitchAttempt = new HttpPost(getFullUrl("/cxs/context.json")); + publicSwitchAttempt.addHeader("Cookie", requestResponsePageView1.getCookieHeaderValue()); + publicSwitchAttempt.addHeader("X-Unomi-Api-Key", testPublicKeyValue); + publicSwitchAttempt.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequestLoginVisitor2), + ContentType.create("application/json"))); + TestUtils.RequestResponse publicSwitchResponse = executeContextJSONRequest(publicSwitchAttempt, SESSION_ID_4); + Assert.assertEquals("Public login must not switch away from the cookie profile", + profileIdVisitor1, publicSwitchResponse.getContextResponse().getProfileId()); + + // Public login still runs copyProperties on the cookie profile; restore visitor1 before the trusted switch. + Profile restoredVisitor1 = profileService.load(profileIdVisitor1); + restoredVisitor1.setProperty(FIRST_NAME, FIRST_NAME_VISITOR_1); + restoredVisitor1.setProperty(LAST_NAME, LAST_NAME_VISITOR_1); + restoredVisitor1.setProperty(EMAIL, EMAIL_VISITOR_1); + profileService.save(restoredVisitor1); + keepTrying("Visitor1 properties not restored", () -> profileService.load(profileIdVisitor1), + p -> FIRST_NAME_VISITOR_1.equals(p.getProperty(FIRST_NAME)), DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + // Trusted private key may switch the browsing profile to VISITOR_2. HttpPost requestLoginVisitor2 = new HttpPost(getFullUrl("/cxs/context.json")); requestLoginVisitor2.addHeader("Cookie", requestResponsePageView1.getCookieHeaderValue()); - requestLoginVisitor2.addHeader("X-Unomi-Api-Key", testPublicKeyValue); + requestLoginVisitor2.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString( + (TEST_TENANT_ID + ":" + testPrivateKeyValue).getBytes())); requestLoginVisitor2.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequestLoginVisitor2), ContentType.create("application/json"))); - TestUtils.RequestResponse requestResponseLoginVisitor2 = executeContextJSONRequest(requestLoginVisitor2, SESSION_ID_4); + TestUtils.RequestResponse requestResponseLoginVisitor2 = executeContextJSONRequest(requestLoginVisitor2, SESSION_ID_4, -1, false); // We should have a new profile id so the session should have been moved from VISITOR_1 to VISITOR_2 String profileIdVisitor2 = requestResponseLoginVisitor2.getContextResponse().getProfileId(); Assert.assertNotEquals("Context profile id should not be the same", profileIdVisitor1, diff --git a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java index 36ce3393d..247460e26 100644 --- a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java @@ -441,6 +441,214 @@ public class ContextServletIT extends BaseIT { DEFAULT_SHOULDBETRUE_TRIES); } + + + + + + + @Test + public void testPublicHttp_updateProperties_cannotUpdateAnotherProfile() throws Exception { + String otherId = "update-other-" + System.currentTimeMillis(); + Profile other = new Profile(otherId); + profileService.save(other); + keepTrying("Other profile not found", () -> profileService.load(otherId), Objects::nonNull, + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + String sessionId = "update-public-session-" + System.currentTimeMillis(); + ContextRequest establishReq = new ContextRequest(); + establishReq.setSessionId(sessionId); + HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(establish); + establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(establishReq), ContentType.APPLICATION_JSON)); + RequestResponse established = executeContextJSONRequest(establish, sessionId); + + Event updateEvent = new Event(); + updateEvent.setEventType("updateProperties"); + updateEvent.setScope(TEST_SCOPE); + Map<String, Object> props = new HashMap<>(); + props.put("targetId", otherId); + props.put("targetType", "profile"); + Map<String, Object> toUpdate = new HashMap<>(); + toUpdate.put("properties.firstName", "CHANGED"); + props.put("update", toUpdate); + updateEvent.setProperties(props); + + ContextRequest probe = new ContextRequest(); + probe.setSessionId(sessionId); + probe.setEvents(Collections.singletonList(updateEvent)); + HttpPost probeRequest = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(probeRequest); + probeRequest.addHeader("Cookie", established.getCookieHeaderValue()); + probeRequest.setEntity(new StringEntity(getObjectMapper().writeValueAsString(probe), ContentType.APPLICATION_JSON)); + executeContextJSONRequest(probeRequest, sessionId); + + shouldBeTrueUntilEnd("Other profile must not be updated by public updateProperties", + () -> profileService.load(otherId), + p -> p.getProperty("firstName") == null, + DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES); + } + + @Test + public void testPrivateKeyHttp_updateProperties_canUpdateAnotherProfile() throws Exception { + String otherId = "trusted-update-other-" + System.currentTimeMillis(); + Profile other = new Profile(otherId); + profileService.save(other); + keepTrying("Other profile not found", () -> profileService.load(otherId), Objects::nonNull, + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + String sessionId = "trusted-update-session-" + System.currentTimeMillis(); + ContextRequest establishReq = new ContextRequest(); + establishReq.setSessionId(sessionId); + HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(establish); + establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(establishReq), ContentType.APPLICATION_JSON)); + RequestResponse established = executeContextJSONRequest(establish, sessionId); + + Event updateEvent = new Event(); + updateEvent.setEventType("updateProperties"); + updateEvent.setScope(TEST_SCOPE); + Map<String, Object> props = new HashMap<>(); + props.put("targetId", otherId); + props.put("targetType", "profile"); + Map<String, Object> toUpdate = new HashMap<>(); + toUpdate.put("properties.firstName", "TRUSTED_HTTP"); + props.put("update", toUpdate); + updateEvent.setProperties(props); + + ContextRequest update = new ContextRequest(); + update.setSessionId(sessionId); + update.setEvents(Collections.singletonList(updateEvent)); + HttpPost trusted = new HttpPost(getFullUrl(CONTEXT_URL)); + addPrivateTenantAuth(trusted, testTenant, testPrivateKeyValue); + trusted.addHeader("Cookie", established.getCookieHeaderValue()); + trusted.setEntity(new StringEntity(getObjectMapper().writeValueAsString(update), ContentType.APPLICATION_JSON)); + executeContextJSONRequest(trusted, sessionId, -1, false); + + waitForProfileProperty(otherId, "firstName", "TRUSTED_HTTP"); + } + + @Test + public void testPublicHttpLogin_cannotMergeIntoExistingOtherProfile() throws Exception { + ConditionType conditionType = getObjectMapper().readValue( + new File("data/tmp/testLoginEventCondition.json").toURI().toURL(), ConditionType.class); + definitionsService.setConditionType(conditionType); + keepTrying("loginEventCondition not registered", + () -> definitionsService.getConditionType("loginEventCondition"), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + Rule rule = getObjectMapper().readValue(new File("data/tmp/testLogin.json").toURI().toURL(), Rule.class); + createAndWaitForRule(rule); + + String otherEmail = "other-takeover-" + System.currentTimeMillis() + "@example.com"; + String otherId = "other-merge-" + System.currentTimeMillis(); + Profile other = new Profile(otherId); + other.setProperty("email", otherEmail); + other.setSystemProperty("mergeIdentifier", otherEmail); + profileService.save(other); + keepTrying("Other not found", () -> profileService.load(otherId), Objects::nonNull, + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + String sessionId = "public-merge-session-" + System.currentTimeMillis(); + ContextRequest pageView = new ContextRequest(); + pageView.setSessionId(sessionId); + HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(establish); + establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(pageView), ContentType.APPLICATION_JSON)); + RequestResponse established = executeContextJSONRequest(establish, sessionId); + String publicCallerId = established.getContextResponse().getProfileId(); + assertNotEquals(otherId, publicCallerId); + + CustomItem loginTarget = new CustomItem(otherEmail, "visitor"); + Map<String, Object> loginProps = new HashMap<>(); + loginProps.put("email", otherEmail); + loginTarget.setProperties(loginProps); + Event login = new Event(); + login.setEventType("login"); + login.setScope(TEST_SCOPE); + login.setTarget(loginTarget); + login.setTimeStamp(new Date()); + + ContextRequest loginRequest = new ContextRequest(); + loginRequest.setSessionId(sessionId); + loginRequest.setEvents(Collections.singletonList(login)); + HttpPost probe = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(probe); + probe.addHeader("Cookie", established.getCookieHeaderValue()); + probe.setEntity(new StringEntity(getObjectMapper().writeValueAsString(loginRequest), ContentType.APPLICATION_JSON)); + RequestResponse afterLogin = executeContextJSONRequest(probe, sessionId); + + assertEquals("Public login must not take over the other profile", + publicCallerId, afterLogin.getContextResponse().getProfileId()); + assertNotNull(profileService.load(otherId)); + rulesService.removeRule("testLogin"); + } + + /** + * Counterpart to {@link #testPublicHttpLogin_cannotMergeIntoExistingOtherProfile()}: the merge + * must still work end to end for a trusted caller, over real HTTP through the auth filter and + * the rules engine, not just when the subject is set programmatically. + */ + @Test + public void testPrivateKeyHttpLogin_canMergeIntoExistingProfile() throws Exception { + ConditionType conditionType = getObjectMapper().readValue( + new File("data/tmp/testLoginEventCondition.json").toURI().toURL(), ConditionType.class); + definitionsService.setConditionType(conditionType); + keepTrying("loginEventCondition not registered", + () -> definitionsService.getConditionType("loginEventCondition"), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + Rule rule = getObjectMapper().readValue(new File("data/tmp/testLogin.json").toURI().toURL(), Rule.class); + createAndWaitForRule(rule); + + String knownEmail = "trusted-merge-" + System.currentTimeMillis() + "@example.com"; + String knownProfileId = "trusted-merge-known-" + System.currentTimeMillis(); + Profile known = new Profile(knownProfileId); + known.setProperty("email", knownEmail); + known.setSystemProperty("mergeIdentifier", knownEmail); + profileService.save(known); + keepTrying("Known profile not found", () -> profileService.load(knownProfileId), Objects::nonNull, + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + // Anonymous browsing first, exactly as a visitor would before logging in. + String sessionId = "trusted-merge-session-" + System.currentTimeMillis(); + ContextRequest pageView = new ContextRequest(); + pageView.setSessionId(sessionId); + HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(establish); + establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(pageView), ContentType.APPLICATION_JSON)); + RequestResponse established = executeContextJSONRequest(establish, sessionId); + String anonymousId = established.getContextResponse().getProfileId(); + assertNotEquals(knownProfileId, anonymousId); + + // The login event is then emitted by a trusted server-side caller after authentication. + CustomItem loginTarget = new CustomItem(knownEmail, "visitor"); + Map<String, Object> loginProps = new HashMap<>(); + loginProps.put("email", knownEmail); + loginTarget.setProperties(loginProps); + Event login = new Event(); + login.setEventType("login"); + login.setScope(TEST_SCOPE); + login.setTarget(loginTarget); + login.setTimeStamp(new Date()); + + ContextRequest loginRequest = new ContextRequest(); + loginRequest.setSessionId(sessionId); + loginRequest.setEvents(Collections.singletonList(login)); + HttpPost trusted = new HttpPost(getFullUrl(CONTEXT_URL)); + addPrivateTenantAuth(trusted, testTenant, testPrivateKeyValue); + trusted.addHeader("Cookie", established.getCookieHeaderValue()); + trusted.setEntity(new StringEntity(getObjectMapper().writeValueAsString(loginRequest), ContentType.APPLICATION_JSON)); + executeContextJSONRequest(trusted, sessionId, -1, false); + + keepTrying("Trusted login should merge the anonymous profile into the known one", + () -> profileService.load(anonymousId), + p -> p != null && knownEmail.equals(p.getProperty("email")), + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + rulesService.removeRule("testLogin"); + } + + + @Test public void testPublicCaller_mismatchedBodyProfileId_ignored() throws Exception { String sessionId = "mismatch-session-" + System.currentTimeMillis(); @@ -782,6 +990,7 @@ public class ContextServletIT extends BaseIT { Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); } + @Test public void testPublicCaller_bodyProfileIdWithoutCookie_rejected() throws Exception { String otherId = "body-only-other-" + System.currentTimeMillis(); diff --git a/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java b/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java index 20011d21c..68853c2b4 100644 --- a/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java @@ -176,6 +176,71 @@ public class ProfileMergeIT extends BaseIT { * - a new one, if it's the first time we encounter his own mergeIdentifier * - a previous one, if we already have a profile in DB with the same mergeIdentifier. (TESTED in this scenario) */ + /** + * Public / publicCallers must not merge into an existing other profile (identity takeover). + * Suite {@code @Before} installs a tenant-admin subject; this test temporarily downgrades it. + */ + @Test + public void testUntrustedCaller_cannotMergeIntoExistingOtherProfile() throws InterruptedException { + createAndWaitForRule(createMergeOnPropertyRule(false, "email")); + + Profile other = new Profile("otherProfileID"); + other.setProperty("email", "[email protected]"); + other.setSystemProperty("mergeIdentifier", "[email protected]"); + profileService.save(other); + + keepTrying("Other profile not found", () -> profileService.load("otherProfileID"), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + Profile publicCaller = new Profile("publicCallerProfileID"); + publicCaller.setProperty("email", "[email protected]"); + Session session = new Session("untrustedMergeSession", publicCaller, new Date(), null); + Event event = new Event(TEST_EVENT_TYPE, session, publicCaller, null, null, publicCaller, new Date()); + + javax.security.auth.Subject previous = securityService.getCurrentSubject(); + try { + securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, false)); + eventService.send(event); + } finally { + securityService.setCurrentSubject(previous); + } + + Assert.assertEquals("publicCallerProfileID", event.getProfile().getItemId()); + Assert.assertEquals("publicCallerProfileID", event.getSession().getProfile().getItemId()); + Assert.assertNotNull(profileService.load("otherProfileID")); + } + + @Test + public void testTrustedPrivateKeySubject_canMergeIntoExistingProfile() throws InterruptedException { + createAndWaitForRule(createMergeOnPropertyRule(false, "email")); + + Profile other = new Profile("trustedOtherProfileID"); + other.setProperty("email", "[email protected]"); + other.setSystemProperty("mergeIdentifier", "[email protected]"); + other.setProperty("firstVisit", new Date(0)); + profileService.save(other); + + keepTrying("Other profile not found", () -> profileService.load("trustedOtherProfileID"), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + Profile caller = new Profile("trustedCallerProfileID"); + caller.setProperty("email", "[email protected]"); + caller.setProperty("firstVisit", new Date()); + Session session = new Session("trustedMergeSession", caller, new Date(), null); + Event event = new Event(TEST_EVENT_TYPE, session, caller, null, null, caller, new Date()); + + javax.security.auth.Subject previous = securityService.getCurrentSubject(); + try { + securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, true)); + eventService.send(event); + } finally { + securityService.setCurrentSubject(previous); + } + + Assert.assertEquals("trustedOtherProfileID", event.getProfile().getItemId()); + Assert.assertEquals("trustedOtherProfileID", event.getSession().getProfile().getItemId()); + } + @Test public void testProfileMergeOnPropertyAction_sessionReassigned_existingProfile() throws InterruptedException { // create rule diff --git a/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java b/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java index ad2206a6b..319fabc0f 100644 --- a/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java @@ -20,6 +20,7 @@ package org.apache.unomi.itests; import org.apache.unomi.api.Event; import org.apache.unomi.api.Profile; import org.apache.unomi.api.rules.Rule; +import org.apache.unomi.api.services.EventService; import org.apache.unomi.plugins.baseplugin.actions.UpdatePropertiesAction; import org.junit.Assert; import org.junit.Before; @@ -118,6 +119,81 @@ public class PropertiesUpdateActionIT extends BaseIT { waitForProfileProperty(PROFILE_TEST_ID, "firstName", "UPDATED FIRST NAME"); } + @Test + public void testUntrustedCaller_cannotUpdateAnotherProfile() throws InterruptedException { + Profile caller = profileService.load(PROFILE_TARGET_TEST_ID); + Profile other = profileService.load(PROFILE_TEST_ID); + Assert.assertNull(other.getProperty("firstName")); + + Event updateProperties = new Event("updateProperties", null, caller, null, null, null, new Date()); + updateProperties.setPersistent(false); + Map<String, Object> propertyToUpdate = new HashMap<>(); + propertyToUpdate.put("properties.firstName", "SHOULD_NOT_APPLY"); + updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE, propertyToUpdate); + updateProperties.setProperty(UpdatePropertiesAction.TARGET_ID_KEY, PROFILE_TEST_ID); + updateProperties.setProperty(UpdatePropertiesAction.TARGET_TYPE_KEY, "profile"); + + javax.security.auth.Subject previous = securityService.getCurrentSubject(); + try { + securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, false)); + int changes = eventService.send(updateProperties); + Assert.assertEquals(EventService.NO_CHANGE, changes); + } finally { + securityService.setCurrentSubject(previous); + } + + shouldBeTrueUntilEnd("Other profile must remain unchanged", + () -> profileService.load(PROFILE_TEST_ID), + p -> p.getProperty("firstName") == null, + DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES); + } + + @Test + public void testUntrustedCaller_cannotWriteSystemProperties() throws InterruptedException { + Profile caller = profileService.load(PROFILE_TEST_ID); + Assert.assertNull(caller.getSystemProperties().get("mergeIdentifier")); + + Event updateProperties = new Event("updateProperties", null, caller, null, null, null, new Date()); + updateProperties.setPersistent(false); + Map<String, Object> propertyToUpdate = new HashMap<>(); + propertyToUpdate.put("systemProperties.mergeIdentifier", "reused"); + updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE, propertyToUpdate); + + javax.security.auth.Subject previous = securityService.getCurrentSubject(); + try { + securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, false)); + eventService.send(updateProperties); + } finally { + securityService.setCurrentSubject(previous); + } + + Assert.assertNull(profileService.load(PROFILE_TEST_ID).getSystemProperties().get("mergeIdentifier")); + } + + @Test + public void testTrustedPrivateKeySubject_canUpdateAnotherProfile() throws InterruptedException { + Profile caller = profileService.load(PROFILE_TARGET_TEST_ID); + Assert.assertNull(profileService.load(PROFILE_TEST_ID).getProperty("firstName")); + + Event updateProperties = new Event("updateProperties", null, caller, null, null, null, new Date()); + updateProperties.setPersistent(false); + Map<String, Object> propertyToUpdate = new HashMap<>(); + propertyToUpdate.put("properties.firstName", "TRUSTED UPDATE"); + updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE, propertyToUpdate); + updateProperties.setProperty(UpdatePropertiesAction.TARGET_ID_KEY, PROFILE_TEST_ID); + updateProperties.setProperty(UpdatePropertiesAction.TARGET_TYPE_KEY, "profile"); + + javax.security.auth.Subject previous = securityService.getCurrentSubject(); + try { + securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, true)); + eventService.send(updateProperties); + } finally { + securityService.setCurrentSubject(previous); + } + + waitForProfileProperty(PROFILE_TEST_ID, "firstName", "TRUSTED UPDATE"); + } + @Test public void testUpdateProperties_CurrentProfile_PROPS_TO_ADD() throws InterruptedException { Profile profile = profileService.load(PROFILE_TEST_ID); diff --git a/manual/src/main/asciidoc/samples/login-sample.adoc b/manual/src/main/asciidoc/samples/login-sample.adoc index dc8a88d20..8e2ae12fb 100644 --- a/manual/src/main/asciidoc/samples/login-sample.adoc +++ b/manual/src/main/asciidoc/samples/login-sample.adoc @@ -14,51 +14,206 @@ [#_login_sample] === Login sample -This sample is an example of what is involved in integrating a login with Apache Unomi. +This sample shows how to integrate a **server-side** login with Apache Unomi so that +`mergeProfilesOnPropertyAction` can merge profiles on email (or another merge key). -==== Warning ! +==== Why server-side? -The example code uses client-side Javascript code to send the login event. This is only -done this way for the sake of samples simplicity but if should NEVER BE DONE THIS WAY in real cases. +Under Unomi 3.1 hardening (https://issues.apache.org/jira/browse/UNOMI-972[UNOMI-972] / +<<_client_facing_hardening_3_1,client-facing hardening>>), cross-profile merge requires a +**trusted** caller (system administrator or tenant private key). A browser call with only a +public API key is not trusted — merge is refused. -The login event should always be sent from the server performing the actual login since it must -only be sent if the user has authenticated properly, and only the authentication server can validate this. +A real authentication server must validate the password, then call Unomi. This sample mimics +that with a small DS servlet inside Unomi (`/login/authenticate`). -==== Installing the samples +==== How it works -Login into the Unomi Karaf SSH shell using something like this : +. Open http://localhost:8181/login/index.html and submit the form + (the password you set as `demoPassword` in the sample configuration). +. The page posts to `/login/authenticate` (same host) — it never posts a login event to `/cxs/context.json` from JavaScript. +. `LoginServlet` checks the demo password, then `POST`s a `login` event to `/cxs/context.json` using Basic auth + (`tenantId:privateKey` — a tenant private key is the only credential the sample accepts). +. The session id is generated by the servlet and kept on the browser's own `HttpSession` — it is + deliberately *not* read from the form. See <<_never_forward_client_supplied_identifiers,Never forward client-supplied identifiers>>. +. The bundled rule `exampleLogin` (`META-INF/cxs/rules/exampleLogin.json`) runs + `mergeProfilesOnPropertyAction` on `target.properties.email` plus `copyPropertiesAction`. -[source] +[plantuml] ---- -ssh -p 8102 karaf@localhost (the password is the one you set in UNOMI_ROOT_PASSWORD; no default is shipped) +@startuml +title Login sample — trusted server-side merge (UNOMI-972) + +actor Browser +participant "Login page\n/login/index.html" as Page +participant "LoginServlet\n/login/authenticate" as Auth +participant "Unomi REST\n/cxs/context.json" as Context +participant "exampleLogin rule" as Rule + +Browser -> Page: Open /login/index.html +Browser -> Page: Submit form (email, password, …) +Page -> Auth: POST /login/authenticate\n(form fields only) + +alt Wrong demo password + Auth --> Page: 401 { error } + Page --> Browser: Show error +else Password OK + Auth -> Auth: Resolve sessionId from HttpSession\n(never from the request) + Auth -> Auth: Build login ContextRequest\n(scope from config) + Auth -> Context: POST /cxs/context.json?sessionId=…\nBasic tenantId:privateKey + activate Context + Context -> Rule: login event + Rule -> Rule: mergeProfilesOnPropertyAction\n(on email) + copyPropertiesAction + Context --> Auth: context JSON + Set-Cookie + deactivate Context + Auth --> Page: Forward Unomi response\n(+ profile cookie) + Page --> Browser: Show profileId +end + +note over Page + Browser never calls /cxs/context.json + for login — only the trusted servlet does. +end note + +note over Context + Public API key alone is not trusted; + merge would be refused. +end note + +@enduml ---- -Install the login samples using the following command: +Source: `samples/login-integration/` (OSGi Declarative Services, no Blueprint). -[source] +[[_never_forward_client_supplied_identifiers]] +==== Never forward client-supplied identifiers + +Moving the login event server-side is only half of the fix. A trusted caller is allowed to adopt +whatever profile owns the `sessionId` it passes, and to merge profiles on whatever identifier the +event carries. A proxy that takes those values from its own untrusted callers and replays them under +trusted credentials hands that power straight back to the browser: anyone who guesses another +visitor's session id could rebind or merge that visitor's profile. + +`LoginServlet` therefore derives the session id from state it controls — a UUID stored on the +browser's container `HttpSession` — and never reads it from a request parameter. Apply the same rule +in your own integration: + +* Derive the `sessionId` from your authenticated server-side session, not from the request body. +* Derive the merge identifier (here, the email) from the account you just authenticated, not from a + form field the caller chose. +* Treat every other Unomi identifier the same way: if the value came from the caller, it must not be + forwarded under credentials the caller does not hold. + +NOTE: The sample's same-origin check is a lightweight stand-in for CSRF protection, and +`demoPassword` is a single shared secret standing in for a user directory. A real integration should +use a per-session CSRF token and authenticate each user against your identity provider. The sample +ships no default `demoPassword` for the same reason Unomi ships no default admin password: a +credential published in source is a credential everyone has. + +==== Build + +From the Unomi source tree: + +[source,bash] ---- -bundle:install mvn:org.apache.unomi/login-integration-sample/${project.version} +mvn -pl samples/login-integration -am install -DskipTests ---- -when the bundle is successfully install you will get an bundle ID back we will call it BUNDLE_ID. +==== Set up -You can then do: +Build the sample, then run the setup script on the Unomi host. It creates the tenant and scope, +issues a tenant private key, generates a demo password, configures the bundle and starts it: -[source] +[source,bash] ---- -bundle:start BUNDLE_ID +mvn -pl samples/login-integration -am install -DskipTests + +export UNOMI_ROOT_PASSWORD='your-admin-password' +./samples/login-integration/setup.sh ---- -If all went well you can access the login samples HTML page here : +`KARAF_HOME` must point at the Unomi install that is **actually running** and serving `UNOMI_URL` — +the script writes into that install's `etc/` and `deploy/` directories, so configuring a different +copy would have no effect. Set it explicitly unless you have exactly one built distribution in the +source tree: -[source] +[source,bash] ---- -http://localhost:8181/login/index.html +cd samples/login-integration +KARAF_HOME=../../package/target/unomi-3.1.0-SNAPSHOT ./setup.sh ---- -You can fill in the form to test it. Note that the hardcoded password is: +The script validates this before changing anything, and refuses to continue if the directory does not +exist, does not look like a Unomi install, is not writable, or belongs to an instance that is stopped +(it checks `karaf.pid` against the running process, so a stale pid file from a previous run is caught +too). If you have more than one built distribution under `package/target`, it will not guess — set +`KARAF_HOME`. + +The script waits until the sample page answers before reporting success, then prints the page URL and +the **generated demo password** — copy it, it is not stored anywhere you can read it back. The private +key is never printed. Re-running is safe: it reuses an existing tenant and scope and issues a fresh key. + +It needs `curl` and `jq`, and filesystem access to the Unomi install — it writes +`etc/org.apache.unomi.samples.login.cfg` (mode `600`, since it holds the private key) and copies the +bundle into `deploy/`, both of which Karaf picks up within a second. `KARAF_HOME` is auto-detected in +the source tree; set it otherwise. No Karaf console, SSH or console credential is involved. Override +`UNOMI_URL`, `UNOMI_TENANT_ID`, `UNOMI_SCOPE` or `DEMO_PASSWORD` if the defaults do not suit; +`./setup.sh --help` lists them. + +NOTE: The script uses the system administrator credential because creating a tenant is an operator +action. The servlet never sees it — it receives only the scoped tenant private key the script issues. +A tenant private key is the **only** credential the sample accepts: a system administrator password +would also satisfy the merge gate, but it grants far more than this sample needs and is not scoped to +a single tenant, so the servlet deliberately refuses to use one. + +===== Setting it up by hand + +If you would rather not run the script, the equivalent steps are: create the tenant and a scope, +`POST /cxs/tenants/<tenant>/apikeys?type=PRIVATE` and keep the returned `plainTextKey` (it is shown +once), then from the Karaf console: -[source] +[source,bash] ---- -test1234 ----- \ No newline at end of file +bundle:install mvn:org.apache.unomi/login-integration-sample/${project.version} +config:edit org.apache.unomi.samples.login +config:property-set tenantId default +config:property-set scope default +config:property-set privateKey <plainTextKey> +config:property-set demoPassword <choose-a-password> +config:update +bundle:start <bundle-id> +---- + +Either way, the bundle reports on activation whether it is usable. A configured sample logs, at +`INFO`: + +---- +Login sample ready - open http://localhost:8181/login/index.html (tenantId=default, scope=default) +---- + +A sample still missing something logs a `WARN` naming exactly what to set. Correct it and run +`config:update` again — the servlet re-reads its configuration and reprints the status without a +restart. + +Ensure the tenant allows login events from the servlet's source IP (typically `127.0.0.1` when +calling localhost) via tenant authorized IPs. + +==== Test profile merge + +. Open http://localhost:8181/login/index.html +. Log in with email `[email protected]` and your configured `demoPassword`. Note the `profileId` in the success message. +. Clear the `context-profile-id` cookie for `localhost` (or use a private browser window) so Unomi would otherwise create a new anonymous profile. +. Log in again with the **same** email. Expect the **same** master `profileId` (merge on email). +. Optional: log in with a different email — expect a different profile. + +If you see `Unable to resolve a tenant`, create the tenant and ensure `tenantId` matches the tenant +the private key belongs to — the key's tenant is what Unomi authenticates against. +If schema validation rejects the scope, create the scope (see above). +If you see a configuration error from `/login/authenticate`, the servlet has no trusted credentials +yet — set `privateKey` as shown above and run `config:update`. + +==== Related + +* <<_client_facing_hardening_3_1,Client-facing hardening (3.1)>> +* <<_how_profile_tracking_works,How profile tracking works>> +* Rule JSON: https://github.com/apache/unomi/blob/master/samples/login-integration/src/main/resources/META-INF/cxs/rules/exampleLogin.json diff --git a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyAction.java b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyAction.java index a29b39f30..ff4832cc3 100644 --- a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyAction.java +++ b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyAction.java @@ -26,6 +26,7 @@ import org.apache.unomi.api.actions.Action; import org.apache.unomi.api.actions.ActionExecutor; import org.apache.unomi.api.conditions.Condition; import org.apache.unomi.api.security.SecurityService; +import org.apache.unomi.api.security.UnomiRoles; import org.apache.unomi.api.services.*; import org.apache.unomi.persistence.spi.PersistenceService; import org.slf4j.Logger; @@ -91,6 +92,13 @@ public class MergeProfilesOnPropertyAction implements ActionExecutor { // Check if the user switched to another profile if (StringUtils.isNotEmpty(currentProfileMergeValue) && !currentProfileMergeValue.equals(mergePropValue)) { + if (!isTrustedIdentityCaller()) { + LOGGER.warn("Refusing profile merge switch for untrusted caller (mergeProp={})", mergePropName); + if (tracer != null) { + tracer.endOperation(false, "Untrusted caller cannot switch merge identity"); + } + return EventService.NO_CHANGE; + } if (tracer != null) { tracer.trace("Profile switch detected", Map.of( "fromValue", currentProfileMergeValue, @@ -119,6 +127,18 @@ public class MergeProfilesOnPropertyAction implements ActionExecutor { return profileUpdated ? EventService.PROFILE_UPDATED : EventService.NO_CHANGE; } + // Merging into another existing profile rebinds the session — require a caller holding + // system access (see isTrustedIdentityCaller), not a public/unauthenticated event. + if (!isTrustedIdentityCaller()) { + LOGGER.warn("Refusing profile merge for untrusted caller (mergeProp={}, candidates={})", + mergePropName, profilesToBeMerge.size()); + if (tracer != null) { + tracer.endOperation(false, "Untrusted caller cannot merge into another profile"); + } + // Keep only the merge identifier write on the current profile when it was empty + return profileUpdated ? EventService.PROFILE_UPDATED : EventService.NO_CHANGE; + } + // add current Profile to profiles to be merged if (profilesToBeMerge.stream().noneMatch(p -> StringUtils.equals(p.getItemId(), eventProfile.getItemId()))) { profilesToBeMerge.add(eventProfile); @@ -327,6 +347,18 @@ public class MergeProfilesOnPropertyAction implements ActionExecutor { } } + /** + * Whether the caller holds system access, i.e. the administrator or tenant administrator role. + * <p> + * This is a role check, not a check of the credential that produced it: a tenant private key + * authenticates as {@link UnomiRoles#TENANT_ADMINISTRATOR} and therefore passes, while a tenant + * public API key or an unauthenticated context event does not. Identity merges rebind sessions, + * so they are restricted to callers that hold that role. + */ + private boolean isTrustedIdentityCaller() { + return securityService != null && securityService.hasSystemAccess(); + } + public void setProfileService(ProfileService profileService) { this.profileService = profileService; } diff --git a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java index af9a2a091..78070d144 100644 --- a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java +++ b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java @@ -24,6 +24,9 @@ import org.apache.unomi.api.Profile; import org.apache.unomi.api.PropertyType; import org.apache.unomi.api.actions.Action; import org.apache.unomi.api.actions.ActionExecutor; +import org.apache.unomi.api.security.SecurityService; +import org.apache.unomi.api.utils.LogSanitizer; +import org.apache.unomi.api.security.UnomiRoles; import org.apache.unomi.api.services.EventService; import org.apache.unomi.api.services.ProfileService; import org.apache.unomi.persistence.spi.PropertyHelper; @@ -46,11 +49,17 @@ public class UpdatePropertiesAction implements ActionExecutor { public static final String TARGET_TYPE_PROFILE = "profile"; + /** The reserved profile field that only a caller with system access may write. */ + private static final String SYSTEM_PROPERTIES_KEY = "systemProperties"; + /** Prefix of the profile properties that only a caller with system access may write. */ + private static final String SYSTEM_PROPERTIES_PREFIX = SYSTEM_PROPERTIES_KEY + "."; + private static final Logger LOGGER = LoggerFactory.getLogger(UpdatePropertiesAction.class.getName()); private ProfileService profileService; private EventService eventService; private TracerService tracerService; + private SecurityService securityService; public int execute(Action action, Event event) { RequestTracer tracer = null; @@ -64,6 +73,8 @@ public class UpdatePropertiesAction implements ActionExecutor { Profile target = event.getProfile(); String targetId = (String) event.getProperty(TARGET_ID_KEY); String targetType = (String) event.getProperty(TARGET_TYPE_KEY); + // Resolved once for the whole action: the caller's roles cannot change while it runs. + final boolean trustedCaller = isTrustedIdentityCaller(); if (tracer != null) { Map<String, Object> traceData = new HashMap<>(); @@ -74,12 +85,20 @@ public class UpdatePropertiesAction implements ActionExecutor { } if (StringUtils.isNotBlank(targetId) && event.getProfile() != null && !targetId.equals(event.getProfile().getItemId())) { + if (!trustedCaller) { + LOGGER.warn("Refusing cross-profile property update for untrusted caller (targetId={})", + LogSanitizer.forLogging(targetId)); + if (tracer != null) { + tracer.endOperation(false, "Untrusted caller cannot update another profile"); + } + return EventService.NO_CHANGE; + } target = TARGET_TYPE_PROFILE.equals(targetType) ? profileService.load(targetId) : profileService.loadPersona(targetId); if (target == null) { if (tracer != null) { tracer.endOperation(false, "No profile found with Id: " + targetId); } - LOGGER.warn("No profile found with Id : {}. Update skipped.", targetId); + LOGGER.warn("No profile found with Id : {}. Update skipped.", LogSanitizer.forLogging(targetId)); return EventService.NO_CHANGE; } } @@ -89,22 +108,26 @@ public class UpdatePropertiesAction implements ActionExecutor { Map<String, Object> propsToAdd = (HashMap<String, Object>) event.getProperties().get(PROPS_TO_ADD); if (propsToAdd != null) { - isProfileOrPersonaUpdated |= processProperties(target, propsToAdd, "setIfMissing"); + isProfileOrPersonaUpdated |= processProperties(target, propsToAdd, "setIfMissing", trustedCaller); } Map<String, Object> propsToUpdate = (HashMap<String, Object>) event.getProperties().get(PROPS_TO_UPDATE); if (propsToUpdate != null) { - isProfileOrPersonaUpdated |= processProperties(target, propsToUpdate, "alwaysSet"); + isProfileOrPersonaUpdated |= processProperties(target, propsToUpdate, "alwaysSet", trustedCaller); } Map<String, Object> propsToAddToSet = (HashMap<String, Object>) event.getProperties().get(PROPS_TO_ADD_TO_SET); if (propsToAddToSet != null) { - isProfileOrPersonaUpdated |= processProperties(target, propsToAddToSet, "addValues"); + isProfileOrPersonaUpdated |= processProperties(target, propsToAddToSet, "addValues", trustedCaller); } List<String> propsToDelete = (List<String>) event.getProperties().get(PROPS_TO_DELETE); if (propsToDelete != null) { for (String prop : propsToDelete) { + if (!trustedCaller && isSystemPropertiesWrite(prop)) { + LOGGER.warn("Refusing systemProperties delete for untrusted caller: {}", LogSanitizer.forLogging(prop)); + continue; + } isProfileOrPersonaUpdated |= PropertyHelper.setProperty(target, prop, null, "remove"); } } @@ -140,11 +163,15 @@ public class UpdatePropertiesAction implements ActionExecutor { } } - private boolean processProperties(Profile target, Map<String, Object> propsMap, String strategy) { + private boolean processProperties(Profile target, Map<String, Object> propsMap, String strategy, boolean trustedCaller) { boolean isProfileOrPersonaUpdated = false; for (String prop : propsMap.keySet()) { + if (!trustedCaller && isSystemPropertiesWrite(prop)) { + LOGGER.warn("Refusing systemProperties update for untrusted caller: {}", LogSanitizer.forLogging(prop)); + continue; + } PropertyType propType = null; - if (prop.startsWith("properties.") || prop.startsWith("systemProperties.")) { + if (prop.startsWith("properties.") || prop.startsWith(SYSTEM_PROPERTIES_PREFIX)) { propType = profileService.getPropertyType(prop.substring(prop.indexOf('.') + 1)); } else { propType = profileService.getPropertyType(prop); @@ -162,6 +189,34 @@ public class UpdatePropertiesAction implements ActionExecutor { return isProfileOrPersonaUpdated; } + + /** + * Whether a property name writes the reserved {@code systemProperties} area. + * <p> + * Matching the {@code systemProperties.} prefix alone was not enough: the bare key + * {@code systemProperties} has no dot, so it slipped through, and for a flat name + * {@link org.apache.unomi.persistence.spi.PropertyHelper#setProperty} falls through to + * {@code BeanUtils.setProperty}, which invokes {@code Profile#setSystemProperties(Map)} and + * replaces the entire map. That is strictly more than the per-key write this gate blocks — it + * is enough to plant {@code mergeIdentifier} and drive the profile-merge action. + * + * @param propertyName the event-supplied property name + * @return true when the name targets systemProperties, whether wholesale or a single entry + */ + private static boolean isSystemPropertiesWrite(String propertyName) { + return SYSTEM_PROPERTIES_KEY.equals(propertyName) || propertyName.startsWith(SYSTEM_PROPERTIES_PREFIX); + } + /** + * Whether the caller holds system access, i.e. the administrator or tenant administrator role. + * <p> + * Cross-profile updates and {@code systemProperties.*} writes are restricted to such callers. + * A tenant private key authenticates as {@link UnomiRoles#TENANT_ADMINISTRATOR} and passes; a + * tenant public API key or an unauthenticated context event does not. + */ + private boolean isTrustedIdentityCaller() { + return securityService != null && securityService.hasSystemAccess(); + } + public void setProfileService(ProfileService profileService) { this.profileService = profileService; } @@ -174,4 +229,8 @@ public class UpdatePropertiesAction implements ActionExecutor { this.tracerService = tracerService; } + public void setSecurityService(SecurityService securityService) { + this.securityService = securityService; + } + } diff --git a/plugins/baseplugin/src/main/resources/OSGI-INF/blueprint/blueprint.xml b/plugins/baseplugin/src/main/resources/OSGI-INF/blueprint/blueprint.xml index 2b10af39a..c317dc34d 100644 --- a/plugins/baseplugin/src/main/resources/OSGI-INF/blueprint/blueprint.xml +++ b/plugins/baseplugin/src/main/resources/OSGI-INF/blueprint/blueprint.xml @@ -181,6 +181,7 @@ <property name="profileService" ref="profileService"/> <property name="eventService" ref="eventService"/> <property name="tracerService" ref="tracerService"/> + <property name="securityService" ref="securityService"/> </bean> </service> diff --git a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java new file mode 100644 index 000000000..0ae1783aa --- /dev/null +++ b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java @@ -0,0 +1,136 @@ +/* + * 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.plugins.baseplugin.actions; + +import org.apache.unomi.api.Event; +import org.apache.unomi.api.PartialList; +import org.apache.unomi.api.Profile; +import org.apache.unomi.api.actions.Action; +import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.security.SecurityService; +import org.apache.unomi.api.security.UnomiRoles; +import org.apache.unomi.api.services.DefinitionsService; +import org.apache.unomi.api.services.EventService; +import org.apache.unomi.api.services.PrivacyService; +import org.apache.unomi.api.services.ProfileService; +import org.apache.unomi.persistence.spi.PersistenceService; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Regression: public/untrusted events must not rebind a session onto another profile via merge. + */ +@RunWith(MockitoJUnitRunner.class) +public class MergeProfilesOnPropertyActionTest { + + @Mock private ProfileService profileService; + @Mock private PersistenceService persistenceService; + @Mock private EventService eventService; + @Mock private DefinitionsService definitionsService; + @Mock private PrivacyService privacyService; + @Mock private SecurityService securityService; + + private MergeProfilesOnPropertyAction actionExecutor; + + @Before + public void setUp() { + actionExecutor = new MergeProfilesOnPropertyAction(); + actionExecutor.setProfileService(profileService); + actionExecutor.setPersistenceService(persistenceService); + actionExecutor.setEventService(eventService); + actionExecutor.setDefinitionsService(definitionsService); + actionExecutor.setPrivacyService(privacyService); + actionExecutor.bindSecurityService(securityService); + actionExecutor.setMaxProfilesInOneMerge("50"); + + when(definitionsService.getConditionType("profilePropertyCondition")).thenReturn(new ConditionType()); + when(securityService.hasSystemAccess()).thenReturn(false); + } + + @Test + public void untrustedCaller_cannotMergeIntoExistingOtherProfile() { + Profile publicCaller = new Profile("public-caller"); + Profile other = new Profile("other"); + other.getSystemProperties().put("mergeIdentifier", "[email protected]"); + + when(persistenceService.query(any(), anyString(), eq(Profile.class), anyInt(), anyInt())) + .thenReturn(new PartialList<>(new ArrayList<>(Collections.singletonList(other)), 0, 1, 1, PartialList.Relation.EQUAL)); + + Event event = new Event("login", null, publicCaller, "systemscope", null, null, null, new Date(), true); + Action action = mergeAction("[email protected]"); + + int changes = actionExecutor.execute(action, event); + + // May write mergeIdentifier onto the untrusted caller profile, but must not rebind to other + assertNotEquals(EventService.PROFILE_UPDATED + EventService.SESSION_UPDATED, changes); + assertEquals("public-caller", event.getProfile().getItemId()); + verify(profileService, never()).mergeProfiles(any(), any()); + } + + @Test + public void trustedTenantAdmin_canMergeIntoExistingProfile() { + when(securityService.hasSystemAccess()).thenReturn(true); + + Profile caller = new Profile("caller"); + Profile other = new Profile("other"); + other.setProperty("firstVisit", new Date(0)); + caller.setProperty("firstVisit", new Date()); + + when(persistenceService.query(any(), anyString(), eq(Profile.class), anyInt(), anyInt())) + .thenReturn(new PartialList<>(new ArrayList<>(Collections.singletonList(other)), 0, 1, 1, PartialList.Relation.EQUAL)); + when(profileService.mergeProfiles(eq(other), any())).thenReturn(other); + when(privacyService.isRequireAnonymousBrowsing(any(Profile.class))).thenReturn(false); + when(privacyService.isRequireAnonymousBrowsing("other")).thenReturn(false); + + Event event = new Event("login", null, caller, "systemscope", null, null, null, new Date(), true); + Action action = mergeAction("[email protected]"); + + int changes = actionExecutor.execute(action, event); + + assertEquals(EventService.PROFILE_UPDATED + EventService.SESSION_UPDATED, changes); + assertEquals("other", event.getProfile().getItemId()); + verify(profileService).mergeProfiles(eq(other), any()); + } + + private static Action mergeAction(String mergeValue) { + Action action = new Action(); + Map<String, Object> params = new HashMap<>(); + params.put("mergeProfilePropertyName", "mergeIdentifier"); + params.put("mergeProfilePropertyValue", mergeValue); + action.setParameterValues(params); + return action; + } +} diff --git a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java new file mode 100644 index 000000000..f1e33efd2 --- /dev/null +++ b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java @@ -0,0 +1,193 @@ +/* + * 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.plugins.baseplugin.actions; + +import org.apache.unomi.api.Event; +import org.apache.unomi.api.Profile; +import org.apache.unomi.api.actions.Action; +import org.apache.unomi.api.security.SecurityService; +import org.apache.unomi.api.security.UnomiRoles; +import org.apache.unomi.api.services.EventService; +import org.apache.unomi.api.services.ProfileService; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Regression: untrusted events must not update another profile or systemProperties. + */ +@RunWith(MockitoJUnitRunner.class) +public class UpdatePropertiesActionTest { + + @Mock private ProfileService profileService; + @Mock private EventService eventService; + @Mock private SecurityService securityService; + + private UpdatePropertiesAction actionExecutor; + + @Before + public void setUp() { + actionExecutor = new UpdatePropertiesAction(); + actionExecutor.setProfileService(profileService); + actionExecutor.setEventService(eventService); + actionExecutor.setSecurityService(securityService); + + when(securityService.hasSystemAccess()).thenReturn(false); + } + + @Test + public void untrustedCaller_cannotUpdateAnotherProfile() { + Profile caller = new Profile("caller"); + Map<String, Object> updateMap = new HashMap<>(); + updateMap.put("properties.email", "changed"); + Map<String, Object> eventProps = new HashMap<>(); + eventProps.put(UpdatePropertiesAction.TARGET_ID_KEY, "other"); + eventProps.put(UpdatePropertiesAction.TARGET_TYPE_KEY, UpdatePropertiesAction.TARGET_TYPE_PROFILE); + eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, updateMap); + + Event event = new Event("updateProperties", null, caller, "systemscope", null, null, eventProps, new Date(), true); + Action action = new Action(); + + int changes = actionExecutor.execute(action, event); + + assertEquals(EventService.NO_CHANGE, changes); + verify(profileService, never()).load(any(String.class)); + verify(profileService, never()).save(any(Profile.class)); + } + + @Test + public void untrustedCaller_cannotWriteSystemProperties() { + Profile caller = new Profile("caller"); + Map<String, Object> updateMap = new HashMap<>(); + updateMap.put("systemProperties.mergeIdentifier", "reused"); + Map<String, Object> eventProps = new HashMap<>(); + eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, updateMap); + + Event event = new Event("updateProperties", null, caller, "systemscope", null, null, eventProps, new Date(), true); + + int changes = actionExecutor.execute(new Action(), event); + + assertEquals(EventService.NO_CHANGE, changes); + assertEquals(null, caller.getSystemProperties().get("mergeIdentifier")); + } + + /** + * The gate matched on the {@code systemProperties.} prefix only, so the exact key + * {@code systemProperties} slipped past it. That is not a harmless near-miss: for a flat name + * PropertyHelper falls through to {@code BeanUtils.setProperty}, which calls + * {@code Profile#setSystemProperties(Map)} and replaces the whole map — a superset of the + * per-key write the gate exists to block, and enough to plant {@code mergeIdentifier} and drive + * the profile-merge action. + */ + @Test + public void untrustedCaller_cannotReplaceTheWholeSystemPropertiesMap() { + Profile caller = new Profile("caller"); + caller.getSystemProperties().put("mergeIdentifier", "[email protected]"); + + Map<String, Object> replacement = new HashMap<>(); + replacement.put("mergeIdentifier", "[email protected]"); + Map<String, Object> updateMap = new HashMap<>(); + updateMap.put("systemProperties", replacement); + Map<String, Object> eventProps = new HashMap<>(); + eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, updateMap); + + Event event = new Event("updateProperties", null, caller, "systemscope", null, null, eventProps, new Date(), true); + + int changes = actionExecutor.execute(new Action(), event); + + assertEquals(EventService.NO_CHANGE, changes); + assertEquals("the untrusted caller must not replace the systemProperties map", + "[email protected]", caller.getSystemProperties().get("mergeIdentifier")); + } + + /** + * The delete mapping does not behave the same way - PropertyHelper's remove strategy bails out + * for a name with no dot - so this passes even against the unfixed gate. Kept as a guard: if that + * remove path ever learns to handle flat names, this catches it rather than the next reporter. + */ + @Test + public void untrustedCaller_cannotDeleteTheWholeSystemPropertiesMap() { + Profile caller = new Profile("caller"); + caller.getSystemProperties().put("mergeIdentifier", "[email protected]"); + + Map<String, Object> eventProps = new HashMap<>(); + eventProps.put(UpdatePropertiesAction.PROPS_TO_DELETE, + java.util.Collections.singletonList("systemProperties")); + + Event event = new Event("updateProperties", null, caller, "systemscope", null, null, eventProps, new Date(), true); + + actionExecutor.execute(new Action(), event); + + assertEquals("the untrusted caller must not clear the systemProperties map", + "[email protected]", caller.getSystemProperties().get("mergeIdentifier")); + } + + /** A trusted caller is still allowed to set it, so the gate is a trust check and not a ban. */ + @Test + public void trustedAdmin_mayReplaceTheSystemPropertiesMap() { + when(securityService.hasSystemAccess()).thenReturn(true); + + Profile caller = new Profile("caller"); + Map<String, Object> replacement = new HashMap<>(); + replacement.put("mergeIdentifier", "[email protected]"); + Map<String, Object> updateMap = new HashMap<>(); + updateMap.put("systemProperties", replacement); + Map<String, Object> eventProps = new HashMap<>(); + eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, updateMap); + + Event event = new Event("updateProperties", null, caller, "systemscope", null, null, eventProps, new Date(), true); + + actionExecutor.execute(new Action(), event); + + assertEquals("[email protected]", caller.getSystemProperties().get("mergeIdentifier")); + } + + @Test + public void trustedAdmin_canUpdateAnotherProfile() { + when(securityService.hasSystemAccess()).thenReturn(true); + + Profile caller = new Profile("caller"); + Profile other = new Profile("other"); + when(profileService.load("other")).thenReturn(other); + + Map<String, Object> updateMap = new HashMap<>(); + updateMap.put("properties.email", "admin-set"); + Map<String, Object> eventProps = new HashMap<>(); + eventProps.put(UpdatePropertiesAction.TARGET_ID_KEY, "other"); + eventProps.put(UpdatePropertiesAction.TARGET_TYPE_KEY, UpdatePropertiesAction.TARGET_TYPE_PROFILE); + eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, updateMap); + + Event event = new Event("updateProperties", null, caller, "systemscope", null, null, eventProps, new Date(), true); + + actionExecutor.execute(new Action(), event); + + verify(profileService).load("other"); + verify(profileService).save(other); + } +} diff --git a/samples/login-integration/pom.xml b/samples/login-integration/pom.xml index 772e2aeab..6d10ebfa5 100644 --- a/samples/login-integration/pom.xml +++ b/samples/login-integration/pom.xml @@ -25,7 +25,7 @@ </parent> <artifactId>login-integration-sample</artifactId> <name>Apache Unomi :: Samples :: External Login plugin</name> - <description>This is a simple Apache Unomi plugin.</description> + <description>Server-side login sample that calls Unomi with trusted credentials so profile merge works (UNOMI-972).</description> <packaging>bundle</packaging> <dependencyManagement> @@ -42,16 +42,40 @@ <dependencies> <dependency> - <groupId>org.apache.unomi</groupId> - <artifactId>unomi-api</artifactId> + <groupId>org.osgi</groupId> + <artifactId>org.osgi.service.component.annotations</artifactId> <scope>provided</scope> </dependency> <dependency> - <groupId>javax.servlet.jsp</groupId> - <artifactId>jsp-api</artifactId> - <version>2.1</version> + <groupId>org.osgi</groupId> + <artifactId>org.osgi.service.metatype.annotations</artifactId> <scope>provided</scope> </dependency> + <dependency> + <groupId>javax.servlet</groupId> + <artifactId>javax.servlet-api</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.fasterxml.jackson.core</groupId> + <artifactId>jackson-databind</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-api</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <scope>test</scope> + </dependency> </dependencies> <build> @@ -62,10 +86,10 @@ <extensions>true</extensions> <configuration> <instructions> - <_wab>src/main/webapp</_wab> - <Embed-Dependency>*;scope=compile|runtime</Embed-Dependency> - <Embed-Directory>WEB-INF/lib</Embed-Directory> - <Web-ContextPath>/login</Web-ContextPath> + <_dsannotations>*</_dsannotations> + <_metatypeannotations>*</_metatypeannotations> + <Export-Package>!*</Export-Package> + <Private-Package>org.apache.unomi.samples.login.*</Private-Package> </instructions> </configuration> </plugin> diff --git a/samples/login-integration/setup.sh b/samples/login-integration/setup.sh new file mode 100755 index 000000000..ea56b13eb --- /dev/null +++ b/samples/login-integration/setup.sh @@ -0,0 +1,299 @@ +#!/bin/sh +# +# 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. +# +# Provisions and configures the Apache Unomi login sample on a LOCAL instance. +# +# This is a demo convenience, not a deployment tool. It uses the system administrator +# credential because provisioning a tenant is an operator action - the sample servlet itself +# only ever receives the scoped tenant private key this script creates for it. +# +# Idempotent: re-running reuses an existing tenant and scope, and issues a fresh private key. +# +# Usage: +# export UNOMI_ROOT_PASSWORD='your-admin-password' +# ./setup.sh [--version <sample-version>] +# +# Optional environment overrides: +# UNOMI_URL base URL of the running Unomi (default http://localhost:8181) +# UNOMI_TENANT_ID tenant to create/use (default default) +# UNOMI_SCOPE scope to create/use (default default) +# KARAF_HOME Unomi install dir (auto-detected in the source tree) +# DEMO_PASSWORD login-form password (default: randomly generated) + +set -eu + +UNOMI_URL="${UNOMI_URL:-http://localhost:8181}" +TENANT_ID="${UNOMI_TENANT_ID:-default}" +SCOPE="${UNOMI_SCOPE:-default}" +PID="org.apache.unomi.samples.login" +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +SAMPLE_VERSION="" + +usage() { + cat <<'USAGE' +Provisions and configures the Apache Unomi login sample on a local instance. + + export UNOMI_ROOT_PASSWORD='your-admin-password' + ./setup.sh [--version <sample-version>] + +Environment overrides: UNOMI_URL, UNOMI_TENANT_ID, UNOMI_SCOPE, KARAF_HOME, DEMO_PASSWORD. +USAGE +} + +fail() { echo "ERROR: $*" >&2; exit 1; } + +# The cfg is parsed as a Java .properties file, which treats backslash as an escape character and +# strips whitespace between the separator and the value. Writing a value verbatim would therefore +# store something different from what the operator typed - "p@ss\\word" silently becomes +# "p@ssword" - and the resulting login failure gives no clue why. Escape backslashes, then escape a +# leading space or tab so it survives. +properties_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/^\([ \t]\)/\\\1/' +} + +while [ $# -gt 0 ]; do + case "$1" in + --version) SAMPLE_VERSION="${2:?--version needs a value}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1 (try --help)" >&2; exit 2 ;; + esac +done + +for cmd in curl jq; do + command -v "$cmd" >/dev/null 2>&1 || fail "$cmd is required but not on PATH" +done + +[ -n "${UNOMI_ROOT_PASSWORD:-}" ] || fail "UNOMI_ROOT_PASSWORD is not set. Export the administrator + password you started Unomi with, then run this script again." + +# Configuration and deployment go through the directories Karaf already watches +# (felix.fileinstall polls etc/ for *.cfg and deploy/ for bundles), so this script needs no Karaf +# console: no SSH, no host keys, and no console credential on the command line. +# +# That also means everything is written to a directory rather than to the running process, so the +# directory has to be validated properly: pointing at a freshly built distribution while a different +# one is actually serving UNOMI_URL would write the config into an install nobody is reading. +if [ -n "${KARAF_HOME:-}" ]; then + KARAF_DIR="$KARAF_HOME" +else + KARAF_DIR="" + for candidate in "${SCRIPT_DIR}"/../../package/target/unomi-*/; do + [ -f "${candidate}etc/config.properties" ] || continue + [ -z "$KARAF_DIR" ] || fail "Found more than one built distribution under package/target. + Set KARAF_HOME to the one that is running." + KARAF_DIR="$candidate" + done + [ -n "$KARAF_DIR" ] || fail "Could not find a Unomi install. Set KARAF_HOME to the directory of the + running instance, for example: + KARAF_HOME=../../package/target/unomi-3.1.0-SNAPSHOT ./setup.sh" +fi + +[ -d "$KARAF_DIR" ] || fail "KARAF_HOME does not exist: ${KARAF_DIR}" +KARAF_DIR=$(CDPATH= cd -- "$KARAF_DIR" && pwd) + +# Looks like a Karaf install at all? +for marker in etc/config.properties bin/karaf deploy; do + [ -e "${KARAF_DIR}/${marker}" ] \ + || fail "${KARAF_DIR} does not look like a Unomi install (missing ${marker}). Set KARAF_HOME." +done + +# Writable? Failing here beats a half-applied setup. +for dir in etc deploy; do + [ -w "${KARAF_DIR}/${dir}" ] || fail "${KARAF_DIR}/${dir} is not writable by $(id -un)." +done + +# Actually running? Karaf writes karaf.pid at startup; a stale file from a previous run is common, +# so the process is checked rather than just the file. Without this the script would happily +# configure a stopped install and only fail later, at the readiness poll, with a confusing message. +KARAF_PID_FILE="${KARAF_DIR}/karaf.pid" +[ -f "$KARAF_PID_FILE" ] || fail "No karaf.pid in ${KARAF_DIR} - that instance has never been started. + Start Unomi there, or point KARAF_HOME at the instance serving ${UNOMI_URL}." +KARAF_PID=$(cat "$KARAF_PID_FILE" 2>/dev/null || true) +{ [ -n "$KARAF_PID" ] && kill -0 "$KARAF_PID" 2>/dev/null; } \ + || fail "${KARAF_DIR} has a stale karaf.pid (process ${KARAF_PID:-unknown} is not running). + Start that instance, or point KARAF_HOME at the one serving ${UNOMI_URL}." + +echo "==> Using Unomi install ${KARAF_DIR} (running, pid ${KARAF_PID})" + +# Credentials go into a mode-600 netrc rather than curl --user: --user places the password in the +# process arguments, where any local user can read it from ps for the lifetime of the request. +NETRC=$(mktemp) || fail "Could not create a temporary file" +chmod 600 "$NETRC" +trap 'rm -f "$NETRC"' EXIT INT TERM HUP +UNOMI_HOST=$(printf '%s' "$UNOMI_URL" | sed -e 's,^[A-Za-z][A-Za-z0-9+.-]*://,,' -e 's,[:/].*$,,') +[ -n "$UNOMI_HOST" ] || fail "Could not parse a host out of UNOMI_URL='${UNOMI_URL}'" +printf 'machine %s login karaf password %s\n' "$UNOMI_HOST" "$UNOMI_ROOT_PASSWORD" > "$NETRC" + +# curl exits 0 for any completed HTTP transaction, including 401/403/500, so a bare "curl || fail" +# reports failed requests as successes. Every call therefore checks the status code explicitly and +# surfaces it, rather than relying on curl's exit status. +# $1 = description used in the error message; remaining args go to curl; body goes to stdout. +admin_request() { + _what="$1"; shift + _out=$(mktemp) || fail "Could not create a temporary file" + _code=$(curl -sS -o "$_out" -w '%{http_code}' --netrc-file "$NETRC" "$@" 2>/dev/null) || _code="000" + case "$_code" in + 2*) + cat "$_out"; rm -f "$_out"; return 0 ;; + 000) + rm -f "$_out" + fail "${_what}: could not connect to ${UNOMI_URL}. Is Unomi running?" ;; + 401|403) + rm -f "$_out" + fail "${_what}: HTTP ${_code}. Check that UNOMI_ROOT_PASSWORD is the administrator + password of the instance at ${UNOMI_URL}." ;; + *) + _body=$(head -c 400 "$_out" 2>/dev/null || true); rm -f "$_out" + fail "${_what}: HTTP ${_code}. ${_body}" ;; + esac +} + +# Existence checks must NOT fail on a non-2xx: "not found" is the normal create-it path, and the +# endpoints differ in how they say it (404, or an empty 2xx body). These two helpers therefore +# report what came back instead of treating it as an error; a genuine permission problem still +# surfaces loudly at the following create call, which goes through admin_request. +admin_status() { curl -sS -o /dev/null -w '%{http_code}' --netrc-file "$NETRC" "$@" 2>/dev/null || echo "000"; } +admin_body_or_empty() { curl -sS --netrc-file "$NETRC" "$@" 2>/dev/null || true; } + +echo "==> Checking Unomi at ${UNOMI_URL}" +admin_request "Connecting to ${UNOMI_URL}" "${UNOMI_URL}/cxs/tenants" >/dev/null +echo " reachable, administrator credentials accepted" + +echo "==> Tenant '${TENANT_ID}'" +if [ "$(admin_status "${UNOMI_URL}/cxs/tenants/${TENANT_ID}")" = "200" ]; then + echo " already exists, reusing" +else + admin_request "Creating tenant '${TENANT_ID}'" -X POST "${UNOMI_URL}/cxs/tenants" \ + -H "Content-Type: application/json" \ + -d "{\"requestedId\":\"${TENANT_ID}\",\"properties\":{\"name\":\"Login sample tenant\"}}" \ + >/dev/null + echo " created" +fi + +echo "==> Scope '${SCOPE}'" +existing_scope=$(admin_body_or_empty -H "X-Unomi-Tenant-Id: ${TENANT_ID}" "${UNOMI_URL}/cxs/scopes/${SCOPE}") +if printf '%s' "$existing_scope" | jq -e '.itemId? // empty' >/dev/null 2>&1; then + echo " already exists, reusing" +else + admin_request "Creating scope '${SCOPE}'" -X POST "${UNOMI_URL}/cxs/scopes" \ + -H "Content-Type: application/json" \ + -H "X-Unomi-Tenant-Id: ${TENANT_ID}" \ + -d "{\"itemId\":\"${SCOPE}\",\"metadata\":{\"id\":\"${SCOPE}\",\"name\":\"Login sample scope\"}}" \ + >/dev/null + echo " created" +fi + +# The plaintext of a private key is returned once, at creation, so it is captured here and never +# echoed. An empty value means the request failed; configuring the sample with it would leave the +# servlet with a blank credential. +echo "==> Issuing a tenant private key" +PRIVATE_KEY=$(admin_request "Issuing a private key for '${TENANT_ID}'" \ + -X POST "${UNOMI_URL}/cxs/tenants/${TENANT_ID}/apikeys?type=PRIVATE" \ + | jq -r '.plainTextKey // empty') +[ -n "$PRIVATE_KEY" ] || fail "No plainTextKey was returned. Check that tenant '${TENANT_ID}' exists + and that the administrator credentials are correct." +echo " issued (not printed)" + +# No demo password ships with the sample, for the same reason Unomi ships no default admin password. +if [ -n "${DEMO_PASSWORD:-}" ]; then + case "$DEMO_PASSWORD" in + *"$(printf '\n')"*) fail "DEMO_PASSWORD must not contain a newline." ;; + esac + LOGIN_PASSWORD="$DEMO_PASSWORD" +else + LOGIN_PASSWORD=$(LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20) \ + || fail "Could not generate a demo password" +fi + +# Written before the bundle is deployed so the component sees its configuration on first activation. +# Holds the private key and the demo password, hence the restrictive mode. +echo "==> Writing ${KARAF_DIR}/etc/${PID}.cfg" +CFG="${KARAF_DIR}/etc/${PID}.cfg" +# Scoped so the restrictive mode applies to the cfg only: leaving umask 077 set would also strip +# group/other bits from the bundle jar copied into deploy/ further down. +_previous_umask=$(umask) +umask 077 +cat > "$CFG" <<EOF +# Generated by samples/login-integration/setup.sh - safe to edit or delete. +unomiBaseUrl=${UNOMI_URL} +tenantId=${TENANT_ID} +scope=${SCOPE} +privateKey=$(properties_escape "$PRIVATE_KEY") +demoPassword=$(properties_escape "$LOGIN_PASSWORD") +EOF +chmod 600 "$CFG" +umask "$_previous_umask" +echo " written (mode 600)" + +echo "==> Deploying the sample bundle" +SAMPLE_JAR=$(ls "${SCRIPT_DIR}"/target/login-integration-sample-*.jar 2>/dev/null | head -1) +[ -n "$SAMPLE_JAR" ] || fail "No built bundle in ${SCRIPT_DIR}/target. + Build it first: mvn -pl samples/login-integration -am install -DskipTests" +cp "$SAMPLE_JAR" "${KARAF_DIR}/deploy/" || fail "Could not copy the bundle into ${KARAF_DIR}/deploy" +echo " copied $(basename "$SAMPLE_JAR") into deploy/" + +# Karaf polls deploy/ once a second, so confirm the sample really came up rather than reporting +# success on the basis of having copied a file. +# +# Probe the servlet, not the static page: /login/index.html is published by LoginSampleResources, +# a separate component with no configuration dependency, so it answers 200 as soon as the bundle +# resolves - even while LoginServlet is still unconfigured and every login returns 503. That window +# is real, not theoretical: the two components activate independently as fileinstall picks up the +# cfg and the jar. Posting a deliberately wrong password distinguishes the states: +# 401 = servlet up AND configured (it got as far as checking the password) +# 503 = deployed but not configured yet +# 404/000 = not deployed yet +echo "==> Waiting for the sample to answer" +DEPLOY_STATUS="" +i=0 +while [ $i -lt 30 ]; do + DEPLOY_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \ + --data "[email protected]&password=setup-probe-wrong-password" \ + "${UNOMI_URL}/login/authenticate" 2>/dev/null || echo "000") + [ "$DEPLOY_STATUS" = "401" ] && break + sleep 1 + i=$((i + 1)) +done +if [ "$DEPLOY_STATUS" != "401" ]; then + case "$DEPLOY_STATUS" in + 503) fail "The sample deployed but never picked up its configuration (still HTTP 503 after ${i}s). + Check ${CFG} and ${KARAF_DIR}/data/log/karaf.log for a line starting 'Login sample'." ;; + *) fail "The sample did not come up at ${UNOMI_URL}/login/authenticate after ${i}s (last status + ${DEPLOY_STATUS}). Check ${KARAF_DIR}/data/log/karaf.log for a line starting 'Login sample'." ;; + esac +fi +echo " up and configured after ${i}s" + +cat <<EOF + +======================================================================== + Login sample is ready. + + Page: ${UNOMI_URL}/login/index.html + Demo password: ${LOGIN_PASSWORD} + + Log in with any email address and the password above. The password was + generated for this run - it is not stored anywhere else, so copy it now. +======================================================================== + +To remove the sample, run: + + rm -f "${KARAF_DIR}/deploy/$(basename "$SAMPLE_JAR")" + rm -f "${CFG}" + +Karaf uninstalls the bundle as soon as the jar disappears from deploy/. +EOF diff --git a/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginSampleResources.java b/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginSampleResources.java new file mode 100644 index 000000000..550656d17 --- /dev/null +++ b/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginSampleResources.java @@ -0,0 +1,37 @@ +/* + * 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.samples.login; + +import org.osgi.service.component.annotations.Component; + +/** + * Publishes the sample HTML/JS under {@code /login/*} via the OSGi Http Whiteboard + * (files live in {@code /static} inside this bundle). + * <p> + * Static resources only: there is no directory index, so the page is reached at + * {@code /login/index.html} rather than {@code /login}. + */ +@Component( + service = Object.class, + immediate = true, + property = { + "osgi.http.whiteboard.resource.pattern=/login/*", + "osgi.http.whiteboard.resource.prefix=/static" + } +) +public class LoginSampleResources { +} diff --git a/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginServlet.java b/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginServlet.java new file mode 100644 index 000000000..b603ce6ed --- /dev/null +++ b/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginServlet.java @@ -0,0 +1,395 @@ +/* + * 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.samples.login; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Modified; +import org.osgi.service.metatype.annotations.AttributeDefinition; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.Servlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Demo "authentication server" for the login sample. + * <p> + * The browser posts the form here. This servlet checks a hardcoded demo password, + * then calls Unomi {@code /cxs/context.json} with <strong>trusted</strong> Basic + * credentials (a tenant private key) so {@code mergeProfilesOnPropertyAction} + * is allowed. The browser must not call Unomi for login events itself. + */ +@Component( + service = Servlet.class, + immediate = true, + configurationPid = "org.apache.unomi.samples.login", + property = { + "osgi.http.whiteboard.servlet.name=LoginSampleServlet", + "osgi.http.whiteboard.servlet.pattern=/login/authenticate" + } +) +@Designate(ocd = LoginServlet.Config.class) +public class LoginServlet extends HttpServlet { + + private static final Logger LOGGER = LoggerFactory.getLogger(LoginServlet.class); + /** Must match the {@code configurationPid} above; quoted in the hint printed when config is missing. */ + private static final String CONFIGURATION_PID = "org.apache.unomi.samples.login"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + /** Attribute holding the Unomi session id we generated for this browser's container session. */ + private static final String UNOMI_SESSION_ID_ATTRIBUTE = "org.apache.unomi.samples.login.unomiSessionId"; + /** + * Idle timeout applied to the container sessions this servlet creates. A login round trip takes + * seconds, so a few minutes is generous; see {@link #resolveSessionId} for why it is capped. + */ + private static final int SESSION_MAX_INACTIVE_SECONDS = 300; + + private String unomiBaseUrl = "http://localhost:8181"; + private String tenantId = "default"; + private String scope = "default"; + private String privateKey = ""; + private String demoPassword = ""; + + @ObjectClassDefinition( + name = "Unomi login sample", + description = "Trusted credentials used by /login/authenticate to call Unomi (UNOMI-972)" + ) + public @interface Config { + + @AttributeDefinition(name = "Unomi base URL", description = "Base URL of this Unomi instance") + String unomiBaseUrl() default "http://localhost:8181"; + + @AttributeDefinition( + name = "Tenant ID", + description = "Tenant the login events belong to. Sent as the Basic auth user name alongside privateKey." + ) + String tenantId() default "default"; + + @AttributeDefinition( + name = "Scope", + description = "Event/source scope (must already exist for the tenant; systemscope is not a valid event scope)" + ) + String scope() default "default"; + + @AttributeDefinition( + name = "Tenant private key", + description = "Required. Plain-text tenant private API key; authenticates as tenant " + + "administrator, which is what allows the profile merge." + ) + String privateKey() default ""; + + @AttributeDefinition( + name = "Demo login password", + description = "Password the sample login form accepts. Required; no default is shipped, " + + "so choose one when configuring the sample. Stands in for the user directory " + + "a real integration would authenticate against." + ) + String demoPassword() default ""; + } + + @Activate + @Modified + public void activate(Config config) { + this.unomiBaseUrl = config.unomiBaseUrl(); + this.tenantId = config.tenantId(); + this.scope = config.scope() != null && !config.scope().isBlank() ? config.scope().trim() : "default"; + this.privateKey = config.privateKey() != null ? config.privateKey().trim() : ""; + this.demoPassword = config.demoPassword() != null ? config.demoPassword().trim() : ""; + logConfigurationStatus(); + } + + /** + * Reports whether the sample is usable, so that starting the bundle after configuring it is a + * self-checking step. Re-runs on every configuration update because {@link Modified} is applied + * to {@link #activate}, so correcting a value and running {@code config:update} reprints this. + * <p> + * Never logs a credential, only whether one is present. + */ + private void logConfigurationStatus() { + List<String> missing = new ArrayList<>(); + if (demoPassword.isEmpty()) { + missing.add("demoPassword (the password the login form accepts)"); + } + if (privateKey.isEmpty()) { + missing.add("privateKey (a tenant private API key)"); + } + + if (missing.isEmpty()) { + LOGGER.info("Login sample ready - open {}/login/index.html (tenantId={}, scope={})", + unomiBaseUrl, tenantId, scope); + return; + } + + LOGGER.warn("Login sample is NOT usable yet, missing configuration: {}.\n" + + "Set it from the Karaf console, then start the bundle again:\n" + + " config:edit {}\n" + + " config:property-set demoPassword <choose-a-password>\n" + + " config:property-set privateKey <tenant-private-key>\n" + + " config:update", + String.join(", ", missing), CONFIGURATION_PID); + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + // This endpoint is an unauthenticated state-changing POST that then calls Unomi with trusted + // credentials, so a hostile page could otherwise drive it from a visitor's browser. A real + // integration must use a proper per-session CSRF token; this same-origin check is only the + // lightweight equivalent that fits a sample. + if (!isSameOrigin(req)) { + writeError(resp, HttpServletResponse.SC_FORBIDDEN, "Cross-origin request rejected"); + return; + } + + String email = trim(req.getParameter("email")); + String firstName = trim(req.getParameter("firstName")); + String lastName = trim(req.getParameter("lastName")); + String password = trim(req.getParameter("password")); + + // No demo password ships with the sample, for the same reason Unomi itself no longer ships a + // default admin password: a credential baked into published source is a credential everyone has. + if (demoPassword.isEmpty()) { + writeError(resp, HttpServletResponse.SC_SERVICE_UNAVAILABLE, + "demoPassword is not configured: run 'config:edit " + CONFIGURATION_PID + "', " + + "'config:property-set demoPassword <password>', 'config:update'"); + return; + } + if (!demoPassword.equals(password)) { + writeError(resp, HttpServletResponse.SC_UNAUTHORIZED, "Invalid credentials"); + return; + } + if (email.isEmpty()) { + writeError(resp, HttpServletResponse.SC_BAD_REQUEST, "email is required"); + return; + } + + // The session id must be derived from state this servlet controls, never from the request + // parameters. We call Unomi with trusted credentials, and a trusted caller is allowed to + // adopt whatever profile owns the session id it passes: forwarding a client-supplied id + // would launder untrusted client input across the trust boundary and let anyone who guesses + // another visitor's session id rebind or merge that visitor's profile. Storing a generated + // id on the container's own HttpSession keeps it unreachable from the page while staying stable + // across requests from the same browser, which is what lets Unomi recover the visitor's + // pre-login anonymous profile. + String sessionId = resolveSessionId(req); + + // Only a tenant private key. A system administrator credential would also satisfy the merge + // gate, but it grants far more than this sample needs and is scoped to the whole instance + // rather than one tenant, so it is deliberately not accepted here. + if (privateKey.isEmpty()) { + writeError(resp, HttpServletResponse.SC_SERVICE_UNAVAILABLE, + "privateKey is not configured: run 'config:edit " + CONFIGURATION_PID + "', " + + "'config:property-set privateKey <tenant-private-key>', 'config:update'"); + return; + } + + ObjectNode contextRequest = MAPPER.createObjectNode(); + ObjectNode source = contextRequest.putObject("source"); + source.put("itemId", "/login"); + source.put("itemType", "page"); + source.put("scope", scope); + + ArrayNode events = contextRequest.putArray("events"); + ObjectNode loginEvent = events.addObject(); + loginEvent.put("eventType", "login"); + loginEvent.put("scope", scope); + ObjectNode target = loginEvent.putObject("target"); + target.put("itemId", email); + target.put("itemType", "exampleUser"); + ObjectNode targetProps = target.putObject("properties"); + targetProps.put("email", email); + targetProps.put("firstName", firstName); + targetProps.put("lastName", lastName); + + contextRequest.set("requiredProfileProperties", MAPPER.valueToTree(List.of("*"))); + contextRequest.set("requiredSessionProperties", MAPPER.valueToTree(List.of("*"))); + + byte[] body = MAPPER.writeValueAsBytes(contextRequest); + + int status; + JsonNode responseJson; + List<String> setCookieValues; + try { + URL url = new URL(unomiBaseUrl.replaceAll("/$", "") + "/cxs/context.json?sessionId=" + + URLEncoder.encode(sessionId, StandardCharsets.UTF_8)); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(10000); + conn.setReadTimeout(30000); + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + conn.setRequestProperty("Accept", "application/json"); + // The tenant is the Basic auth user name: Unomi derives the tenant context from the key + // itself, so no X-Unomi-Tenant-Id header is needed. + String token = Base64.getEncoder() + .encodeToString((tenantId + ":" + privateKey).getBytes(StandardCharsets.UTF_8)); + conn.setRequestProperty("Authorization", "Basic " + token); + + try (OutputStream os = conn.getOutputStream()) { + os.write(body); + } + + status = conn.getResponseCode(); + InputStream stream = status >= 400 ? conn.getErrorStream() : conn.getInputStream(); + if (stream != null) { + responseJson = MAPPER.readTree(stream); + } else { + responseJson = MAPPER.createObjectNode(); + } + // getHeaderField() only returns the first value: Unomi can set several cookies + // (profile id and session id), so every value has to be forwarded. + setCookieValues = headerValues(conn, "Set-Cookie"); + } catch (IOException e) { + // Never let the container render a stack trace: it would disclose the Unomi endpoint + // and internal class names to an unauthenticated caller. + LOGGER.warn("Login sample could not complete the call to Unomi", e); + writeError(resp, HttpServletResponse.SC_BAD_GATEWAY, "Profile service unavailable, please try again later"); + return; + } + + if (setCookieValues != null) { + for (String setCookie : setCookieValues) { + if (setCookie != null) { + resp.addHeader("Set-Cookie", setCookie); + } + } + } + resp.setStatus(status); + resp.setContentType("application/json; charset=utf-8"); + MAPPER.writeValue(resp.getOutputStream(), responseJson); + } + + /** + * Returns <em>all</em> values of a response header. Unlike {@code getHeaderFields().get(name)}, + * this keeps the case-insensitive matching that {@code getHeaderField(name)} provided. + * <p> + * Package-private rather than private so {@code LoginServletTest} can cover it without + * reflection. + */ + static List<String> headerValues(HttpURLConnection conn, String name) { + for (Map.Entry<String, List<String>> entry : conn.getHeaderFields().entrySet()) { + if (name.equalsIgnoreCase(entry.getKey())) { + return entry.getValue(); + } + } + return null; + } + + /** + * Returns the Unomi session id bound to this browser's container session, generating one on + * first use. Deliberately not read from any request parameter or header — see the call site. + * <p> + * Package-private rather than private so {@code LoginServletTest} can exercise the trust + * boundary directly instead of going through reflection. + */ + static String resolveSessionId(HttpServletRequest req) { + HttpSession httpSession = req.getSession(true); + // Guard against two concurrent first requests from the same browser generating two different + // ids. The session object is the conventional mutex here; do not lock on an interned session + // id, which shares a JVM-wide monitor with any other code that interns the same value. + synchronized (httpSession) { + Object existing = httpSession.getAttribute(UNOMI_SESSION_ID_ATTRIBUTE); + if (existing instanceof String && !((String) existing).isEmpty()) { + return (String) existing; + } + String generated = UUID.randomUUID().toString(); + httpSession.setAttribute(UNOMI_SESSION_ID_ATTRIBUTE, generated); + // Bound the lifetime of the sessions this servlet creates. The demo password gate above + // is NOT authentication: it is a single shared demo password, so + // anyone can pass it repeatedly while discarding the session cookie each time. Every such + // POST would otherwise pin a container session in memory for the container's default + // timeout (commonly 30 minutes), which is a cheap memory-exhaustion path. Expiring these + // sessions after a few minutes keeps the id stable for a real browser's login round trip + // while letting the container reclaim the throwaway ones almost immediately. + httpSession.setMaxInactiveInterval(SESSION_MAX_INACTIVE_SECONDS); + return generated; + } + } + + /** + * Lightweight CSRF defence: when the browser sends an {@code Origin} header it must match the + * origin this request was addressed to. A missing header (same-origin form posts on older + * browsers, curl) is tolerated; an unparsable or mismatching one is rejected. + * <p> + * Package-private rather than private so {@code LoginServletTest} can cover it without + * reflection. + */ + static boolean isSameOrigin(HttpServletRequest req) { + String origin = trim(req.getHeader("Origin")); + if (origin.isEmpty()) { + return true; + } + URI originUri; + try { + originUri = new URI(origin); + } catch (URISyntaxException e) { + LOGGER.debug("Rejecting login request with unparsable Origin header", e); + return false; + } + String originScheme = originUri.getScheme(); + String originHost = originUri.getHost(); + if (originScheme == null || originHost == null) { + // Includes the opaque "null" origin sent by sandboxed frames. + return false; + } + return originScheme.equalsIgnoreCase(req.getScheme()) + && originHost.equalsIgnoreCase(req.getServerName()) + && defaultedPort(originScheme, originUri.getPort()) == req.getServerPort(); + } + + private static int defaultedPort(String scheme, int port) { + if (port != -1) { + return port; + } + return "https".equalsIgnoreCase(scheme) ? 443 : 80; + } + + private static void writeError(HttpServletResponse resp, int status, String message) throws IOException { + Map<String, String> error = new LinkedHashMap<>(); + error.put("error", message); + resp.setStatus(status); + resp.setContentType("application/json; charset=utf-8"); + MAPPER.writeValue(resp.getOutputStream(), error); + } + + private static String trim(String s) { + return s == null ? "" : s.trim(); + } +} diff --git a/samples/login-integration/src/main/resources/static/index.html b/samples/login-integration/src/main/resources/static/index.html new file mode 100644 index 000000000..7f2c995b4 --- /dev/null +++ b/samples/login-integration/src/main/resources/static/index.html @@ -0,0 +1,75 @@ +<!-- + 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. +--> +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Unomi login sample</title> + <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" + integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous"> + <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script> + <script src="javascript/login-example.js"></script> +</head> +<body> +<div class="container" style="max-width: 720px; margin-top: 2em;"> + <h1>Login integration sample</h1> + + <div id="alert_placeholder"></div> + + <form id="loginForm"> + <div class="form-group"> + <label for="firstname">First name</label> + <input type="text" name="firstName" id="firstname" class="form-control" placeholder="First name"/> + </div> + <div class="form-group"> + <label for="lastname">Last name</label> + <input type="text" name="lastName" id="lastname" class="form-control" placeholder="Last name"/> + </div> + <div class="form-group"> + <label for="email">Email (merge key)</label> + <input type="text" name="email" id="email" class="form-control" placeholder="[email protected]" required/> + </div> + <div class="form-group"> + <label for="password">Password</label> + <input type="password" name="password" id="password" class="form-control" placeholder="password printed by setup.sh" required/> + </div> + <button type="submit" class="btn btn-primary">Login</button> + </form> + + <p class="help-block" style="margin-top: 1.5em;"> + To test merge: login once, note <code>profileId</code>, clear the <code>context-profile-id</code> cookie + (or use a private window), login again with the <strong>same email</strong> — you should get the same master profile. + </p> + + <div class="panel panel-info" style="margin-top: 1.5em;"> + <div class="panel-heading"><strong>How this sample works</strong></div> + <div class="panel-body"> + <ol> + <li>This page posts the form to a <strong>server-side servlet</strong> + (<code>/login/authenticate</code>) — not to <code>/cxs/context.json</code>.</li> + <li>The servlet checks the demo password <code>setup.sh</code> generated and stored as + <code>demoPassword</code>, then calls Unomi with <strong>trusted</strong> Basic + credentials (a tenant private key).</li> + <li>The bundled <code>exampleLogin</code> rule merges on email + <code>mergeProfilesOnPropertyAction</code> (trusted callers only, UNOMI-972).</li> + </ol> + </div> + </div> +</div> +</body> +</html> diff --git a/samples/login-integration/src/main/resources/static/javascript/login-example.js b/samples/login-integration/src/main/resources/static/javascript/login-example.js new file mode 100644 index 000000000..bbc82fac2 --- /dev/null +++ b/samples/login-integration/src/main/resources/static/javascript/login-example.js @@ -0,0 +1,58 @@ +/* + * 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. + */ +(function () { + // No session id is generated or sent from the browser. /login/authenticate calls Unomi with + // trusted credentials, and a trusted caller is allowed to adopt the profile that owns the + // session id it passes, so the id must come from server-side state only (the servlet derives + // it from its own HttpSession). Sending a client-chosen id here would let anyone rebind + // another visitor's profile. + + function show(ok, message) { + var cls = ok ? "alert-success" : "alert-danger"; + $("#alert_placeholder").html( + '<div class="alert ' + cls + '"><a class="close" data-dismiss="alert">×</a><span></span></div>' + ); + $("#alert_placeholder .alert span").text(message); + } + + $(function () { + $("#loginForm").on("submit", function (event) { + event.preventDefault(); + $.ajax({ + url: "/login/authenticate", + type: "POST", + data: { + firstName: $("#firstname").val(), + lastName: $("#lastname").val(), + email: $("#email").val(), + password: $("#password").val() + }, + dataType: "json" + }).done(function (body) { + var email = body.profileProperties && body.profileProperties.email; + show(true, "OK — profileId=" + body.profileId + + (email ? (", email=" + email) : "") + + ". Clear context-profile-id and login again with the same email to verify merge."); + }).fail(function (xhr) { + var body = xhr.responseJSON || {}; + var msg = body.error || body.errorMessage || xhr.responseText || ("HTTP " + xhr.status); + show(false, msg); + }); + return false; + }); + }); +})(); diff --git a/samples/login-integration/src/main/webapp/WEB-INF/web.xml b/samples/login-integration/src/main/webapp/WEB-INF/web.xml deleted file mode 100644 index dc145f299..000000000 --- a/samples/login-integration/src/main/webapp/WEB-INF/web.xml +++ /dev/null @@ -1,24 +0,0 @@ -<!-- - ~ 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. - --> -<web-app xmlns="http://java.sun.com/xml/ns/javaee" - xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd" - version="3.0"> - <welcome-file-list> - <welcome-file>index.html</welcome-file> - </welcome-file-list> -</web-app> \ No newline at end of file diff --git a/samples/login-integration/src/main/webapp/index.html b/samples/login-integration/src/main/webapp/index.html deleted file mode 100644 index 8b03cbded..000000000 --- a/samples/login-integration/src/main/webapp/index.html +++ /dev/null @@ -1,70 +0,0 @@ -<!-- -~ 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. ---> -<html> -<head> - <meta charset="utf-8"> - <meta http-equiv="X-UA-Compatible" content="IE=edge"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>Login integration example</title> - <!-- Latest compiled and minified CSS --> - <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" - integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous"> - <!-- Optional theme --> - <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap-theme.min.css" - integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous"> - <!-- jQuery (necessary for Bootstrap's JavaScript plugins) --> - <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script> - <!-- Latest compiled and minified JavaScript --> - <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" - integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" - crossorigin="anonymous"></script> - <script src="javascript/login-example.js"></script> -</head> -<body> -<div class="container"> - <h1>Login integration example</h1> - <p>This is a small example of integrating Apache Unomi with an event login in order to merge profile based on emails - as merge keys (see associated rule file in src/main/resources/META-INF/cxs/rules/exampleLogin.json).</p> - <p>Important: note that login events should normally always be sent from the server performing the login, not through - Javascript for security reasons. Here we provide this type of example only for brievety and clarity.</p> - <div id="alert_placeholder"></div> - <form id="loginForm"> - <div class="form-group"> - <label for="firstname">First name</label> - <input type="text" name="firstName" id="firstname" class="form-control" - placeholder="Enter your first name here"/> - </div> - <div class="form-group"> - <label for="lastname">Last name</label> - <input type="text" name="lastName" id="lastname" class="form-control" - placeholder="Enter your last name here"/> - </div> - <div class="form-group"> - <label for="email">Email</label> - <input type="text" name="email" id="email" class="form-control" placeholder="Enter your email here" - > - </div> - <div class="form-group"> - <label for="email">Password</label> - <input type="password" name="password" id="password" class="form-control" - placeholder="Enter your password here"> - </div> - <button id="loginButton" type="submit" class="btn btn-default">Login</button> - </form> -</div> -</body> -</html> diff --git a/samples/login-integration/src/main/webapp/javascript/login-example.js b/samples/login-integration/src/main/webapp/javascript/login-example.js deleted file mode 100644 index 2704ac853..000000000 --- a/samples/login-integration/src/main/webapp/javascript/login-example.js +++ /dev/null @@ -1,139 +0,0 @@ -/* - * 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. - */ - -(function () { - - // We use this method to generate unique sessions IDs - function generateGuid() { - function s4() { - var array = new Uint16Array(1); - window.crypto.getRandomValues(array); - return array[0].toString(16).padStart(4, '0'); - } - - return s4() + s4() + '-' + s4() + '-' + s4() + '-' + - s4() + '-' + s4() + s4() + s4(); - } - - // -- COOKIE HELPER METHODS --- - - function createCookie(name, value, days) { - var expires; - - if (days) { - var date = new Date(); - date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); - expires = "; expires=" + date.toGMTString(); - } else { - expires = ""; - } - document.cookie = encodeURIComponent(name) + "=" + encodeURIComponent(value) + expires + "; path=/"; - } - - function readCookie(name) { - var nameEQ = encodeURIComponent(name) + "="; - var ca = document.cookie.split(';'); - for (var i = 0; i < ca.length; i++) { - var c = ca[i]; - while (c.charAt(0) === ' ') c = c.substring(1, c.length); - if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length, c.length)); - } - return null; - } - - function eraseCookie(name) { - createCookie(name, "", -1); - } - - // -- BOOTSTRAP HELPER METHODS --- - - bootstrapAlert = {}; - bootstrapAlert.success = function (message) { - $('#alert_placeholder').html('<div class="alert alert-success"><a class="close" data-dismiss="alert">×</a><span>' + message + '</span></div>') - }; - bootstrapAlert.danger = function (message) { - $('#alert_placeholder').html('<div class="alert alert-danger"><a class="close" data-dismiss="alert">×</a><span>' + message + '</span></div>') - }; - - $(document).ready(function () { - - // first we check if we have an existing session ID cookie, if not we generate a new session identifier and - // store it in the cookie. - var unomiSessionId = readCookie('unomi-session-id'); - if (!unomiSessionId) { - unomiSessionId = generateGuid(); - console.log("No existing session cookie found, creating a new one with value " + unomiSessionId); - createCookie('unomi-session-id', unomiSessionId, 1); - } - console.log("Setting up form listener..."); - $("#loginForm").submit(function (event) { - var email = $('#email').val(); - var firstName = $('#firstname').val(); - var lastName = $('#lastname').val(); - var password = $('#password').val(); - if (password != 'test1234') { - bootstrapAlert.danger("Wrong password (default is : test1234)"); - event.preventDefault(); - return false; - } - var contextRequest = { - source: { // the source is required for the request to be process properly - itemId: location.pathname, - itemType: 'webpage', - scope: 'test' // the scope is used to regroup events and sessions into sub-groups (eg sites) - }, - events: [{ // here we provide a simple login event, but as this is actually an array we could provide other events at the same time (page view, clicks, mouse movements, ...) - eventType: "login", - properties: {}, - target: { - itemId: email, - itemType: "exampleUser", - properties: { - preferredLanguage: "en", - email: email, - firstName: firstName, - lastName: lastName - } - } - }], - requiredProfileProperties: ['*'], // this tells Unomi to send us back all the profile properties (by default none are returned) - requiredSessionProperties: ['*'] // this tells Unomi to send us back all the session properties (by default none are returned) - }; - // now let's perform the actual call to Apache Unomi, asking it to process the events and give us back the updated (or created) profile. - // as we have a rule listening to a login event, it will be executed and its actions will be processed. - $.ajax({ - url: "http://localhost:8181/cxs/context.json?sessionId=" + unomiSessionId, - type: 'POST', - data: JSON.stringify(contextRequest), // make sure you sent JSON and not form-encoded, otherwise Unomi will generate an error - contentType: 'application/json; charset=utf-8', - dataType: 'json', - async: false, - headers : { - 'X-Unomi-Api-Key' : '670c26d1cc413346c3b2fd9ce65dab41' // this is configured in the etc/org.apache.unomi.thirdparty.cfg - }, - success: function (data) { - console.log("Unomi response:", data); - bootstrapAlert.success("Successfully sent login event to Apache Unomi ! (profileId=" + data.profileId + ",properties.email=" + data.profileProperties.email + ",nbOfVisits=" + data.profileProperties.nbOfVisits + ")"); - } - }); - event.preventDefault(); - return false; - }); - }); - -})(); - diff --git a/samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java b/samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java new file mode 100644 index 000000000..9a220fdc4 --- /dev/null +++ b/samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java @@ -0,0 +1,353 @@ +/* + * 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.samples.login; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atMostOnce; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the security-relevant helpers of {@link LoginServlet}. + * <p> + * The methods under test are package-private (rather than private) purely so these tests can call + * them directly instead of reaching through reflection; they are not part of any public API. + */ +class LoginServletTest { + + // ------------------------------------------------------------------------------------------ + // resolveSessionId — the trust boundary. A client-supplied session id must never be honoured. + // ------------------------------------------------------------------------------------------ + + @Test + @DisplayName("regression guard: a client-supplied sessionId parameter is ignored entirely") + void clientSuppliedSessionIdParameterIsIgnored() { + String publicCallerSuppliedId = "other-session-id-we-want-to-reuse"; + HttpSession session = statefulSession(); + HttpServletRequest req = requestWithSession(session); + // Simulate every channel an untrusted caller controls: query/form parameters and headers. + when(req.getParameter(anyString())).thenReturn(publicCallerSuppliedId); + when(req.getHeader(anyString())).thenReturn(publicCallerSuppliedId); + + String resolved = LoginServlet.resolveSessionId(req); + + assertNotEquals(publicCallerSuppliedId, resolved, + "the servlet must not adopt a session id supplied by the caller"); + // Stronger than comparing values: prove the request's untrusted surface is never + // even consulted, so no future refactor can quietly reintroduce the vulnerability. + verify(req, never()).getParameter(anyString()); + verify(req, never()).getParameterValues(anyString()); + verify(req, never()).getHeader(anyString()); + verify(req, never()).getCookies(); + } + + @Test + @DisplayName("the generated session id is a server-side random UUID stored on the container session") + void generatedSessionIdIsARandomUuidStoredOnTheSession() { + HttpSession session = statefulSession(); + + String resolved = LoginServlet.resolveSessionId(requestWithSession(session)); + + assertNotNull(resolved); + assertDoesNotThrow(() -> UUID.fromString(resolved), "expected a random UUID, got: " + resolved); + assertEquals(resolved, session.getAttribute("org.apache.unomi.samples.login.unomiSessionId"), + "the resolved id must be the one persisted on the container session"); + } + + @Test + @DisplayName("the same browser session yields a stable session id across calls") + void sameSessionYieldsStableSessionId() { + HttpSession session = statefulSession(); + + String first = LoginServlet.resolveSessionId(requestWithSession(session)); + String second = LoginServlet.resolveSessionId(requestWithSession(session)); + String third = LoginServlet.resolveSessionId(requestWithSession(session)); + + assertEquals(first, second); + assertEquals(first, third); + } + + @Test + @DisplayName("two different browser sessions yield different session ids") + void differentSessionsYieldDifferentSessionIds() { + String first = LoginServlet.resolveSessionId(requestWithSession(statefulSession())); + String second = LoginServlet.resolveSessionId(requestWithSession(statefulSession())); + + assertNotEquals(first, second); + } + + @Test + @DisplayName("sessions created by this servlet get a short idle timeout so they cannot accumulate") + void createdSessionsAreGivenAShortIdleTimeout() { + HttpSession session = statefulSession(); + + LoginServlet.resolveSessionId(requestWithSession(session)); + + verify(session).setMaxInactiveInterval(intThatIsAShortTimeout()); + } + + @Test + @DisplayName("the idle timeout is applied only when the id is first created, not on every request") + void idleTimeoutIsAppliedOnlyOnFirstUse() { + HttpSession session = statefulSession(); + + LoginServlet.resolveSessionId(requestWithSession(session)); + LoginServlet.resolveSessionId(requestWithSession(session)); + LoginServlet.resolveSessionId(requestWithSession(session)); + + verify(session, atMostOnce()).setMaxInactiveInterval(anyInt()); + } + + @Test + @DisplayName("an existing container session is reused rather than replaced") + void existingSessionIsReused() { + HttpSession session = statefulSession(); + HttpServletRequest req = requestWithSession(session); + + LoginServlet.resolveSessionId(req); + + // getSession(true) is correct: the servlet needs a session to exist. What must not happen is + // the servlet inventing a second identity source. + verify(req, times(1)).getSession(anyBoolean()); + } + + // ------------------------------------------------------------------------------------------ + // isSameOrigin — lightweight CSRF defence. + // ------------------------------------------------------------------------------------------ + + @Test + @DisplayName("a matching origin is accepted") + void sameOriginIsAccepted() { + assertTrue(LoginServlet.isSameOrigin( + requestWithOrigin("http://example.com:8181", "http", "example.com", 8181))); + } + + @Test + @DisplayName("an http origin with no explicit port matches port 80") + void defaultHttpPortIsAccepted() { + assertTrue(LoginServlet.isSameOrigin( + requestWithOrigin("http://example.com", "http", "example.com", 80))); + } + + @Test + @DisplayName("an https origin with no explicit port matches port 443") + void defaultHttpsPortIsAccepted() { + assertTrue(LoginServlet.isSameOrigin( + requestWithOrigin("https://example.com", "https", "example.com", 443))); + } + + @Test + @DisplayName("origin comparison is case-insensitive on scheme and host") + void originComparisonIsCaseInsensitive() { + assertTrue(LoginServlet.isSameOrigin( + requestWithOrigin("HTTP://Example.COM:8181", "http", "example.com", 8181))); + } + + @Test + @DisplayName("a different host is rejected") + void differentHostIsRejected() { + assertFalse(LoginServlet.isSameOrigin( + requestWithOrigin("http://evil.example.net:8181", "http", "example.com", 8181))); + } + + @Test + @DisplayName("a different port is rejected") + void differentPortIsRejected() { + assertFalse(LoginServlet.isSameOrigin( + requestWithOrigin("http://example.com:9090", "http", "example.com", 8181))); + } + + @Test + @DisplayName("an implicit default port that does not match the served port is rejected") + void implicitDefaultPortMismatchIsRejected() { + assertFalse(LoginServlet.isSameOrigin( + requestWithOrigin("http://example.com", "http", "example.com", 8181))); + } + + @Test + @DisplayName("a different scheme is rejected") + void differentSchemeIsRejected() { + assertFalse(LoginServlet.isSameOrigin( + requestWithOrigin("https://example.com:8181", "http", "example.com", 8181))); + } + + @Test + @DisplayName("a missing Origin header is tolerated") + void missingOriginIsTolerated() { + assertTrue(LoginServlet.isSameOrigin( + requestWithOrigin(null, "http", "example.com", 8181))); + } + + @Test + @DisplayName("a blank Origin header is tolerated") + void blankOriginIsTolerated() { + assertTrue(LoginServlet.isSameOrigin( + requestWithOrigin(" ", "http", "example.com", 8181))); + } + + @Test + @DisplayName("the opaque \"null\" origin sent by sandboxed frames is rejected") + void opaqueNullOriginIsRejected() { + assertFalse(LoginServlet.isSameOrigin( + requestWithOrigin("null", "http", "example.com", 8181)), + "the literal string \"null\" is an opaque origin, not a missing header"); + } + + @Test + @DisplayName("an unparsable Origin header is rejected") + void unparsableOriginIsRejected() { + assertFalse(LoginServlet.isSameOrigin( + requestWithOrigin("http://exa mple.com", "http", "example.com", 8181))); + } + + @Test + @DisplayName("a syntactically valid but host-less Origin is rejected") + void hostlessOriginIsRejected() { + assertFalse(LoginServlet.isSameOrigin( + requestWithOrigin("file:///etc/passwd", "http", "example.com", 8181))); + } + + // ------------------------------------------------------------------------------------------ + // headerValues — multi-value, case-insensitive Set-Cookie forwarding. + // ------------------------------------------------------------------------------------------ + + @Test + @DisplayName("headerValues returns every value of a repeated header") + void headerValuesReturnsAllValues() throws Exception { + Map<String, List<String>> headers = new LinkedHashMap<>(); + headers.put("Set-Cookie", Arrays.asList("context-profile-id=p1; Path=/", "context-session-id=s1; Path=/")); + + List<String> values = LoginServlet.headerValues(connectionWithHeaders(headers), "Set-Cookie"); + + assertEquals(Arrays.asList("context-profile-id=p1; Path=/", "context-session-id=s1; Path=/"), values); + } + + @Test + @DisplayName("headerValues matches the header name case-insensitively") + void headerValuesMatchesNameCaseInsensitively() throws Exception { + Map<String, List<String>> headers = new LinkedHashMap<>(); + // Servers are free to use any casing; HttpURLConnection preserves what came off the wire. + headers.put("set-cookie", new ArrayList<>(Arrays.asList("a=1", "b=2"))); + + List<String> values = LoginServlet.headerValues(connectionWithHeaders(headers), "Set-Cookie"); + + assertEquals(Arrays.asList("a=1", "b=2"), values); + } + + @Test + @DisplayName("headerValues tolerates the null status-line key and returns null for an absent header") + void headerValuesReturnsNullWhenAbsent() throws Exception { + Map<String, List<String>> headers = new LinkedHashMap<>(); + // HttpURLConnection.getHeaderFields() maps the HTTP status line under a null key. + headers.put(null, Arrays.asList("HTTP/1.1 200 OK")); + headers.put("Content-Type", Arrays.asList("application/json")); + + assertNull(LoginServlet.headerValues(connectionWithHeaders(headers), "Set-Cookie")); + } + + // ------------------------------------------------------------------------------------------ + // Fakes / helpers + // ------------------------------------------------------------------------------------------ + + /** A mock {@link HttpSession} with real attribute storage, so id stability can be observed. */ + private static HttpSession statefulSession() { + HttpSession session = mock(HttpSession.class); + Map<String, Object> attributes = new HashMap<>(); + when(session.getAttribute(anyString())).thenAnswer(inv -> attributes.get(inv.<String>getArgument(0))); + doAnswer(inv -> { + attributes.put(inv.getArgument(0), inv.getArgument(1)); + return null; + }).when(session).setAttribute(anyString(), any()); + return session; + } + + private static HttpServletRequest requestWithSession(HttpSession session) { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getSession(anyBoolean())).thenReturn(session); + return req; + } + + private static HttpServletRequest requestWithOrigin(String origin, String scheme, String serverName, int port) { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getHeader("Origin")).thenReturn(origin); + when(req.getScheme()).thenReturn(scheme); + when(req.getServerName()).thenReturn(serverName); + when(req.getServerPort()).thenReturn(port); + return req; + } + + private static HttpURLConnection connectionWithHeaders(Map<String, List<String>> headers) throws Exception { + return new HttpURLConnection(new URL("http://localhost:8181/cxs/context.json")) { + @Override + public Map<String, List<String>> getHeaderFields() { + return headers; + } + + @Override + public void connect() { + // never actually connects + } + + @Override + public void disconnect() { + // nothing to release + } + + @Override + public boolean usingProxy() { + return false; + } + }; + } + + /** + * Matches any timeout that is positive and no longer than ten minutes: the exact value is a + * tuning detail, but "short and bounded" is the security property we care about. + */ + private static int intThatIsAShortTimeout() { + return org.mockito.ArgumentMatchers.intThat(seconds -> seconds > 0 && seconds <= 600); + } +}
