feat(auth-capture): full cookie bundle extraction + richer refresh-auth

Problem: Meow upstream uses Cloudflare, which sets cf_clearance + session
cookies that must all be sent together. The old code only captured a single
session-named cookie via a whitelist, discarding cf_clearance entirely, and
wrote back only 'name=value' instead of the full cookie string.

Changes:

auth_capture_service.py:
  - Add _cookie_matches_hostname(): hostname suffix matching supporting
    dot-prefixed domains (.saki.lat matches api.saki.lat)
  - Add _build_cookie_bundle(): collects ALL cookies matching the current
    page's hostname, returns complete 'name1=v1; name2=v2' string
  - _curate_candidates(): new 'cookie_bundle' candidate type (type=0 in sort,
    highest priority), carries cookie_count + cookie_names in extra fields
  - extract_all(): obtain real-time page URL from session.page.url and pass
    to _curate_candidates so cookie domain filtering is accurate
  - Sort order: cookie_bundle > cookie > bearer_token/api_key > credential
  - Fix bug in original JWT dedup check (was assigning instead of checking)

custom_pages.py:
  - Add logging import + logger
  - _pick_best_candidate(): cookie preferred_auth_type now tries cookie_bundle
    first, then single cookie; bearer/api_key use existing type_map logic
  - RefreshAuthResponse: add optional 'warning' field
  - refresh_auth(): handle ctype='cookie_bundle' same as 'cookie'; always
    write full candidate.value as cookie_string (works for both types)
  - Post-write validation: attempt get_available_groups with new credentials;
    on failure, still commit (lenient mode) but set warning message explaining
    cf_clearance IP-binding as the likely cause; success logs at INFO level

Tests (test_auth_capture.py, 19 cases):
  - _cookie_matches_hostname: exact, dot-prefix subdomain, empty domain,
    different domain, evil-subdomain partial match rejection
  - _build_cookie_bundle: cf_clearance included, cross-domain excluded,
    single cookie, empty value excluded, no cookies
  - _curate_candidates: bundle ranks first, value is full string, bundle
    beats single session cookie, bearer wins when no cookies, empty case,
    cookie_count/cookie_names in extra, session fallback preserved,
    new_api_user propagation to bundle

All 46 tests pass.
This commit is contained in:
liumangmang
2026-06-02 09:32:23 +08:00
parent f17317b13c
commit 4f9acdc99c
3 changed files with 352 additions and 17 deletions
+56 -12
View File
@@ -1,6 +1,7 @@
"""Custom pages CRUD router + authenticated iframe proxy."""
from __future__ import annotations
import logging
import re
from datetime import datetime, timezone
from typing import Any, List, Literal, Optional
@@ -21,6 +22,8 @@ from app.services.auth_capture_service import extract_all
from app.services.browser_session_service import browser_sessions
from app.utils.auth import decode_token, get_current_user, get_user_from_token_param
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/custom-pages", tags=["custom-pages"])
# Headers that prevent iframe embedding — strip them from proxied responses
@@ -215,17 +218,29 @@ import json as _json
class RefreshAuthResponse(BaseModel):
success: bool
message: str
warning: Optional[str] = None
def _pick_best_candidate(candidates: list[dict], preferred_auth_type: str) -> Optional[dict]:
if not candidates:
return None
type_map = {"cookie": "cookie", "bearer": "bearer_token", "api_key": "api_key"}
preferred = type_map.get(preferred_auth_type)
if preferred:
# cookie_bundle > cookie > bearer_token > api_key
# preferred_auth_type="cookie" 时优先匹配 bundle,其次单 cookie
if preferred_auth_type == "cookie":
for c in candidates:
if c["type"] == preferred:
if c["type"] == "cookie_bundle":
return c
for c in candidates:
if c["type"] == "cookie":
return c
elif preferred_auth_type in ("bearer", "api_key"):
type_map = {"bearer": "bearer_token", "api_key": "api_key"}
preferred = type_map.get(preferred_auth_type)
if preferred:
for c in candidates:
if c["type"] == preferred:
return c
# fallback:排序后取第一个
return candidates[0]
@@ -260,12 +275,10 @@ async def refresh_auth(pid: int, db: Session = Depends(get_db), _=Depends(get_cu
existing_config = _json.loads(upstream.auth_config_json or "{}")
ctype = candidate["type"]
if ctype == "cookie":
if ctype in ("cookie_bundle", "cookie"):
upstream.auth_type = "cookie"
if candidate.get("cookie_name") and candidate.get("cookie_value"):
existing_config["cookie_string"] = f"{candidate['cookie_name']}={candidate['cookie_value']}"
else:
existing_config["cookie_string"] = candidate.get("value", "")
# cookie_bundle.value 已是完整 cookie_stringcookie.value 是 "name=value" 格式
existing_config["cookie_string"] = candidate.get("value", "")
if candidate.get("new_api_user"):
existing_config["new_api_user"] = candidate["new_api_user"]
elif ctype == "bearer_token":
@@ -281,7 +294,7 @@ async def refresh_auth(pid: int, db: Session = Depends(get_db), _=Depends(get_cu
except UnicodeEncodeError:
return RefreshAuthResponse(
success=False,
message=f"提取到的 Token 含有非 HTTP 标头字符,请确认已在远程浏览器中正确登录并重试",
message="提取到的 Token 含有非 HTTP 标头字符,请确认已在远程浏览器中正确登录并重试",
)
existing_config["token"] = token
elif ctype == "api_key":
@@ -291,9 +304,40 @@ async def refresh_auth(pid: int, db: Session = Depends(get_db), _=Depends(get_cu
upstream.auth_config_json = _json.dumps(existing_config, ensure_ascii=False)
upstream.updated_at = datetime.now(timezone.utc)
db.commit()
return RefreshAuthResponse(success=True, message=f"凭证已刷新 ({upstream.auth_type})")
# ── 宽松验证:写回后尝试调用 get_available_groups 验证凭证可用性 ──
# 失败时仍然 commit(凭证已写入),但在 message 里说明验证失败
# 这样用户仍能看到新凭证已写入,便于 debugcf_clearance 绑 IP 时验证必然失败)
warning_msg: Optional[str] = None
try:
from app.services.upstream_client import UpstreamClient
groups_endpoint = upstream.groups_endpoint or "/groups/available"
new_auth_config = _json.loads(upstream.auth_config_json)
with UpstreamClient(
base_url=upstream.base_url,
api_prefix=upstream.api_prefix or "",
auth_type=upstream.auth_type,
auth_config=new_auth_config,
timeout=float(upstream.timeout_seconds or 30),
) as uc:
uc.get_available_groups(groups_endpoint)
logger.info("refresh_auth: upstream %s credential verification passed", upstream.id)
except Exception as exc:
warning_msg = (
f"凭证已写入但 API 验证失败:{exc}"
"若 SmartUp 与远程浏览器不在同一 IPcf_clearance 可能无法复用,请手动测试连接。"
)
logger.warning(
"refresh_auth: upstream %s credential verification failed (written anyway): %s",
upstream.id, exc,
)
db.commit()
auth_type_label = upstream.auth_type
cookie_count = candidate.get("cookie_count", "")
count_str = f"{cookie_count} 个 cookie" if cookie_count else ""
success_msg = f"凭证已刷新 ({auth_type_label}{count_str})"
return RefreshAuthResponse(success=True, message=success_msg, warning=warning_msg)
# ---- Frame Proxy (simple: strip X-Frame-Options / CSP, pass through content) ----