auth providers
This commit is contained in:
51
auth.md
51
auth.md
@@ -31,6 +31,10 @@ PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin
|
|||||||
# rw is accepted as an alias of full.
|
# rw is accepted as an alias of full.
|
||||||
# Admin users ignore this value and can access all profiles.
|
# Admin users ignore this value and can access all profiles.
|
||||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
@@ -99,22 +103,50 @@ location / {
|
|||||||
proxy_pass $forward_scheme://$server:$port;
|
proxy_pass $forward_scheme://$server:$port;
|
||||||
auth_request /tinyauth;
|
auth_request /tinyauth;
|
||||||
error_page 401 = @tinyauth_login;
|
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 {
|
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-proto $scheme;
|
||||||
proxy_set_header x-forwarded-host $http_host;
|
proxy_set_header x-forwarded-host $http_host;
|
||||||
proxy_set_header x-forwarded-uri $request_uri;
|
proxy_set_header x-forwarded-uri $request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
location @tinyauth_login {
|
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.
|
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
|
## Generic reverse proxy authentication
|
||||||
|
|
||||||
Use this when another proxy authenticates users and sends a username header.
|
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;
|
- `rw` - grants read-write access to all profiles;
|
||||||
- `full` - same as `rw`.
|
- `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
|
## Troubleshooting
|
||||||
|
|
||||||
If the user is created but profiles are missing:
|
If the user is created but profiles are missing:
|
||||||
|
|||||||
@@ -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.
|
# 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 = _env_csv("PYTORRENT_API_ALLOWED_ORIGINS")
|
||||||
API_ALLOWED_ORIGINS = _API_ALLOWED_ORIGINS or _env_csv("PYTORRENT_SOCKETIO_CORS_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)
|
TRAFFIC_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS", 90, 1)
|
||||||
JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1)
|
JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import secrets
|
|||||||
|
|
||||||
from urllib.parse import urlparse
|
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 werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
from ..config import (
|
from ..config import (
|
||||||
@@ -17,6 +17,7 @@ from ..config import (
|
|||||||
AUTH_PROXY_AUTO_CREATE_ROLE,
|
AUTH_PROXY_AUTO_CREATE_ROLE,
|
||||||
AUTH_PROXY_USER_HEADER,
|
AUTH_PROXY_USER_HEADER,
|
||||||
API_ALLOWED_ORIGINS,
|
API_ALLOWED_ORIGINS,
|
||||||
|
AUTH_BYPASS_HOSTS,
|
||||||
)
|
)
|
||||||
from ..db import connect, default_user_id, utcnow
|
from ..db import connect, default_user_id, utcnow
|
||||||
|
|
||||||
@@ -67,8 +68,22 @@ def password_hash(password: str) -> str:
|
|||||||
return generate_password_hash(password or "")
|
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:
|
def current_user_id() -> int:
|
||||||
if not enabled():
|
if not enabled() or auth_bypassed_request():
|
||||||
return default_user_id()
|
return default_user_id()
|
||||||
api_user_id = getattr(g, "api_user_id", None)
|
api_user_id = getattr(g, "api_user_id", None)
|
||||||
if api_user_id:
|
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):
|
if not user or not int(user.get("is_active") or 0):
|
||||||
return None
|
return None
|
||||||
g.external_user_id = int(user["id"])
|
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)
|
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:
|
def logout_user() -> None:
|
||||||
session.clear()
|
session.clear()
|
||||||
|
|
||||||
@@ -595,6 +626,8 @@ def install_guards(app) -> None:
|
|||||||
if not enabled():
|
if not enabled():
|
||||||
return None
|
return None
|
||||||
g.api_token_authenticated = False
|
g.api_token_authenticated = False
|
||||||
|
if auth_bypassed_request():
|
||||||
|
return None
|
||||||
if request.path.startswith("/api/"):
|
if request.path.startswith("/api/"):
|
||||||
token_user = authenticate_api_token(_request_api_token())
|
token_user = authenticate_api_token(_request_api_token())
|
||||||
if token_user:
|
if token_user:
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ def register_socketio_handlers(socketio):
|
|||||||
@socketio.on("connect")
|
@socketio.on("connect")
|
||||||
def handle_connect():
|
def handle_connect():
|
||||||
ensure_poller_started()
|
ensure_poller_started()
|
||||||
if auth.enabled() and not auth.current_user_id():
|
if auth.enabled() and not auth.ensure_request_user():
|
||||||
disconnect()
|
disconnect()
|
||||||
return False
|
return False
|
||||||
profile = active_profile()
|
profile = active_profile()
|
||||||
@@ -234,7 +234,7 @@ def register_socketio_handlers(socketio):
|
|||||||
|
|
||||||
@socketio.on("select_profile")
|
@socketio.on("select_profile")
|
||||||
def handle_select_profile(data):
|
def handle_select_profile(data):
|
||||||
if auth.enabled() and not auth.current_user_id():
|
if auth.enabled() and not auth.ensure_request_user():
|
||||||
disconnect()
|
disconnect()
|
||||||
return
|
return
|
||||||
old_profile = active_profile()
|
old_profile = active_profile()
|
||||||
|
|||||||
Reference in New Issue
Block a user