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:
@@ -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_string;cookie.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 里说明验证失败
|
||||
# 这样用户仍能看到新凭证已写入,便于 debug(cf_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 与远程浏览器不在同一 IP,cf_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) ----
|
||||
|
||||
Reference in New Issue
Block a user