220 lines
8.4 KiB
Python
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")
|