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:
@@ -203,3 +203,7 @@ koha/ Dockerfile (Koha from the project's Debian repo), entrypoi
|
|||||||
servers, label templates/layouts)
|
servers, label templates/layouts)
|
||||||
quickadd/ the scan-to-add service (Python standard library + yaz)
|
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.
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
TZ: ${TZ:-UTC}
|
TZ: ${TZ:-UTC}
|
||||||
KOHA_QUICKADD_URL: http://quickadd:8082
|
KOHA_QUICKADD_URL: http://quickadd:8082
|
||||||
|
KOHA_SSO_SECRET: ${SSO_SECRET:-}
|
||||||
KOHA_DB_HOST: db
|
KOHA_DB_HOST: db
|
||||||
KOHA_DB_PASS: ${DB_PASSWORD}
|
KOHA_DB_PASS: ${DB_PASSWORD}
|
||||||
KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
|
KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
@@ -60,6 +61,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
TZ: ${TZ:-UTC}
|
TZ: ${TZ:-UTC}
|
||||||
KOHA_API: http://koha:8081/api/v1
|
KOHA_API: http://koha:8081/api/v1
|
||||||
|
SSO_SECRET: ${SSO_SECRET:-}
|
||||||
ADMIN_USER: ${ADMIN_USER:-admin}
|
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
VIEWER_USER: ${VIEWER_USER:-guest}
|
VIEWER_USER: ${VIEWER_USER:-guest}
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ ADMIN_USER=admin
|
|||||||
ADMIN_PASSWORD=$(rand 16)
|
ADMIN_PASSWORD=$(rand 16)
|
||||||
VIEWER_USER=guest
|
VIEWER_USER=guest
|
||||||
VIEWER_PASSWORD=$(rand 14)
|
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_ROOT_PASSWORD=$(rand 32)
|
||||||
DB_PASSWORD=$(rand 32)
|
DB_PASSWORD=$(rand 32)
|
||||||
KOHA_SUITE=stable
|
KOHA_SUITE=stable
|
||||||
@@ -142,6 +144,7 @@ else
|
|||||||
if ! grep -q '^VIEWER_PASSWORD=' .env; then
|
if ! grep -q '^VIEWER_PASSWORD=' .env; then
|
||||||
{ echo "VIEWER_USER=guest"; echo "VIEWER_PASSWORD=$(rand 14)"; } >> .env
|
{ echo "VIEWER_USER=guest"; echo "VIEWER_PASSWORD=$(rand 14)"; } >> .env
|
||||||
fi
|
fi
|
||||||
|
grep -q '^SSO_SECRET=' .env || echo "SSO_SECRET=$(rand 40)" >> .env
|
||||||
# Existing installs keep their current public-catalogue behaviour.
|
# Existing installs keep their current public-catalogue behaviour.
|
||||||
grep -q '^OPAC_BIND=' .env || echo "OPAC_BIND=0.0.0.0" >> .env
|
grep -q '^OPAC_BIND=' .env || echo "OPAC_BIND=0.0.0.0" >> .env
|
||||||
if ! grep -q '^BIND_ADDR=' .env; then
|
if ! grep -q '^BIND_ADDR=' .env; then
|
||||||
|
|||||||
@@ -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; }; \
|
command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \
|
||||||
done
|
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 bootstrap.pl ensure.pl entrypoint.sh /opt/koha-init/
|
||||||
COPY seed/ /opt/koha-init/seed/
|
COPY seed/ /opt/koha-init/seed/
|
||||||
RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init
|
RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init
|
||||||
|
|||||||
@@ -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
|
# (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.)
|
# same URL shapes. The staff site is the natural home for a home library.)
|
||||||
if [ -n "${KOHA_QUICKADD_URL:-}" ]; then
|
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=" <IfModule mod_headers.c>
|
||||||
|
<Location /add/>
|
||||||
|
RequestHeader set X-Quickadd-Proxy \"$KOHA_SSO_SECRET\"
|
||||||
|
</Location>
|
||||||
|
</IfModule>"
|
||||||
|
fi
|
||||||
cat > /etc/koha/apache-quickadd.conf << APACHE
|
cat > /etc/koha/apache-quickadd.conf << APACHE
|
||||||
<IfModule mod_proxy_http.c>
|
<IfModule mod_proxy_http.c>
|
||||||
RedirectMatch ^/add\$ /add/
|
RedirectMatch ^/add\$ /add/
|
||||||
ProxyPass /add/ ${KOHA_QUICKADD_URL%/}/
|
ProxyPass /add/ ${KOHA_QUICKADD_URL%/}/
|
||||||
ProxyPassReverse /add/ ${KOHA_QUICKADD_URL%/}/
|
ProxyPassReverse /add/ ${KOHA_QUICKADD_URL%/}/
|
||||||
|
${SSO_BLOCK}
|
||||||
</IfModule>
|
</IfModule>
|
||||||
APACHE
|
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"
|
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
|
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
|
# 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
|
# 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.*
|
rm -f /etc/apache2/mods-enabled/mpm_itk.*
|
||||||
a2enmod mpm_prefork >/dev/null 2>&1 || true
|
a2enmod mpm_prefork >/dev/null 2>&1 || true
|
||||||
sed -i '/AssignUserID/d' "$SITE"
|
sed -i '/AssignUserID/d' "$SITE"
|
||||||
|
|||||||
@@ -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' } );
|
||||||
+52
-3
@@ -28,6 +28,11 @@ KOHA_API = os.environ.get("KOHA_API", "http://koha:8081/api/v1").rstrip("/")
|
|||||||
USER = os.environ.get("ADMIN_USER", "admin")
|
USER = os.environ.get("ADMIN_USER", "admin")
|
||||||
PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
|
PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
|
||||||
# Optional read-only login to hand out: may open the page and search, nothing else.
|
# 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_USER = os.environ.get("VIEWER_USER", "")
|
||||||
VIEWER_PASSWORD = os.environ.get("VIEWER_PASSWORD", "")
|
VIEWER_PASSWORD = os.environ.get("VIEWER_PASSWORD", "")
|
||||||
LIBRARY = os.environ.get("KOHA_LIBRARY", "HOME")
|
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()
|
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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
server_version = "QuickAdd/1.0"
|
server_version = "QuickAdd/1.0"
|
||||||
|
|
||||||
@@ -806,12 +838,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
def _role(self):
|
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:
|
if not PASSWORD:
|
||||||
return None
|
return None
|
||||||
header = self.headers.get("Authorization", "")
|
header = self.headers.get("Authorization", "")
|
||||||
if not header.startswith("Basic "):
|
if header.startswith("Basic "):
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
u, _, p = base64.b64decode(header[6:]).decode().partition(":")
|
u, _, p = base64.b64decode(header[6:]).decode().partition(":")
|
||||||
except (ValueError, UnicodeDecodeError):
|
except (ValueError, UnicodeDecodeError):
|
||||||
@@ -822,6 +854,20 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
is_viewer = bool(VIEWER_USER and VIEWER_PASSWORD) & hmac.compare_digest(ub, VIEWER_USER.encode()) \
|
is_viewer = bool(VIEWER_USER and VIEWER_PASSWORD) & hmac.compare_digest(ub, VIEWER_USER.encode()) \
|
||||||
& hmac.compare_digest(pb, VIEWER_PASSWORD.encode())
|
& hmac.compare_digest(pb, VIEWER_PASSWORD.encode())
|
||||||
return "admin" if is_admin else "viewer" if is_viewer else None
|
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):
|
def _guard(self, need_admin=True):
|
||||||
role = self._role()
|
role = self._role()
|
||||||
@@ -832,6 +878,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if need_admin and role != "admin":
|
if need_admin and role != "admin":
|
||||||
self._send(403, {"status": "error", "message": "This login can search but not change anything."})
|
self._send(403, {"status": "error", "message": "This login can search but not change anything."})
|
||||||
return False
|
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
|
self.role = role
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -211,8 +211,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function api(path, body) {
|
async function api(path, body) {
|
||||||
const r = await fetch(path.replace(/^\//, ''), body ? { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
// X-Quickadd marks our own requests: a staff-site login (cookie) is only accepted for changes
|
||||||
body: JSON.stringify(body) } : {});
|
// 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();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +331,7 @@
|
|||||||
$('status').textContent = 'Reading the barcode…';
|
$('status').textContent = 'Reading the barcode…';
|
||||||
try {
|
try {
|
||||||
const blob = await shrink(f);
|
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 : []);
|
resolve(r.ok ? (await r.json()).codes : []);
|
||||||
} catch (e) { resolve([]); }
|
} catch (e) { resolve([]); }
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user