feat: auth capture — interactive browser, CDP header capture, cookie auth

- 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
This commit is contained in:
SmartUp Developer
2026-05-18 11:44:10 +08:00
parent 4d1237c58f
commit 08c855677a
6 changed files with 495 additions and 132 deletions
+3
View File
@@ -337,11 +337,14 @@ export interface AuthCaptureResult {
cookies: Record<string, any>[]
storage: Record<string, string>
session_storage: Record<string, string>
auth_headers: Record<string, string>[]
candidates: {
type: 'bearer_token' | 'cookie' | 'credential'
source: string
value: string
preview: string
label: string
confidence: number
cookie_name?: string
cookie_value?: string
}[]
+336 -85
View File
@@ -2,7 +2,7 @@
<el-dialog
v-model="visible"
title="🔐 远程浏览器认证提取"
width="900px"
width="960px"
:close-on-click-modal="false"
:before-close="handleClose"
destroy-on-close
@@ -15,73 +15,138 @@
<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>
<div class="capture-extra-fields" v-if="showExtraFields">
<el-form-item label="登录账号">
<el-input v-model="loginUsername" placeholder="用于自动填充(可选)" />
</el-form-item>
<el-form-item label="登录密码">
<el-input v-model="loginPassword" type="password" placeholder="用于自动填充(可选)" />
</el-form-item>
<el-form-item label="用户名选择器">
<el-input v-model="usernameSelector" placeholder="input[name=email]" />
</el-form-item>
<el-form-item label="密码选择器">
<el-input v-model="passwordSelector" placeholder="input[name=password]" />
</el-form-item>
<el-form-item label="提交按钮选择器">
<el-input v-model="submitSelector" placeholder="button[type=submit]" />
</el-form-item>
</div>
<div class="capture-launch-row">
<el-button text size="small" @click="showExtraFields = !showExtraFields">
{{ showExtraFields ? '收起' : '自动填充选项' }}
</el-button>
<el-button type="primary" :loading="launching" @click="launchBrowser">
<el-icon><Pointer /></el-icon>
打开远程浏览器
</el-button>
</div>
</el-form>
</div>
<!-- Step 2: Browser viewer + manual login -->
<div v-else-if="sessionId && !extracted" class="capture-step">
<!-- Step 2: Interactive browser + manual login -->
<div v-else class="capture-step">
<div class="capture-step-header">
<h4>步骤 2在浏览器中手动登录</h4>
<div class="capture-actions">
<el-button size="small" @click="sendEvent('back')" :disabled="!sessionId">
<el-icon><Back /></el-icon>
</el-button>
<el-button size="small" @click="sendEvent('forward')" :disabled="!sessionId">
<el-icon><Right /></el-icon>
</el-button>
<el-button size="small" @click="sendEvent('reload')" :disabled="!sessionId">
<el-icon><Refresh /></el-icon>
</el-button>
<el-divider direction="vertical" />
<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>
<p class="capture-hint">在下方浏览器中完成登录后点击提取认证信息获取 token / cookie</p>
<!-- 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>
<!-- Interactive browser frame -->
<div
ref="browserFrameRef"
class="browser-viewport"
tabindex="0"
@keydown.prevent="onKeydown"
@wheel.prevent="onWheel"
@pointerdown.stop.prevent="onPointerDown"
@pointermove.stop.prevent="onPointerMove"
@pointerup.stop.prevent="onPointerUp"
@pointercancel.stop.prevent="onPointerCancel"
@dblclick.prevent="onDblClick"
@contextmenu.prevent
>
<img
v-if="screenshotUrl"
:src="screenshotUrl"
class="browser-frame"
alt="远程浏览器"
draggable="false"
@load="onScreenshotLoaded"
@error="onScreenshotError"
/>
<div v-else class="browser-loading">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
<p>正在启动浏览器</p>
</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>
<!-- Extraction results (overlay after extract) -->
<transition name="el-zoom-in-top">
<div v-if="extracted && result" class="candidate-panel">
<div class="candidate-panel-header">
<span>提取到 {{ result.candidates.length }} 个认证凭据</span>
<el-button size="small" text @click="resetExtract">重新提取</el-button>
</div>
<div v-if="result.candidates.length === 0" class="candidate-empty">
未找到认证凭据请确认已成功登录后重试
</div>
<div v-else 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-row">
<el-radio :model-value="selectedIndex === i" :label="i" @click.stop="selectedIndex = i">
<span class="candidate-badge" :class="c.type || 'credential'">
{{ c.type === 'bearer_token' ? 'Bearer' : c.type === 'cookie' ? 'Cookie' : 'Key' }}
</span>
<span class="candidate-label">{{ c.label }}</span>
</el-radio>
<span v-if="c.confidence" class="candidate-confidence" :class="confidenceClass(c.confidence)">
{{ c.confidence }}%
</span>
</div>
<div class="candidate-preview">
<code>{{ maskValue(c.preview || c.value) }}</code>
</div>
</div>
</div>
<div class="candidate-actions">
<el-button size="small" @click="handleClose">关闭</el-button>
<el-button size="small" type="primary" :disabled="selectedIndex < 0" @click="confirmSelection">
填入当前表单
</el-button>
</div>
</div>
</transition>
</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 { ref, watch, nextTick } from 'vue'
import { Pointer, Search, Back, Right, Refresh, Loading } from '@element-plus/icons-vue'
import { authCaptureApi, browserSessionsApi, type AuthCaptureResult } from '@/api'
import { useAuthStore } from '@/stores/auth'
const props = defineProps<{
@@ -95,13 +160,21 @@ const emit = defineEmits<{
}>()
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 })
// Auto-fill fields
const showExtraFields = ref(false)
const loginUsername = ref('')
const loginPassword = ref('')
const usernameSelector = ref('input[name="email"],input[name="username"],input[type="email"]')
const passwordSelector = ref('input[name="password"],input[type="password"]')
const submitSelector = ref('button[type="submit"],input[type="submit"]')
// Session
const sessionId = ref('')
const launching = ref(false)
const extracting = ref(false)
@@ -109,6 +182,11 @@ const extracted = ref(false)
const result = ref<AuthCaptureResult | null>(null)
const selectedIndex = ref(-1)
const screenshotUrl = ref('')
const browserFrameRef = ref<HTMLElement | null>(null)
// Pointer tracking for mouse events
const pointerPos = ref({ x: 0, y: 0 })
const imgNaturalSize = ref({ w: 0, h: 0 })
async function launchBrowser() {
if (!targetUrl.value) return
@@ -116,17 +194,121 @@ async function launchBrowser() {
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()}`
screenshotUrl.value = buildScreenshotUrl()
// Auto-fill login form after page loads
if (loginUsername.value || loginPassword.value) {
setTimeout(() => {
if (loginUsername.value) {
browserSessionsApi.event(sessionId.value, {
type: 'type',
text: loginUsername.value,
})
}
// Focus password field via tab
setTimeout(() => {
if (loginPassword.value) {
browserSessionsApi.event(sessionId.value, {
type: 'type',
text: loginPassword.value,
})
}
}, 300)
}, 2000)
}
} catch (e: any) {
console.error('launch failed', e)
// fallback
} finally {
launching.value = false
}
}
function buildScreenshotUrl() {
const token = auth.token
return browserSessionsApi.screenshotUrl(sessionId.value, token) + '&t=' + Date.now()
}
function refreshScreenshot() {
screenshotUrl.value = buildScreenshotUrl()
}
let screenshotLoading = false
function onScreenshotLoaded() {
screenshotLoading = false
// Trigger next refresh after a short interval if still in browser view
if (sessionId.value && !extracted.value) {
setTimeout(refreshScreenshot, 500)
}
}
function onScreenshotError() {
screenshotLoading = false
if (sessionId.value && !extracted.value) {
setTimeout(refreshScreenshot, 1500)
}
}
// ——— Event forwarding ———
async function sendEvent(type: string, extra: Record<string, any> = {}) {
if (!sessionId.value) return
try {
const eventData: any = { type, ...extra }
await browserSessionsApi.event(sessionId.value, eventData)
refreshScreenshot()
} catch (e) {
console.error('event failed', e)
}
}
function scalePointer(e: PointerEvent) {
const el = browserFrameRef.value
if (!el) return { x: e.clientX, y: e.clientY }
const rect = el.getBoundingClientRect()
// Scale from display size to natural image size
const scaleX = imgNaturalSize.value.w / rect.width
const scaleY = imgNaturalSize.value.h / rect.height
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY,
}
}
function onPointerDown(e: PointerEvent) {
const p = scalePointer(e)
pointerPos.value = p
sendEvent('click', { x: p.x, y: p.y, button: e.button === 2 ? 'right' : 'left' })
}
function onPointerMove(e: PointerEvent) {
const p = scalePointer(e)
pointerPos.value = p
}
function onPointerUp(e: PointerEvent) {
pointerPos.value = scalePointer(e)
}
function onPointerCancel() { /* no-op */ }
function onDblClick(e: MouseEvent) {
const p = scalePointer(e as unknown as PointerEvent)
sendEvent('dblclick', { x: p.x, y: p.y })
}
function onWheel(e: WheelEvent) {
sendEvent('scroll', { delta_x: e.deltaX, delta_y: e.deltaY, x: pointerPos.value.x, y: pointerPos.value.y })
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
sendEvent('key', { key: 'Enter' })
} else if (e.key === 'Backspace') {
sendEvent('key', { key: 'Backspace' })
} else if (e.key === 'Escape') {
sendEvent('key', { key: 'Escape' })
} else if (e.key === 'Tab') {
sendEvent('key', { key: 'Tab' })
} else if (e.key.length === 1) {
sendEvent('type', { text: e.key })
}
}
// ——— Extraction ———
async function extractCredentials() {
if (!sessionId.value) return
extracting.value = true
@@ -155,11 +337,11 @@ function confirmSelection() {
closeDialog()
}
function resetSession() {
function resetExtract() {
extracted.value = false
result.value = null
selectedIndex.value = -1
// Keep existing session — user can try extracting again
refreshScreenshot()
}
async function handleClose() {
@@ -175,98 +357,167 @@ function closeDialog() {
}
function maskValue(v: string): string {
if (v.length <= 8) return '***'
return v.slice(0, 6) + '…' + v.slice(-4)
if (!v || v.length <= 8) return '***'
if (v.length <= 16) return v.slice(0, 4) + '…' + v.slice(-4)
return v.slice(0, 8) + '…' + v.slice(-6)
}
function confidenceClass(score: number): string {
if (score >= 80) return 'conf-high'
if (score >= 50) return 'conf-mid'
return 'conf-low'
}
</script>
<style scoped>
.auth-capture-body {
min-height: 300px;
min-height: 350px;
}
.capture-step {
padding: 8px 0;
padding: 4px 0;
}
.capture-step-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 8px;
}
.capture-step-header h4 {
margin: 0;
}
.capture-actions {
display: flex;
gap: 8px;
gap: 6px;
align-items: center;
}
.capture-hint {
color: var(--el-text-color-secondary);
font-size: 0.85rem;
margin: 4px 0 12px;
margin: 0 0 10px;
}
.capture-extra-fields {
margin-top: 8px;
padding: 8px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.capture-launch-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
}
.browser-viewport {
border: 1px solid var(--el-border-color);
border-radius: 8px;
overflow: hidden;
background: #000;
cursor: crosshair;
outline: none;
position: relative;
min-height: 300px;
}
.browser-frame {
display: block;
width: 100%;
height: auto;
max-height: 480px;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
}
.candidate-list {
.browser-loading {
display: flex;
flex-direction: column;
gap: 8px;
margin: 12px 0;
align-items: center;
justify-content: center;
min-height: 300px;
color: var(--el-text-color-secondary);
gap: 12px;
}
.candidate-card {
/* Candidate panel */
.candidate-panel {
margin-top: 12px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
padding: 10px 12px;
overflow: hidden;
}
.candidate-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--el-fill-color-light);
font-size: 0.85rem;
font-weight: 600;
}
.candidate-empty {
padding: 24px;
text-align: center;
color: var(--el-text-color-secondary);
font-size: 0.85rem;
}
.candidate-list {
max-height: 280px;
overflow-y: auto;
}
.candidate-card {
padding: 8px 12px;
cursor: pointer;
transition: border-color 0.15s;
border-bottom: 1px solid var(--el-border-color-light);
transition: background 0.1s;
}
.candidate-card:last-child {
border-bottom: none;
}
.candidate-card:hover,
.candidate-card.selected {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.candidate-radio {
margin-bottom: 4px;
.candidate-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2px;
}
.candidate-type-badge {
.candidate-badge {
display: inline-block;
padding: 0 6px;
border-radius: 4px;
font-size: 0.75rem;
font-size: 0.72rem;
font-weight: 600;
margin-right: 6px;
}
.candidate-type-badge.bearer_token {
background: #e6f7ff;
color: #1890ff;
}
.candidate-type-badge.cookie {
background: #fff7e6;
color: #d48806;
}
.candidate-badge.bearer_token { background: #e6f7ff; color: #1890ff; }
.candidate-badge.cookie { background: #fff7e6; color: #d48806; }
.candidate-badge.credential { background: #f6ffed; color: #52c41a; }
.candidate-label {
font-size: 0.85rem;
font-size: 0.82rem;
color: var(--el-text-color-secondary);
}
.candidate-value code {
font-size: 0.8rem;
.candidate-confidence {
font-size: 0.75rem;
font-weight: 600;
padding: 1px 6px;
border-radius: 8px;
}
.conf-high { background: #f6ffed; color: #52c41a; }
.conf-mid { background: #fff7e6; color: #d48806; }
.conf-low { background: #fff2f0; color: #ff4d4f; }
.candidate-preview {
margin-left: 24px;
}
.candidate-preview code {
font-size: 0.78rem;
color: var(--el-text-color-regular);
word-break: break-all;
}
.capture-step-footer {
.candidate-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
padding: 8px 12px;
background: var(--el-fill-color-light);
}
</style>
+19 -4
View File
@@ -234,6 +234,7 @@
<el-select v-model="form.auth_type" style="width: 100%">
<el-option label="无认证" value="none" />
<el-option label="Bearer Token" value="bearer" />
<el-option label="Cookie" value="cookie" />
<el-option label="API Key" value="api_key" />
<el-option label="邮箱密码登录" value="login_password" />
</el-select>
@@ -249,6 +250,17 @@
</div>
</el-form-item>
</template>
<template v-else-if="form.auth_type === 'cookie'">
<el-form-item label="Cookie">
<div class="auth-field-row">
<el-input v-model="form.auth_config.cookie_string" type="password" show-password placeholder="name=value; name2=value2" />
<el-button size="small" @click="openAuthCapture">
<el-icon><Pointer /></el-icon>
提取
</el-button>
</div>
</el-form-item>
</template>
<template v-else-if="form.auth_type === 'api_key'">
<el-form-item label="API Key">
<el-input v-model="form.auth_config.key" type="password" show-password placeholder="***" />
@@ -446,12 +458,15 @@ function openAuthCapture() {
function handleAuthCaptureSelect(candidate: { type: string; value: string; cookie_name?: string; cookie_value?: string }) {
if (candidate.type === 'bearer_token') {
form.value.auth_type = 'bearer'
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
form.value.auth_type = 'cookie'
form.value.auth_config.cookie_string = candidate.cookie_name && candidate.cookie_value
? `${candidate.cookie_name}=${candidate.cookie_value}`
: candidate.value
}
ElMessage.success('已填入认证信息')
ElMessage.success(`已填入${candidate.type === 'cookie' ? 'Cookie' : 'Bearer Token'}认证信息`)
}
const quickPlatform = ref('sub2api')
@@ -521,7 +536,7 @@ const recentChecks = computed(() =>
)
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', cookie: 'Cookie', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : `${t}Z`
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm:ss')
const fmtTimeFull = (t: string) => dayjs(toUTC(t)).format('YYYY-MM-DD HH:mm:ss')