Files
bindery/bindery/auth.py
T
tim 09d6b76a17 Add the JSON API and logins
Logins follow shelfmark: none, CWA accounts, or a reverse proxy header.
CWA accounts are checked against the OPDS feed, with a lockout after
repeated wrong passwords. An API key works in every mode, and changes
need an X-Requested-With header or the key. URL_BASE serves the app
under a path prefix.
2026-09-27 23:38:28 +02:00

168 lines
6.0 KiB
Python

"""Logins: none, CWA accounts, or a reverse proxy header. An API key works in every mode.
CWA accounts use an optional read-only app.db, with OPDS as the fallback when
the database cannot answer. A rejected database login never falls back.
"""
import hmac
import logging
import threading
import time
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from bindery import calibre_db, config
from bindery.cwa import CwaClient, CwaError
logger = logging.getLogger(__name__)
AUTH_NONE, AUTH_CWA, AUTH_PROXY = "none", "cwa", "proxy"
MAX_FAILURES = 5 # for one user name from one address
MAX_ADDRESS_FAILURES = 20 # for all user names from one address
LOCKOUT_SECONDS = 15 * 60
class AuthError(Exception):
pass
@dataclass
class Identity:
name: str
via: str # "none", "session", "proxy" or "api_key"
def config_error() -> str | None:
"""Why the login settings cannot work, or None.
Bindery then refuses every request, so a mistake in the settings never turns the logins off.
"""
method = config.AUTH_METHOD
if method not in (AUTH_NONE, AUTH_CWA, AUTH_PROXY):
return f"AUTH_METHOD must be none, cwa or proxy, not {method!r}"
if method == AUTH_CWA and not (config.CWA_URL or config.CWA_DB_PATH):
return "AUTH_METHOD=cwa needs CWA_URL or CWA_DB_PATH"
if method == AUTH_PROXY and not config.PROXY_AUTH_USER_HEADER:
return "AUTH_METHOD=proxy needs PROXY_AUTH_USER_HEADER"
return None
def active_mode() -> str:
return config.AUTH_METHOD
def _allowed(name: str) -> bool:
names = {n.casefold() for n in config.CWA_ALLOWED_USERS}
return not names or name.casefold() in names
def _api_key_identity(headers: Mapping[str, str]) -> Identity | None:
if not config.API_KEY:
return None
candidates = [headers.get("X-Api-Key", "")]
auth = headers.get("Authorization", "")
if auth.lower().startswith("bearer "):
candidates.append(auth[7:].strip())
if any(c and hmac.compare_digest(c.encode(), config.API_KEY.encode()) for c in candidates):
return Identity("api", "api_key")
return None
def current_identity(headers: Mapping[str, str], session: Mapping[str, Any]) -> Identity | None:
"""Who makes this request, or None when a login is needed."""
if config_error():
return None
identity = _api_key_identity(headers)
if identity:
return identity
mode = active_mode()
if mode == AUTH_NONE:
return Identity("", "none")
if mode == AUTH_PROXY:
name = (headers.get(config.PROXY_AUTH_USER_HEADER) or "").strip()
return Identity(name, "proxy") if name else None
name = session.get("user")
if name and session.get("mode") == AUTH_CWA and _allowed(name):
return Identity(name, "session")
return None
def client_address(headers: Mapping[str, str], remote_addr: str | None) -> str:
"""The address of the client. With TRUST_PROXY, the last X-Forwarded-For entry, which the proxy added."""
if config.TRUST_PROXY:
forwarded = [a.strip() for a in headers.get("X-Forwarded-For", "").split(",") if a.strip()]
if forwarded:
return forwarded[-1]
return remote_addr or ""
class LoginLimiter:
"""Refuses logins from a client address after too many wrong passwords.
A user name is locked only for the address that sent the wrong passwords,
so another client cannot lock a user out. Each address also has a limit
for all user names together.
"""
def __init__(self) -> None:
self._failures: dict[str, list[tuple[float, str]]] = {} # address: (time, user name)
self._lock = threading.Lock()
def _prune(self) -> None:
since = time.monotonic() - LOCKOUT_SECONDS
self._failures = {a: kept for a, f in self._failures.items() if (kept := [x for x in f if x[0] > since])}
def locked(self, name: str, address: str) -> bool:
with self._lock:
self._prune()
failures = self._failures.get(address, [])
mine = [n for _, n in failures if n == name.casefold()]
return len(mine) >= MAX_FAILURES or len(failures) >= MAX_ADDRESS_FAILURES
def failed(self, name: str, address: str) -> None:
with self._lock:
self._failures.setdefault(address, []).append((time.monotonic(), name.casefold()))
def clear(self, name: str, address: str) -> None:
with self._lock:
kept = [x for x in self._failures.get(address, []) if x[1] != name.casefold()]
self._failures[address] = kept
limiter = LoginLimiter()
def _login_ok(username: str, password: str, client: CwaClient) -> bool:
if config.CWA_DB_PATH:
try:
return calibre_db.login_ok(config.CWA_DB_PATH, username, password)
except calibre_db.DatabaseUnavailable as e:
logger.warning(f"Cannot read the Calibre-Web login database. Trying OPDS: {e}")
return client.login_ok(username, password)
def login_cwa(username: str, password: str, client: CwaClient, address: str) -> Identity:
"""Check a CWA login. Raises AuthError with a message for the user."""
username = username.strip()
if active_mode() != AUTH_CWA:
raise AuthError("Bindery does not use CWA logins")
if limiter.locked(username, address):
raise AuthError("Too many wrong passwords. Try again in 15 minutes.")
try:
ok = (
_allowed(username)
and username.casefold() != "guest"
and bool(password)
and _login_ok(username, password, client)
)
except CwaError as e:
# The error holds the address of CWA, which a user who is not logged in must not see.
logger.warning(f"Cannot check the login of {username}: {e}")
raise AuthError("Cannot check the login with CWA. The log has the details.") from e
if not ok:
limiter.failed(username, address)
raise AuthError("Wrong user name or password")
limiter.clear(username, address)
return Identity(username, "session")