Replace token auth with single-user login

This commit is contained in:
Dymas
2026-05-25 16:30:01 +02:00
parent 8549944be1
commit 1487257bed
8 changed files with 237 additions and 136 deletions
+76 -69
View File
@@ -8,6 +8,8 @@ import os
import shutil
import subprocess
import traceback
from base64 import b64decode
from binascii import Error as BinasciiError
from functools import lru_cache
from dataclasses import dataclass
from http.cookies import SimpleCookie
@@ -28,8 +30,11 @@ from app_support import (
debug_log,
normalize_config,
remote_access_allowed,
remote_access_token_configured,
remote_access_token_matches,
remote_access_credentials_configured,
remote_access_credentials_match,
remote_access_session_matches,
remote_access_session_value,
sanitize_public_config,
send_discord_webhook_event,
)
@@ -92,8 +97,8 @@ def installed_ani_cli_version():
class Handler(BaseHTTPRequestHandler):
server_version = "AniCliWeb/1.0"
remote_token_cookie_name = "ani_cli_web_token"
remote_token_cookie_max_age_seconds = 30 * 24 * 60 * 60
remote_session_cookie_name = "ani_cli_web_session"
remote_session_cookie_max_age_seconds = 30 * 24 * 60 * 60
quiet_poll_paths = {"/api/queue", "/api/watchlist/refresh-status"}
def _context(self):
@@ -138,15 +143,24 @@ class Handler(BaseHTTPRequestHandler):
return candidate
return peer_host
def _remote_token(self):
return (
self.headers.get("X-AniCli-Web-Token")
or self.headers.get("x-ani-cli-web-token")
or Handler._bearer_token(self)
or Handler._cookie_token(self)
or Handler._query_token(self)
or ""
)
def _authorization_header(self):
return str(self.headers.get("Authorization") or self.headers.get("authorization") or "").strip()
def _basic_auth_credentials(self):
header = Handler._authorization_header(self)
if not header.startswith("Basic "):
return ("", "")
encoded = header[len("Basic ") :].strip()
if not encoded:
return ("", "")
try:
decoded = b64decode(encoded.encode("ascii"), validate=True).decode("utf-8")
except (BinasciiError, UnicodeDecodeError, ValueError):
return ("", "")
username, separator, password = decoded.partition(":")
if not separator:
return ("", "")
return (username.strip(), password.strip())
def _bearer_token(self):
header = str(self.headers.get("Authorization") or "").strip()
@@ -154,7 +168,7 @@ class Handler(BaseHTTPRequestHandler):
return ""
return header[len("Bearer ") :].strip()
def _cookie_token(self):
def _cookie_value(self, name):
raw = str(self.headers.get("Cookie") or self.headers.get("cookie") or "").strip()
if not raw:
return ""
@@ -163,21 +177,20 @@ class Handler(BaseHTTPRequestHandler):
cookie.load(raw)
except Exception:
return ""
morsel = cookie.get(Handler.remote_token_cookie_name)
morsel = cookie.get(name)
if not morsel:
return ""
return morsel.value.strip()
def _query_token(self):
parsed = urlparse(self.path)
params = parse_qs(parsed.query, keep_blank_values=True)
for key in ("token", "remote_token"):
values = params.get(key) or []
for value in values:
text = str(value).strip()
if text:
return text
return ""
def _session_cookie(self):
return Handler._cookie_value(self, Handler.remote_session_cookie_name)
def _remote_credentials_valid(self):
session = Handler._session_cookie(self)
if session and remote_access_session_matches(session):
return True
username, password = Handler._basic_auth_credentials(self)
return remote_access_credentials_match(username, password)
def _normalized_next_path(self, value):
raw = str(value or "").strip()
@@ -194,52 +207,43 @@ class Handler(BaseHTTPRequestHandler):
query = parsed.query or ""
return urlunparse(("", "", path, "", query, ""))
def _current_path_without_tokens(self, parsed):
def _current_path_without_auth_query(self, parsed):
params = parse_qs(parsed.query, keep_blank_values=True)
params.pop("token", None)
params.pop("remote_token", None)
query = urlencode(params, doseq=True)
return urlunparse(("", "", parsed.path or "/", "", query, ""))
def _remote_auth_cookie(self, token):
def _remote_auth_cookie(self, session_value):
return (
f"{Handler.remote_token_cookie_name}={token}; "
f"Path=/; HttpOnly; SameSite=Lax; Max-Age={Handler.remote_token_cookie_max_age_seconds}"
f"{Handler.remote_session_cookie_name}={session_value}; "
f"Path=/; HttpOnly; SameSite=Lax; Max-Age={Handler.remote_session_cookie_max_age_seconds}"
)
def _set_remote_auth_cookie_and_redirect(self, token, location):
def _clear_remote_auth_cookie(self):
return (
f"{Handler.remote_session_cookie_name}=; "
"Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
)
def _set_remote_auth_cookie_and_redirect(self, session_value, location):
Handler.write_response_bytes(
self,
b"",
HTTPStatus.SEE_OTHER,
{
"Location": Handler._normalized_next_path(self, location),
"Set-Cookie": Handler._remote_auth_cookie(self, token),
"Set-Cookie": Handler._remote_auth_cookie(self, session_value),
},
)
def _remote_cookie_bootstrap_response(self, parsed):
client_host = Handler._effective_client_host(self)
if client_address_is_local(client_host):
return False
if not remote_access_allowed() or not remote_access_token_configured():
return False
if Handler._cookie_token(self):
return False
token = Handler._query_token(self)
if not token or not remote_access_token_matches(token):
return False
location = Handler._current_path_without_tokens(self, parsed)
Handler._set_remote_auth_cookie_and_redirect(self, token, location)
return True
def _is_browser_page_request(self, parsed):
if getattr(self, "command", "") != "GET":
return False
return not str(parsed.path or "").startswith("/api/")
def _render_remote_login_page(self, parsed, message=""):
next_path = Handler._current_path_without_tokens(self, parsed)
next_path = Handler._current_path_without_auth_query(self, parsed)
error_html = ""
if message:
error_html = f'<p class="error">{message}</p>'
@@ -334,17 +338,20 @@ class Handler(BaseHTTPRequestHandler):
<section class="panel">
<div>
<h1>{APP_NAME}</h1>
<p class="muted">Enter the remote access token for this browser. After that, the app keeps a persistent auth cookie and future visits stay signed in.</p>
<p class="muted">Sign in with the configured username and password. After that, this browser keeps a persistent auth session cookie.</p>
</div>
{error_html}
<form method="post" action="/auth/login">
<input type="hidden" name="next" value="{next_path}">
<label>Remote access token
<input name="token" type="password" autocomplete="current-password" autofocus required>
<label>Username
<input name="username" type="text" autocomplete="username" autofocus required>
</label>
<label>Password
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button type="submit">Sign in</button>
</form>
<p class="muted">API clients can still use <code>Authorization: Bearer &lt;token&gt;</code> or <code>?token=...</code>.</p>
<p class="muted">API clients can also use <code>Authorization: Basic &lt;base64(username:password)&gt;</code>.</p>
</section>
</body>
</html>
@@ -365,25 +372,23 @@ class Handler(BaseHTTPRequestHandler):
raise PermissionError(
"Remote access is disabled. Set ANI_CLI_WEB_ALLOW_REMOTE=1 to allow non-local clients."
)
if not remote_access_token_configured():
if not remote_access_credentials_configured():
raise PermissionError(
"Remote access requires ANI_CLI_WEB_REMOTE_TOKEN for non-local clients."
"Remote access requires ANI_CLI_WEB_AUTH_USERNAME and ANI_CLI_WEB_AUTH_PASSWORD for non-local clients."
)
if not remote_access_token_matches(Handler._remote_token(self)):
raise PermissionError("Remote access token missing or invalid.")
if not Handler._remote_credentials_valid(self):
raise PermissionError("Remote username or password missing or invalid.")
def do_GET(self):
parsed = urlparse(self.path)
try:
if Handler._remote_cookie_bootstrap_response(self, parsed):
return
if parsed.path == "/auth/login":
if Handler._cookie_token(self) and remote_access_token_matches(Handler._cookie_token(self)):
if Handler._session_cookie(self) and remote_access_session_matches(Handler._session_cookie(self)):
params = parse_qs(parsed.query, keep_blank_values=True)
next_path = (params.get("next") or ["/"])[0]
Handler._set_remote_auth_cookie_and_redirect(self, Handler._cookie_token(self), next_path)
Handler._set_remote_auth_cookie_and_redirect(self, Handler._session_cookie(self), next_path)
return
if remote_access_allowed() and remote_access_token_configured():
if remote_access_allowed() and remote_access_credentials_configured():
Handler._render_remote_login_page(self, parsed)
return
self.ensure_client_access()
@@ -396,7 +401,7 @@ class Handler(BaseHTTPRequestHandler):
elif parsed.path == "/watchlist":
self.html(Handler._context(self).watchlist_html)
elif parsed.path == "/api/config":
self.json(Handler._context(self).get_config_snapshot())
self.json(sanitize_public_config(Handler._context(self).get_config_snapshot()))
elif parsed.path == "/api/version":
self.json({"name": APP_NAME, "version": VERSION, "ani_cli_version": installed_ani_cli_version()})
elif parsed.path == "/api/dependencies":
@@ -466,19 +471,20 @@ class Handler(BaseHTTPRequestHandler):
parsed = urlparse(self.path)
try:
if parsed.path == "/auth/login":
if not remote_access_allowed() or not remote_access_token_configured():
if not remote_access_allowed() or not remote_access_credentials_configured():
raise PermissionError("Remote access sign-in is not available.")
payload = Handler.body_form(self)
token = str(payload.get("token") or "").strip()
username = str(payload.get("username") or "").strip()
password = str(payload.get("password") or "").strip()
next_path = payload.get("next") or "/"
if not remote_access_token_matches(token):
if not remote_access_credentials_match(username, password):
Handler._render_remote_login_page(
self,
urlparse(Handler._normalized_next_path(self, next_path)),
"Remote access token missing or invalid.",
"Username or password missing or invalid.",
)
return
Handler._set_remote_auth_cookie_and_redirect(self, token, next_path)
Handler._set_remote_auth_cookie_and_redirect(self, remote_access_session_value(), next_path)
return
self.ensure_client_access()
if parsed.path == "/api/config":
@@ -569,11 +575,12 @@ class Handler(BaseHTTPRequestHandler):
self.exception(exc)
def update_config(self, payload):
payload = Handler.require_json_object(self, payload)
config = normalize_config(payload)
Path(config["download_dir"]).expanduser().mkdir(parents=True, exist_ok=True)
context = Handler._context(self)
context.save_runtime_config(config)
self.json(context.get_config_snapshot())
context.save_runtime_config(payload)
self.json(sanitize_public_config(context.get_config_snapshot()))
def require_fields(self, payload, *names):
payload = Handler.require_json_object(self, payload)
@@ -698,7 +705,7 @@ class Handler(BaseHTTPRequestHandler):
self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'"))
elif isinstance(exc, PermissionError):
parsed = urlparse(getattr(self, "path", "") or "/")
if remote_access_allowed() and remote_access_token_configured() and Handler._is_browser_page_request(self, parsed):
if remote_access_allowed() and remote_access_credentials_configured() and Handler._is_browser_page_request(self, parsed):
Handler._render_remote_login_page(self, parsed, str(exc))
else:
self.error(HTTPStatus.FORBIDDEN, str(exc))