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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user