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([]); }
};