auth providers
This commit is contained in:
@@ -54,14 +54,9 @@ PYTORRENT_AUTH_PROVIDER=tinyauth
|
|||||||
|
|
||||||
# Headers passed by Tinyauth
|
# Headers passed by Tinyauth
|
||||||
PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User
|
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
|
# Headers passed by external reverse proxy
|
||||||
#PYTORRENT_AUTH_PROXY_USER_HEADER=X-Forwarded-User
|
#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
|
# Auto-create user when authenticated externally but missing in DB
|
||||||
PYTORRENT_AUTH_PROXY_AUTO_CREATE=true
|
PYTORRENT_AUTH_PROXY_AUTO_CREATE=true
|
||||||
|
|||||||
150
auth.md
Normal file
150
auth.md
Normal file
@@ -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.
|
||||||
@@ -30,25 +30,21 @@ USE_OFFLINE_LIBS = _env_bool("PYTORRENT_USE_OFFLINE_LIBS", False)
|
|||||||
# Note: Optional authentication remains disabled unless explicitly enabled in .env.
|
# Note: Optional authentication remains disabled unless explicitly enabled in .env.
|
||||||
AUTH_ENABLE = _env_bool("PYTORRENT_AUTH_ENABLE", False)
|
AUTH_ENABLE = _env_bool("PYTORRENT_AUTH_ENABLE", False)
|
||||||
AUTH_PROVIDER = os.getenv("PYTORRENT_AUTH_PROVIDER", "local").strip().lower() or "local"
|
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"}:
|
if AUTH_PROVIDER not in {"local", "proxy", "tinyauth"}:
|
||||||
AUTH_PROVIDER = "local"
|
AUTH_PROVIDER = "local"
|
||||||
if AUTH_PROXY_DEFAULT_ROLE not in {"user", "admin"}:
|
|
||||||
AUTH_PROXY_DEFAULT_ROLE = "user"
|
# Note: External auth reads only one identity value from the trusted reverse proxy.
|
||||||
if AUTH_PROXY_DEFAULT_ACCESS not in {"none", "ro", "full"}:
|
AUTH_PROXY_USER_HEADER = os.getenv("PYTORRENT_AUTH_PROXY_USER_HEADER", "Remote-User").strip() or "Remote-User"
|
||||||
AUTH_PROXY_DEFAULT_ACCESS = "ro"
|
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"):
|
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.
|
# Note: Auth mode cannot use Flask's development secret; persist a local random session key instead.
|
||||||
_secret_file = BASE_DIR / "data" / ".session_secret"
|
_secret_file = BASE_DIR / "data" / ".session_secret"
|
||||||
|
|||||||
@@ -13,11 +13,8 @@ from ..config import (
|
|||||||
AUTH_ENABLE,
|
AUTH_ENABLE,
|
||||||
AUTH_PROVIDER,
|
AUTH_PROVIDER,
|
||||||
AUTH_PROXY_AUTO_CREATE,
|
AUTH_PROXY_AUTO_CREATE,
|
||||||
AUTH_PROXY_DEFAULT_ACCESS,
|
AUTH_PROXY_AUTO_CREATE_PERMISSION,
|
||||||
AUTH_PROXY_DEFAULT_ROLE,
|
AUTH_PROXY_AUTO_CREATE_ROLE,
|
||||||
AUTH_PROXY_EMAIL_HEADER,
|
|
||||||
AUTH_PROXY_NAME_HEADER,
|
|
||||||
AUTH_PROXY_SUBJECT_HEADER,
|
|
||||||
AUTH_PROXY_USER_HEADER,
|
AUTH_PROXY_USER_HEADER,
|
||||||
)
|
)
|
||||||
from ..db import connect, default_user_id, utcnow
|
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:
|
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)
|
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:
|
if not username:
|
||||||
return None
|
return None
|
||||||
|
safe_username = _safe_username(username)
|
||||||
return {
|
return {
|
||||||
"provider": provider(),
|
"provider": provider(),
|
||||||
"username": _safe_username(username),
|
"username": safe_username,
|
||||||
"email": email[:254],
|
"subject": safe_username,
|
||||||
"display_name": display_name[:160],
|
|
||||||
"subject": (subject or username or email)[:254],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _grant_default_external_permissions(conn, user_id: int, now: str) -> None:
|
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
|
return
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO user_profile_permissions(user_id,profile_id,access_level,created_at,updated_at) VALUES(?,?,?,?,?)",
|
"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()
|
).fetchone()
|
||||||
if not user:
|
if not user:
|
||||||
user = conn.execute("SELECT * FROM users WHERE username=?", (identity["username"],)).fetchone()
|
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 user:
|
||||||
if not AUTH_PROXY_AUTO_CREATE:
|
if not AUTH_PROXY_AUTO_CREATE:
|
||||||
return None
|
return None
|
||||||
@@ -307,11 +315,11 @@ def authenticate_external_user() -> dict[str, Any] | None:
|
|||||||
(
|
(
|
||||||
identity["username"],
|
identity["username"],
|
||||||
None,
|
None,
|
||||||
identity["email"] or None,
|
None,
|
||||||
identity["display_name"] or None,
|
None,
|
||||||
identity["provider"],
|
identity["provider"],
|
||||||
identity["subject"] or identity["username"],
|
identity["subject"] or identity["username"],
|
||||||
AUTH_PROXY_DEFAULT_ROLE,
|
AUTH_PROXY_AUTO_CREATE_ROLE,
|
||||||
1,
|
1,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
@@ -324,15 +332,16 @@ def authenticate_external_user() -> dict[str, Any] | None:
|
|||||||
user_id = int(user["id"])
|
user_id = int(user["id"])
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""UPDATE users
|
"""UPDATE users
|
||||||
SET email=COALESCE(NULLIF(?, ''), email),
|
SET external_auth_provider=?,
|
||||||
display_name=COALESCE(NULLIF(?, ''), display_name),
|
|
||||||
external_auth_provider=?,
|
|
||||||
external_subject=COALESCE(NULLIF(?, ''), external_subject),
|
external_subject=COALESCE(NULLIF(?, ''), external_subject),
|
||||||
updated_at=?
|
updated_at=?
|
||||||
WHERE id=?""",
|
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()
|
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):
|
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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user