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:
@@ -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||'')}`,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user