tiny_auth_support #6
@@ -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, 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):
|
def _ok(payload=None):
|
||||||
@@ -42,7 +42,7 @@ def register_auth_routes(bp):
|
|||||||
def auth_users_list():
|
def auth_users_list():
|
||||||
if not auth_enabled():
|
if not auth_enabled():
|
||||||
abort(404)
|
abort(404)
|
||||||
return _ok({"users": list_users()})
|
return _ok({"users": list_users(), "auth": external_auth_summary()})
|
||||||
|
|
||||||
@bp.post("/auth/users")
|
@bp.post("/auth/users")
|
||||||
def auth_users_create():
|
def auth_users_create():
|
||||||
|
|||||||
@@ -65,6 +65,22 @@ def uses_external_provider() -> bool:
|
|||||||
return enabled() and provider() in {"proxy", "tinyauth"}
|
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:
|
def password_hash(password: str) -> str:
|
||||||
return generate_password_hash(password or "")
|
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()
|
username = str(data.get("username") or "").strip()
|
||||||
role = "admin" if data.get("role") == "admin" else "user"
|
role = "admin" if data.get("role") == "admin" else "user"
|
||||||
is_active = 1 if data.get("is_active", True) else 0
|
is_active = 1 if data.get("is_active", True) else 0
|
||||||
|
password_editable = not uses_external_provider()
|
||||||
if not username:
|
if not username:
|
||||||
raise ValueError("Username is required")
|
raise ValueError("Username is required")
|
||||||
with connect() as conn:
|
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),
|
(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:
|
||||||
|
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(
|
cur = conn.execute(
|
||||||
"INSERT INTO users(username,password_hash,email,display_name,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)), 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)
|
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))
|
conn.execute("UPDATE users SET password_hash=?, updated_at=? WHERE id=?", (password_hash(str(data.get("password"))), now, user_id))
|
||||||
if role != "admin":
|
if role != "admin":
|
||||||
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
|
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)
|
abort(403)
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
rows = conn.execute(
|
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,),
|
(uid,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [_token_response(row) for row in rows]
|
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)
|
abort(403)
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
conn.execute(
|
# Note: Report missing/already revoked tokens instead of showing a false success in the UI.
|
||||||
"UPDATE api_tokens SET revoked_at=COALESCE(revoked_at, ?), updated_at=? WHERE id=? AND user_id=?",
|
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),
|
(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:
|
def authenticate_api_token(token: str) -> dict[str, Any] | None:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { torrentDetailsSource } from './torrentDetails.js';
|
|||||||
import { modalsSource } from './modals.js';
|
import { modalsSource } from './modals.js';
|
||||||
import { rssSource } from './rss.js';
|
import { rssSource } from './rss.js';
|
||||||
import { smartQueueSource } from './smartQueue.js';
|
import { smartQueueSource } from './smartQueue.js';
|
||||||
|
import { authUsersSource } from './authUsers.js';
|
||||||
import { plannerSource } from './planner.js';
|
import { plannerSource } from './planner.js';
|
||||||
import { pollerSource } from './poller.js';
|
import { pollerSource } from './poller.js';
|
||||||
import { profilesSource } from './profiles.js';
|
import { profilesSource } from './profiles.js';
|
||||||
@@ -29,6 +30,7 @@ export const moduleSources = [
|
|||||||
modalsSource,
|
modalsSource,
|
||||||
rssSource,
|
rssSource,
|
||||||
smartQueueSource,
|
smartQueueSource,
|
||||||
|
authUsersSource,
|
||||||
plannerSource,
|
plannerSource,
|
||||||
dashboardSource,
|
dashboardSource,
|
||||||
operationLogsSource,
|
operationLogsSource,
|
||||||
|
|||||||
2
pytorrent/static/js/authUsers.js
Normal file
2
pytorrent/static/js/authUsers.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3759,6 +3759,54 @@ body,
|
|||||||
display: block;
|
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 {
|
#toolsModal .modal-body {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|||||||
@@ -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>
|
<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' %}
|
{% 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 %}
|
{% 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="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">
|
<div id="toolRatio" class="d-none">
|
||||||
|
|||||||
Reference in New Issue
Block a user