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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
This commit is contained in:
Claude
2026-11-06 22:45:01 +00:00
parent 770a6a1dfb
commit 10e991e158
8 changed files with 142 additions and 20 deletions
+62 -13
View File
@@ -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
+5 -3
View File
@@ -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([]); }
};