From 086401083e86371145d5a16713ee80425ed14fa9 Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Sun, 15 Jun 2025 06:55:25 +0200 Subject: [PATCH] Add simple authentication (#181) This commit adds user authentication, using Calibre-Web's app.db as its authentication source, as requested in #56. It uses @prinzpi's [comment](https://github.com/calibrain/calibre-web-automated-book-downloader/issues/56#issuecomment-2919335169) as a starting point, but integrates the logic directly into the app. This requires the user to specify the environment variable CONFIG_ROOT, set to Calibre-Web's config directory (the directory that contains the app.db database that holds the user's authentication information). If the user does not wish to add authentication, they can simply leave CONFIG_ROOT unset, or not pointing at Calibre-Web's app.db directory. --- app.py | 80 ++++++++++++++++++++++++++++++++++++++++++++-- docker-compose.yml | 8 +++-- env.py | 2 ++ readme.md | 4 +++ 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index e065d15..ac9b102 100644 --- a/app.py +++ b/app.py @@ -2,15 +2,18 @@ import logging import io, re, os +import sqlite3 +from functools import wraps from flask import Flask, request, jsonify, render_template, send_file, send_from_directory from werkzeug.middleware.proxy_fix import ProxyFix -from werkzeug.wrappers import Response +from werkzeug.security import check_password_hash +from werkzeug.wrappers import Response from flask import url_for as flask_url_for import typing from logger import setup_logger from config import _SUPPORTED_BOOK_LANGUAGE, BOOK_LANGUAGE -from env import FLASK_HOST, FLASK_PORT, APP_ENV, DEBUG +from env import FLASK_HOST, FLASK_PORT, APP_ENV, CWA_DB_PATH, DEBUG import backend from models import SearchFilters @@ -29,6 +32,32 @@ werkzeug_logger = logging.getLogger('werkzeug') werkzeug_logger.handlers = logger.handlers werkzeug_logger.setLevel(logger.level) +# Set up authentication defaults +# The secret key will reset every time we restart, which will +# require users to authenticate again +app.config.update( + SECRET_KEY = os.urandom(64) +) + +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + # If the CWA_DB_PATH variable exists, but isn't a valid + # path, return a server error + if CWA_DB_PATH is not None and not os.path.isfile(CWA_DB_PATH): + logger.error(f"CWA_DB_PATH is set to {CWA_DB_PATH} but this is not a valid path") + return Response("Internal Server Error", 500) + if not authenticate(): + return Response( + response="Unauthorized", + status=401, + headers={ + "WWW-Authenticate": 'Basic realm="Calibre-Web-Automated-Book-Downloader"', + }, + ) + return f(*args, **kwargs) + return decorated_function + def register_dual_routes(app : Flask) -> None: """ Register each route both with and without the /request prefix. @@ -69,6 +98,7 @@ def url_for_with_request(endpoint : str, **values : typing.Any) -> str: return flask_url_for(endpoint, **values) @app.route('/') +@login_required def index() -> str: """ Render main page with search and status table. @@ -89,6 +119,7 @@ if DEBUG: import time from cloudflare_bypasser import _reset_driver as STOP_GUI @app.route('/debug', methods=['GET']) + @login_required def debug() -> Union[Response, Tuple[Response, int]]: """ This will run the /app/debug.sh script, which will generate a debug zip with all the logs @@ -123,6 +154,7 @@ if DEBUG: return jsonify({"error": str(e)}), 500 @app.route('/api/search', methods=['GET']) +@login_required def api_search() -> Union[Response, Tuple[Response, int]]: """ Search for books matching the provided query. @@ -161,6 +193,7 @@ def api_search() -> Union[Response, Tuple[Response, int]]: return jsonify({"error": str(e)}), 500 @app.route('/api/info', methods=['GET']) +@login_required def api_info() -> Union[Response, Tuple[Response, int]]: """ Get detailed book information. @@ -185,6 +218,7 @@ def api_info() -> Union[Response, Tuple[Response, int]]: return jsonify({"error": str(e)}), 500 @app.route('/api/download', methods=['GET']) +@login_required def api_download() -> Union[Response, Tuple[Response, int]]: """ Queue a book for download. @@ -209,6 +243,7 @@ def api_download() -> Union[Response, Tuple[Response, int]]: return jsonify({"error": str(e)}), 500 @app.route('/api/status', methods=['GET']) +@login_required def api_status() -> Union[Response, Tuple[Response, int]]: """ Get current download queue status. @@ -224,6 +259,7 @@ def api_status() -> Union[Response, Tuple[Response, int]]: return jsonify({"error": str(e)}), 500 @app.route('/api/localdownload', methods=['GET']) +@login_required def api_local_download() -> Union[Response, Tuple[Response, int]]: """ Download an EPUB file from local storage if available. @@ -287,6 +323,46 @@ def internal_error(error: Exception) -> Union[Response, Tuple[Response, int]]: logger.error_trace(f"500 error: {error}") return jsonify({"error": "Internal server error"}), 500 +def authenticate() -> bool: + """ + Helper function that validates Basic credentials + against a Calibre-Web app.db SQLite database + + Database structure: + - Table 'user' with columns: 'name' (username), 'password' + """ + + # If the database doesn't exist, the user is always authenticated + if not CWA_DB_PATH: + return True + + # If no authorization object exists, return false to prompt + # a request to the user + if not request.authorization: + return False + + username = request.authorization.get("username") + password = request.authorization.get("password") + + # Validate credentials against database + try: + conn = sqlite3.connect(CWA_DB_PATH) + cur = conn.cursor() + cur.execute("SELECT password FROM user WHERE name = ?", (username,)) + row = cur.fetchone() + conn.close() + + # Check if user exists and password is correct + if not row or not row[0] or not check_password_hash(row[0], password): + logger.error("User not found or password check failed") + return False + + except Exception as e: + logger.error_trace(f"CWA DB or authentication send_from_directory: {e}") + return False + + logger.info(f"Authentication successful for user {username}") + return True # Register all routes with /request prefix register_dual_routes(app) diff --git a/docker-compose.yml b/docker-compose.yml index 8c349d0..36d51c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,10 +10,14 @@ services: APP_ENV: prod UID: 1000 GID: 100 + CWA_DB_PATH: /auth/app.db ports: - 8084:8084 restart: unless-stopped volumes: - # This is where the books will be downloaded to, usually it would be - # the same as whatever you gave in "calibre-web-automated" + # This is where the books will be downloaded to, usually it would be + # the same as whatever you gave in "calibre-web-automated" - /tmp/data/calibre-web/ingest:/cwa-book-ingest + # This is the location of CWA's app.db, which contains authentication + # details + - /cwa/config/path/app.db:/auth/app.db:ro diff --git a/env.py b/env.py index df2bd5d..cc6229d 100644 --- a/env.py +++ b/env.py @@ -4,6 +4,8 @@ from pathlib import Path def string_to_bool(s: str) -> bool: return s.lower() in ["true", "yes", "1", "y"] +CWA_DB = os.getenv("CWA_DB_PATH") +CWA_DB_PATH = Path(CWA_DB) if CWA_DB else None LOG_ROOT = Path(os.getenv("LOG_ROOT", "/var/log/")) LOG_DIR = LOG_ROOT / "cwa-book-downloader" TMP_DIR = Path(os.getenv("TMP_DIR", "/tmp/cwa-book-downloader")) diff --git a/readme.md b/readme.md index 819771e..55dc195 100644 --- a/readme.md +++ b/readme.md @@ -60,9 +60,12 @@ An intuitive web interface for searching and requesting book downloads, designed | `TZ` | Container timezone | `UTC` | | `UID` | Runtime user ID | `1000` | | `GID` | Runtime group ID | `100` | +| `CWA_DB_PATH` | Calibre-Web's database | None | | `ENABLE_LOGGING` | Enable log file | `true` | | `LOG_LEVEL` | Log level to use | `info` | +If you wish to enable authentication, you must set `CWA_DB_PATH` to point to Calibre-Web's `app.db`, in order to match the username and password. + If logging is enabld, log folder default location is `/var/log/cwa-book-downloader` Available log levels: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. Higher levels show fewer messages. @@ -165,6 +168,7 @@ volumes: ```yaml volumes: - /your/local/path:/cwa-book-ingest + - /cwa/config/path/app.db:/auth/app.db:ro ``` Mount should align with your Calibre-Web-Automated ingest folder.