diff --git a/.env.example b/.env.example index 69d2750..6a8eb13 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,8 @@ PYTORRENT_DB_PATH=data/pytorrent.sqlite3 PYTORRENT_HOST=0.0.0.0 PYTORRENT_PORT=8090 PYTORRENT_DEBUG=0 -PYTORRENT_POLL_INTERVAL=0.5 -MIN_POLL_INTERVAL_SECONDS=0.5 +PYTORRENT_POLL_INTERVAL=1 +MIN_POLL_INTERVAL_SECONDS=1 PYTORRENT_WORKERS=16 PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb PYTORRENT_ALLOW_UNSAFE_WERKZEUG=0 @@ -13,9 +13,6 @@ PYTORRENT_SCGI_RETRIES=8 # css/js libs PYTORRENT_USE_OFFLINE_LIBS=true -# python -m pytorrent.cli reset-password admin new_Pass -PYTORRENT_AUTH_ENABLE=false - # Reverse proxy / HTTPS PYTORRENT_PROXY_FIX_ENABLE=false PYTORRENT_SESSION_COOKIE_SECURE=false @@ -41,4 +38,34 @@ PYTORRENT_LOG_DIR=data/logs PYTORRENT_LOG_RETENTION_HOURS=24 PYTORRENT_GUNICORN_ACCESS_LOG=data/logs/gunicorn-access.log PYTORRENT_GUNICORN_ERROR_LOG=data/logs/gunicorn-error.log -PYTORRENT_GUNICORN_LOG_LEVEL=info \ No newline at end of file +PYTORRENT_GUNICORN_LOG_LEVEL=info + +#### AUTH + +# python -m pytorrent.cli reset-password admin new_Pass +PYTORRENT_AUTH_ENABLE=false + +# Authentication provider +# Available variants: +# - local = built-in login screen with username/password +# - tinyauth = external auth via Tinyauth / reverse proxy headers +# - proxy = generic external reverse proxy auth +PYTORRENT_AUTH_PROVIDER=tinyauth + +# Headers passed by Tinyauth +PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User +PYTORRENT_AUTH_PROXY_EMAIL_HEADER=Remote-Email +PYTORRENT_AUTH_PROXY_NAME_HEADER=Remote-Name +PYTORRENT_AUTH_PROXY_SUB_HEADER=Remote-Sub + +# Headers passed by external reverse proxy +#PYTORRENT_AUTH_PROXY_USER_HEADER=X-Forwarded-User +#PYTORRENT_AUTH_PROXY_EMAIL_HEADER=X-Forwarded-Email +#PYTORRENT_AUTH_PROXY_NAME_HEADER=X-Forwarded-Name + +# Auto-create user when authenticated externally but missing in DB +PYTORRENT_AUTH_PROXY_AUTO_CREATE=true + +# Defaults for auto-created users +PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin +PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw \ No newline at end of file diff --git a/pytorrent/config.py b/pytorrent/config.py index 2cc8457..ede33d4 100644 --- a/pytorrent/config.py +++ b/pytorrent/config.py @@ -29,6 +29,26 @@ DEBUG = _env_bool("PYTORRENT_DEBUG", False) USE_OFFLINE_LIBS = _env_bool("PYTORRENT_USE_OFFLINE_LIBS", False) # Note: Optional authentication remains disabled unless explicitly enabled in .env. AUTH_ENABLE = _env_bool("PYTORRENT_AUTH_ENABLE", False) +AUTH_PROVIDER = os.getenv("PYTORRENT_AUTH_PROVIDER", "local").strip().lower() or "local" +if AUTH_PROVIDER == "tinyauth": + AUTH_PROXY_USER_HEADER = os.getenv("PYTORRENT_AUTH_PROXY_USER_HEADER", "Remote-User") + AUTH_PROXY_EMAIL_HEADER = os.getenv("PYTORRENT_AUTH_PROXY_EMAIL_HEADER", "Remote-Email") + AUTH_PROXY_NAME_HEADER = os.getenv("PYTORRENT_AUTH_PROXY_NAME_HEADER", "Remote-Name") + AUTH_PROXY_SUBJECT_HEADER = os.getenv("PYTORRENT_AUTH_PROXY_SUBJECT_HEADER", "Remote-Sub") +else: + AUTH_PROXY_USER_HEADER = os.getenv("PYTORRENT_AUTH_PROXY_USER_HEADER", "Remote-User") + AUTH_PROXY_EMAIL_HEADER = os.getenv("PYTORRENT_AUTH_PROXY_EMAIL_HEADER", "Remote-Email") + AUTH_PROXY_NAME_HEADER = os.getenv("PYTORRENT_AUTH_PROXY_NAME_HEADER", "Remote-Name") + AUTH_PROXY_SUBJECT_HEADER = os.getenv("PYTORRENT_AUTH_PROXY_SUBJECT_HEADER", "") +AUTH_PROXY_AUTO_CREATE = _env_bool("PYTORRENT_AUTH_PROXY_AUTO_CREATE", False) +AUTH_PROXY_DEFAULT_ROLE = os.getenv("PYTORRENT_AUTH_PROXY_DEFAULT_ROLE", "user").strip().lower() +AUTH_PROXY_DEFAULT_ACCESS = os.getenv("PYTORRENT_AUTH_PROXY_DEFAULT_ACCESS", "ro").strip().lower() +if AUTH_PROVIDER not in {"local", "proxy", "tinyauth"}: + AUTH_PROVIDER = "local" +if AUTH_PROXY_DEFAULT_ROLE not in {"user", "admin"}: + AUTH_PROXY_DEFAULT_ROLE = "user" +if AUTH_PROXY_DEFAULT_ACCESS not in {"none", "ro", "full"}: + AUTH_PROXY_DEFAULT_ACCESS = "ro" if AUTH_ENABLE and (not _SECRET_KEY_ENV or SECRET_KEY == "dev-change-me"): # Note: Auth mode cannot use Flask's development secret; persist a local random session key instead. _secret_file = BASE_DIR / "data" / ".session_secret" diff --git a/pytorrent/db.py b/pytorrent/db.py index d5fd514..b89b2ea 100644 --- a/pytorrent/db.py +++ b/pytorrent/db.py @@ -10,6 +10,10 @@ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT, + email TEXT, + display_name TEXT, + external_auth_provider TEXT, + external_subject TEXT, role TEXT DEFAULT 'user', is_active INTEGER DEFAULT 1, created_at TEXT NOT NULL, @@ -501,6 +505,10 @@ CREATE TABLE IF NOT EXISTS tracker_favicon_cache ( MIGRATIONS = [ "ALTER TABLE api_tokens ADD COLUMN last_used_at TEXT", + "ALTER TABLE users ADD COLUMN email TEXT", + "ALTER TABLE users ADD COLUMN display_name TEXT", + "ALTER TABLE users ADD COLUMN external_auth_provider TEXT", + "ALTER TABLE users ADD COLUMN external_subject TEXT", "ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'", "ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1", "ALTER TABLE users ADD COLUMN updated_at TEXT", diff --git a/pytorrent/routes/auth_api.py b/pytorrent/routes/auth_api.py index 0395c5c..697511f 100644 --- a/pytorrent/routes/auth_api.py +++ b/pytorrent/routes/auth_api.py @@ -2,7 +2,7 @@ from __future__ import annotations from flask import abort, jsonify, request -from ..services.auth import current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, list_api_tokens, create_api_token, revoke_api_token +from ..services.auth import current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, provider as auth_provider, list_api_tokens, create_api_token, revoke_api_token def _ok(payload=None): @@ -21,13 +21,13 @@ def register_auth_routes(bp): user = login_user(str(data.get("username") or ""), str(data.get("password") or "")) if not user: return jsonify({"ok": False, "error": "Invalid username or password"}), 401 - return _ok({"user": user, "auth_enabled": auth_enabled()}) + return _ok({"user": user, "auth_enabled": auth_enabled(), "auth_provider": auth_provider()}) @bp.get("/auth/me") def auth_me(): if not auth_enabled(): abort(404) - return _ok({"user": current_user(), "auth_enabled": auth_enabled()}) + return _ok({"user": current_user(), "auth_enabled": auth_enabled(), "auth_provider": auth_provider()}) @bp.post("/auth/logout") def auth_logout(): diff --git a/pytorrent/routes/main.py b/pytorrent/routes/main.py index 79d2fe5..560455f 100644 --- a/pytorrent/routes/main.py +++ b/pytorrent/routes/main.py @@ -174,13 +174,23 @@ def login(): # Note: When optional authentication is disabled, /login is intentionally unavailable. if not auth.enabled(): abort(404) + next_url = request.args.get("next") or url_for("main.index") + if auth.uses_external_provider(): + user = auth.authenticate_external_user() + if user: + return redirect(next_url) + return render_template( + "login.html", + error="External authentication headers were not accepted by pyTorrent.", + external_provider=auth.provider(), + ), 401 error = "" if request.method == "POST": user = auth.login_user(request.form.get("username", ""), request.form.get("password", "")) if user: - return redirect(request.args.get("next") or url_for("main.index")) + return redirect(next_url) error = "Invalid username or password" - return render_template("login.html", error=error) + return render_template("login.html", error=error, external_provider=None) @bp.get("/logout") diff --git a/pytorrent/services/auth.py b/pytorrent/services/auth.py index 1e3810a..58169da 100644 --- a/pytorrent/services/auth.py +++ b/pytorrent/services/auth.py @@ -9,7 +9,17 @@ from urllib.parse import urlparse from flask import abort, g, jsonify, redirect, request, session, url_for from werkzeug.security import check_password_hash, generate_password_hash -from ..config import AUTH_ENABLE +from ..config import ( + AUTH_ENABLE, + AUTH_PROVIDER, + AUTH_PROXY_AUTO_CREATE, + AUTH_PROXY_DEFAULT_ACCESS, + AUTH_PROXY_DEFAULT_ROLE, + AUTH_PROXY_EMAIL_HEADER, + AUTH_PROXY_NAME_HEADER, + AUTH_PROXY_SUBJECT_HEADER, + AUTH_PROXY_USER_HEADER, +) from ..db import connect, default_user_id, utcnow PUBLIC_ENDPOINTS = {"main.login", "main.logout", "api.auth_login", "api.auth_me", "static"} @@ -47,6 +57,14 @@ def enabled() -> bool: return bool(AUTH_ENABLE) +def provider() -> str: + return AUTH_PROVIDER if AUTH_PROVIDER in {"local", "proxy", "tinyauth"} else "local" + + +def uses_external_provider() -> bool: + return enabled() and provider() in {"proxy", "tinyauth"} + + def password_hash(password: str) -> str: return generate_password_hash(password or "") @@ -57,6 +75,9 @@ def current_user_id() -> int: api_user_id = getattr(g, "api_user_id", None) if api_user_id: return int(api_user_id) + external_user_id = getattr(g, "external_user_id", None) + if external_user_id: + return int(external_user_id) try: return int(session.get("user_id") or 0) except Exception: @@ -69,7 +90,7 @@ def current_user() -> dict[str, Any] | None: return None with connect() as conn: return conn.execute( - "SELECT id, username, role, is_active, created_at, updated_at FROM users WHERE id=?", + "SELECT id, username, email, display_name, external_auth_provider, external_subject, role, is_active, created_at, updated_at FROM users WHERE id=?", (uid,), ).fetchone() @@ -200,6 +221,8 @@ def require_profile_write(profile_id: int | None) -> None: def login_user(username: str, password: str) -> dict[str, Any] | None: if not enabled(): return {"id": default_user_id(), "username": "default", "role": "admin", "is_active": 1} + if uses_external_provider(): + return None with connect() as conn: user = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).fetchone() if not user or not int(user.get("is_active") or 0): @@ -213,6 +236,109 @@ def login_user(username: str, password: str) -> dict[str, Any] | None: return current_user() + + +def _clean_header_value(name: str) -> str: + if not name: + return "" + value = request.headers.get(name) or request.headers.get(name.lower()) or request.headers.get(name.upper()) or "" + return str(value).strip() + + +def _safe_username(value: str, fallback: str = "external-user") -> str: + raw = str(value or "").strip() + if "@" in raw: + raw = raw.split("@", 1)[0] + clean = "".join(ch for ch in raw if ch.isalnum() or ch in {".", "_", "-"}).strip("._-") + return (clean or fallback)[:80] + + +def _external_identity_from_headers() -> dict[str, str] | None: + username = _clean_header_value(AUTH_PROXY_USER_HEADER) + email = _clean_header_value(AUTH_PROXY_EMAIL_HEADER) + display_name = _clean_header_value(AUTH_PROXY_NAME_HEADER) + subject = _clean_header_value(AUTH_PROXY_SUBJECT_HEADER) if AUTH_PROXY_SUBJECT_HEADER else "" + if not username and email: + username = email.split("@", 1)[0] + if not username: + return None + return { + "provider": provider(), + "username": _safe_username(username), + "email": email[:254], + "display_name": display_name[:160], + "subject": (subject or username or email)[:254], + } + + +def _grant_default_external_permissions(conn, user_id: int, now: str) -> None: + if AUTH_PROXY_DEFAULT_ACCESS == "none" or AUTH_PROXY_DEFAULT_ROLE == "admin": + return + conn.execute( + "INSERT OR IGNORE INTO user_profile_permissions(user_id,profile_id,access_level,created_at,updated_at) VALUES(?,?,?,?,?)", + (user_id, 0, AUTH_PROXY_DEFAULT_ACCESS, now, now), + ) + + +def authenticate_external_user() -> dict[str, Any] | None: + if not uses_external_provider(): + return None + identity = _external_identity_from_headers() + if not identity: + return None + now = utcnow() + with connect() as conn: + user = None + if identity["subject"]: + user = conn.execute( + "SELECT * FROM users WHERE external_auth_provider=? AND external_subject=?", + (identity["provider"], identity["subject"]), + ).fetchone() + if not user: + user = conn.execute("SELECT * FROM users WHERE username=?", (identity["username"],)).fetchone() + if not user and identity["email"]: + user = conn.execute("SELECT * FROM users WHERE lower(email)=lower(?)", (identity["email"],)).fetchone() + if not user: + if not AUTH_PROXY_AUTO_CREATE: + return None + cur = conn.execute( + """INSERT INTO users(username,password_hash,email,display_name,external_auth_provider,external_subject,role,is_active,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)""", + ( + identity["username"], + None, + identity["email"] or None, + identity["display_name"] or None, + identity["provider"], + identity["subject"] or identity["username"], + AUTH_PROXY_DEFAULT_ROLE, + 1, + now, + now, + ), + ) + user_id = int(cur.lastrowid) + _grant_default_external_permissions(conn, user_id, now) + user = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone() + else: + user_id = int(user["id"]) + conn.execute( + """UPDATE users + SET email=COALESCE(NULLIF(?, ''), email), + display_name=COALESCE(NULLIF(?, ''), display_name), + external_auth_provider=?, + external_subject=COALESCE(NULLIF(?, ''), external_subject), + updated_at=? + WHERE id=?""", + (identity["email"], identity["display_name"], identity["provider"], identity["subject"], now, user_id), + ) + user = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone() + if not user or not int(user.get("is_active") or 0): + return None + g.external_user_id = int(user["id"]) + return _public_user(user) + + def logout_user() -> None: session.clear() @@ -236,7 +362,7 @@ def list_users() -> list[dict[str, Any]]: require_admin() with connect() as conn: users = conn.execute( - "SELECT id, username, role, is_active, created_at, updated_at FROM users ORDER BY username COLLATE NOCASE" + "SELECT id, username, email, display_name, external_auth_provider, external_subject, role, is_active, created_at, updated_at FROM users ORDER BY username COLLATE NOCASE" ).fetchall() perms = conn.execute( "SELECT user_id, profile_id, access_level FROM user_profile_permissions ORDER BY user_id, profile_id" @@ -271,13 +397,13 @@ def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any if not row: raise ValueError("User does not exist") conn.execute( - "UPDATE users SET username=?, role=?, is_active=?, updated_at=? WHERE id=?", - (username, role, is_active, now, user_id), + "UPDATE users SET username=?, email=?, display_name=?, role=?, is_active=?, updated_at=? WHERE id=?", + (username, str(data.get("email") or "").strip() or None, str(data.get("display_name") or "").strip() or None, role, is_active, now, user_id), ) else: cur = conn.execute( - "INSERT INTO users(username,password_hash,role,is_active,created_at,updated_at) VALUES(?,?,?,?,?,?)", - (username, password_hash(str(data.get("password") or username)), role, is_active, now, now), + "INSERT INTO users(username,password_hash,email,display_name,role,is_active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", + (username, password_hash(str(data.get("password") or username)), str(data.get("email") or "").strip() or None, str(data.get("display_name") or "").strip() or None, role, is_active, now, now), ) user_id = int(cur.lastrowid) if data.get("password"): @@ -293,7 +419,7 @@ def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any ) else: conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,)) - return conn.execute("SELECT id, username, role, is_active, created_at, updated_at FROM users WHERE id=?", (user_id,)).fetchone() + return conn.execute("SELECT id, username, email, display_name, external_auth_provider, external_subject, role, is_active, created_at, updated_at FROM users WHERE id=?", (user_id,)).fetchone() def delete_user(user_id: int) -> None: @@ -323,6 +449,10 @@ def _public_user(row: dict[str, Any] | None) -> dict[str, Any] | None: return { "id": int(row["id"]), "username": row.get("username"), + "email": row.get("email"), + "display_name": row.get("display_name"), + "external_auth_provider": row.get("external_auth_provider"), + "external_subject": row.get("external_subject"), "role": row.get("role") or "user", "is_active": int(row.get("is_active") or 0), "created_at": row.get("created_at"), @@ -445,6 +575,8 @@ def install_guards(app) -> None: if token_user: g.api_user_id = int(token_user["id"]) g.api_token_authenticated = True + if not getattr(g, "api_user_id", None): + authenticate_external_user() endpoint = request.endpoint or "" if endpoint in PUBLIC_ENDPOINTS or endpoint.startswith("static"): return None diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 18b4ab6..667de8d 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -2379,6 +2379,17 @@ body.mobile-mode .mobile-filter-bar { text-align: left; } +.auth-provider-note { + margin-top: 1rem; + padding: 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: rgba(var(--bs-tertiary-bg-rgb), 0.5); + color: var(--bs-secondary-color); + font-size: 0.82rem; + text-align: left; +} + .auth-form { margin-top: 1.2rem; text-align: left; diff --git a/pytorrent/templates/login.html b/pytorrent/templates/login.html index 8146098..a827ad8 100644 --- a/pytorrent/templates/login.html +++ b/pytorrent/templates/login.html @@ -16,6 +16,13 @@
External authentication is enabled through {{ external_provider }}.
+ {% if error %}Authentication is enabled for this pyTorrent instance.
{% if error %}