diff --git a/.env.example b/.env.example index 6a8eb13..0f62d9b 100644 --- a/.env.example +++ b/.env.example @@ -54,18 +54,13 @@ 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 +PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw diff --git a/auth.md b/auth.md new file mode 100644 index 0000000..affafd9 --- /dev/null +++ b/auth.md @@ -0,0 +1,150 @@ +# Authentication configuration + +## Overview + +pyTorrent supports three authentication modes: + +- `local` - built-in pyTorrent login screen with username and password. +- `tinyauth` - external authentication through Tinyauth and a trusted reverse proxy username header. +- `proxy` - generic external authentication through a trusted reverse proxy username header. + +When `tinyauth` or `proxy` is used, pyTorrent does not show the local login form. The reverse proxy must authenticate the request first and pass the authenticated username to pyTorrent in the configured header. + +## Environment variables + +```env +PYTORRENT_AUTH_ENABLE=true + +# local | tinyauth | proxy +PYTORRENT_AUTH_PROVIDER=tinyauth + +# Header that contains the authenticated username. +PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User + +# Create a local pyTorrent user when the external user is missing. +PYTORRENT_AUTH_PROXY_AUTO_CREATE=true + +# Role for auto-created external users: user | admin +PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin + +# Permission for auto-created role=user accounts: none | ro | rw | full +# rw is accepted as an alias of full. +# Admin users ignore this value and can access all profiles. +PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw +``` + +## Local authentication + +Use this when pyTorrent should manage its own login screen and passwords. + +```env +PYTORRENT_AUTH_ENABLE=true +PYTORRENT_AUTH_PROVIDER=local +``` + +Password reset example: + +```bash +python -m pytorrent.cli reset-password admin new_Pass +``` + +## Tinyauth authentication + +Use this when Tinyauth protects pyTorrent before the request reaches the application. + +```env +PYTORRENT_AUTH_ENABLE=true +PYTORRENT_AUTH_PROVIDER=tinyauth +PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User +PYTORRENT_AUTH_PROXY_AUTO_CREATE=true +PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin +PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw +``` + +Behavior: + +- Tinyauth authenticates the browser request. +- The reverse proxy forwards the authenticated username in `Remote-User`. +- pyTorrent reads only that username header. +- If the username already exists in pyTorrent, that user is used. +- If the username does not exist and `PYTORRENT_AUTH_PROXY_AUTO_CREATE=true`, pyTorrent creates it. +- Passwordless external users are synchronized with `PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE` and `PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION` on login. + +## Example Nginx / Nginx Proxy Manager advanced vhost + +```nginx +location / { + proxy_pass $forward_scheme://$server:$port; + auth_request /tinyauth; + error_page 401 = @tinyauth_login; +} + +location /tinyauth { + proxy_pass http://10.87.7.99:3000/api/auth/nginx; + proxy_set_header x-forwarded-proto $scheme; + proxy_set_header x-forwarded-host $http_host; + proxy_set_header x-forwarded-uri $request_uri; +} + +location @tinyauth_login { + return 302 http://auth.linuxiarz.pl/login?redirect_uri=$scheme://$http_host$request_uri; +} +``` + +Use `PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User` when this setup forwards `Remote-User` to pyTorrent. + +## Generic reverse proxy authentication + +Use this when another proxy authenticates users and sends a username header. + +```env +PYTORRENT_AUTH_ENABLE=true +PYTORRENT_AUTH_PROVIDER=proxy +PYTORRENT_AUTH_PROXY_USER_HEADER=X-Forwarded-User +PYTORRENT_AUTH_PROXY_AUTO_CREATE=true +PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=user +PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw +``` + +## Auto-created user permissions + +`PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin`: + +- user is created as admin; +- profile permissions are not needed; +- all profiles are visible and writable. + +`PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=user`: + +- `none` - creates the user without profile access; +- `ro` - grants read-only access to all profiles; +- `rw` - grants read-write access to all profiles; +- `full` - same as `rw`. + +## Troubleshooting + +If the user is created but profiles are missing: + +1. Check the created user's role in pyTorrent user management. +2. For admin access, use: + +```env +PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin +``` + +3. For non-admin read-write access, use: + +```env +PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=user +PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw +``` + +4. Delete the wrongly auto-created external user or log in again. Passwordless external users are synchronized on login by the current config. + +If login fails completely, verify that the configured header reaches pyTorrent: + +```env +PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User +``` + +The configured header must contain a non-empty username. diff --git a/pytorrent/config.py b/pytorrent/config.py index ede33d4..82851a7 100644 --- a/pytorrent/config.py +++ b/pytorrent/config.py @@ -30,25 +30,21 @@ 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" + +# Note: External auth reads only one identity value from the trusted reverse proxy. +AUTH_PROXY_USER_HEADER = os.getenv("PYTORRENT_AUTH_PROXY_USER_HEADER", "Remote-User").strip() or "Remote-User" +AUTH_PROXY_AUTO_CREATE = _env_bool("PYTORRENT_AUTH_PROXY_AUTO_CREATE", False) +AUTH_PROXY_AUTO_CREATE_ROLE = os.getenv("PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE", "user").strip().lower() +AUTH_PROXY_AUTO_CREATE_PERMISSION = os.getenv("PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION", "ro").strip().lower() +if AUTH_PROXY_AUTO_CREATE_ROLE not in {"user", "admin"}: + AUTH_PROXY_AUTO_CREATE_ROLE = "user" +# Note: Keep rw as an operator-friendly alias while storing full internally. +if AUTH_PROXY_AUTO_CREATE_PERMISSION == "rw": + AUTH_PROXY_AUTO_CREATE_PERMISSION = "full" +if AUTH_PROXY_AUTO_CREATE_PERMISSION not in {"none", "ro", "full"}: + AUTH_PROXY_AUTO_CREATE_PERMISSION = "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/services/auth.py b/pytorrent/services/auth.py index 58169da..eb564fb 100644 --- a/pytorrent/services/auth.py +++ b/pytorrent/services/auth.py @@ -13,11 +13,8 @@ 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_AUTO_CREATE_PERMISSION, + AUTH_PROXY_AUTO_CREATE_ROLE, AUTH_PROXY_USER_HEADER, ) from ..db import connect, default_user_id, utcnow @@ -254,29 +251,42 @@ def _safe_username(value: str, fallback: str = "external-user") -> str: def _external_identity_from_headers() -> dict[str, str] | None: + # Note: Tinyauth and generic proxy auth use a single trusted username header. 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 + safe_username = _safe_username(username) return { "provider": provider(), - "username": _safe_username(username), - "email": email[:254], - "display_name": display_name[:160], - "subject": (subject or username or email)[:254], + "username": safe_username, + "subject": safe_username, } def _grant_default_external_permissions(conn, user_id: int, now: str) -> None: - if AUTH_PROXY_DEFAULT_ACCESS == "none" or AUTH_PROXY_DEFAULT_ROLE == "admin": + # Note: Admins can see and write all profiles through role-based access. + if AUTH_PROXY_AUTO_CREATE_PERMISSION == "none" or AUTH_PROXY_AUTO_CREATE_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), + (user_id, 0, AUTH_PROXY_AUTO_CREATE_PERMISSION, now, now), + ) + + +def _sync_external_auto_created_user(conn, user: dict[str, Any], now: str) -> None: + # Note: Passwordless external users follow the external auto-create defaults on login. + if not AUTH_PROXY_AUTO_CREATE or user.get("password_hash"): + return + if user.get("external_auth_provider") and user.get("external_auth_provider") != provider(): + return + user_id = int(user["id"]) + conn.execute("UPDATE users SET role=?, updated_at=? WHERE id=?", (AUTH_PROXY_AUTO_CREATE_ROLE, now, user_id)) + if AUTH_PROXY_AUTO_CREATE_ROLE == "admin" or AUTH_PROXY_AUTO_CREATE_PERMISSION == "none": + conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,)) + return + conn.execute( + "INSERT OR REPLACE INTO user_profile_permissions(user_id,profile_id,access_level,created_at,updated_at) VALUES(?,?,?,?,?)", + (user_id, 0, AUTH_PROXY_AUTO_CREATE_PERMISSION, now, now), ) @@ -296,8 +306,6 @@ def authenticate_external_user() -> dict[str, Any] | None: ).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 @@ -307,11 +315,11 @@ def authenticate_external_user() -> dict[str, Any] | None: ( identity["username"], None, - identity["email"] or None, - identity["display_name"] or None, + None, + None, identity["provider"], identity["subject"] or identity["username"], - AUTH_PROXY_DEFAULT_ROLE, + AUTH_PROXY_AUTO_CREATE_ROLE, 1, now, now, @@ -324,15 +332,16 @@ def authenticate_external_user() -> dict[str, Any] | None: user_id = int(user["id"]) conn.execute( """UPDATE users - SET email=COALESCE(NULLIF(?, ''), email), - display_name=COALESCE(NULLIF(?, ''), display_name), - external_auth_provider=?, + SET 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), + (identity["provider"], identity["subject"], now, user_id), ) user = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone() + if user: + _sync_external_auto_created_user(conn, user, now) + 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"])