From 10e991e158c6ced2e2d505fedcb50e46d1c40199 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 22:45:01 +0000 Subject: [PATCH] library: staff signed in to Koha open Add books without a second login Quick Add asks Koha (C4::Auth::check_cookie_auth via a secret-guarded svc script) whether the browser's session may edit the catalogue; falls back to its own login otherwise. Cookie-authenticated writes need an X-Quickadd header; the direct port never accepts cookies. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/README.md | 4 ++ library/docker-compose.yml | 2 + library/install.sh | 3 ++ library/koha/Dockerfile | 4 ++ library/koha/entrypoint.sh | 22 ++++++++-- library/koha/quickadd-session.pl | 44 +++++++++++++++++++ library/quickadd/app.py | 75 ++++++++++++++++++++++++++------ library/quickadd/index.html | 8 ++-- 8 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 library/koha/quickadd-session.pl diff --git a/library/README.md b/library/README.md index c074576..09eb6a9 100644 --- a/library/README.md +++ b/library/README.md @@ -203,3 +203,7 @@ koha/ Dockerfile (Koha from the project's Debian repo), entrypoi servers, label templates/layouts) quickadd/ the scan-to-add service (Python standard library + yaz) ``` + +## Signing in once + +Staff who are signed in to the library site (and allowed to edit the catalogue) open **Add books** without a second login. Koha itself confirms the sign-in; the shared secret is `SSO_SECRET` in `.env`. The read-only `guest` login still uses the Add books prompt, and the direct Add books port never accepts a Koha sign-in. If `SSO_SECRET` is empty, Add books just asks for its own login as before. diff --git a/library/docker-compose.yml b/library/docker-compose.yml index 77009fa..1153cd7 100644 --- a/library/docker-compose.yml +++ b/library/docker-compose.yml @@ -35,6 +35,7 @@ services: environment: TZ: ${TZ:-UTC} KOHA_QUICKADD_URL: http://quickadd:8082 + KOHA_SSO_SECRET: ${SSO_SECRET:-} KOHA_DB_HOST: db KOHA_DB_PASS: ${DB_PASSWORD} KOHA_ADMIN_USER: ${ADMIN_USER:-admin} @@ -60,6 +61,7 @@ services: environment: TZ: ${TZ:-UTC} KOHA_API: http://koha:8081/api/v1 + SSO_SECRET: ${SSO_SECRET:-} ADMIN_USER: ${ADMIN_USER:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD} VIEWER_USER: ${VIEWER_USER:-guest} diff --git a/library/install.sh b/library/install.sh index da63d67..0ecded4 100755 --- a/library/install.sh +++ b/library/install.sh @@ -127,6 +127,8 @@ ADMIN_USER=admin ADMIN_PASSWORD=$(rand 16) VIEWER_USER=guest VIEWER_PASSWORD=$(rand 14) +# Lets the library site vouch for a signed-in staff user to the Add books page (no second login). +SSO_SECRET=$(rand 40) DB_ROOT_PASSWORD=$(rand 32) DB_PASSWORD=$(rand 32) KOHA_SUITE=stable @@ -142,6 +144,7 @@ else if ! grep -q '^VIEWER_PASSWORD=' .env; then { echo "VIEWER_USER=guest"; echo "VIEWER_PASSWORD=$(rand 14)"; } >> .env fi + grep -q '^SSO_SECRET=' .env || echo "SSO_SECRET=$(rand 40)" >> .env # Existing installs keep their current public-catalogue behaviour. grep -q '^OPAC_BIND=' .env || echo "OPAC_BIND=0.0.0.0" >> .env if ! grep -q '^BIND_ADDR=' .env; then diff --git a/library/koha/Dockerfile b/library/koha/Dockerfile index 5dda088..03d9005 100644 --- a/library/koha/Dockerfile +++ b/library/koha/Dockerfile @@ -39,6 +39,10 @@ RUN for c in koha-create koha-plack koha-zebra koha-rebuild-zebra koha-upgrade-s command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \ done +# The endpoint Quick Add uses to recognise a signed-in staff user (see the file header). +COPY quickadd-session.pl /usr/share/koha/intranet/cgi-bin/svc/quickadd-session +RUN chmod 0755 /usr/share/koha/intranet/cgi-bin/svc/quickadd-session + COPY bootstrap.pl ensure.pl entrypoint.sh /opt/koha-init/ COPY seed/ /opt/koha-init/seed/ RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init diff --git a/library/koha/entrypoint.sh b/library/koha/entrypoint.sh index c1532c1..6d7bbae 100755 --- a/library/koha/entrypoint.sh +++ b/library/koha/entrypoint.sh @@ -60,22 +60,36 @@ if [ ! -d "/etc/koha/sites/$INST" ]; then # (The staff and public catalogue sites cannot share one address: Koha gives both the # same URL shapes. The staff site is the natural home for a home library.) if [ -n "${KOHA_QUICKADD_URL:-}" ]; then + # Single sign-on for staff: when KOHA_SSO_SECRET is set, this web server tells Quick Add (via a + # secret header nobody outside can forge: it is overwritten here on every request) that the + # request came through the staff site, and Quick Add then asks Koha whether the browser's login + # cookie belongs to a staff user who may edit the catalogue. Without the secret, or if anything + # about that fails, Quick Add just asks for its own login as before. + SSO_BLOCK="" + if [ -n "${KOHA_SSO_SECRET:-}" ]; then + install -m 0640 -o root -g "$INST-koha" /dev/null /etc/koha/quickadd-sso.secret + printf '%s' "$KOHA_SSO_SECRET" > /etc/koha/quickadd-sso.secret + SSO_BLOCK=" + + RequestHeader set X-Quickadd-Proxy \"$KOHA_SSO_SECRET\" + + " + fi cat > /etc/koha/apache-quickadd.conf << APACHE RedirectMatch ^/add\$ /add/ ProxyPass /add/ ${KOHA_QUICKADD_URL%/}/ ProxyPassReverse /add/ ${KOHA_QUICKADD_URL%/}/ +${SSO_BLOCK} APACHE sed -i 's|^\(\s*\)Include /etc/koha/apache-shared-intranet.conf|&\n\1Include /etc/koha/apache-quickadd.conf|' "/etc/apache2/sites-available/$INST.conf" fi - # Run Apache itself as the instance user instead of mpm_itk's per-vhost - # uid switching (which needs extra capabilities and is pointless here: - # Plack does the real work as that user already). # koha-create insists on mpm_itk being enabled, but inside a container it can # not switch users (initgroups: Operation not permitted -> HTTP 500 on every - # page). Now that the instance exists, swap to plain prefork. + # page). Now that the instance exists, swap to plain prefork; Apache runs as the + # instance user (Plack does the real work as that user already). rm -f /etc/apache2/mods-enabled/mpm_itk.* a2enmod mpm_prefork >/dev/null 2>&1 || true sed -i '/AssignUserID/d' "$SITE" diff --git a/library/koha/quickadd-session.pl b/library/koha/quickadd-session.pl new file mode 100644 index 0000000..e60a487 --- /dev/null +++ b/library/koha/quickadd-session.pl @@ -0,0 +1,44 @@ +#!/usr/bin/perl +# Installed as .../intranet/cgi-bin/svc/quickadd-session (URL /cgi-bin/koha/svc/quickadd-session). +# +# Lets the Add books page (Quick Add) ask: "is this browser signed in to the staff site as +# someone allowed to edit the catalogue?" so staff do not have to log in a second time. +# +# It wraps Koha's own C4::Auth::check_cookie_auth (documented for exactly this: services that +# are invoked by pages authenticated in the usual way) and only answers callers that present a +# shared secret, so it cannot be used to probe sessions from outside. The browser's address is +# passed in explicitly, so Koha's "session is tied to the IP that logged in" rule keeps working. +use Modern::Perl; +use CGI qw( -utf8 ); +use JSON qw( encode_json ); +use Digest::SHA qw( sha256_hex ); +use C4::Auth qw( check_cookie_auth ); + +my $q = CGI->new; + +# Takes the CGI object as an argument: under Plack this file runs wrapped in a sub, where a named +# sub would otherwise see a stale copy of a file-level variable. +sub reply { + my ( $q, $status, $data ) = @_; + print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ), + encode_json($data); + exit; +} + +# 1. The caller must know the shared secret (a file only the Koha user and root can read). +my $secret = ''; +if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh } +my $given = $ENV{HTTP_X_QUICKADD_SECRET} // ''; +reply( $q, '403 Forbidden', { status => 'forbidden' } ) + unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret); + +# 2. Plausible-looking inputs only. +my $sid = $q->param('sid') // ''; +my $ip = $q->param('ip') // ''; +reply( $q, '200 OK', { status => 'failed' } ) + unless $sid =~ /\A[A-Za-z0-9]{16,64}\z/ && $ip =~ /\A[0-9A-Fa-f:.]{3,45}\z/; + +# 3. Ask Koha. Staff who may edit the catalogue (superlibrarians always may) are let in. +my ( $status, $session ) = check_cookie_auth( $sid, { editcatalogue => 'edit_catalogue' }, { remote_addr => $ip } ); +reply( $q, '200 OK', { status => 'ok', user => scalar $session->param('id') } ) if $status eq 'ok' && $session; +reply( $q, '200 OK', { status => $status || 'failed' } ); diff --git a/library/quickadd/app.py b/library/quickadd/app.py index f82a644..3d4ad68 100644 --- a/library/quickadd/app.py +++ b/library/quickadd/app.py @@ -28,6 +28,11 @@ KOHA_API = os.environ.get("KOHA_API", "http://koha:8081/api/v1").rstrip("/") USER = os.environ.get("ADMIN_USER", "admin") PASSWORD = os.environ.get("ADMIN_PASSWORD", "") # Optional read-only login to hand out: may open the page and search, nothing else. +# Single sign-on: requests that reach us through the staff site's web server carry this secret in +# X-Quickadd-Proxy; we then ask Koha whether the browser's login cookie is a staff user who may edit +# the catalogue. Unset = feature off (only the Basic logins below work). +SSO_SECRET = os.environ.get("SSO_SECRET", "") +KOHA_SESSION_URL = KOHA_API.rsplit("/api/v1", 1)[0] + "/cgi-bin/koha/svc/quickadd-session" VIEWER_USER = os.environ.get("VIEWER_USER", "") VIEWER_PASSWORD = os.environ.get("VIEWER_PASSWORD", "") LIBRARY = os.environ.get("KOHA_LIBRARY", "HOME") @@ -782,6 +787,33 @@ def decode_photo(data, kind): INDEX = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html"), "rb").read() +_SESSION_CACHE = {} + + +def koha_session_user(sid, ip): + """Ask Koha whether this staff session may edit the catalogue. Returns the user name or None. + Any problem (Koha starting, endpoint missing, odd reply) reads as 'not signed in', which just + means Quick Add asks for its own login.""" + key, now = (sid, ip), time.time() + hit = _SESSION_CACHE.get(key) + if hit and hit[0] > now: + return hit[1] + user = None + try: + req = urllib.request.Request(KOHA_SESSION_URL + "?" + urllib.parse.urlencode({"sid": sid, "ip": ip}), + headers={"X-Quickadd-Secret": SSO_SECRET}) + with urllib.request.urlopen(req, timeout=4) as r: + data = json.load(r) + if data.get("status") == "ok": + user = data.get("user") or "staff" + except Exception: + user = None + if len(_SESSION_CACHE) > 500: + _SESSION_CACHE.clear() + _SESSION_CACHE[key] = (now + (20 if user else 5), user) # short: logging out takes effect quickly + return user + + class Handler(BaseHTTPRequestHandler): server_version = "QuickAdd/1.0" @@ -806,22 +838,36 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(body) def _role(self): - """'admin', 'viewer' (read-only guest) or None.""" + """'admin', 'viewer' (read-only guest) or None. Sets self.via to 'koha' for a staff-site login.""" + self.via = None if not PASSWORD: return None header = self.headers.get("Authorization", "") - if not header.startswith("Basic "): - return None - try: - u, _, p = base64.b64decode(header[6:]).decode().partition(":") - except (ValueError, UnicodeDecodeError): - return None - ub, pb = u.encode(), p.encode() - # Both comparisons always run, so timing does not reveal which account exists. - is_admin = hmac.compare_digest(ub, USER.encode()) & hmac.compare_digest(pb, PASSWORD.encode()) - is_viewer = bool(VIEWER_USER and VIEWER_PASSWORD) & hmac.compare_digest(ub, VIEWER_USER.encode()) \ - & hmac.compare_digest(pb, VIEWER_PASSWORD.encode()) - return "admin" if is_admin else "viewer" if is_viewer else None + if header.startswith("Basic "): + try: + u, _, p = base64.b64decode(header[6:]).decode().partition(":") + except (ValueError, UnicodeDecodeError): + return None + ub, pb = u.encode(), p.encode() + # Both comparisons always run, so timing does not reveal which account exists. + is_admin = hmac.compare_digest(ub, USER.encode()) & hmac.compare_digest(pb, PASSWORD.encode()) + is_viewer = bool(VIEWER_USER and VIEWER_PASSWORD) & hmac.compare_digest(ub, VIEWER_USER.encode()) \ + & hmac.compare_digest(pb, VIEWER_PASSWORD.encode()) + return "admin" if is_admin else "viewer" if is_viewer else None + # No Basic login: accept a staff-site login, but only for requests that came through the staff + # site's web server (it overwrites X-Quickadd-Proxy with the shared secret on every request). + if SSO_SECRET and hmac.compare_digest(self.headers.get("X-Quickadd-Proxy", "").encode(), SSO_SECRET.encode()): + sid = "" + for part in self.headers.get("Cookie", "").split(";"): + k, _, v = part.strip().partition("=") + if k == "CGISESSID": + sid = v + # the browser's address as the web server saw it: the LAST entry (the one it appended) + ip = self.headers.get("X-Forwarded-For", "").split(",")[-1].strip() + if sid and ip and koha_session_user(sid, ip): + self.via = "koha" + return "admin" + return None def _guard(self, need_admin=True): role = self._role() @@ -832,6 +878,9 @@ class Handler(BaseHTTPRequestHandler): if need_admin and role != "admin": self._send(403, {"status": "error", "message": "This login can search but not change anything."}) return False + if self.via == "koha" and self.command == "POST" and self.headers.get("X-Quickadd") != "1": + self._send(403, {"status": "error", "message": "Missing request header."}) + return False self.role = role return True diff --git a/library/quickadd/index.html b/library/quickadd/index.html index 5d9306c..b813688 100644 --- a/library/quickadd/index.html +++ b/library/quickadd/index.html @@ -211,8 +211,10 @@ }; async function api(path, body) { - const r = await fetch(path.replace(/^\//, ''), body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) } : {}); + // X-Quickadd marks our own requests: a staff-site login (cookie) is only accepted for changes + // that carry it, which a page on another website cannot add (cross-site request forgery). + const r = await fetch(path.replace(/^\//, ''), body ? { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Quickadd': '1' }, + body: JSON.stringify(body) } : { headers: { 'X-Quickadd': '1' } }); return r.json(); } @@ -329,7 +331,7 @@ $('status').textContent = 'Reading the barcode…'; try { const blob = await shrink(f); - const r = await fetch('api/scan?kind=' + kind, { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: blob }); + const r = await fetch('api/scan?kind=' + kind, { method: 'POST', headers: { 'Content-Type': 'image/jpeg', 'X-Quickadd': '1' }, body: blob }); resolve(r.ok ? (await r.json()).codes : []); } catch (e) { resolve([]); } };