Improve remote auth bootstrap flow

This commit is contained in:
Dymas
2026-05-23 14:37:48 +02:00
parent 91691244d7
commit c6ba7fdbe2
6 changed files with 254 additions and 20 deletions
+209 -16
View File
@@ -74,6 +74,7 @@ def dependency_status():
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
quiet_poll_paths = {"/api/queue", "/api/watchlist/refresh-status"}
def _context(self):
@@ -159,6 +160,45 @@ class Handler(BaseHTTPRequestHandler):
return text
return ""
def _normalized_next_path(self, value):
raw = str(value or "").strip()
if not raw:
return "/"
parsed = urlparse(raw)
if parsed.scheme or parsed.netloc:
return "/"
path = parsed.path or "/"
if not path.startswith("/"):
return "/"
if path == "/auth/login":
path = "/"
query = parsed.query or ""
return urlunparse(("", "", path, "", query, ""))
def _current_path_without_tokens(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):
return (
f"{Handler.remote_token_cookie_name}={token}; "
f"Path=/; HttpOnly; SameSite=Lax; Max-Age={Handler.remote_token_cookie_max_age_seconds}"
)
def _set_remote_auth_cookie_and_redirect(self, token, 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),
},
)
def _remote_cookie_bootstrap_response(self, parsed):
client_host = Handler._effective_client_host(self)
if client_address_is_local(client_host):
@@ -170,23 +210,131 @@ class Handler(BaseHTTPRequestHandler):
token = Handler._query_token(self)
if not token or not remote_access_token_matches(token):
return False
params = parse_qs(parsed.query, keep_blank_values=True)
params.pop("token", None)
params.pop("remote_token", None)
query = urlencode(params, doseq=True)
location = urlunparse(("", "", parsed.path or "/", "", query, ""))
cookie = (
f"{Handler.remote_token_cookie_name}={token}; "
"Path=/; HttpOnly; SameSite=Lax"
)
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)
error_html = ""
if message:
error_html = f'<p class="error">{message}</p>'
html = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{APP_NAME} sign in</title>
<style>
:root {{
color-scheme: dark;
--bg: #0b0813;
--panel: rgba(25, 18, 41, 0.94);
--line: rgba(255, 255, 255, 0.08);
--text: #f6f2ff;
--muted: #b8b0cf;
--accent: #9d7bff;
--error: #ffb4c2;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(157, 123, 255, 0.18), transparent 28%),
linear-gradient(180deg, #120c1d 0%, #0b0813 56%, #07050d 100%);
color: var(--text);
font: 15px/1.5 "Segoe UI", Inter, system-ui, sans-serif;
}}
.panel {{
width: min(100%, 420px);
display: grid;
gap: 14px;
padding: 22px;
border: 1px solid var(--line);
border-radius: 22px;
background: var(--panel);
}}
h1, p {{ margin: 0; }}
h1 {{ font-size: 28px; line-height: 1.05; }}
.muted {{ color: var(--muted); }}
.error {{
color: var(--error);
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 144, 164, 0.12);
border: 1px solid rgba(255, 144, 164, 0.18);
}}
form {{
display: grid;
gap: 12px;
}}
label {{
display: grid;
gap: 8px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}}
input {{
width: 100%;
min-height: 44px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(10, 7, 18, 0.82);
color: var(--text);
padding: 0 13px;
}}
button {{
min-height: 42px;
border: 0;
border-radius: 14px;
background: linear-gradient(135deg, var(--accent), #7b5cff);
color: #fcfaff;
font: inherit;
font-weight: 700;
cursor: pointer;
}}
code {{
font-size: 13px;
color: var(--text);
}}
</style>
</head>
<body>
<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>
</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>
<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>
</section>
</body>
</html>
"""
Handler.write_response_bytes(
self,
b"",
HTTPStatus.SEE_OTHER,
{
"Location": location,
"Set-Cookie": cookie,
},
html.encode("utf-8"),
HTTPStatus.UNAUTHORIZED,
{"Content-Type": "text/html; charset=utf-8"},
)
return True
@@ -210,6 +358,15 @@ class Handler(BaseHTTPRequestHandler):
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)):
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)
return
if remote_access_allowed() and remote_access_token_configured():
Handler._render_remote_login_page(self, parsed)
return
self.ensure_client_access()
if parsed.path == "/":
self.html(Handler._context(self).index_html)
@@ -281,6 +438,21 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self):
parsed = urlparse(self.path)
try:
if parsed.path == "/auth/login":
if not remote_access_allowed() or not remote_access_token_configured():
raise PermissionError("Remote access sign-in is not available.")
payload = Handler.body_form(self)
token = str(payload.get("token") or "").strip()
next_path = payload.get("next") or "/"
if not remote_access_token_matches(token):
Handler._render_remote_login_page(
self,
urlparse(Handler._normalized_next_path(self, next_path)),
"Remote access token missing or invalid.",
)
return
Handler._set_remote_auth_cookie_and_redirect(self, token, next_path)
return
self.ensure_client_access()
if parsed.path == "/api/config":
Handler.update_config(self, Handler.require_json_object(self, self.body_json()))
@@ -423,6 +595,23 @@ class Handler(BaseHTTPRequestHandler):
except json.JSONDecodeError as exc:
raise ValueError("Invalid JSON body") from exc
def body_form(self):
try:
length = int(self.headers.get("Content-Length", "0") or "0")
except ValueError as exc:
raise ValueError("Invalid Content-Length header") from exc
if length < 0:
raise ValueError("Invalid Content-Length header")
if length > MAX_JSON_BODY_BYTES:
raise Handler._context(self).http_error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Request body too large.")
raw_bytes = self.rfile.read(length) if length else b""
try:
raw = raw_bytes.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError("Request body must be valid UTF-8 form data.") from exc
params = parse_qs(raw, keep_blank_values=True)
return {key: values[-1] if values else "" for key, values in params.items()}
def html(self, content):
data = content.encode("utf-8")
self.write_response_bytes(data, HTTPStatus.OK, {"Content-Type": "text/html; charset=utf-8"})
@@ -469,7 +658,11 @@ class Handler(BaseHTTPRequestHandler):
elif isinstance(exc, KeyError):
self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'"))
elif isinstance(exc, PermissionError):
self.error(HTTPStatus.FORBIDDEN, str(exc))
parsed = urlparse(getattr(self, "path", "") or "/")
if remote_access_allowed() and remote_access_token_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))
elif isinstance(exc, ValueError):
self.error(HTTPStatus.BAD_REQUEST, str(exc))
else: