feat: 全面优化 — UI/UX重构 + 流量审计与会话管理 + 协议部署

UI/UX 优化:
- 登录页分屏布局 + 可访问性修复 + 内联错误提示
- 侧栏菜单分组(专线运维/资源管理/系统) + 图标修正 + 子路由高亮
- Dashboard 统计卡渐变图标 + 空状态引导 + 统计卡可点击
- 创建向导: 依赖检测门控 + 高级折叠 + 必填校验 + WireGuard推荐标
- 专线列表: 空状态CTA + 操作列「更多」下拉 + 状态筛选 + SSE日志流
- 协议品牌SVG图标(ProtocolIcon组件)替换emoji
- 设计令牌系统(tokens.css)统一颜色/间距/圆角/阴影

功能增强:
- 在线会话管理(sessionmgr): 跨协议会话同步 + 踢下线 + 连接审计日志
- 流量配额引擎(trafficmgr): 配额检查 + 超额动作(block/warn/throttle) + 周期重置
- 异常流量告警: 突发流量检测(>90MB/15s) + 审计记录
- 节点SSH凭证管理: 密码/私钥认证 + 即时测试连接 + AES-GCM加密存储
- 协议二进制管理(binmgr): FRP/OpenVPN GitHub下载 + 系统包安装引导(apt/yum/dnf)
- 右上角修改密码弹窗 + MFA独立验证页 + 密码强度提示
- 用户管理: 批量导入 + LDAP浏览导入 + 平台用户导入VPN拨号用户
- 404路由 + IPSec对接页可达性修复
- MFA备用码持久化弹窗(替代Toast)
- 轮询可见性守卫 + ECharts resize修复

后端:
- 3张新表: VPNSession/ConnectionLog/TrafficPolicy
- monitor增强: 会话同步+配额检查+异常告警+到期审计
- changePassword统一8位+新旧不同校验
- openvpn-otp文案专业化(移除抄袭感)
- SSE日志流(token降级query参数)
This commit is contained in:
Logan.Li committed 2026-07-23 01:42:36 +08:00
1 parent 58c036c34c
commit 1e2f8ef24f
43 files changed
+6783 -460

No files matched your search

+406 -18
View File
@@ -1,29 +1,417 @@
<template>
<el-card class="page-card" shadow="never">
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><div><b>VPN 拨号用户</b><span style="margin-left:8px;color:#909399;font-size:12px">本地密码或 LDAP 身份,按 OpenVPN 用户统计流量</span></div><el-button type="primary" @click="openCreate">添加用户</el-button></div></template>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<b>VPN 拨号用户</b>
<span style="margin-left:8px;color:#909399;font-size:12px">
OpenVPN 双因素认证(TOTP / 静态挑战 / PIN+OTP)
</span>
</div>
<el-button type="primary" @click="openCreate">添加用户</el-button>
</div>
</template>
<el-table :data="users" size="small">
<el-table-column prop="username" label="用户名" /><el-table-column prop="display_name" label="姓名" /><el-table-column label="隧道"><template #default="{row}">{{ tunnelName(row.tunnel_id) }}</template></el-table-column>
<el-table-column prop="source" label="来源" width="90" /><el-table-column label="流量 ↓ / ↑" width="170"><template #default="{row}">{{ bytes(row.rx_bytes) }} / {{ bytes(row.tx_bytes) }}</template></el-table-column>
<el-table-column label="最近在线" width="180"><template #default="{row}">{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString('zh-CN') : '-' }}</template></el-table-column>
<el-table-column label="状态" width="90"><template #default="{row}"><el-tag :type="row.enabled ? 'success':'info'" size="small">{{ row.enabled ? '启用':'禁用' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="180"><template #default="{row}"><el-button link size="small" @click="download(row)">下载配置</el-button><el-button link size="small" @click="toggle(row)">{{ row.enabled ? '禁用':'启用' }}</el-button><el-button type="danger" link size="small" @click="remove(row)">删除</el-button></template></el-table-column>
<el-table-column prop="username" label="用户名" />
<el-table-column prop="display_name" label="姓名" />
<el-table-column label="隧道" min-width="160">
<template #default="{ row }">
{{ tunnelName(row.tunnel_id) }}
<el-tag size="small" style="margin-left:6px" type="info">{{ tunnelProto(row.tunnel_id) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="source" label="来源" width="90" />
<el-table-column label="OTP / MFA" width="130">
<template #default="{ row }">
<template v-if="isOpenVPN(row.tunnel_id)">
<el-tag :type="row.mfa_enabled ? 'success' : 'info'" size="small">
{{ row.mfa_enabled ? '已绑定' : '未绑定' }}
</el-tag>
<el-tag v-if="row.mfa_has_pin" size="small" type="warning" style="margin-left:4px">PIN</el-tag>
<el-tag v-if="isLocked(row)" size="small" type="danger" style="margin-left:4px">锁定</el-tag>
</template>
<span v-else style="color:#c0c4cc">—</span>
</template>
</el-table-column>
<el-table-column label="流量 ↓ / ↑" width="170">
<template #default="{ row }">{{ bytes(row.rx_bytes) }} / {{ bytes(row.tx_bytes) }}</template>
</el-table-column>
<el-table-column label="最近在线" width="180">
<template #default="{ row }">{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString('zh-CN') : '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280">
<template #default="{ row }">
<el-button link size="small" @click="download(row)">下载配置</el-button>
<el-button v-if="isOpenVPN(row.tunnel_id)" link size="small" type="primary" @click="openOTPManager(row)">OTP 管理</el-button>
<el-button link size="small" @click="toggle(row)">{{ row.enabled ? '禁用' : '启用' }}</el-button>
<el-button type="danger" link size="small" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="visible" title="添加 VPN 拨号用户" width="480px"><el-form label-width="100px"><el-form-item label="OpenVPN 隧道" required><el-select v-model="form.tunnel_id" style="width:100%"><el-option v-for="t in tunnels" :key="t.id" :value="t.id" :label="t.name" /></el-select></el-form-item><el-form-item label="用户名" required><el-input v-model="form.username" /></el-form-item><el-form-item label="显示名"><el-input v-model="form.display_name" /></el-form-item><el-form-item label="认证来源"><el-radio-group v-model="form.source"><el-radio-button value="local">本地</el-radio-button><el-radio-button value="ldap">LDAP</el-radio-button></el-radio-group></el-form-item><el-form-item v-if="form.source==='local'" label="密码" required><el-input v-model="form.password" type="password" show-password placeholder="至少 8 位" /></el-form-item><el-form-item v-else label="LDAP DN" required><el-input v-model="form.ldap_dn" placeholder="uid=user,ou=people,dc=example,dc=com" /></el-form-item><el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item></el-form><template #footer><el-button @click="visible=false">取消</el-button><el-button type="primary" @click="create">创建</el-button></template></el-dialog>
<el-dialog v-model="visible" title="添加 VPN 拨号用户" width="480px">
<el-form label-width="100px">
<el-form-item label="拨号隧道" required>
<el-select v-model="form.tunnel_id" style="width:100%" filterable>
<el-option v-for="t in tunnels" :key="t.id" :value="t.id"
:label="`${t.name} (${protocolDisplay(t.protocol)})`" />
</el-select>
</el-form-item>
<el-form-item label="用户名" required>
<el-input v-model="form.username">
<template #append>
<el-button @click="openPlatformPicker">从已有用户选</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="显示名"><el-input v-model="form.display_name" /></el-form-item>
<el-form-item label="认证来源">
<el-radio-group v-model="form.source">
<el-radio-button value="local">本地</el-radio-button>
<el-radio-button value="ldap">LDAP</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.source === 'local'" label="密码" required>
<el-input v-model="form.password" type="password" show-password placeholder="至少 8 位" />
</el-form-item>
<el-form-item v-else label="LDAP DN" required>
<el-input v-model="form.ldap_dn" placeholder="uid=user,ou=people,dc=example,dc=com" />
</el-form-item>
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="create">创建</el-button>
</template>
</el-dialog>
<!-- 从平台用户选择对话框 -->
<el-dialog v-model="pickerVisible" title="选择已有平台用户" width="560px" align-center>
<el-table :data="platformUsers" size="small" max-height="360" v-loading="pickerLoading"
@row-click="pickPlatformUser">
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="email" label="邮箱" min-width="160" />
<el-table-column label="来源" width="80">
<template #default="{ row }">
<el-tag :type="row.source === 'ldap' ? 'primary' : 'info'" size="small">{{ row.source === 'ldap' ? 'LDAP' : '本地' }}</el-tag>
</template>
</el-table-column>
<template #empty><el-empty description="暂无平台用户" /></template>
</el-table>
<p style="text-align:center;color:var(--text-secondary);font-size:12px;margin-top:8px">点击行即选中并自动填充</p>
</el-dialog>
<!-- OTP 双因素认证管理 -->
<el-dialog v-model="otpVisible" :title="`双因素认证 — ${otpUser?.username || ''}`" width="520px">
<el-alert type="info" :closable="false" style="margin-bottom:12px"
title="基于 TOTP 的双因素认证(SHA1 / base32 / 30s / 6 位),兼容主流 Authenticator 应用。支持静态挑战二次输入与 PIN+OTP 组合模式。" />
<el-descriptions :column="1" border size="small" v-if="otpStatus">
<el-descriptions-item label="绑定状态">
<el-tag :type="otpStatus.mfa_enabled ? 'success' : 'info'" size="small">
{{ otpStatus.mfa_enabled ? '已绑定 TOTP' : '未绑定' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="静态 PIN">{{ otpStatus.mfa_has_pin ? '已设置' : '未设置' }}</el-descriptions-item>
<el-descriptions-item label="失败次数">{{ otpStatus.mfa_fail_count || 0 }}</el-descriptions-item>
<el-descriptions-item label="锁定">
<span v-if="otpStatus.mfa_locked" style="color:#f56c6c">
是(至 {{ otpStatus.mfa_locked_until ? new Date(otpStatus.mfa_locked_until).toLocaleString('zh-CN') : '' }})
</span>
<span v-else>否</span>
</el-descriptions-item>
<el-descriptions-item label="最近 OTP 成功">
{{ otpStatus.mfa_last_used_at ? new Date(otpStatus.mfa_last_used_at).toLocaleString('zh-CN') : '—' }}
</el-descriptions-item>
<el-descriptions-item label="兼容">{{ otpStatus.compatible }}</el-descriptions-item>
</el-descriptions>
<div style="margin-top:16px;display:flex;flex-wrap:wrap;gap:8px">
<el-button v-if="!otpStatus?.mfa_enabled" type="primary" @click="setupMFA">绑定 Authenticator</el-button>
<el-button v-else type="danger" plain @click="disableMFA">关闭 MFA</el-button>
<el-button v-if="otpStatus?.mfa_enabled" @click="regenBackup">重新生成备用码</el-button>
<el-button v-if="otpStatus?.mfa_enabled" @click="showPinDialog">设置/清除 PIN</el-button>
<el-button v-if="otpStatus?.mfa_locked" type="warning" @click="unlockMFA">解锁</el-button>
<el-button v-if="otpStatus?.mfa_enabled" @click="exportOtpSecrets">导出密钥</el-button>
</div>
<div v-if="bindStep" style="margin-top:16px;text-align:center;border-top:1px solid #ebeef5;padding-top:16px">
<p style="font-size:13px;color:#606266">使用 Google Authenticator / Microsoft Authenticator / FreeOTP 扫描</p>
<canvas ref="mfaCanvas"></canvas>
<div v-if="mfaSecret" style="margin-top:8px;font-size:12px;color:#909399;word-break:break-all">
base32 密钥:{{ mfaSecret }}
</div>
<el-input v-model="mfaCode" placeholder="输入 6 位验证码确认绑定" style="margin-top:12px;max-width:280px" maxlength="8" />
<div style="margin-top:10px">
<el-button type="primary" :loading="mfaSaving" @click="enableMFA">验证并启用</el-button>
<el-button @click="bindStep = false">取消</el-button>
</div>
</div>
<el-alert v-if="otpStatus?.mfa_enabled" type="success" :closable="false" style="margin-top:16px"
title="客户端连接方式:密码框填登录密码,第二因子填 6 位动态验证码(若设置了 PIN,则填 PIN+验证码,如 1234123456)。也支持密码后直接附加 6 位码。" />
</el-dialog>
<el-dialog v-model="pinVisible" title="设置静态 PIN" width="400px">
<el-alert type="info" :closable="false" style="margin-bottom:12px"
title="连接时第二因子需输入 PIN + 6 位动态验证码(无空格拼接)。留空保存可清除已设置的 PIN。" />
<el-input v-model="pinValue" type="password" show-password placeholder="4-32 位 PIN,留空清除" />
<template #footer>
<el-button @click="pinVisible = false">取消</el-button>
<el-button type="primary" @click="savePin">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { nextTick, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import QRCode from 'qrcode'
import api from '../api'
const users=ref([]), tunnels=ref([]), visible=ref(false)
const form=ref({tunnel_id:null,username:'',display_name:'',source:'local',password:'',ldap_dn:'',email:'',enabled:true})
const bytes=n=>{if(!n)return '0 B';const u=['B','KB','MB','GB','TB'];let i=0;while(n>=1024&&i<4){n/=1024;i++}return `${n.toFixed(1)} ${u[i]}`}
const tunnelName=id=>tunnels.value.find(t=>t.id===id)?.name||`#${id}`
async function load(){const [u,t]=await Promise.all([api.get('/vpn-users'),api.get('/tunnels',{params:{protocol:'openvpn'}})]);users.value=u.data||[];tunnels.value=t.data||[]}
function openCreate(){form.value={tunnel_id:tunnels.value[0]?.id||null,username:'',display_name:'',source:'local',password:'',ldap_dn:'',email:'',enabled:true};visible.value=true}
async function create(){try{await api.post('/vpn-users',form.value);visible.value=false;ElMessage.success('拨号用户已创建');load()}catch(e){ElMessage.error(e.response?.data?.error||'创建失败')}}
async function toggle(row){await api.put(`/vpn-users/${row.id}`,{enabled:!row.enabled,display_name:row.display_name,email:row.email});load()}
async function remove(row){await ElMessageBox.confirm(`删除拨号用户 ${row.username}?`,'确认');await api.delete(`/vpn-users/${row.id}`);load()}
async function download(row){try{const res=await fetch(`/api/vpn-users/${row.id}/config`,{headers:{Authorization:`Bearer ${localStorage.getItem('vpn_token')}`}});if(!res.ok){const data=await res.json();throw new Error(data.error)}const url=URL.createObjectURL(await res.blob());const a=document.createElement('a');a.href=url;a.download=`openvpn-${row.username}.ovpn`;a.click();URL.revokeObjectURL(url)}catch(e){ElMessage.error(e.message||'下载失败')}}
import { protocolDisplay } from '../protocols'
const users = ref([])
const tunnels = ref([])
const visible = ref(false)
const form = ref({ tunnel_id: null, username: '', display_name: '', source: 'local', password: '', ldap_dn: '', email: '', enabled: true })
// 平台用户选择器
const pickerVisible = ref(false)
const pickerLoading = ref(false)
const platformUsers = ref([])
const otpVisible = ref(false)
const otpUser = ref(null)
const otpStatus = ref(null)
const bindStep = ref(false)
const mfaCode = ref('')
const mfaSecret = ref('')
const mfaCanvas = ref(null)
const mfaSaving = ref(false)
const pinVisible = ref(false)
const pinValue = ref('')
const bytes = n => {
if (!n) return '0 B'
const u = ['B', 'KB', 'MB', 'GB', 'TB']
let i = 0
while (n >= 1024 && i < 4) { n /= 1024; i++ }
return `${n.toFixed(1)} ${u[i]}`
}
const tunnelName = id => tunnels.value.find(t => t.id === id)?.name || `#${id}`
const tunnelProto = id => {
const t = tunnels.value.find(x => x.id === id)
return t ? protocolDisplay(t.protocol) : ''
}
const isOpenVPN = id => tunnels.value.find(x => x.id === id)?.protocol === 'openvpn'
const isLocked = row => row.mfa_locked_until && new Date(row.mfa_locked_until) > new Date()
async function load() {
const [u, all] = await Promise.all([api.get('/vpn-users'), api.get('/tunnels')])
users.value = u.data || []
tunnels.value = (all.data || []).filter(t => ['openvpn', 'l2tp', 'ikev2'].includes(t.protocol))
}
function openCreate() {
form.value = {
tunnel_id: tunnels.value[0]?.id || null,
username: '', display_name: '', source: 'local', password: '', ldap_dn: '', email: '', enabled: true
}
visible.value = true
}
// 从平台用户选择导入
async function openPlatformPicker() {
pickerVisible.value = true
pickerLoading.value = true
try {
const { data } = await api.get('/users')
platformUsers.value = data || []
} catch { platformUsers.value = [] } finally { pickerLoading.value = false }
}
function pickPlatformUser(row) {
form.value.username = row.username
form.value.display_name = row.email ? row.email.split('@')[0] : row.username
form.value.email = row.email || ''
form.value.source = row.source === 'ldap' ? 'ldap' : 'local'
// LDAP 用户需填 DN(留空由后端校验提示)
form.value.ldap_dn = row.source === 'ldap' ? `uid=${row.username}` : ''
pickerVisible.value = false
ElMessage.success(`已填充用户「${row.username}」信息`)
}
async function create() {
try {
await api.post('/vpn-users', form.value)
visible.value = false
ElMessage.success('拨号用户已创建')
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '创建失败')
}
}
async function toggle(row) {
await api.put(`/vpn-users/${row.id}`, { enabled: !row.enabled, display_name: row.display_name, email: row.email })
load()
}
async function remove(row) {
await ElMessageBox.confirm(`删除拨号用户 ${row.username}?`, '确认')
await api.delete(`/vpn-users/${row.id}`)
load()
}
async function download(row) {
try {
const res = await fetch(`/api/vpn-users/${row.id}/config`, {
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error)
}
const t = tunnels.value.find(x => x.id === row.tunnel_id)
const ext = t?.protocol === 'openvpn' ? 'ovpn' : 'txt'
const url = URL.createObjectURL(await res.blob())
const a = document.createElement('a')
a.href = url
a.download = `${t?.protocol || 'vpn'}-${row.username}.${ext}`
a.click()
URL.revokeObjectURL(url)
} catch (e) {
ElMessage.error(e.message || '下载失败')
}
}
async function openOTPManager(row) {
otpUser.value = row
bindStep.value = false
otpVisible.value = true
await refreshOTPStatus()
}
async function refreshOTPStatus() {
if (!otpUser.value) return
try {
const { data } = await api.get(`/vpn-users/${otpUser.value.id}/mfa`)
otpStatus.value = data
} catch (e) {
otpStatus.value = {
mfa_enabled: otpUser.value.mfa_enabled,
mfa_has_pin: otpUser.value.mfa_has_pin,
mfa_fail_count: otpUser.value.mfa_fail_count || 0,
mfa_locked: isLocked(otpUser.value),
mfa_locked_until: otpUser.value.mfa_locked_until,
mfa_last_used_at: otpUser.value.mfa_last_used_at,
compatible: 'TOTP 标准(兼容 Google/Microsoft Authenticator)'
}
}
}
async function setupMFA() {
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/setup`)
mfaCode.value = ''
mfaSecret.value = data.secret || ''
bindStep.value = true
await nextTick()
if (mfaCanvas.value && data.otpauth_url) {
QRCode.toCanvas(mfaCanvas.value, data.otpauth_url, { width: 200, margin: 1 })
}
} catch (e) {
ElMessage.error(e.response?.data?.error || '无法开始 MFA 设置')
}
}
async function enableMFA() {
if (!mfaCode.value) return ElMessage.warning('请输入验证码')
mfaSaving.value = true
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/enable`, { code: mfaCode.value })
bindStep.value = false
const codes = (data.backup_codes || []).join(' ')
ElMessageBox.alert(
`双因素认证已启用。\n\n一次性备用码(请妥善保存,丢失手机时用于登录):\n${codes}\n\n客户端连接方式:密码框填登录密码,第二因子填 6 位动态验证码(若设置了 PIN 则填 PIN+验证码)。`,
'绑定成功',
{ type: 'success' }
)
await refreshOTPStatus()
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '验证失败')
} finally {
mfaSaving.value = false
}
}
async function disableMFA() {
await ElMessageBox.confirm(`关闭 ${otpUser.value.username} 的 MFA?`, '确认', { type: 'warning' })
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/disable`)
ElMessage.success('MFA 已关闭')
await refreshOTPStatus()
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '操作失败')
}
}
async function regenBackup() {
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/backup-codes`)
ElMessageBox.alert(`新备用码(旧码失效):\n\n${(data.backup_codes || []).join(' ')}`, '备用码', { type: 'success' })
} catch (e) {
ElMessage.error(e.response?.data?.error || '生成失败')
}
}
function showPinDialog() {
pinValue.value = ''
pinVisible.value = true
}
async function savePin() {
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/pin`, { pin: pinValue.value })
pinVisible.value = false
ElMessage.success(pinValue.value ? 'PIN 已设置' : 'PIN 已清除')
await refreshOTPStatus()
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '保存失败')
}
}
async function unlockMFA() {
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/unlock`)
ElMessage.success('已解锁')
await refreshOTPStatus()
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '解锁失败')
}
}
async function exportOtpSecrets() {
try {
const { data } = await api.get(`/vpn-users/${otpUser.value.id}/mfa/otp-secrets`)
ElMessageBox.alert(
`TOTP 密钥行(敏感信息,请勿泄露):\n\n${data.line}\n\n格式说明:${data.format}`,
'导出密钥',
{ type: 'warning' }
)
} catch (e) {
ElMessage.error(e.response?.data?.error || '导出失败')
}
}
onMounted(load)
</script>