This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch UNOMI-972-credentials-profile-binding-privileged-rest in repository https://gitbox.apache.org/repos/asf/unomi.git
commit 3a0bb091a16730c8acf894408d81f9bfb96dce5d Author: Serge Huber <[email protected]> AuthorDate: Mon Aug 10 09:27:28 2026 +0200 UNOMI-972: rewrite the login sample to demonstrate the trusted server-side pattern Coupled to reported issue 6. The sample shipped exampleLogin.json, which wired a public login event to mergeProfilesOnPropertyAction - exactly the pattern the documentation warns against, and the pattern the report cites. The gate added for issue 6 refuses it, so the sample would otherwise ship broken as well as misleading. The browser now posts only to the sample's own /login/authenticate, which validates a demo password and then calls /cxs/context.json itself with trusted credentials, so the merge is performed by a caller the server can vouch for. The session id is server-generated and held on the container session rather than accepted from the request, and a same-origin check stands in for the per-session CSRF token a real integration would use. No demo password ships with the sample, for the same reason Unomi no longer ships a default admin password: a credential baked into published source is a credential everyone has. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> --- manual/src/main/asciidoc/samples/login-sample.adoc | 203 +++++++++-- 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 +++ .../unomi/samples/login/LoginServletTest.java | 353 ++++++++++++++++++ 8 files changed, 1430 insertions(+), 34 deletions(-) diff --git a/manual/src/main/asciidoc/samples/login-sample.adoc b/manual/src/main/asciidoc/samples/login-sample.adoc index 0a33e2377..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 (default password is karaf) +@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/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..e3a1585c2 --- /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 victim'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 victim's profile. Storing a generated + // id on the container's own HttpSession keeps it attacker-unreachable 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/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..c575e9395 --- /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 attackerSuppliedId = "victim-session-id-we-want-to-hijack"; + HttpSession session = statefulSession(); + HttpServletRequest req = requestWithSession(session); + // Simulate every channel an attacker controls: query/form parameters and headers. + when(req.getParameter(anyString())).thenReturn(attackerSuppliedId); + when(req.getHeader(anyString())).thenReturn(attackerSuppliedId); + + String resolved = LoginServlet.resolveSessionId(req); + + assertNotEquals(attackerSuppliedId, resolved, + "the servlet must not adopt a session id supplied by the caller"); + // Stronger than comparing values: prove the request's attacker-controlled 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); + } +}
