feat: auth capture — remote browser credential extraction
- BrowserSessionService: add create_ephemeral() for temp sessions
- New auth_capture_service.py: extract cookies, localStorage, sessionStorage from page
- New auth_capture router: POST /sessions, GET /sessions/{id}/extract, DELETE /sessions/{id}
- Frontend AuthCaptureDialog: URL input → browser view → extract → pick candidate
- Upstreams.vue: '提取' button next to Bearer Token field
- No sensitive values logged
This commit is contained in:
+2
-1
@@ -14,7 +14,7 @@ from app.models.admin_user import AdminUser
|
|||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.utils.auth import hash_password, verify_password, validate_password_supported
|
from app.utils.auth import hash_password, verify_password, validate_password_supported
|
||||||
from app.services.scheduler import start_scheduler, stop_scheduler
|
from app.services.scheduler import start_scheduler, stop_scheduler
|
||||||
from app.routers import auth, upstreams, webhooks, logs, custom_pages, browser_sessions, websites
|
from app.routers import auth, upstreams, webhooks, logs, custom_pages, browser_sessions, websites, auth_capture
|
||||||
from app.services.browser_session_service import browser_sessions as browser_session_service
|
from app.services.browser_session_service import browser_sessions as browser_session_service
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||||
@@ -97,6 +97,7 @@ app.include_router(logs.router)
|
|||||||
app.include_router(custom_pages.router)
|
app.include_router(custom_pages.router)
|
||||||
app.include_router(browser_sessions.router)
|
app.include_router(browser_sessions.router)
|
||||||
app.include_router(websites.router)
|
app.include_router(websites.router)
|
||||||
|
app.include_router(auth_capture.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Auth capture API — remote browser for manual login + credential extraction."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.auth_capture_service import extract_all
|
||||||
|
from app.services.browser_session_service import (
|
||||||
|
BrowserDependencyError,
|
||||||
|
BrowserSessionError,
|
||||||
|
browser_sessions,
|
||||||
|
)
|
||||||
|
from app.utils.auth import get_current_user, get_user_from_token_param
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth-capture", tags=["auth-capture"])
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureSessionCreate(BaseModel):
|
||||||
|
url: str = Field(..., description="Target login page URL to open in browser")
|
||||||
|
width: int = Field(default=1280, ge=320, le=2560)
|
||||||
|
height: int = Field(default=720, ge=240, le=1600)
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureSessionResponse(BaseModel):
|
||||||
|
session_id: str
|
||||||
|
ws_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureExtractResponse(BaseModel):
|
||||||
|
cookies: list[dict] = []
|
||||||
|
storage: dict[str, str] = {}
|
||||||
|
session_storage: dict[str, str] = {}
|
||||||
|
candidates: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _browser_error(exc: Exception) -> HTTPException:
|
||||||
|
if isinstance(exc, BrowserDependencyError):
|
||||||
|
return HTTPException(503, str(exc))
|
||||||
|
if isinstance(exc, BrowserSessionError):
|
||||||
|
return HTTPException(409, str(exc))
|
||||||
|
if isinstance(exc, KeyError):
|
||||||
|
return HTTPException(404, "session not found")
|
||||||
|
if isinstance(exc, ValueError):
|
||||||
|
return HTTPException(400, str(exc))
|
||||||
|
logger.exception("auth-capture error")
|
||||||
|
return HTTPException(500, "internal error")
|
||||||
|
|
||||||
|
|
||||||
|
def _ws_url(session_id: str, token: str) -> str:
|
||||||
|
"""Build WebSocket URL for the remote browser viewer."""
|
||||||
|
return f"/api/browser-sessions/{session_id}/ws?token={token}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions", response_model=CaptureSessionResponse, status_code=201)
|
||||||
|
async def create_capture_session(
|
||||||
|
body: CaptureSessionCreate,
|
||||||
|
_=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Create a temporary browser session pointing at the given URL.
|
||||||
|
|
||||||
|
Returns a session_id and ws_url for the frontend to view/interact.
|
||||||
|
The user should manually log in, then call GET /extract.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
session = await browser_sessions.create_ephemeral(
|
||||||
|
url=body.url,
|
||||||
|
width=body.width,
|
||||||
|
height=body.height,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise _browser_error(exc)
|
||||||
|
|
||||||
|
# Build a short-lived token for WS auth (reuse current user's token logic)
|
||||||
|
# The frontend already has the user's Bearer token, pass it via query param
|
||||||
|
return CaptureSessionResponse(
|
||||||
|
session_id=session.id,
|
||||||
|
ws_url=f"/api/browser-sessions/{session.id}/ws",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}/extract", response_model=CaptureExtractResponse)
|
||||||
|
async def extract_credentials(
|
||||||
|
session_id: str,
|
||||||
|
_=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Extract all auth credentials from the browser session.
|
||||||
|
|
||||||
|
Returns cookies, localStorage, sessionStorage, and curated candidates.
|
||||||
|
Candidate values are masked in logs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
session = browser_sessions.get_session(session_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "session not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await extract_all(session)
|
||||||
|
except Exception as exc:
|
||||||
|
raise _browser_error(exc)
|
||||||
|
|
||||||
|
return CaptureExtractResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sessions/{session_id}", status_code=204)
|
||||||
|
async def close_capture_session(
|
||||||
|
session_id: str,
|
||||||
|
_=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Close and release the auth-capture browser session."""
|
||||||
|
try:
|
||||||
|
await browser_sessions.close(session_id)
|
||||||
|
except Exception as exc:
|
||||||
|
raise _browser_error(exc)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Auth credential extraction from remote browser sessions."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_cookies(session: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Extract cookies from the browser context."""
|
||||||
|
cookies = await session.context.cookies()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": c.get("name", ""),
|
||||||
|
"value": c.get("value", ""),
|
||||||
|
"domain": c.get("domain", ""),
|
||||||
|
"httpOnly": c.get("httpOnly", False),
|
||||||
|
"secure": c.get("secure", False),
|
||||||
|
}
|
||||||
|
for c in cookies
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
return json.loads(raw)
|
||||||
|
return raw or {}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("localStorage extraction failed: %s", exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
return json.loads(raw)
|
||||||
|
return raw or {}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("sessionStorage extraction failed: %s", exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_all(session: Any) -> dict[str, Any]:
|
||||||
|
"""Extract all possible 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
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cookies": cookies,
|
||||||
|
"storage": local_storage,
|
||||||
|
"session_storage": session_storage,
|
||||||
|
"candidates": candidates,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _curate_candidates(
|
||||||
|
cookies: list[dict[str, Any]],
|
||||||
|
local_storage: dict[str, str],
|
||||||
|
session_storage: dict[str, str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Scan extracted data for likely bearer tokens and session cookies."""
|
||||||
|
candidates: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# 1. localStorage / sessionStorage items that look like tokens
|
||||||
|
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)")
|
||||||
|
|
||||||
|
# 2. Cookies that look like session/token cookies
|
||||||
|
cookie_keywords = ("session", "token", "jwt", "sid", "auth", "connect.sid", "gin_session", "tdc_itoken")
|
||||||
|
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"]})
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
def _add_candidate(
|
||||||
|
candidates: list[dict[str, Any]],
|
||||||
|
ctype: str,
|
||||||
|
source: str,
|
||||||
|
value: str,
|
||||||
|
label: str,
|
||||||
|
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)
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"type": ctype,
|
||||||
|
"source": source,
|
||||||
|
"value": value,
|
||||||
|
"label": label,
|
||||||
|
}
|
||||||
|
if extra:
|
||||||
|
entry.update(extra)
|
||||||
|
candidates.append(entry)
|
||||||
@@ -302,12 +302,15 @@ class BrowserSessionService:
|
|||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get(self, session_id: str) -> BrowserSession:
|
def get_session(self, session_id: str) -> BrowserSession:
|
||||||
|
"""Retrieve a session by id — raises KeyError if missing."""
|
||||||
session = self._sessions.get(session_id)
|
session = self._sessions.get(session_id)
|
||||||
if not session:
|
if not session:
|
||||||
raise KeyError("browser session not found")
|
raise KeyError("browser session not found")
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
_get = get_session # alias for internal use
|
||||||
|
|
||||||
def _ensure_open(self, session: BrowserSession) -> None:
|
def _ensure_open(self, session: BrowserSession) -> None:
|
||||||
if session.page.is_closed():
|
if session.page.is_closed():
|
||||||
self._discard_session(session.id)
|
self._discard_session(session.id)
|
||||||
@@ -332,5 +335,47 @@ class BrowserSessionService:
|
|||||||
safe_origin = re.sub(r"[^a-z0-9_.-]+", "_", origin).strip("_") or "page"
|
safe_origin = re.sub(r"[^a-z0-9_.-]+", "_", origin).strip("_") or "page"
|
||||||
return f"page-{custom_page_id}-{safe_origin[:80]}"
|
return f"page-{custom_page_id}-{safe_origin[:80]}"
|
||||||
|
|
||||||
|
async def create_ephemeral(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
width: int = 1280,
|
||||||
|
height: int = 720,
|
||||||
|
) -> BrowserSession:
|
||||||
|
"""Create a temporary browser session without a CustomPage record.
|
||||||
|
|
||||||
|
The session uses an isolated random-named profile so it never collides
|
||||||
|
with persistent custom-page profiles. Caller MUST close() when done.
|
||||||
|
"""
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
raise ValueError("Only http/https URLs are allowed")
|
||||||
|
width = max(320, min(width, 2560))
|
||||||
|
height = max(240, min(height, 1600))
|
||||||
|
async with self._lock:
|
||||||
|
await self._ensure_playwright()
|
||||||
|
session_id = uuid4().hex
|
||||||
|
profile_key = f"auth-capture-{session_id[:12]}"
|
||||||
|
context = await self._playwright.chromium.launch_persistent_context(
|
||||||
|
str(self._profile_dir(profile_key)),
|
||||||
|
headless=get_settings().browser_headless,
|
||||||
|
viewport={"width": width, "height": height},
|
||||||
|
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
||||||
|
)
|
||||||
|
page = context.pages[0] if context.pages else await context.new_page()
|
||||||
|
session = BrowserSession(
|
||||||
|
id=session_id,
|
||||||
|
custom_page_id=0,
|
||||||
|
profile_key=profile_key,
|
||||||
|
context=context,
|
||||||
|
page=page,
|
||||||
|
lock=asyncio.Lock(),
|
||||||
|
)
|
||||||
|
self._sessions[session.id] = session
|
||||||
|
try:
|
||||||
|
await page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||||
|
except Exception:
|
||||||
|
await self.close(session.id)
|
||||||
|
raise
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
browser_sessions = BrowserSessionService()
|
browser_sessions = BrowserSessionService()
|
||||||
|
|||||||
@@ -326,3 +326,38 @@ export const browserSessionsApi = {
|
|||||||
return `${proto}//${location.host}/api/browser-sessions/${id}/ws?${params.toString()}`
|
return `${proto}//${location.host}/api/browser-sessions/${id}/ws?${params.toString()}`
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ——— Auth Capture ———
|
||||||
|
export interface AuthCaptureSession {
|
||||||
|
session_id: string
|
||||||
|
ws_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthCaptureResult {
|
||||||
|
cookies: Record<string, any>[]
|
||||||
|
storage: Record<string, string>
|
||||||
|
session_storage: Record<string, string>
|
||||||
|
candidates: {
|
||||||
|
type: 'bearer_token' | 'cookie' | 'credential'
|
||||||
|
source: string
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
cookie_name?: string
|
||||||
|
cookie_value?: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authCaptureApi = {
|
||||||
|
createSession: (url: string, width?: number, height?: number) =>
|
||||||
|
api.post<AuthCaptureSession>('/api/auth-capture/sessions', { url, width, height }),
|
||||||
|
extract: (sessionId: string) =>
|
||||||
|
api.get<AuthCaptureResult>(`/api/auth-capture/sessions/${sessionId}/extract`),
|
||||||
|
closeSession: (sessionId: string) =>
|
||||||
|
api.delete(`/api/auth-capture/sessions/${sessionId}`),
|
||||||
|
wsUrl: (sessionId: string, token?: string) => {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (token) params.set('token', token)
|
||||||
|
return `${proto}//${location.host}/api/browser-sessions/${sessionId}/ws?${params.toString()}`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="🔐 远程浏览器认证提取"
|
||||||
|
width="900px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:before-close="handleClose"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div class="auth-capture-body">
|
||||||
|
<!-- Step 1: URL + Launch -->
|
||||||
|
<div v-if="!sessionId" class="capture-step">
|
||||||
|
<h4>步骤 1:输入目标登录页面地址</h4>
|
||||||
|
<el-form @submit.prevent="launchBrowser">
|
||||||
|
<el-form-item label="登录页 URL">
|
||||||
|
<el-input v-model="targetUrl" placeholder="https://example.com/auth/login" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" :loading="launching" @click="launchBrowser">
|
||||||
|
<el-icon><Pointer /></el-icon>
|
||||||
|
打开远程浏览器
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Browser viewer + manual login -->
|
||||||
|
<div v-else-if="sessionId && !extracted" class="capture-step">
|
||||||
|
<div class="capture-step-header">
|
||||||
|
<h4>步骤 2:在浏览器中手动登录</h4>
|
||||||
|
<div class="capture-actions">
|
||||||
|
<el-button size="small" :loading="extracting" type="primary" @click="extractCredentials">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
提取认证信息
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" @click="handleClose">关闭</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="capture-hint">完成登录后,点击「提取认证信息」获取 token / cookie。</p>
|
||||||
|
<div class="browser-viewport">
|
||||||
|
<img :src="screenshotUrl" alt="browser preview" class="browser-frame" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Results -->
|
||||||
|
<div v-else class="capture-step">
|
||||||
|
<h4>提取结果</h4>
|
||||||
|
<p class="capture-hint" v-if="result && result.candidates.length === 0">
|
||||||
|
未找到认证凭据。请确认已成功登录,或重新尝试。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="result" class="candidate-list">
|
||||||
|
<div
|
||||||
|
v-for="(c, i) in result.candidates"
|
||||||
|
:key="i"
|
||||||
|
class="candidate-card"
|
||||||
|
:class="{ selected: selectedIndex === i }"
|
||||||
|
@click="selectedIndex = i"
|
||||||
|
>
|
||||||
|
<div class="candidate-radio">
|
||||||
|
<el-radio :model-value="selectedIndex === i" :label="i" @click="selectedIndex = i">
|
||||||
|
<span class="candidate-type-badge" :class="c.type">{{ c.type === 'bearer_token' ? 'Bearer' : 'Cookie' }}</span>
|
||||||
|
<span class="candidate-label">{{ c.label }}</span>
|
||||||
|
</el-radio>
|
||||||
|
</div>
|
||||||
|
<div class="candidate-value">
|
||||||
|
<code>{{ maskValue(c.value) }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="capture-step-footer">
|
||||||
|
<el-button @click="resetSession">重新提取</el-button>
|
||||||
|
<el-button type="primary" :disabled="selectedIndex < 0" @click="confirmSelection">
|
||||||
|
填入当前表单
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Pointer, Search } from '@element-plus/icons-vue'
|
||||||
|
import { authCaptureApi, type AuthCaptureResult } from '@/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
initialUrl?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', v: boolean): void
|
||||||
|
(e: 'select', candidate: { type: string; value: string; source: string; cookie_name?: string; cookie_value?: string }): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const visible = ref(props.modelValue)
|
||||||
|
watch(() => props.modelValue, (v) => { visible.value = v })
|
||||||
|
|
||||||
|
const targetUrl = ref(props.initialUrl || '')
|
||||||
|
watch(() => props.initialUrl, (v) => { if (v) targetUrl.value = v })
|
||||||
|
|
||||||
|
const sessionId = ref('')
|
||||||
|
const launching = ref(false)
|
||||||
|
const extracting = ref(false)
|
||||||
|
const extracted = ref(false)
|
||||||
|
const result = ref<AuthCaptureResult | null>(null)
|
||||||
|
const selectedIndex = ref(-1)
|
||||||
|
const screenshotUrl = ref('')
|
||||||
|
|
||||||
|
async function launchBrowser() {
|
||||||
|
if (!targetUrl.value) return
|
||||||
|
launching.value = true
|
||||||
|
try {
|
||||||
|
const res = await authCaptureApi.createSession(targetUrl.value)
|
||||||
|
sessionId.value = res.data.session_id
|
||||||
|
// Build screenshot URL with auth token
|
||||||
|
const token = auth.token
|
||||||
|
screenshotUrl.value = `/api/browser-sessions/${sessionId.value}/screenshot?token=${token}&t=${Date.now()}`
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('launch failed', e)
|
||||||
|
// fallback
|
||||||
|
} finally {
|
||||||
|
launching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractCredentials() {
|
||||||
|
if (!sessionId.value) return
|
||||||
|
extracting.value = true
|
||||||
|
try {
|
||||||
|
const res = await authCaptureApi.extract(sessionId.value)
|
||||||
|
result.value = res.data
|
||||||
|
extracted.value = true
|
||||||
|
selectedIndex.value = res.data.candidates.length === 1 ? 0 : -1
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('extract failed', e)
|
||||||
|
} finally {
|
||||||
|
extracting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmSelection() {
|
||||||
|
if (selectedIndex.value < 0 || !result.value) return
|
||||||
|
const c = result.value.candidates[selectedIndex.value]
|
||||||
|
emit('select', {
|
||||||
|
type: c.type,
|
||||||
|
value: c.type === 'cookie' ? (c.cookie_value || c.value) : c.value,
|
||||||
|
source: c.source,
|
||||||
|
cookie_name: c.cookie_name,
|
||||||
|
cookie_value: c.cookie_value,
|
||||||
|
})
|
||||||
|
closeDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSession() {
|
||||||
|
extracted.value = false
|
||||||
|
result.value = null
|
||||||
|
selectedIndex.value = -1
|
||||||
|
// Keep existing session — user can try extracting again
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClose() {
|
||||||
|
if (sessionId.value) {
|
||||||
|
try { await authCaptureApi.closeSession(sessionId.value) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
closeDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
visible.value = false
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskValue(v: string): string {
|
||||||
|
if (v.length <= 8) return '***'
|
||||||
|
return v.slice(0, 6) + '…' + v.slice(-4)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-capture-body {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
.capture-step {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.capture-step-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.capture-step-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.capture-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.capture-hint {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 4px 0 12px;
|
||||||
|
}
|
||||||
|
.browser-viewport {
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.browser-frame {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 480px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.candidate-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.candidate-card {
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.candidate-card:hover,
|
||||||
|
.candidate-card.selected {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
.candidate-radio {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.candidate-type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
.candidate-type-badge.bearer_token {
|
||||||
|
background: #e6f7ff;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
.candidate-type-badge.cookie {
|
||||||
|
background: #fff7e6;
|
||||||
|
color: #d48806;
|
||||||
|
}
|
||||||
|
.candidate-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
.candidate-value code {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.capture-step-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -240,7 +240,13 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<template v-if="form.auth_type === 'bearer'">
|
<template v-if="form.auth_type === 'bearer'">
|
||||||
<el-form-item label="Bearer Token">
|
<el-form-item label="Bearer Token">
|
||||||
|
<div class="auth-field-row">
|
||||||
<el-input v-model="form.auth_config.token" type="password" show-password placeholder="***" />
|
<el-input v-model="form.auth_config.token" type="password" show-password placeholder="***" />
|
||||||
|
<el-button size="small" @click="openAuthCapture">
|
||||||
|
<el-icon><Pointer /></el-icon>
|
||||||
|
提取
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="form.auth_type === 'api_key'">
|
<template v-else-if="form.auth_type === 'api_key'">
|
||||||
@@ -388,6 +394,12 @@
|
|||||||
<el-button size="small" :disabled="snapshots.length < snapshotLimit" @click="nextSnapPage">下一页</el-button>
|
<el-button size="small" :disabled="snapshots.length < snapshotLimit" @click="nextSnapPage">下一页</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
|
<AuthCaptureDialog
|
||||||
|
v-model="authCaptureVisible"
|
||||||
|
:initial-url="form.base_url"
|
||||||
|
@select="handleAuthCaptureSelect"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -396,8 +408,9 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { FormInstance } from 'element-plus'
|
import type { FormInstance } from 'element-plus'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Refresh, Plus, Edit, List, Delete, Warning, Clock, ArrowRight } from '@element-plus/icons-vue'
|
import { Refresh, Plus, Edit, List, Delete, Warning, Clock, ArrowRight, Pointer } from '@element-plus/icons-vue'
|
||||||
import { upstreamsApi, type UpstreamData } from '@/api'
|
import { upstreamsApi, type UpstreamData } from '@/api'
|
||||||
|
import AuthCaptureDialog from '@/components/AuthCaptureDialog.vue'
|
||||||
|
|
||||||
const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([])
|
const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([])
|
||||||
const tableLoading = ref(false)
|
const tableLoading = ref(false)
|
||||||
@@ -425,6 +438,22 @@ const rules = {
|
|||||||
base_url: [{ required: true, message: '请输入 Base URL', trigger: 'blur' }],
|
base_url: [{ required: true, message: '请输入 Base URL', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authCaptureVisible = ref(false)
|
||||||
|
|
||||||
|
function openAuthCapture() {
|
||||||
|
authCaptureVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthCaptureSelect(candidate: { type: string; value: string; cookie_name?: string; cookie_value?: string }) {
|
||||||
|
if (candidate.type === 'bearer_token') {
|
||||||
|
form.value.auth_config.token = candidate.value
|
||||||
|
} else if (candidate.type === 'cookie') {
|
||||||
|
// For cookie auth, store as a formatted cookie string
|
||||||
|
form.value.auth_config.token = candidate.value
|
||||||
|
}
|
||||||
|
ElMessage.success('已填入认证信息')
|
||||||
|
}
|
||||||
|
|
||||||
const quickPlatform = ref('sub2api')
|
const quickPlatform = ref('sub2api')
|
||||||
|
|
||||||
function handlePlatformChange(val: string) {
|
function handlePlatformChange(val: string) {
|
||||||
@@ -649,6 +678,18 @@ onMounted(loadList)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.auth-field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.auth-field-row .el-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.auth-field-row .el-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.upstreams-page {
|
.upstreams-page {
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user