diff --git a/auth.md b/auth.md index c598e5b..ab9e540 100644 --- a/auth.md +++ b/auth.md @@ -31,6 +31,10 @@ PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin # 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 + +# Optional: trusted direct-IP/local hosts that should skip pyTorrent auth. +# Use this only on private networks, never on public proxy hostnames. +PYTORRENT_AUTH_BYPASS_HOSTS=10.11.1.11:8090,10.11.1.11 ``` @@ -97,24 +101,52 @@ Behavior: ```nginx location / { proxy_pass $forward_scheme://$server:$port; - auth_request /tinyauth; - error_page 401 = @tinyauth_login; + auth_request /tinyauth; + error_page 401 = @tinyauth_login; + + auth_request_set $redirection_url $upstream_http_x_tinyauth_location; + auth_request_set $auth_user $upstream_http_remote_user; + proxy_set_header Remote-User $auth_user; + } location /tinyauth { - proxy_pass http://10.10.11.11:3000/api/auth/nginx; + proxy_pass http://10.11.1.11: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.domian/login?redirect_uri=$scheme://$http_host$request_uri; + return 302 http://auth.example.com/login?redirect_uri=$scheme://$http_host$request_uri; } ``` Use `PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User` when this setup forwards `Remote-User` to pyTorrent. +## Direct-IP auth bypass + +Use this only when pyTorrent is reachable on a trusted private IP and you want: + +- reverse proxy hostname protected by Tinyauth; +- direct private IP access without pyTorrent login. + +Example: + +```env +PYTORRENT_AUTH_ENABLE=true +PYTORRENT_AUTH_PROVIDER=tinyauth +PYTORRENT_AUTH_BYPASS_HOSTS=10.11.1.11:8090,10.11.1.11 +``` + +Behavior: + +- requests with `Host: 10.11.1.11:8090` or `Host: 10.11.1.11` use the built-in default admin user; +- requests through the reverse proxy still require the configured auth provider; +- profile permissions are ignored for bypassed direct-IP requests because they run as the default admin user. + +Do not add public domains to this list. + ## Generic reverse proxy authentication Use this when another proxy authenticates users and sends a username header. @@ -143,6 +175,21 @@ PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw - `rw` - grants read-write access to all profiles; - `full` - same as `rw`. +## Connection badge behind Tinyauth + +The top-right badge shows Socket.IO connectivity, not REST API health. + +If the application loads data through REST API but the badge stays `offline`, the most common cause is that the Socket.IO handshake or follow-up events are not authenticated with the same external identity header. pyTorrent resolves external auth during Socket.IO connect/events as well as normal REST requests. + +For Tinyauth, make sure the same location that proxies pyTorrent also forwards `Remote-User` to all paths, including `/socket.io/`: + +```nginx +auth_request_set $auth_user $upstream_http_remote_user; +proxy_set_header Remote-User $auth_user; +``` + +No separate badge-disable option is needed. The badge should become `online` when Socket.IO connects correctly. + ## Troubleshooting If the user is created but profiles are missing: diff --git a/pytorrent/config.py b/pytorrent/config.py index 99c6f3a..fe713c6 100644 --- a/pytorrent/config.py +++ b/pytorrent/config.py @@ -94,6 +94,8 @@ SOCKETIO_CORS_ALLOWED_ORIGINS = None if not _SOCKETIO_CORS else [item.strip() fo # Note: API origin checks are separate from Socket.IO CORS. When unset, reuse the Socket.IO allowlist for operator-friendly reverse proxy setups. _API_ALLOWED_ORIGINS = _env_csv("PYTORRENT_API_ALLOWED_ORIGINS") API_ALLOWED_ORIGINS = _API_ALLOWED_ORIGINS or _env_csv("PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS") +# Note: Optional auth bypass for trusted direct-IP/local access. Values can be hosts or host:port pairs. +AUTH_BYPASS_HOSTS = {item.lower() for item in _env_csv("PYTORRENT_AUTH_BYPASS_HOSTS")} TRAFFIC_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS", 90, 1) JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1) diff --git a/pytorrent/services/auth.py b/pytorrent/services/auth.py index 16858d8..1e26f62 100644 --- a/pytorrent/services/auth.py +++ b/pytorrent/services/auth.py @@ -6,7 +6,7 @@ import secrets from urllib.parse import urlparse -from flask import abort, g, jsonify, redirect, request, session, url_for +from flask import abort, g, has_request_context, jsonify, redirect, request, session, url_for from werkzeug.security import check_password_hash, generate_password_hash from ..config import ( @@ -17,6 +17,7 @@ from ..config import ( AUTH_PROXY_AUTO_CREATE_ROLE, AUTH_PROXY_USER_HEADER, API_ALLOWED_ORIGINS, + AUTH_BYPASS_HOSTS, ) from ..db import connect, default_user_id, utcnow @@ -67,8 +68,22 @@ def password_hash(password: str) -> str: return generate_password_hash(password or "") +def _host_matches_bypass(host: str) -> bool: + clean = str(host or "").strip().lower() + if not clean: + return False + return clean in AUTH_BYPASS_HOSTS or clean.split(":", 1)[0] in AUTH_BYPASS_HOSTS + + +def auth_bypassed_request() -> bool: + # Note: Allows trusted direct-IP access to keep auth enabled for reverse-proxy traffic. + if not enabled() or not AUTH_BYPASS_HOSTS or not has_request_context(): + return False + return _host_matches_bypass(request.host) + + def current_user_id() -> int: - if not enabled(): + if not enabled() or auth_bypassed_request(): return default_user_id() api_user_id = getattr(g, "api_user_id", None) if api_user_id: @@ -361,9 +376,25 @@ def authenticate_external_user() -> dict[str, Any] | None: if not user or not int(user.get("is_active") or 0): return None g.external_user_id = int(user["id"]) + session["user_id"] = int(user["id"]) + session["username"] = user.get("username") + session["role"] = user.get("role") or "user" return _public_user(user) +def ensure_request_user() -> int: + # Note: Socket.IO events do not go through Flask before_request like normal REST calls, + # so external proxy auth must be resolved explicitly during the Socket.IO handshake/events. + if not enabled() or auth_bypassed_request(): + return default_user_id() + uid = current_user_id() + if uid: + return uid + if uses_external_provider(): + authenticate_external_user() + return current_user_id() + + def logout_user() -> None: session.clear() @@ -595,6 +626,8 @@ def install_guards(app) -> None: if not enabled(): return None g.api_token_authenticated = False + if auth_bypassed_request(): + return None if request.path.startswith("/api/"): token_user = authenticate_api_token(_request_api_token()) if token_user: diff --git a/pytorrent/services/websocket.py b/pytorrent/services/websocket.py index 45fa188..3529e89 100644 --- a/pytorrent/services/websocket.py +++ b/pytorrent/services/websocket.py @@ -217,7 +217,7 @@ def register_socketio_handlers(socketio): @socketio.on("connect") def handle_connect(): ensure_poller_started() - if auth.enabled() and not auth.current_user_id(): + if auth.enabled() and not auth.ensure_request_user(): disconnect() return False profile = active_profile() @@ -234,7 +234,7 @@ def register_socketio_handlers(socketio): @socketio.on("select_profile") def handle_select_profile(data): - if auth.enabled() and not auth.current_user_id(): + if auth.enabled() and not auth.ensure_request_user(): disconnect() return old_profile = active_profile()