From 4c71148ff90ee527295adb573601a8a2cd3c4c20 Mon Sep 17 00:00:00 2001 From: SmartUp Developer Date: Tue, 19 May 2026 09:27:14 +0800 Subject: [PATCH] feat: one-click upstream auth refresh from custom page viewer - Add linked_upstream_id to CustomPage model with DB migration - New POST /api/custom-pages/{pid}/refresh-auth endpoint extracts credentials from active remote browser and updates linked upstream - PageViewer toolbar shows key icon button when page has linked upstream - CustomPages form adds upstream dropdown for remote_browser pages - Auth capture extracts New-Api-User from localStorage uid/user/self API - Upstream client sends New-Api-User header in cookie auth mode - Fix auth capture dialog: transparent background, field persistence, login URL defaults to base_url/login, focus on click for keyboard input - Fix upstream test ASCII encoding with non-header characters validation Co-Authored-By: Claude Opus 4.7 --- backend/app/database.py | 2 + backend/app/models/custom_page.py | 1 + backend/app/routers/auth_capture.py | 17 +- backend/app/routers/custom_pages.py | 82 ++++++++ backend/app/services/auth_capture_service.py | 74 +++++++- .../app/services/browser_session_service.py | 9 +- backend/app/services/upstream_client.py | 28 ++- backend/test_browser_session_service.py | 25 +++ frontend/src/api/index.ts | 32 ++-- frontend/src/components/AuthCaptureDialog.vue | 177 +++++++++++++++--- frontend/src/views/CustomPages.vue | 17 +- frontend/src/views/PageViewer.vue | 24 +++ frontend/src/views/Upstreams.vue | 27 ++- 13 files changed, 462 insertions(+), 53 deletions(-) diff --git a/backend/app/database.py b/backend/app/database.py index 54078fa..24b5f69 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -64,4 +64,6 @@ def _migrate_custom_pages(): "AND NULLIF(TRIM(login_password), '') IS NOT NULL" ) ) + if "linked_upstream_id" not in columns: + conn.execute(text("ALTER TABLE custom_pages ADD COLUMN linked_upstream_id INTEGER")) diff --git a/backend/app/models/custom_page.py b/backend/app/models/custom_page.py index 7f30ecf..7f4f71f 100644 --- a/backend/app/models/custom_page.py +++ b/backend/app/models/custom_page.py @@ -25,6 +25,7 @@ class CustomPage(Base): login_submit_selector: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) login_autofill_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) login_autofill_backfilled_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + linked_upstream_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column( DateTime, diff --git a/backend/app/routers/auth_capture.py b/backend/app/routers/auth_capture.py index d606bfa..6ece665 100644 --- a/backend/app/routers/auth_capture.py +++ b/backend/app/routers/auth_capture.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Optional +from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field @@ -21,6 +21,8 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/auth-capture", tags=["auth-capture"]) +SENSITIVE_CANDIDATE_FIELDS = frozenset({"value", "cookie_value"}) + class CaptureSessionCreate(BaseModel): url: str = Field(..., description="Target login page URL to open in browser") @@ -41,6 +43,14 @@ class CaptureExtractResponse(BaseModel): candidates: list[dict] = [] +def _sanitize_candidate(candidate: dict[str, Any]) -> dict[str, Any]: + return { + key: value + for key, value in candidate.items() + if key not in SENSITIVE_CANDIDATE_FIELDS + } + + def _browser_error(exc: Exception) -> HTTPException: if isinstance(exc, BrowserDependencyError): return HTTPException(503, str(exc)) @@ -108,8 +118,9 @@ async def extract_credentials( raise _browser_error(exc) if not include_raw: - # Strip raw data — only keep curated candidates - return CaptureExtractResponse(candidates=result.get("candidates", [])) + # Strip raw data — only keep curated candidates with masked previews + candidates = [_sanitize_candidate(candidate) for candidate in result.get("candidates", [])] + return CaptureExtractResponse(candidates=candidates) return CaptureExtractResponse(**result) diff --git a/backend/app/routers/custom_pages.py b/backend/app/routers/custom_pages.py index ad60527..aa611a6 100644 --- a/backend/app/routers/custom_pages.py +++ b/backend/app/routers/custom_pages.py @@ -17,6 +17,8 @@ from app.models.admin_user import AdminUser from app.models.custom_page import CustomPage from app.models.upstream import Upstream from app.services.upstream_client import _find_user_id +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 router = APIRouter(prefix="/api/custom-pages", tags=["custom-pages"]) @@ -48,6 +50,7 @@ class CustomPageCreate(BaseModel): login_password_selector: Optional[str] = None login_submit_selector: Optional[str] = None login_autofill_enabled: bool = False + linked_upstream_id: Optional[int] = None class CustomPageUpdate(BaseModel): @@ -66,6 +69,7 @@ class CustomPageUpdate(BaseModel): login_submit_selector: Optional[str] = None login_autofill_enabled: Optional[bool] = None login_password_clear: Optional[bool] = None + linked_upstream_id: Optional[int] = None class CustomPageResponse(BaseModel): @@ -84,6 +88,7 @@ class CustomPageResponse(BaseModel): login_submit_selector: Optional[str] login_autofill_enabled: bool login_password_configured: bool + linked_upstream_id: Optional[int] created_at: datetime updated_at: datetime @@ -118,6 +123,7 @@ def _page_response(page: CustomPage) -> CustomPageResponse: login_submit_selector=page.login_submit_selector, login_autofill_enabled=page.login_autofill_enabled, login_password_configured=bool(page.login_password), + linked_upstream_id=page.linked_upstream_id, created_at=page.created_at, updated_at=page.updated_at, ) @@ -201,6 +207,82 @@ def delete_page(pid: int, db: Session = Depends(get_db), _=Depends(get_current_u db.commit() +# ---- One-click refresh auth ---- + +import json as _json + + +class RefreshAuthResponse(BaseModel): + success: bool + message: str + + +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: + for c in candidates: + if c["type"] == preferred: + return c + return candidates[0] + + +@router.post("/{pid}/refresh-auth", response_model=RefreshAuthResponse) +async def refresh_auth(pid: int, db: Session = Depends(get_db), _=Depends(get_current_user)): + page = db.query(CustomPage).filter(CustomPage.id == pid).first() + if not page: + raise HTTPException(404, "page not found") + if page.access_mode != "remote_browser": + raise HTTPException(400, "page is not in remote_browser mode") + if not page.linked_upstream_id: + raise HTTPException(400, "page has no linked upstream") + upstream = db.query(Upstream).filter(Upstream.id == page.linked_upstream_id).first() + if not upstream: + raise HTTPException(404, "linked upstream not found") + + try: + session = browser_sessions.find_by_page_id(page.id) + except KeyError: + return RefreshAuthResponse(success=False, message="请先打开远程浏览器并登录") + + try: + result = await extract_all(session) + except Exception as exc: + return RefreshAuthResponse(success=False, message=f"提取失败: {exc}") + + candidates = result.get("candidates", []) + candidate = _pick_best_candidate(candidates, upstream.auth_type) + if not candidate: + return RefreshAuthResponse(success=False, message="未提取到有效凭证,请确认已在远程浏览器中登录") + + existing_config = _json.loads(upstream.auth_config_json or "{}") + ctype = candidate["type"] + + if ctype == "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", "") + if candidate.get("new_api_user"): + existing_config["new_api_user"] = candidate["new_api_user"] + elif ctype == "bearer_token": + upstream.auth_type = "bearer" + existing_config["token"] = candidate.get("value", "") + elif ctype == "api_key": + upstream.auth_type = "api_key" + existing_config["key"] = candidate.get("value", "") + existing_config.setdefault("header", "X-API-Key") + + 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})") + + # ---- Frame Proxy (simple: strip X-Frame-Options / CSP, pass through content) ---- _STRIP_RESP = { diff --git a/backend/app/services/auth_capture_service.py b/backend/app/services/auth_capture_service.py index c861d68..74dd227 100644 --- a/backend/app/services/auth_capture_service.py +++ b/backend/app/services/auth_capture_service.py @@ -59,6 +59,32 @@ async def extract_session_storage(page: Any) -> dict[str, str]: return {} +async def extract_new_api_user_id(page: Any) -> str: + try: + value = await page.evaluate(""" + async () => { + const uid = localStorage.getItem('uid') + if (uid) return uid + const userRaw = localStorage.getItem('user') + if (userRaw) { + try { + const user = JSON.parse(userRaw) + if (user?.id) return String(user.id) + } catch {} + } + const response = await fetch('/api/user/self', { credentials: 'include' }) + if (!response.ok) return '' + const payload = await response.json() + const data = payload?.data || payload + return data?.id ? String(data.id) : '' + } + """) + return str(value or "").strip() + except Exception as exc: + logger.debug("New-API user id extraction failed: %s", exc) + return "" + + async def extract_request_headers(session: Any) -> list[dict[str, str]]: """Return Authorization / API-Key headers captured continuously by CDP. @@ -83,7 +109,8 @@ async def extract_all(session: Any) -> dict[str, Any]: local_storage = await extract_local_storage(page) session_storage = await extract_session_storage(page) auth_headers = await extract_request_headers(session) - candidates = _curate_candidates(cookies, local_storage, session_storage, auth_headers) + new_api_user = _find_new_api_user(local_storage, session_storage) or await extract_new_api_user_id(page) + candidates = _curate_candidates(cookies, local_storage, session_storage, auth_headers, new_api_user) return { "cookies": cookies, @@ -99,6 +126,7 @@ def _curate_candidates( local_storage: dict[str, str], session_storage: dict[str, str], auth_headers: list[dict[str, str]], + new_api_user: str = "", ) -> list[dict[str, Any]]: """Scan extracted data for likely credentials with confidence scoring.""" candidates: list[dict[str, Any]] = [] @@ -148,19 +176,59 @@ def _curate_candidates( _add(candidates, "bearer_token", f"{store_name}.{key}", val, _preview(val), f"{store_name}.{key} (API Key)", 90) + if not new_api_user: + new_api_user = _find_new_api_user(local_storage, session_storage) + # 3. Session cookies for c in cookies: cname = c["name"].lower() if any(k in cname for k in SESSION_COOKIE_NAMES): preview = _preview(c["value"]) cookie_val = f"{c['name']}={c['value']}" + confidence = 99 if cname == "session" else 85 + extra = {"cookie_name": c["name"], "cookie_value": c["value"]} + if cname == "session" and new_api_user: + extra["new_api_user"] = new_api_user _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"]}) + f"Cookie {c['name']} ({c['domain']})", confidence, + extra=extra) + candidates.sort(key=lambda item: ( + 0 if item.get("type") == "cookie" and item.get("cookie_name") == "session" else + 1 if item.get("type") == "cookie" else + 2, + -int(item.get("confidence") or 0), + )) return candidates +def _find_storage_value(*stores: dict[str, str], key: str) -> str: + for store in stores: + value = store.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _find_new_api_user(*stores: dict[str, str]) -> str: + uid = _find_storage_value(*stores, key="uid") + if uid: + return uid + user_raw = _find_storage_value(*stores, key="user") + if not user_raw: + return "" + try: + user = json.loads(user_raw) + except Exception: + return "" + if isinstance(user, dict): + for key in ("id", "user_id", "userId"): + value = user.get(key) + if value is not None: + return str(value).strip() + return "" + + def _add( candidates: list[dict[str, Any]], ctype: str, diff --git a/backend/app/services/browser_session_service.py b/backend/app/services/browser_session_service.py index 4d31b53..eca6c7a 100644 --- a/backend/app/services/browser_session_service.py +++ b/backend/app/services/browser_session_service.py @@ -129,7 +129,7 @@ class BrowserSessionService: elif event_type == "type": text = str(payload.get("text", "")) if text: - await page.keyboard.type(text) + await page.keyboard.insert_text(text) elif event_type == "key": key = str(payload.get("key", "")) if key: @@ -325,6 +325,13 @@ class BrowserSessionService: raise KeyError("browser session not found") return session + def find_by_page_id(self, custom_page_id: int) -> BrowserSession: + """Find the active session for a custom page. Raises KeyError if none.""" + for session in self._sessions.values(): + if session.custom_page_id == custom_page_id and not session.page.is_closed(): + return session + raise KeyError(f"no active browser session for page {custom_page_id}") + _get = get_session # alias for internal use def _ensure_open(self, session: BrowserSession) -> None: diff --git a/backend/app/services/upstream_client.py b/backend/app/services/upstream_client.py index 185c3c3..18b4205 100644 --- a/backend/app/services/upstream_client.py +++ b/backend/app/services/upstream_client.py @@ -28,6 +28,19 @@ def _find_token(value: Any) -> str: return "" +def _clean_auth_header_value(value: Any, field_name: str) -> str: + text = str(value or "").strip() + if not text: + return "" + if text.startswith("Bearer "): + text = text[7:].strip() + try: + text.encode("latin-1") + except UnicodeEncodeError as exc: + raise UpstreamError(f"{field_name} contains non-HTTP-header characters; please re-extract and apply the full credential") from exc + return text + + def _find_user_id(value: Any) -> str: if isinstance(value, dict): for key in ("id", "user_id", "userId"): @@ -232,25 +245,32 @@ class UpstreamClient: if not auth: return headers if self.auth_type == "bearer": - token = self.auth_config.get("token", "") + token = _clean_auth_header_value(self.auth_config.get("token", ""), "Bearer token") if token: headers["Authorization"] = f"Bearer {token}" elif self.auth_type == "api_key": - key = self.auth_config.get("key", "") + key = _clean_auth_header_value(self.auth_config.get("key", ""), "API key") 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", "") + cookie_str = _clean_auth_header_value(self.auth_config.get("cookie_string", ""), "Cookie") if cookie_str: headers["Cookie"] = cookie_str + new_api_user = _clean_auth_header_value(self.auth_config.get("new_api_user", ""), "New-Api-User") + if new_api_user: + headers["New-Api-User"] = new_api_user elif self.auth_type == "login_password" and self._token: - headers["Authorization"] = f"Bearer {self._token}" + token = _clean_auth_header_value(self._token, "Login token") + if token: + headers["Authorization"] = f"Bearer {token}" if self.auth_type == "login_password" and self._new_api_user: headers["New-Api-User"] = self._new_api_user return headers def _request(self, method: str, path: str, body: Any = None, auth: bool = True) -> Any: + if auth and self.auth_type == "cookie" and "user/self" in path and not self.auth_config.get("new_api_user"): + raise UpstreamError("New-API user endpoint requires New-Api-User; re-extract the session cookie after login and save the upstream") url = self._url(path) if body is not None: resp = self._client.request( diff --git a/backend/test_browser_session_service.py b/backend/test_browser_session_service.py index a9c3359..c95eef9 100644 --- a/backend/test_browser_session_service.py +++ b/backend/test_browser_session_service.py @@ -1,5 +1,6 @@ import asyncio +from app.routers.auth_capture import _sanitize_candidate from app.services.browser_session_service import BrowserSessionService @@ -102,3 +103,27 @@ def test_autofill_returns_without_selectors_when_disabled_or_missing_credentials poll_interval_seconds=0, )) assert missing_password_page.queries == [] + + +def test_sanitize_candidate_strips_secret_fields_but_keeps_metadata(): + sanitized = _sanitize_candidate({ + "type": "cookie", + "source": "cookie:session", + "value": "Bearer secret-token", + "preview": "Bearer s…token", + "label": "session cookie", + "confidence": 90, + "cookie_name": "session", + "cookie_value": "secret-cookie", + "domain": "example.test", + }) + + assert sanitized == { + "type": "cookie", + "source": "cookie:session", + "preview": "Bearer s…token", + "label": "session cookie", + "confidence": 90, + "cookie_name": "session", + "domain": "example.test", + } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 3d6236d..82ead61 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -259,6 +259,7 @@ export interface CustomPageData { login_submit_selector: string | null login_autofill_enabled: boolean login_password_configured: boolean + linked_upstream_id: number | null created_at: string updated_at: string } @@ -279,6 +280,7 @@ export interface CustomPageForm { login_submit_selector?: string login_autofill_enabled?: boolean login_password_clear?: boolean + linked_upstream_id?: number | null } export const customPagesApi = { @@ -287,6 +289,7 @@ export const customPagesApi = { create: (data: CustomPageForm) => api.post('/api/custom-pages', data), update: (id: number, data: Partial) => api.put(`/api/custom-pages/${id}`, data), delete: (id: number) => api.delete(`/api/custom-pages/${id}`), + refreshAuth: (id: number) => api.post<{ success: boolean; message: string }>(`/api/custom-pages/${id}/refresh-auth`), } // ——— Remote browser sessions ——— @@ -333,28 +336,33 @@ export interface AuthCaptureSession { ws_url: string } +export interface AuthCaptureCandidate { + type: 'bearer_token' | 'cookie' | 'credential' | 'api_key' + source: string + preview: string + label: string + confidence: number + value?: string + cookie_name?: string + cookie_value?: string + new_api_user?: string +} + export interface AuthCaptureResult { cookies: Record[] storage: Record session_storage: Record auth_headers: Record[] - candidates: { - type: 'bearer_token' | 'cookie' | 'credential' | 'api_key' - source: string - value: string - preview: string - label: string - confidence: number - cookie_name?: string - cookie_value?: string - }[] + candidates: AuthCaptureCandidate[] } export const authCaptureApi = { createSession: (url: string, width?: number, height?: number) => api.post('/api/auth-capture/sessions', { url, width, height }), - extract: (sessionId: string) => - api.get(`/api/auth-capture/sessions/${sessionId}/extract`), + extract: (sessionId: string, options?: { includeRaw?: boolean }) => + api.get(`/api/auth-capture/sessions/${sessionId}/extract`, { + params: options?.includeRaw ? { include_raw: true } : undefined, + }), closeSession: (sessionId: string) => api.delete(`/api/auth-capture/sessions/${sessionId}`), wsUrl: (sessionId: string, token?: string) => { diff --git a/frontend/src/components/AuthCaptureDialog.vue b/frontend/src/components/AuthCaptureDialog.vue index 11ca9a6..dc7a342 100644 --- a/frontend/src/components/AuthCaptureDialog.vue +++ b/frontend/src/components/AuthCaptureDialog.vue @@ -121,13 +121,13 @@
- {{ c.preview || maskValue(c.value) }} + {{ candidatePreview(c) }}
关闭 - + 填入当前表单
@@ -140,8 +140,9 @@