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
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 0.41.0 - 2026-05-25
- Replaced the shared remote-access token flow with a classic single-user username/password login, backed by a persistent signed session cookie for browser sessions and HTTP Basic auth for API clients.
- Added `ANI_CLI_WEB_AUTH_USERNAME` and `ANI_CLI_WEB_AUTH_PASSWORD` support plus matching `config.json` fields, while keeping those private auth fields out of the public config API so the web UI does not leak or overwrite them.
## 0.40.6 - 2026-05-25
- Fixed the Config page `Dependencies` panel so it stretches across the full settings width, centered its dependency badges, and added the installed `ani-cli` version to the runtime info block.
+20 -12
View File
@@ -2,7 +2,7 @@
Small local web UI for a system-wide `ani-cli` install.
Current version: `0.40.0`
Current version: `0.41.0`
## Project Layout
@@ -276,23 +276,28 @@ To allow LAN or remote access:
```sh
ANI_CLI_WEB_ALLOW_REMOTE=1
ANI_CLI_WEB_REMOTE_TOKEN=choose-a-long-random-token
ANI_CLI_WEB_AUTH_USERNAME=admin
ANI_CLI_WEB_AUTH_PASSWORD=choose-a-long-random-password
```
Non-local clients must send one of:
Non-local browser visits are redirected to a sign-in form automatically when no valid session cookie is present.
- `Authorization: Bearer <token>`
- `X-AniCli-Web-Token: <token>`
API clients can authenticate with:
Browser bootstrap also works:
- `Authorization: Basic <base64(username:password)>`
```text
http://YOUR-HOST:8421/?token=your-token-here
The browser login stores a persistent HTTP-only session cookie, so you usually only need to sign in once per browser.
You can also set the same credentials directly in `.ani-cli-web/config.json`:
```json
{
"auth_username": "admin",
"auth_password": "choose-a-long-random-password"
}
```
For normal browser visits, the app now shows a sign-in page automatically when the token cookie is missing.
The app stores the token in an HTTP-only cookie and redirects to the clean URL. The cookie is persistent, so you usually only need to sign in once per browser.
Environment variables take precedence over values saved in `config.json`.
## Download Queue
@@ -343,7 +348,8 @@ Environment variables:
- `ANI_CLI_WEB_HOST`: server host, default `127.0.0.1`
- `ANI_CLI_WEB_PORT`: server port, default `8421`
- `ANI_CLI_WEB_ALLOW_REMOTE`: allow non-loopback clients
- `ANI_CLI_WEB_REMOTE_TOKEN`: shared secret for remote access
- `ANI_CLI_WEB_AUTH_USERNAME`: single remote-login username
- `ANI_CLI_WEB_AUTH_PASSWORD`: single remote-login password
- `ANI_CLI_WEB_DEBUG`: enable debug logging
- `ANI_CLI_WEB_STATE_ROOT`: override the project-local state directory
- `UPDATE_ON_START`: run `sudo ani-cli --update` in the container before startup
@@ -351,6 +357,8 @@ Environment variables:
Saved config fields include:
- `auth_username`
- `auth_password`
- `discord_webhook_url`
- `discord_webhook_events`
- `watchlist_auto_refresh_enabled`
+1 -1
View File
@@ -1 +1 @@
0.40.6
0.41.0
+5 -1
View File
@@ -2269,7 +2269,11 @@ def get_config_snapshot(fallback=None):
def save_runtime_config(config):
normalized = normalize_config(config)
current = get_config_snapshot(fallback=DEFAULT_CONFIG)
merged = dict(current)
if isinstance(config, dict):
merged.update(config)
normalized = normalize_config(merged)
scheduler = None
with RUNTIME_LOCK:
global CONFIG
+59 -7
View File
@@ -2,6 +2,7 @@
"""Shared constants and helper utilities for ani-cli-web."""
import hashlib
import hmac
import ipaddress
import json
@@ -19,7 +20,7 @@ from uuid import uuid4
PROJECT_ROOT = Path(__file__).resolve().parent
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
APP_NAME = "ani-cli-web"
VERSION = "0.40.6"
VERSION = "0.41.0"
ALLANIME_BASE = "allanime.day"
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
ALLANIME_REFERER = "https://allmanga.to"
@@ -82,17 +83,56 @@ def remote_access_allowed():
return env_flag("ANI_CLI_WEB_ALLOW_REMOTE")
def remote_access_token():
return str(os.environ.get("ANI_CLI_WEB_REMOTE_TOKEN") or "").strip()
def remote_access_username(config=None):
configured = str(os.environ.get("ANI_CLI_WEB_AUTH_USERNAME") or "").strip()
if configured:
return configured
if isinstance(config, dict):
return str(config.get("auth_username") or "").strip()
return ""
def remote_access_token_configured():
return bool(remote_access_token())
def remote_access_password(config=None):
configured = str(os.environ.get("ANI_CLI_WEB_AUTH_PASSWORD") or "").strip()
if configured:
return configured
if isinstance(config, dict):
return str(config.get("auth_password") or "").strip()
return ""
def remote_access_token_matches(value):
def remote_access_credentials_configured(config=None):
return bool(remote_access_username(config) and remote_access_password(config))
def remote_access_credentials_match(username, password, config=None):
expected_username = remote_access_username(config)
expected_password = remote_access_password(config)
provided_username = str(username or "").strip()
provided_password = str(password or "").strip()
return bool(
expected_username
and expected_password
and hmac.compare_digest(provided_username, expected_username)
and hmac.compare_digest(provided_password, expected_password)
)
def remote_access_session_value(config=None):
username = remote_access_username(config)
password = remote_access_password(config)
if not username or not password:
return ""
return hmac.new(
f"{username}\0{password}".encode("utf-8"),
b"ani-cli-web-session",
hashlib.sha256,
).hexdigest()
def remote_access_session_matches(value, config=None):
provided = str(value or "").strip()
expected = remote_access_token()
expected = remote_access_session_value(config)
return bool(expected and provided and hmac.compare_digest(provided, expected))
@@ -145,7 +185,10 @@ DEFAULT_CONFIG = {
"auto_download_quality": "best",
"discord_webhook_url": "",
"discord_webhook_events": [],
"auth_username": str(os.environ.get("ANI_CLI_WEB_AUTH_USERNAME") or "").strip(),
"auth_password": str(os.environ.get("ANI_CLI_WEB_AUTH_PASSWORD") or "").strip(),
}
PRIVATE_CONFIG_KEYS = {"auth_username", "auth_password"}
def now_iso():
@@ -295,6 +338,8 @@ def normalize_config(data):
auto_download_quality = auto_download_quality[:-1]
webhook_url = str(config.get("discord_webhook_url") or "").strip()
webhook_events = config.get("discord_webhook_events")
auth_username = str(config.get("auth_username") or "").strip()
auth_password = str(config.get("auth_password") or "").strip()
if isinstance(auto_refresh_enabled, str):
auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"}
@@ -344,9 +389,16 @@ def normalize_config(data):
normalized_events.append(normalized_name)
config["discord_webhook_url"] = webhook_url
config["discord_webhook_events"] = normalized_events
config["auth_username"] = auth_username
config["auth_password"] = auth_password
return config
def sanitize_public_config(config):
source = normalize_config(config or {})
return {key: value for key, value in source.items() if key not in PRIVATE_CONFIG_KEYS}
def discord_webhook_enabled(config, event_name, force=False):
if not isinstance(config, dict):
return False
+2 -1
View File
@@ -19,7 +19,8 @@ services:
ANI_CLI_WEB_HOST: 0.0.0.0
ANI_CLI_WEB_PORT: ${ANI_CLI_WEB_PORT:-8421}
ANI_CLI_WEB_ALLOW_REMOTE: ${ANI_CLI_WEB_ALLOW_REMOTE:-false}
ANI_CLI_WEB_REMOTE_TOKEN: ${ANI_CLI_WEB_REMOTE_TOKEN:-}
ANI_CLI_WEB_AUTH_USERNAME: ${ANI_CLI_WEB_AUTH_USERNAME:-}
ANI_CLI_WEB_AUTH_PASSWORD: ${ANI_CLI_WEB_AUTH_PASSWORD:-}
ANI_CLI_DOWNLOAD_DIR: /downloads
volumes:
- ./downloads:/downloads
+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))
+69 -45
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env python3
import base64
import importlib.util
import io
import os
@@ -101,7 +102,7 @@ class NetworkGuardTests(unittest.TestCase):
self.assertFalse(APP.client_address_is_local("192.168.1.25"))
self.assertFalse(APP.client_address_is_local("10.0.0.8"))
def test_remote_access_requires_token_for_non_local_clients(self):
def test_remote_access_requires_credentials_for_non_local_clients(self):
handler = DummyHandler("/api/config")
handler.client_address = ("192.168.1.25", 8421)
@@ -109,68 +110,56 @@ class NetworkGuardTests(unittest.TestCase):
with self.assertRaises(PermissionError) as ctx:
APP.Handler.ensure_client_access(handler)
self.assertIn("REMOTE_TOKEN", str(ctx.exception))
self.assertIn("AUTH_USERNAME", str(ctx.exception))
def test_remote_access_accepts_valid_token_for_non_local_clients(self):
def test_remote_access_accepts_valid_basic_auth_for_non_local_clients(self):
handler = DummyHandler("/api/config")
handler.client_address = ("192.168.1.25", 8421)
handler.headers["Authorization"] = "Bearer secret-token"
encoded = base64.b64encode(b"demo:secret-pass").decode("ascii")
handler.headers["Authorization"] = f"Basic {encoded}"
with mock.patch.dict(
os.environ,
{"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"},
{
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
},
clear=False,
):
APP.Handler.ensure_client_access(handler)
def test_remote_access_accepts_valid_cookie_token_for_non_local_clients(self):
def test_remote_access_accepts_valid_session_cookie_for_non_local_clients(self):
handler = DummyHandler("/api/config")
handler.client_address = ("192.168.1.25", 8421)
handler.headers["Cookie"] = "ani_cli_web_token=secret-token"
session_value = http_handler.remote_access_session_value(
{"auth_username": "demo", "auth_password": "secret-pass"}
)
handler.headers["Cookie"] = f"ani_cli_web_session={session_value}"
with mock.patch.dict(
os.environ,
{"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"},
{
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
},
clear=False,
):
APP.Handler.ensure_client_access(handler)
def test_remote_access_accepts_valid_query_token_for_non_local_clients(self):
handler = DummyHandler("/api/config?token=secret-token")
handler.client_address = ("192.168.1.25", 8421)
with mock.patch.dict(
os.environ,
{"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"},
clear=False,
):
APP.Handler.ensure_client_access(handler)
def test_remote_get_bootstraps_cookie_and_redirects_when_query_token_valid(self):
handler = DummyHandler("/?token=secret-token")
handler.client_address = ("192.168.1.25", 8421)
with mock.patch.dict(
os.environ,
{"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"},
clear=False,
):
APP.Handler.do_GET(handler)
headers = dict(handler.response_headers)
self.assertEqual(handler.response_status, HTTPStatus.SEE_OTHER)
self.assertEqual(headers.get("Location"), "/")
self.assertIn("ani_cli_web_token=secret-token", headers.get("Set-Cookie", ""))
self.assertIn("Max-Age=", headers.get("Set-Cookie", ""))
def test_remote_browser_get_without_token_renders_login_page(self):
def test_remote_browser_get_without_session_renders_login_page(self):
handler = DummyHandler("/")
handler.client_address = ("192.168.1.25", 8421)
handler.command = "GET"
with mock.patch.dict(
os.environ,
{"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"},
{
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
},
clear=False,
):
APP.Handler.do_GET(handler)
@@ -178,16 +167,21 @@ class NetworkGuardTests(unittest.TestCase):
body = handler.wfile.getvalue().decode("utf-8")
self.assertEqual(handler.response_status, HTTPStatus.UNAUTHORIZED)
self.assertIn('<form method="post" action="/auth/login">', body)
self.assertIn("Remote access token", body)
self.assertIn("Username", body)
self.assertIn("Password", body)
def test_remote_login_post_sets_cookie_and_redirects(self):
body = b"token=secret-token&next=%2Fwatchlist"
body = b"username=demo&password=secret-pass&next=%2Fwatchlist"
handler = DummyHandler("/auth/login", body=body, content_length=len(body))
handler.client_address = ("192.168.1.25", 8421)
with mock.patch.dict(
os.environ,
{"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"},
{
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
},
clear=False,
):
APP.Handler.do_POST(handler)
@@ -195,9 +189,9 @@ class NetworkGuardTests(unittest.TestCase):
headers = dict(handler.response_headers)
self.assertEqual(handler.response_status, HTTPStatus.SEE_OTHER)
self.assertEqual(headers.get("Location"), "/watchlist")
self.assertIn("ani_cli_web_token=secret-token", headers.get("Set-Cookie", ""))
self.assertIn("ani_cli_web_session=", headers.get("Set-Cookie", ""))
def test_loopback_proxy_with_forwarded_remote_ip_requires_token(self):
def test_loopback_proxy_with_forwarded_remote_ip_requires_credentials(self):
handler = DummyHandler("/api/config")
handler.client_address = ("127.0.0.1", 8421)
handler.headers["X-Forwarded-For"] = "203.0.113.10"
@@ -206,7 +200,7 @@ class NetworkGuardTests(unittest.TestCase):
with self.assertRaises(PermissionError) as ctx:
APP.Handler.ensure_client_access(handler)
self.assertIn("REMOTE_TOKEN", str(ctx.exception))
self.assertIn("AUTH_USERNAME", str(ctx.exception))
class DownloadQueueCancelTests(unittest.TestCase):
@@ -1767,6 +1761,33 @@ class HandlerRouteTests(unittest.TestCase):
["runtime_error", "watchlist_refresh_failed"],
)
def test_config_routes_hide_auth_fields_and_preserve_saved_credentials(self):
APP.save_runtime_config(
{
"download_dir": "/tmp/example",
"mode": "sub",
"quality": "best",
"auth_username": "demo",
"auth_password": "secret-pass",
}
)
post_body = b'{"download_dir":"/tmp/updated","mode":"dub","quality":"720"}'
post_handler = DummyHandler("/api/config", body=post_body, content_length=len(post_body))
APP.Handler.do_POST(post_handler)
self.assertEqual(post_handler.json_status, HTTPStatus.OK)
self.assertNotIn("auth_username", post_handler.json_payload)
self.assertNotIn("auth_password", post_handler.json_payload)
self.assertEqual(APP.get_config_snapshot()["auth_username"], "demo")
self.assertEqual(APP.get_config_snapshot()["auth_password"], "secret-pass")
get_handler = DummyHandler("/api/config")
APP.Handler.do_GET(get_handler)
self.assertEqual(get_handler.json_status, HTTPStatus.OK)
self.assertNotIn("auth_username", get_handler.json_payload)
self.assertNotIn("auth_password", get_handler.json_payload)
def test_config_webhook_test_route_uses_unsaved_payload(self):
body = b'{"discord_webhook_url":"https://discord.example/webhook","discord_webhook_events":["runtime_error"]}'
handler = DummyHandler("/api/config/webhook/test", body=body, content_length=len(body))
@@ -1911,10 +1932,13 @@ class HandlerRouteTests(unittest.TestCase):
handler = DummyHandler("/api/config")
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
http_handler, "debug_log"
) as debug_log, mock.patch.object(http_handler.traceback, "print_exc") as print_exc:
) as debug_log, mock.patch.object(http_handler, "send_discord_webhook_event") as send_webhook, mock.patch.object(
http_handler.traceback, "print_exc"
) as print_exc:
APP.Handler.exception(handler, RuntimeError("boom"))
print_exc.assert_not_called()
send_webhook.assert_called_once()
debug_log.assert_called_once()
self.assertEqual(handler.error_status, HTTPStatus.INTERNAL_SERVER_ERROR)
self.assertEqual(handler.error_message, "Internal server error.")