Files
bindery/bindery/auth.py
T

220 lines
8.4 KiB
Python

"""Logins: none, one local account, CWA accounts, or a proxy. API keys work 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 hashlib
import hmac
import logging
import threading
import time
from collections.abc import Mapping
from dataclasses import dataclass
from functools import lru_cache
from typing import Any
from werkzeug.security import check_password_hash
from bindery import calibre_db, config
from bindery.cwa import CwaClient, CwaError
logger = logging.getLogger(__name__)
AUTH_NONE, AUTH_LOCAL, AUTH_CWA, AUTH_PROXY = "none", "local", "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"
@lru_cache(maxsize=8)
def _valid_local_hash(value: str) -> bool:
"""Validate once per configured hash, not with an expensive hash on each request."""
try:
method, salt, digest = value.split("$")
if not salt or not method.startswith(("scrypt:", "pbkdf2:")):
return False
size = 64 if method.startswith("scrypt:") else hashlib.new(method.split(":")[1]).digest_size
if len(digest) != size * 2 or bytes.fromhex(digest).hex() != digest:
return False
# This also rejects unsupported parameters and hashes of an empty password.
return not check_password_hash(value, "")
except (ValueError, OverflowError):
return False
def local_password_version() -> str:
"""Invalidate local sessions when the configured password hash changes."""
return hashlib.sha256(config.LOCAL_PASSWORD_HASH.encode()).hexdigest()
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_LOCAL, AUTH_CWA, AUTH_PROXY):
return f"AUTH_METHOD must be none, local, cwa or proxy, not {method!r}"
if method == AUTH_LOCAL:
if not config.LOCAL_USERNAME or not config.LOCAL_PASSWORD_HASH:
return "AUTH_METHOD=local needs BINDERY_USERNAME and BINDERY_PASSWORD_HASH"
if not _valid_local_hash(config.LOCAL_PASSWORD_HASH):
return "BINDERY_PASSWORD_HASH must be a Werkzeug scrypt or PBKDF2 hash of a nonempty password"
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 not name or session.get("mode") != mode:
return None
if mode == AUTH_LOCAL:
if name == config.LOCAL_USERNAME and session.get("local_password_version") == local_password_version():
return Identity(name, "session")
elif 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_local(username: str, password: str, address: str) -> Identity:
username = username.strip()
if active_mode() != AUTH_LOCAL:
raise AuthError("Bindery does not use local logins")
if error := config_error():
raise AuthError(error)
if limiter.locked(username, address):
raise AuthError("Too many wrong passwords. Try again in 15 minutes.")
# Check the password even for a wrong user name, so both failures take the same work.
password_ok = check_password_hash(config.LOCAL_PASSWORD_HASH, password)
if not (password_ok and password and hmac.compare_digest(username.encode(), config.LOCAL_USERNAME.encode())):
limiter.failed(username, address)
raise AuthError("Wrong user name or password")
limiter.clear(username, address)
return Identity(config.LOCAL_USERNAME, "session")
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")