tiny_auth_support #6

Merged
gru merged 11 commits from tiny_auth_support into master 2026-05-26 08:04:52 +02:00
7 changed files with 84 additions and 9 deletions
Showing only changes of commit 1df01e8cc6 - Show all commits

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
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, provider as auth_provider, uses_external_provider, 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, uses_external_provider, external_auth_summary, list_api_tokens, create_api_token, revoke_api_token
def _ok(payload=None):
@@ -42,7 +42,7 @@ def register_auth_routes(bp):
def auth_users_list():
if not auth_enabled():
abort(404)
return _ok({"users": list_users()})
return _ok({"users": list_users(), "auth": external_auth_summary()})
@bp.post("/auth/users")
def auth_users_create():

View File

@@ -65,6 +65,22 @@ def uses_external_provider() -> bool:
return enabled() and provider() in {"proxy", "tinyauth"}
def external_auth_summary() -> dict[str, Any]:
# Note: Exposes safe auth-mode facts for the Users panel without leaking secrets.
return {
"enabled": enabled(),
"provider": provider(),
"external": uses_external_provider(),
"auto_create": bool(AUTH_PROXY_AUTO_CREATE) if uses_external_provider() else False,
"auto_create_role": AUTH_PROXY_AUTO_CREATE_ROLE,
"auto_create_permission": AUTH_PROXY_AUTO_CREATE_PERMISSION,
"bypass_enabled": bool(AUTH_BYPASS_HOSTS),
"bypass_hosts": sorted(AUTH_BYPASS_HOSTS),
"bypass_user": AUTH_BYPASS_USER,
"password_editable": not uses_external_provider(),
}
def password_hash(password: str) -> str:
return generate_password_hash(password or "")
@@ -465,6 +481,7 @@ def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any
username = str(data.get("username") or "").strip()
role = "admin" if data.get("role") == "admin" else "user"
is_active = 1 if data.get("is_active", True) else 0
password_editable = not uses_external_provider()
if not username:
raise ValueError("Username is required")
with connect() as conn:
@@ -477,12 +494,15 @@ def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any
(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:
initial_password_hash = password_hash(str(data.get("password") or username)) if password_editable else None
# Note: TinyAuth/proxy users are passwordless in pyTorrent; credentials stay with the auth provider.
cur = conn.execute(
"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)), str(data.get("email") or "").strip() or None, str(data.get("display_name") or "").strip() or None, role, is_active, now, now),
(username, initial_password_hash, 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)
if data.get("password"):
if data.get("password") and password_editable:
# Note: Password changes are intentionally disabled for external auth providers.
conn.execute("UPDATE users SET password_hash=?, updated_at=? WHERE id=?", (password_hash(str(data.get("password"))), now, user_id))
if role != "admin":
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
@@ -558,7 +578,7 @@ def list_api_tokens(user_id: int) -> list[dict[str, Any]]:
abort(403)
with connect() as conn:
rows = conn.execute(
"SELECT id,user_id,name,token_prefix,last_used_at,created_at,updated_at,revoked_at FROM api_tokens WHERE user_id=? ORDER BY created_at DESC",
"SELECT id,user_id,name,token_prefix,last_used_at,created_at,updated_at,revoked_at FROM api_tokens WHERE user_id=? AND revoked_at IS NULL ORDER BY created_at DESC",
(uid,),
).fetchall()
return [_token_response(row) for row in rows]
@@ -602,10 +622,13 @@ def revoke_api_token(user_id: int, token_id: int) -> None:
abort(403)
now = utcnow()
with connect() as conn:
conn.execute(
"UPDATE api_tokens SET revoked_at=COALESCE(revoked_at, ?), updated_at=? WHERE id=? AND user_id=?",
# Note: Report missing/already revoked tokens instead of showing a false success in the UI.
cur = conn.execute(
"UPDATE api_tokens SET revoked_at=COALESCE(revoked_at, ?), updated_at=? WHERE id=? AND user_id=? AND revoked_at IS NULL",
(now, now, tid, uid),
)
if cur.rowcount <= 0:
raise ValueError("Active API token not found")
def authenticate_api_token(token: str) -> dict[str, Any] | None:

View File

@@ -9,6 +9,7 @@ import { torrentDetailsSource } from './torrentDetails.js';
import { modalsSource } from './modals.js';
import { rssSource } from './rss.js';
import { smartQueueSource } from './smartQueue.js';
import { authUsersSource } from './authUsers.js';
import { plannerSource } from './planner.js';
import { pollerSource } from './poller.js';
import { profilesSource } from './profiles.js';
@@ -29,6 +30,7 @@ export const moduleSources = [
modalsSource,
rssSource,
smartQueueSource,
authUsersSource,
plannerSource,
dashboardSource,
operationLogsSource,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3759,6 +3759,54 @@ body,
display: block;
}
/* Users and external authentication panel */
.auth-provider-info {
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.85rem;
padding: 0.85rem;
}
.auth-provider-info-title {
align-items: center;
display: flex;
font-weight: 700;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.auth-provider-info ul {
color: var(--bs-secondary-color);
margin: 0;
padding-left: 1.15rem;
}
.auth-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
min-width: 15rem;
}
.auth-actions .btn {
align-items: center;
display: inline-flex;
gap: 0.3rem;
justify-content: center;
}
.auth-users-table {
min-width: 760px;
}
@media (max-width: 576px) {
.auth-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 0;
}
}
#toolsModal .modal-body {
min-width: 0;
overflow-x: hidden;

View File

@@ -279,7 +279,7 @@
<div id="toolLogs" class="d-none"><div class="surface-section"><div class="section-title"><i class="fa-solid fa-book"></i> Operation log retention</div><div class="tool-note mb-3">Manage operation log retention and review profile-scoped statistics without changing torrent data.</div><div class="operation-log-settings-grid"><label class="form-field"><span>Retention mode</span><select id="operationLogRetentionMode" class="form-select form-select-sm"><option value="days">By days</option><option value="lines">By line count</option><option value="both">Days and line count</option><option value="manual">Manual cleanup only</option></select></label><label class="form-field"><span>Retention days</span><input id="operationLogRetentionDays" class="form-control form-control-sm" type="number" min="1" max="3650" value="30"></label><label class="form-field"><span>Keep lines</span><input id="operationLogRetentionLines" class="form-control form-control-sm" type="number" min="100" max="1000000" value="5000"></label><div class="operation-log-settings-actions"><button id="saveOperationLogRetentionBtn" class="btn btn-sm btn-primary" type="button"><i class="fa-solid fa-floppy-disk"></i> Save retention</button><button id="applyOperationLogRetentionBtn" class="btn btn-sm btn-outline-warning" type="button"><i class="fa-solid fa-filter-circle-xmark"></i> Apply retention now</button><button id="clearOperationLogsBtn" class="btn btn-sm btn-outline-danger" type="button"><i class="fa-solid fa-trash"></i> Clear current filter</button></div></div><div class="operation-log-view-settings"><div><b>Default log view</b><small>Controls the default category and job log visibility used by the Logs modal.</small></div><label class="form-field"><span>Default log category</span><select id="operationLogDefaultType" class="form-select form-select-sm"><option value="">All non-job types</option><option value="torrent_added">Torrent added</option><option value="torrent_removed">Torrent removed</option><option value="torrent_completed">Torrent completed</option><option value="job_started">Job started</option><option value="job_done">Job done</option><option value="job_failed">Job failed</option></select></label><label class="form-check form-switch operation-log-hide-jobs"><input id="operationLogHideJobsDefault" class="form-check-input" type="checkbox" checked><span class="form-check-label">Hide job logs by default</span></label><button id="saveOperationLogViewBtn" class="btn btn-sm btn-outline-primary" type="button"><i class="fa-solid fa-eye-slash"></i> Save log view</button></div><div id="operationLogStats" class="mt-3"><span class="spinner-border spinner-border-sm"></span> Loading statistics...</div></div></div>
{% if auth_enabled and current_user and current_user.role == 'admin' %}
<div id="toolUsers" class="d-none"><div class="surface-section"><div class="section-title"><i class="fa-solid fa-users-gear"></i> Users</div><div class="tool-note mb-3">Manage optional pyTorrent users. Empty profile means all profiles. R/O blocks rTorrent-changing actions; Full allows them.</div><div class="user-form-grid"><input id="authUserId" type="hidden"><input id="authUsername" class="form-control" placeholder="User"><input id="authPassword" class="form-control" type="password" placeholder="Password / new password"><select id="authRole" class="form-select"><option value="user">user</option><option value="admin">admin</option></select><select id="authProfile" class="form-select"><option value="0">All profiles</option></select><select id="authAccess" class="form-select"><option value="ro">R/O</option><option value="full">Full</option></select><label class="form-check form-switch mb-0"><input id="authActive" class="form-check-input" type="checkbox" checked><span class="form-check-label">Active</span></label><button id="authUserSaveBtn" class="btn btn-sm btn-primary" type="button"><i class="fa-solid fa-floppy-disk"></i> Save user</button><button id="authUserCancelBtn" class="btn btn-sm btn-outline-secondary d-none" type="button"><i class="fa-solid fa-xmark"></i> Cancel</button></div><div id="authTokenInline" class="api-token-inline d-none mt-3"></div><div id="authUsersManager" class="mt-3"></div></div></div>
<div id="toolUsers" class="d-none"><div class="surface-section"><div class="section-title"><i class="fa-solid fa-users-gear"></i> Users</div><div class="tool-note mb-3">Manage optional pyTorrent users. Empty profile means all profiles. R/O blocks rTorrent-changing actions; Full allows them.</div><div id="authProviderInfo" class="auth-provider-info d-none mb-3"></div><div class="user-form-grid"><input id="authUserId" type="hidden"><input id="authUsername" class="form-control" placeholder="User"><input id="authPassword" class="form-control" type="password" placeholder="Password / new password"><select id="authRole" class="form-select"><option value="user">user</option><option value="admin">admin</option></select><select id="authProfile" class="form-select"><option value="0">All profiles</option></select><select id="authAccess" class="form-select"><option value="ro">R/O</option><option value="full">Full</option></select><label class="form-check form-switch mb-0"><input id="authActive" class="form-check-input" type="checkbox" checked><span class="form-check-label">Active</span></label><button id="authUserSaveBtn" class="btn btn-sm btn-primary" type="button"><i class="fa-solid fa-floppy-disk"></i> Save user</button><button id="authUserCancelBtn" class="btn btn-sm btn-outline-secondary d-none" type="button"><i class="fa-solid fa-xmark"></i> Cancel</button></div><div id="authTokenInline" class="api-token-inline d-none mt-3"></div><div id="authUsersManager" class="mt-3"></div></div></div>
{% endif %}
<div id="toolLabels" class="d-none"><div class="surface-section"><div class="section-title"><i class="fa-solid fa-tags"></i> Labels</div><div class="tool-note mb-3">Create reusable labels and remove labels that are no longer needed.</div><div class="input-group input-group-sm mb-3"><span class="input-group-text"><i class="fa-solid fa-tag"></i></span><input id="newLabelName" class="form-control" placeholder="New label"><button id="newLabelBtn" class="btn btn-primary" type="button"><i class="fa-solid fa-plus"></i> Add label</button></div><div id="labelsManager" class="labels-manager"></div></div></div>
<div id="toolRatio" class="d-none">