Add cert+password dual-factor VPN authentication

OpenVPN now requires BOTH a valid client certificate AND a
username/password to establish a VPN connection.

Architecture:
  Client .ovpn has 'auth-user-pass' → prompts for credentials
  Server.conf has 'auth-user-pass-verify verify.sh via-env'
  verify.sh (bash+curl) calls POST /api/vpn/verify on the manager
  Manager verifies bcrypt hash via Go's golang.org/x/crypto/bcrypt
  Endpoint is localhost-only (127.0.0.1) for security

Model changes:
  Instance: new AuthMode field ('cert' | 'cert+password', default cert+password)
  VPNUser:  new PasswordHash (bcrypt) + Password (plaintext, transient)

Backend:
  model: AuthMode type, VPNUser.PasswordHash, VPNUser.Password (transient)
  store: ListUsers clears PasswordHash before returning
  service: CreateUser hashes password with bcrypt, enforces min 4 chars
  service: ResetVPNPassword for admin password reset
  service: UpdateUser preserves PasswordHash from old record
  service: CreateInstance defaults AuthMode=cert+password
  api: POST /api/vpn/verify (no JWT, localhost-only, bcrypt verify)
  api: POST /instances/:id/users/:uid/password (admin reset VPN pwd)
  openvpn: WriteVerifyScript generates bash+curl verify script
  openvpn: WriteServerConf adds script-security/auth-user-pass-verify
  openvpn: GenerateClientOVPN adds auth-user-pass directive

Frontend:
  Users.vue: password field on create form
  Users.vue: '重置密码' button in table + dialog
  Users.vue: '证书+密码' tag in auth column
  api: Inst.resetVPNPassword() method

Security:
  /api/vpn/verify rejects non-127.0.0.1 clients (403)
  PasswordHash never exposed via any API response
  verify.sh uses localhost curl (no external dependencies)
  bcrypt cost=10 (same as admin passwords)

Verified: correct pwd → 200, wrong pwd → 401, missing user → 401,
non-localhost → 403, .ovpn has auth-user-pass, server.conf has
script-security 2 + auth-user-pass-verify + verify-client-cert require.
This commit is contained in:
cnbugs
2026-08-09 22:59:32 +08:00
parent 7c0f8cf4d8
commit 8be6add7bc
8 changed files with 235 additions and 6 deletions
+1
View File
@@ -54,6 +54,7 @@ export const Inst = {
createUser: (id, data) => api.post(`/instances/${id}/users`, data).then(r => r.data),
updateUser: (id, uid, data) => api.put(`/instances/${id}/users/${uid}`, data).then(r => r.data),
revokeUser: (id, uid) => api.post(`/instances/${id}/users/${uid}/revoke`).then(r => r.data),
resetVPNPassword: (id, uid, new_password) => api.post(`/instances/${id}/users/${uid}/password`, { new_password }).then(r => r.data),
deleteUser: (id, uid) => api.delete(`/instances/${id}/users/${uid}`).then(r => r.data),
ovpnUrl: (id, uid, host) => `/api/instances/${id}/users/${uid}/ovpn?host=${encodeURIComponent(host||'')}`,
}
+47 -5
View File
@@ -18,6 +18,11 @@
<el-table-column prop="username" label="用户名(CN)" />
<el-table-column prop="real_name" label="备注" />
<el-table-column prop="email" label="邮箱" />
<el-table-column label="认证" width="100">
<template #default>
<el-tag size="small" type="warning">证书+密码</el-tag>
</template>
</el-table-column>
<el-table-column prop="static_ip" label="固定 IP" width="140" />
<el-table-column label="允许网段" width="180">
<template #default="{row}">
@@ -33,9 +38,10 @@
<el-table-column prop="created_at" label="创建时间" width="170">
<template #default="{row}">{{ row.created_at?.slice(0,19) }}</template>
</el-table-column>
<el-table-column label="操作" width="300">
<el-table-column label="操作" width="380">
<template #default="{row}">
<el-button size="small" @click="download(row)">下载 .ovpn</el-button>
<el-button size="small" type="info" @click="openResetPwd(row)">重置密码</el-button>
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button v-if="row.enabled" size="small" type="warning" @click="revoke(row)">吊销</el-button>
<el-popconfirm title="确定删除此用户及其证书?" @confirm="del(row)">
@@ -50,6 +56,9 @@
<el-form-item label="用户名(CN)">
<el-input v-model="form.username" :disabled="!!form.id" placeholder="字母数字下划线"/>
</el-form-item>
<el-form-item v-if="!form.id" label="登录密码" required>
<el-input v-model="form.password" type="password" show-password placeholder="≥4 位,VPN 连接时输入" />
</el-form-item>
<el-form-item label="备注"><el-input v-model="form.real_name" /></el-form-item>
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
<el-form-item label="固定 IP"><el-input v-model="form.static_ip" placeholder="可选, 如 10.8.0.10" /></el-form-item>
@@ -65,6 +74,20 @@
<el-button type="primary" @click="submit">{{ form.id ? '保存' : '生成并签发' }}</el-button>
</template>
</el-dialog>
<!-- 重置 VPN 密码对话框 -->
<el-dialog v-model="pwdDlg" title="重置 VPN 登录密码" width="420px">
<p class="muted">为用户 <b>{{ pwdTarget?.username }}</b> 设置新的 VPN 登录密码,立即生效</p>
<el-form label-width="80px">
<el-form-item label="新密码" required>
<el-input v-model="pwdForm.new_password" type="password" show-password placeholder="≥4 位" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pwdDlg=false">取消</el-button>
<el-button type="primary" @click="submitResetPwd">确认重置</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
@@ -80,7 +103,12 @@ const users = ref([])
const instanceId = ref('')
const host = ref(window.location.hostname)
const dlg = ref(false)
const form = reactive({ id:'', username:'', real_name:'', email:'', static_ip:'', allow_networks:[] })
const form = reactive({ id:'', username:'', password:'', real_name:'', email:'', static_ip:'', allow_networks:[] })
// 重置密码对话框
const pwdDlg = ref(false)
const pwdTarget = ref(null)
const pwdForm = reactive({ new_password:'' })
async function loadInstances() {
instances.value = await Inst.list()
@@ -96,11 +124,11 @@ async function loadUsers() {
watch(() => route.query.instance, v => { if (v) { instanceId.value = v; loadUsers() } })
function openCreate() {
Object.assign(form, { id:'', username:'', real_name:'', email:'', static_ip:'', allow_networks:[] })
Object.assign(form, { id:'', username:'', password:'', real_name:'', email:'', static_ip:'', allow_networks:[] })
dlg.value = true
}
function openEdit(row) {
Object.assign(form, row, { allow_networks: row.allow_networks || [] })
Object.assign(form, row, { password:'', allow_networks: row.allow_networks || [] })
dlg.value = true
}
async function submit() {
@@ -110,6 +138,7 @@ async function submit() {
await Inst.updateUser(instanceId.value, form.id, form)
ElMessage.success('已保存')
} else {
if (!form.password) return ElMessage.warning('请输入登录密码')
await Inst.createUser(instanceId.value, { ...form })
ElMessage.success('已创建并签发证书')
}
@@ -127,6 +156,19 @@ async function del(row) {
ElMessage.success('已删除')
loadUsers()
}
function openResetPwd(row) {
pwdTarget.value = row
pwdForm.new_password = ''
pwdDlg.value = true
}
async function submitResetPwd() {
if (!pwdForm.new_password || pwdForm.new_password.length < 4) return ElMessage.warning('密码至少 4 位')
try {
await Inst.resetVPNPassword(instanceId.value, pwdTarget.value.id, pwdForm.new_password)
ElMessage.success('密码已重置')
pwdDlg.value = false
} catch(e) {}
}
async function download(row) {
const tok = localStorage.getItem('token')
const url = Inst.ovpnUrl(instanceId.value, row.id, host.value)
@@ -137,4 +179,4 @@ async function download(row) {
}
onMounted(loadInstances)
</script>
</script>