auth providers
This commit is contained in:
37
.env.example
37
.env.example
@@ -3,8 +3,8 @@ PYTORRENT_DB_PATH=data/pytorrent.sqlite3
|
|||||||
PYTORRENT_HOST=0.0.0.0
|
PYTORRENT_HOST=0.0.0.0
|
||||||
PYTORRENT_PORT=8090
|
PYTORRENT_PORT=8090
|
||||||
PYTORRENT_DEBUG=0
|
PYTORRENT_DEBUG=0
|
||||||
PYTORRENT_POLL_INTERVAL=0.5
|
PYTORRENT_POLL_INTERVAL=1
|
||||||
MIN_POLL_INTERVAL_SECONDS=0.5
|
MIN_POLL_INTERVAL_SECONDS=1
|
||||||
PYTORRENT_WORKERS=16
|
PYTORRENT_WORKERS=16
|
||||||
PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb
|
PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb
|
||||||
PYTORRENT_ALLOW_UNSAFE_WERKZEUG=0
|
PYTORRENT_ALLOW_UNSAFE_WERKZEUG=0
|
||||||
@@ -13,9 +13,6 @@ PYTORRENT_SCGI_RETRIES=8
|
|||||||
# css/js libs
|
# css/js libs
|
||||||
PYTORRENT_USE_OFFLINE_LIBS=true
|
PYTORRENT_USE_OFFLINE_LIBS=true
|
||||||
|
|
||||||
# python -m pytorrent.cli reset-password admin new_Pass
|
|
||||||
PYTORRENT_AUTH_ENABLE=false
|
|
||||||
|
|
||||||
# Reverse proxy / HTTPS
|
# Reverse proxy / HTTPS
|
||||||
PYTORRENT_PROXY_FIX_ENABLE=false
|
PYTORRENT_PROXY_FIX_ENABLE=false
|
||||||
PYTORRENT_SESSION_COOKIE_SECURE=false
|
PYTORRENT_SESSION_COOKIE_SECURE=false
|
||||||
@@ -42,3 +39,33 @@ PYTORRENT_LOG_RETENTION_HOURS=24
|
|||||||
PYTORRENT_GUNICORN_ACCESS_LOG=data/logs/gunicorn-access.log
|
PYTORRENT_GUNICORN_ACCESS_LOG=data/logs/gunicorn-access.log
|
||||||
PYTORRENT_GUNICORN_ERROR_LOG=data/logs/gunicorn-error.log
|
PYTORRENT_GUNICORN_ERROR_LOG=data/logs/gunicorn-error.log
|
||||||
PYTORRENT_GUNICORN_LOG_LEVEL=info
|
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
|
||||||
@@ -29,6 +29,26 @@ DEBUG = _env_bool("PYTORRENT_DEBUG", False)
|
|||||||
USE_OFFLINE_LIBS = _env_bool("PYTORRENT_USE_OFFLINE_LIBS", False)
|
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"
|
||||||
|
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"):
|
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"
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password_hash TEXT,
|
password_hash TEXT,
|
||||||
|
email TEXT,
|
||||||
|
display_name TEXT,
|
||||||
|
external_auth_provider TEXT,
|
||||||
|
external_subject TEXT,
|
||||||
role TEXT DEFAULT 'user',
|
role TEXT DEFAULT 'user',
|
||||||
is_active INTEGER DEFAULT 1,
|
is_active INTEGER DEFAULT 1,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
@@ -501,6 +505,10 @@ CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
|
|||||||
|
|
||||||
MIGRATIONS = [
|
MIGRATIONS = [
|
||||||
"ALTER TABLE api_tokens ADD COLUMN last_used_at TEXT",
|
"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 role TEXT DEFAULT 'user'",
|
||||||
"ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1",
|
"ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1",
|
||||||
"ALTER TABLE users ADD COLUMN updated_at TEXT",
|
"ALTER TABLE users ADD COLUMN updated_at TEXT",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from flask import abort, jsonify, request
|
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):
|
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 ""))
|
user = login_user(str(data.get("username") or ""), str(data.get("password") or ""))
|
||||||
if not user:
|
if not user:
|
||||||
return jsonify({"ok": False, "error": "Invalid username or password"}), 401
|
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")
|
@bp.get("/auth/me")
|
||||||
def auth_me():
|
def auth_me():
|
||||||
if not auth_enabled():
|
if not auth_enabled():
|
||||||
abort(404)
|
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")
|
@bp.post("/auth/logout")
|
||||||
def auth_logout():
|
def auth_logout():
|
||||||
|
|||||||
@@ -174,13 +174,23 @@ def login():
|
|||||||
# Note: When optional authentication is disabled, /login is intentionally unavailable.
|
# Note: When optional authentication is disabled, /login is intentionally unavailable.
|
||||||
if not auth.enabled():
|
if not auth.enabled():
|
||||||
abort(404)
|
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 = ""
|
error = ""
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
user = auth.login_user(request.form.get("username", ""), request.form.get("password", ""))
|
user = auth.login_user(request.form.get("username", ""), request.form.get("password", ""))
|
||||||
if user:
|
if user:
|
||||||
return redirect(request.args.get("next") or url_for("main.index"))
|
return redirect(next_url)
|
||||||
error = "Invalid username or password"
|
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")
|
@bp.get("/logout")
|
||||||
|
|||||||
@@ -9,7 +9,17 @@ from urllib.parse import urlparse
|
|||||||
from flask import abort, g, jsonify, redirect, request, session, url_for
|
from flask import abort, g, 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 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
|
from ..db import connect, default_user_id, utcnow
|
||||||
|
|
||||||
PUBLIC_ENDPOINTS = {"main.login", "main.logout", "api.auth_login", "api.auth_me", "static"}
|
PUBLIC_ENDPOINTS = {"main.login", "main.logout", "api.auth_login", "api.auth_me", "static"}
|
||||||
@@ -47,6 +57,14 @@ def enabled() -> bool:
|
|||||||
return bool(AUTH_ENABLE)
|
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:
|
def password_hash(password: str) -> str:
|
||||||
return generate_password_hash(password or "")
|
return generate_password_hash(password or "")
|
||||||
|
|
||||||
@@ -57,6 +75,9 @@ def current_user_id() -> int:
|
|||||||
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:
|
||||||
return int(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:
|
try:
|
||||||
return int(session.get("user_id") or 0)
|
return int(session.get("user_id") or 0)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -69,7 +90,7 @@ def current_user() -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
return conn.execute(
|
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,),
|
(uid,),
|
||||||
).fetchone()
|
).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:
|
def login_user(username: str, password: str) -> dict[str, Any] | None:
|
||||||
if not enabled():
|
if not enabled():
|
||||||
return {"id": default_user_id(), "username": "default", "role": "admin", "is_active": 1}
|
return {"id": default_user_id(), "username": "default", "role": "admin", "is_active": 1}
|
||||||
|
if uses_external_provider():
|
||||||
|
return None
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
user = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).fetchone()
|
user = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).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):
|
||||||
@@ -213,6 +236,109 @@ def login_user(username: str, password: str) -> dict[str, Any] | None:
|
|||||||
return current_user()
|
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:
|
def logout_user() -> None:
|
||||||
session.clear()
|
session.clear()
|
||||||
|
|
||||||
@@ -236,7 +362,7 @@ def list_users() -> list[dict[str, Any]]:
|
|||||||
require_admin()
|
require_admin()
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
users = conn.execute(
|
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()
|
).fetchall()
|
||||||
perms = conn.execute(
|
perms = conn.execute(
|
||||||
"SELECT user_id, profile_id, access_level FROM user_profile_permissions ORDER BY user_id, profile_id"
|
"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:
|
if not row:
|
||||||
raise ValueError("User does not exist")
|
raise ValueError("User does not exist")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE users SET username=?, role=?, is_active=?, updated_at=? WHERE id=?",
|
"UPDATE users SET username=?, email=?, display_name=?, role=?, is_active=?, updated_at=? WHERE id=?",
|
||||||
(username, role, is_active, now, user_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:
|
else:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO users(username,password_hash,role,is_active,created_at,updated_at) VALUES(?,?,?,?,?,?)",
|
"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)), role, is_active, now, now),
|
(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)
|
user_id = int(cur.lastrowid)
|
||||||
if data.get("password"):
|
if data.get("password"):
|
||||||
@@ -293,7 +419,7 @@ def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
|
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:
|
def delete_user(user_id: int) -> None:
|
||||||
@@ -323,6 +449,10 @@ def _public_user(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
|||||||
return {
|
return {
|
||||||
"id": int(row["id"]),
|
"id": int(row["id"]),
|
||||||
"username": row.get("username"),
|
"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",
|
"role": row.get("role") or "user",
|
||||||
"is_active": int(row.get("is_active") or 0),
|
"is_active": int(row.get("is_active") or 0),
|
||||||
"created_at": row.get("created_at"),
|
"created_at": row.get("created_at"),
|
||||||
@@ -445,6 +575,8 @@ def install_guards(app) -> None:
|
|||||||
if token_user:
|
if token_user:
|
||||||
g.api_user_id = int(token_user["id"])
|
g.api_user_id = int(token_user["id"])
|
||||||
g.api_token_authenticated = True
|
g.api_token_authenticated = True
|
||||||
|
if not getattr(g, "api_user_id", None):
|
||||||
|
authenticate_external_user()
|
||||||
endpoint = request.endpoint or ""
|
endpoint = request.endpoint or ""
|
||||||
if endpoint in PUBLIC_ENDPOINTS or endpoint.startswith("static"):
|
if endpoint in PUBLIC_ENDPOINTS or endpoint.startswith("static"):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -2379,6 +2379,17 @@ body.mobile-mode .mobile-filter-bar {
|
|||||||
text-align: left;
|
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 {
|
.auth-form {
|
||||||
margin-top: 1.2rem;
|
margin-top: 1.2rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|||||||
@@ -16,6 +16,13 @@
|
|||||||
<div class="initial-loader-brand"><i class="fa-solid fa-robot"></i> pyTorrent</div>
|
<div class="initial-loader-brand"><i class="fa-solid fa-robot"></i> pyTorrent</div>
|
||||||
<div class="auth-lock" aria-hidden="true"><i class="fa-solid fa-lock"></i></div>
|
<div class="auth-lock" aria-hidden="true"><i class="fa-solid fa-lock"></i></div>
|
||||||
<h1 class="initial-loader-title">Sign in</h1>
|
<h1 class="initial-loader-title">Sign in</h1>
|
||||||
|
{% if external_provider %}
|
||||||
|
<p class="initial-loader-text">External authentication is enabled through {{ external_provider }}.</p>
|
||||||
|
{% if error %}<div class="alert alert-warning auth-alert">{{ error }}</div>{% endif %}
|
||||||
|
<div class="auth-provider-note">
|
||||||
|
pyTorrent expects trusted reverse-proxy identity headers. If you are already signed in, check provider headers and user mapping.
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
<p class="initial-loader-text">Authentication is enabled for this pyTorrent instance.</p>
|
<p class="initial-loader-text">Authentication is enabled for this pyTorrent instance.</p>
|
||||||
{% if error %}<div class="alert alert-danger auth-alert">{{ error }}</div>{% endif %}
|
{% if error %}<div class="alert alert-danger auth-alert">{{ error }}</div>{% endif %}
|
||||||
<form class="auth-form" method="post">
|
<form class="auth-form" method="post">
|
||||||
@@ -25,6 +32,7 @@
|
|||||||
<input id="password" class="form-control" name="password" type="password" autocomplete="current-password">
|
<input id="password" class="form-control" name="password" type="password" autocomplete="current-password">
|
||||||
<button class="btn btn-primary w-100" type="submit"><i class="fa-solid fa-right-to-bracket"></i> Log in</button>
|
<button class="btn btn-primary w-100" type="submit"><i class="fa-solid fa-right-to-bracket"></i> Log in</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user