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:
@@ -326,3 +326,38 @@ export const browserSessionsApi = {
|
||||
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>
|
||||
<template v-if="form.auth_type === 'bearer'">
|
||||
<el-form-item label="Bearer Token">
|
||||
<el-input v-model="form.auth_config.token" type="password" show-password placeholder="***" />
|
||||
<div class="auth-field-row">
|
||||
<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>
|
||||
</template>
|
||||
<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>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<AuthCaptureDialog
|
||||
v-model="authCaptureVisible"
|
||||
:initial-url="form.base_url"
|
||||
@select="handleAuthCaptureSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -396,8 +408,9 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
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 AuthCaptureDialog from '@/components/AuthCaptureDialog.vue'
|
||||
|
||||
const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([])
|
||||
const tableLoading = ref(false)
|
||||
@@ -425,6 +438,22 @@ const rules = {
|
||||
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')
|
||||
|
||||
function handlePlatformChange(val: string) {
|
||||
@@ -649,6 +678,18 @@ onMounted(loadList)
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user