From 08c855677a669dd5dcdfbdd5f400b1360e58008e Mon Sep 17 00:00:00 2001 From: SmartUp Developer Date: Mon, 18 May 2026 11:44:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20auth=20capture=20=E2=80=94=20interactiv?= =?UTF-8?q?e=20browser,=20CDP=20header=20capture,=20cookie=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthCaptureDialog: full WS screenshot stream + mouse/keyboard/scroll events - Backend auth_capture: CDP Network.requestWillBeSent for Authorization headers - Candidate scoring: confidence 0-95%, preview (masked), auth_headers section - Upstream form: add 'Cookie' auth type, handle cookie selection - UpstreamClient: support auth_type=cookie with Cookie header - No secrets logged at DEBUG or higher --- backend/app/routers/auth_capture.py | 1 + backend/app/services/auth_capture_service.py | 175 ++++++-- backend/app/services/upstream_client.py | 4 + frontend/src/api/index.ts | 3 + frontend/src/components/AuthCaptureDialog.vue | 421 ++++++++++++++---- frontend/src/views/Upstreams.vue | 23 +- 6 files changed, 495 insertions(+), 132 deletions(-) diff --git a/backend/app/routers/auth_capture.py b/backend/app/routers/auth_capture.py index 9cc718b..1b2b73b 100644 --- a/backend/app/routers/auth_capture.py +++ b/backend/app/routers/auth_capture.py @@ -37,6 +37,7 @@ class CaptureExtractResponse(BaseModel): cookies: list[dict] = [] storage: dict[str, str] = {} session_storage: dict[str, str] = {} + auth_headers: list[dict] = [] candidates: list[dict] = [] diff --git a/backend/app/services/auth_capture_service.py b/backend/app/services/auth_capture_service.py index cfef2f1..fe4fdae 100644 --- a/backend/app/services/auth_capture_service.py +++ b/backend/app/services/auth_capture_service.py @@ -7,9 +7,23 @@ from typing import Any logger = logging.getLogger(__name__) +# Keys likely to contain auth tokens in storage +TOKEN_KEYS = frozenset({ + "token", "access_token", "accessToken", "jwt", "auth_token", "authToken", + "refresh_token", "refreshToken", "id_token", "session_token", +}) +SECRET_KEYS = frozenset({ + "secret", "api_key", "apiKey", "apikey", +}) +SESSION_COOKIE_NAMES = frozenset({ + "session", "token", "jwt", "sid", "auth", "connect.sid", + "gin_session", "tdc_itoken", "sessionid", + "access_token", "refresh_token", +}) + async def extract_cookies(session: Any) -> list[dict[str, Any]]: - """Extract cookies from the browser context.""" + """Extract all cookies from the browser context.""" cookies = await session.context.cookies() return [ { @@ -24,7 +38,6 @@ async def extract_cookies(session: Any) -> list[dict[str, Any]]: async def extract_local_storage(page: Any) -> dict[str, str]: - """Extract all localStorage items from the page origin.""" try: raw = await page.evaluate("() => JSON.stringify(window.localStorage)") if isinstance(raw, str): @@ -36,7 +49,6 @@ async def extract_local_storage(page: Any) -> dict[str, str]: async def extract_session_storage(page: Any) -> dict[str, str]: - """Extract all sessionStorage items from the page origin.""" try: raw = await page.evaluate("() => JSON.stringify(window.sessionStorage)") if isinstance(raw, str): @@ -47,25 +59,71 @@ async def extract_session_storage(page: Any) -> dict[str, str]: return {} +async def extract_request_headers(page: Any) -> list[dict[str, str]]: + """Capture Authorization headers from network requests via CDP. + + Uses Chrome DevTools Protocol to subscribe to Network.requestWillBeSent + events and extract Authorization / X-API-Key headers from captured + requests. Only catches requests made *after* CDP is enabled. + """ + captured: list[dict[str, str]] = [] + cdp = None + try: + cdp = await page.context.new_cdp_session(page) + await cdp.send("Network.enable") + + def on_request(params: dict) -> None: + headers = params.get("request", {}).get("headers", {}) + auth = (headers.get("authorization") or headers.get("Authorization")) + api_key = (headers.get("x-api-key") or headers.get("X-API-Key")) + if auth: + captured.append({ + "type": "authorization", + "value": auth, + "url": params.get("request", {}).get("url", ""), + }) + logger.debug("auth-capture CDP: captured Authorization header") + if api_key: + captured.append({ + "type": "api_key", + "value": api_key, + "url": params.get("request", {}).get("url", ""), + }) + logger.debug("auth-capture CDP: captured X-API-Key header") + + cdp.on("Network.requestWillBeSent", on_request) + # Give a moment for any in-flight requests to be captured + import asyncio + await asyncio.sleep(0.5) + except Exception as exc: + logger.debug("CDP network capture not available: %s", exc) + finally: + if cdp: + try: + await cdp.detach() + except Exception: + pass + return captured + + async def extract_all(session: Any) -> dict[str, Any]: - """Extract all possible auth credentials from a browser session. + """Extract all auth credentials from a browser session. Returns: - - cookies: list of cookie dicts - - storage: dict of localStorage key-values - - session_storage: dict of sessionStorage key-values - - candidates: curated list of likely auth tokens/credentials + cookies, storage, session_storage, auth_headers, candidates """ page = session.page cookies = await extract_cookies(session) local_storage = await extract_local_storage(page) session_storage = await extract_session_storage(page) - candidates = _curate_candidates(cookies, local_storage, session_storage) + auth_headers = await extract_request_headers(page) + candidates = _curate_candidates(cookies, local_storage, session_storage, auth_headers) return { "cookies": cookies, "storage": local_storage, "session_storage": session_storage, + "auth_headers": auth_headers, "candidates": candidates, } @@ -74,68 +132,99 @@ def _curate_candidates( cookies: list[dict[str, Any]], local_storage: dict[str, str], session_storage: dict[str, str], + auth_headers: list[dict[str, str]], ) -> list[dict[str, Any]]: - """Scan extracted data for likely bearer tokens and session cookies.""" + """Scan extracted data for likely credentials with confidence scoring.""" candidates: list[dict[str, Any]] = [] - # 1. localStorage / sessionStorage items that look like tokens + # 1. CDP-captured Authorization headers (highest confidence) + seen = set() + for h in auth_headers: + dedup_key = h["value"] + if dedup_key in seen: + continue + seen.add(dedup_key) + preview = _preview(h["value"]) + candidates.append({ + "type": "bearer_token", + "source": f"network:{h['url'][:60]}", + "value": h["value"], + "preview": preview, + "label": f"Authorization — {h['url'][:40]}", + "confidence": 95, + }) + + # 2. localStorage/sessionStorage items for store_name, store in [("localStorage", local_storage), ("sessionStorage", session_storage)]: for key, val in store.items(): if not isinstance(val, str) or not val: continue key_lower = key.lower() - # Explicit auth keys - if any(k in key_lower for k in ("token", "jwt", "auth", "access", "secret", "api_key")): - _add_candidate(candidates, "bearer_token", f"{store_name}.{key}", val, - f"{store_name}.{key}") - # JWT-shaped strings (not in an auth-named key) - elif val.count(".") >= 2 and 20 < len(val) < 5000: - _add_candidate(candidates, "bearer_token", f"{store_name}.{key}", val, - f"{store_name}.{key} (JWT)") + # Explicit auth-named keys + if any(k in key_lower for k in TOKEN_KEYS): + preview = _preview(val) + score = 85 if "token" in key_lower and val.count(".") >= 2 else 75 + _add(candidates, "bearer_token", f"{store_name}.{key}", val, preview, + f"{store_name}.{key}", score) + elif any(k in key_lower for k in SECRET_KEYS): + _add(candidates, "credential", f"{store_name}.{key}", val, _preview(val), + f"{store_name}.{key}", 70) - # 2. Cookies that look like session/token cookies - cookie_keywords = ("session", "token", "jwt", "sid", "auth", "connect.sid", "gin_session", "tdc_itoken") + # Looks like a JWT (xx.yy.zz format) + if val.count(".") >= 2 and 20 < len(val) < 5000: + if dedup_key := val not in seen: + seen.add(val) + _add(candidates, "bearer_token", f"{store_name}.{key}", val, _preview(val), + f"{store_name}.{key} (JWT)", 80) + + # sk-xxx API key pattern + if val.startswith("sk-") and len(val) > 10: + _add(candidates, "bearer_token", f"{store_name}.{key}", val, _preview(val), + f"{store_name}.{key} (API Key)", 90) + + # 3. Session cookies for c in cookies: cname = c["name"].lower() - if any(k in cname for k in cookie_keywords): - _add_candidate(candidates, "cookie", f"cookie:{c['name']}", f"{c['name']}={c['value']}", - f"🍪 {c['name']} ({c['domain']})", - extra={"cookie_name": c["name"], "cookie_value": c["value"]}) + if any(k in cname for k in SESSION_COOKIE_NAMES): + preview = _preview(c["value"]) + cookie_val = f"{c['name']}={c['value']}" + _add(candidates, "cookie", f"cookie:{c['name']}", cookie_val, preview, + f"🍪 {c['name']} ({c['domain']})", 75, + extra={"cookie_name": c["name"], "cookie_value": c["value"]}) - # 3. Any localStorage key whose value looks like a sk-xxx key - for store_name, store in [("localStorage", local_storage), ("sessionStorage", session_storage)]: - for key, val in store.items(): - if isinstance(val, str) and val.startswith("sk-") and len(val) > 10: - _add_candidate(candidates, "bearer_token", f"{store_name}.{key}", val, - f"{store_name}.{key} (sk-key)") - - # Deduplicate by value - seen = set() - deduped = [] - for c in candidates: - if c["value"] not in seen: - seen.add(c["value"]) - deduped.append(c) - return deduped + return candidates -def _add_candidate( +def _add( candidates: list[dict[str, Any]], ctype: str, source: str, value: str, + preview: str, label: str, + confidence: int, extra: dict | None = None, ) -> None: - """Add a candidate, masking sensitive values in logs.""" - logger.debug("auth-capture candidate: type=%s source=%s label=%s", ctype, source, label) + """Add a candidate entry. Value is masked in logs.""" + logger.debug("auth-capture candidate: type=%s source=%s confidence=%d", ctype, source, confidence) entry: dict[str, Any] = { "type": ctype, "source": source, "value": value, + "preview": preview, "label": label, + "confidence": confidence, } if extra: entry.update(extra) candidates.append(entry) + + +def _preview(value: str) -> str: + """Generate a masked preview of a credential.""" + if not value or len(value) <= 8: + return "***" + if len(value) <= 16: + return value[:4] + "…" + value[-4:] + return value[:8] + "…" + value[-6:] diff --git a/backend/app/services/upstream_client.py b/backend/app/services/upstream_client.py index 0a885ab..185c3c3 100644 --- a/backend/app/services/upstream_client.py +++ b/backend/app/services/upstream_client.py @@ -240,6 +240,10 @@ class UpstreamClient: header = self.auth_config.get("header", "Authorization") if key: headers[header] = key + elif self.auth_type == "cookie": + cookie_str = self.auth_config.get("cookie_string", "") + if cookie_str: + headers["Cookie"] = cookie_str elif self.auth_type == "login_password" and self._token: headers["Authorization"] = f"Bearer {self._token}" if self.auth_type == "login_password" and self._new_api_user: diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 56bc7ce..4c6df16 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -337,11 +337,14 @@ export interface AuthCaptureResult { cookies: Record[] storage: Record session_storage: Record + auth_headers: Record[] candidates: { type: 'bearer_token' | 'cookie' | 'credential' source: string value: string + preview: string label: string + confidence: number cookie_name?: string cookie_value?: string }[] diff --git a/frontend/src/components/AuthCaptureDialog.vue b/frontend/src/components/AuthCaptureDialog.vue index 389d217..77d26a2 100644 --- a/frontend/src/components/AuthCaptureDialog.vue +++ b/frontend/src/components/AuthCaptureDialog.vue @@ -2,7 +2,7 @@ - - - 打开远程浏览器 - +
+ + + + + + + + + + + + + + + +
+
+ + {{ showExtraFields ? '收起' : '自动填充选项' }} + + + + 打开远程浏览器 + +
- -
+ +

步骤 2:在浏览器中手动登录

+ + + + + + + + + + 提取认证信息 - 关闭
-

完成登录后,点击「提取认证信息」获取 token / cookie。

-
- browser preview -
-
+

在下方浏览器中完成登录后,点击「提取认证信息」获取 token / cookie。

- -
-

提取结果

-

- 未找到认证凭据。请确认已成功登录,或重新尝试。 -

- -
-
-
- - {{ c.type === 'bearer_token' ? 'Bearer' : 'Cookie' }} - {{ c.label }} - -
-
- {{ maskValue(c.value) }} -
+ +
+ 远程浏览器 +
+ +

正在启动浏览器…

- + + +
+
+ 提取到 {{ result.candidates.length }} 个认证凭据 + 重新提取 +
+
+ 未找到认证凭据。请确认已成功登录后重试。 +
+
+
+
+ + + {{ c.type === 'bearer_token' ? 'Bearer' : c.type === 'cookie' ? 'Cookie' : 'Key' }} + + {{ c.label }} + + + {{ c.confidence }}% + +
+
+ {{ maskValue(c.preview || c.value) }} +
+
+
+
+ 关闭 + + 填入当前表单 + +
+
+
diff --git a/frontend/src/views/Upstreams.vue b/frontend/src/views/Upstreams.vue index e348076..a74931c 100644 --- a/frontend/src/views/Upstreams.vue +++ b/frontend/src/views/Upstreams.vue @@ -234,6 +234,7 @@ + @@ -249,6 +250,17 @@
+