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
@@ -51,6 +51,7 @@ func main() {
log.Fatalf("open store: %v", err)
}
ovm := openvpn.NewManager(cfg.OpenVPNBin, cfg.DataDir)
ovm.SetPort(cfg.Port) // 让 verify.sh 知道回调哪个端口
if err := ovm.EnsureCA(); err != nil {
log.Printf("warn: ensure CA: %v", err)
}
+60
View File
@@ -41,6 +41,8 @@ func (s *Server) Router(distDir string) *gin.Engine {
// 公共
r.POST("/api/login", s.login)
r.GET("/api/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
// OpenVPN auth-user-pass-verify 回调(无 JWT,仅 localhost 可用)
r.POST("/api/vpn/verify", s.verifyVPNUser)
auth := r.Group("/api", middleware.JWTAuth(s.Cfg.JWTSecret))
{
@@ -61,6 +63,7 @@ func (s *Server) Router(distDir string) *gin.Engine {
auth.POST("/instances/:id/users", s.createUser)
auth.PUT("/instances/:id/users/:uid", s.updateUser)
auth.POST("/instances/:id/users/:uid/revoke", s.revokeUser)
auth.POST("/instances/:id/users/:uid/password", s.resetVPNPassword)
auth.DELETE("/instances/:id/users/:uid", s.deleteUser)
auth.GET("/instances/:id/users/:uid/ovpn", s.downloadOVPN)
@@ -364,6 +367,24 @@ func (s *Server) revokeUser(c *gin.Context) {
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) resetVPNPassword(c *gin.Context) {
var body struct {
NewPassword string `json:"new_password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "bad request"})
return
}
uid := c.Param("uid")
if err := s.Svc.ResetVPNPassword(uid, body.NewPassword); err != nil {
s.Svc.AuditForAPI(c, "reset_vpn_password", uid, err.Error(), "failed")
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "reset_vpn_password", uid, "", "ok")
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) deleteUser(c *gin.Context) {
uid := c.Param("uid")
if err := s.Svc.DeleteUser(uid); err != nil {
@@ -393,6 +414,45 @@ func (s *Server) downloadOVPN(c *gin.Context) {
c.File(p)
}
// verifyVPNUser OpenVPN auth-user-pass-verify 回调。
// 由 OpenVPN 通过 verify.sh 脚本调用(curl POST)。
// 安全:仅允许 127.0.0.1 来源,其他 IP 拒绝。
func (s *Server) verifyVPNUser(c *gin.Context) {
// 仅允许 localhost
remoteIP := c.ClientIP()
if remoteIP != "127.0.0.1" && remoteIP != "::1" {
c.JSON(403, gin.H{"error": "forbidden"})
return
}
var req struct {
Instance string `json:"instance"`
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "bad request"})
return
}
// 查找实例
inst, err := s.Svc.Store.GetInstanceByName(req.Instance)
if err != nil {
c.JSON(401, gin.H{"error": "auth failed"})
return
}
// 查找用户
user, err := s.Svc.Store.GetUserByCN(inst.ID, req.Username)
if err != nil || !user.Enabled {
c.JSON(401, gin.H{"error": "auth failed"})
return
}
// bcrypt 验证密码
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(401, gin.H{"error": "auth failed"})
return
}
c.JSON(200, gin.H{"ok": true})
}
// ---- certs ----
func (s *Server) listCerts(c *gin.Context) {
+14 -1
View File
@@ -13,6 +13,16 @@ const (
AccessWhitelist AccessMode = "whitelist"
)
// AuthMode 控制客户端认证方式。
// - "cert": 仅证书认证(向后兼容)
// - "cert+password": 证书 + 用户名密码双因素认证(更安全)
type AuthMode string
const (
AuthCert AuthMode = "cert"
AuthCertPassword AuthMode = "cert+password"
)
// Instance 一个 OpenVPN 服务端实例。
// 每个实例使用独立端口与 PKI,运行在自己的 server.conf 下,
// 由 systemd 单元(或后台进程)托管,本服务通过 management 接口与之通信。
@@ -30,6 +40,7 @@ type Instance struct {
Extra string `json:"extra"` // 用户追加配置
AccessMode AccessMode `json:"access_mode"` // open | whitelist
AllowNetworks []string `json:"allow_networks"` // 实例级白名单 CIDR 列表
AuthMode AuthMode `json:"auth_mode"` // cert | cert+password
Status string `json:"status"` // running/stopped/error
PID int `json:"pid"`
CreatedAt time.Time `json:"created_at"`
@@ -41,7 +52,9 @@ type Instance struct {
type VPNUser struct {
ID string `json:"id"`
InstanceID string `json:"instance_id"`
Username string `json:"username"` // 证书 CN
Username string `json:"username"` // 证书 CN, 同时也是 VPN 登录用户名
Password string `json:"password,omitempty"` // 明文密码(仅创建/重置时传入,不持久化)
PasswordHash string `json:"password_hash"` // bcrypt 哈希;store.ListUsers 返回时清空
RealName string `json:"real_name"` // 备注
Email string `json:"email"`
Enabled bool `json:"enabled"` // 是否启用, false 即吊销/禁用
+41
View File
@@ -92,6 +92,9 @@ func (s *Service) CreateInstance(in model.Instance) (*model.Instance, error) {
if in.AccessMode == "" {
in.AccessMode = model.AccessOpen
}
if in.AuthMode == "" {
in.AuthMode = model.AuthCertPassword // 默认双因素(更安全)
}
// 校验白名单 CIDR
for _, c := range in.AllowNetworks {
if err := openvpn.ValidateCIDR(c); err != nil {
@@ -299,9 +302,27 @@ func (s *Service) CreateUser(u model.VPNUser) (*model.VPNUser, error) {
return nil, fmt.Errorf("user allow_networks: %w", err)
}
}
// 双因素模式必须有密码
if in.AuthMode == model.AuthCertPassword {
if u.Password == "" {
return nil, fmt.Errorf("password required for cert+password auth mode")
}
}
// 哈希密码(bcrypt),即使 cert 模式也存储(便于后续切换)
if u.Password != "" {
if len(u.Password) < 4 {
return nil, fmt.Errorf("password too short (min 4)")
}
hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
u.PasswordHash = string(hash)
}
u.ID = uuid.NewString()
u.Enabled = true
u.CreatedAt = time.Now()
u.Password = "" // 不持久化明文
// 签发证书(Manager 按实例名索引 PKI
if _, _, err := s.Ovm.IssueCert(in.Name, u.Username); err != nil {
return nil, err
@@ -318,6 +339,7 @@ func (s *Service) CreateUser(u model.VPNUser) (*model.VPNUser, error) {
if err := s.Store.UpsertUser(u); err != nil {
return nil, err
}
u.PasswordHash = "" // 不向外暴露
return &u, nil
}
@@ -384,6 +406,8 @@ func (s *Service) UpdateUser(u model.VPNUser) error {
}
u.CreatedAt = old.CreatedAt
u.Enabled = old.Enabled
u.PasswordHash = old.PasswordHash // 保留原密码哈希,不被空覆盖
u.Password = "" // 不持久化明文
u.RevokedAt = old.RevokedAt
in, err := s.Store.GetInstance(u.InstanceID)
if err != nil {
@@ -396,6 +420,23 @@ func (s *Service) UpdateUser(u model.VPNUser) error {
return s.Store.UpsertUser(u)
}
// ResetVPNPassword 由管理员重置 VPN 用户的登录密码。
func (s *Service) ResetVPNPassword(userID, newPassword string) error {
if len(newPassword) < 4 {
return fmt.Errorf("password too short (min 4)")
}
u, err := s.Store.GetUser(userID)
if err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
u.PasswordHash = string(hash)
return s.Store.UpsertUser(*u)
}
// writeUserCCD 把允许网段写进 ccd/<cn>,client-connect 脚本读取后推送 route。
// 文件内容:
// - 第 1 行 ifconfig-push (固定 IP)
+1
View File
@@ -147,6 +147,7 @@ func (s *Store) ListUsers(instanceID string) []model.VPNUser {
out := []model.VPNUser{}
for _, u := range s.data.Users {
if instanceID == "" || u.InstanceID == instanceID {
u.PasswordHash = "" // 不向外暴露 hash
out = append(out, u)
}
}
+70
View File
@@ -29,6 +29,7 @@ type Manager struct {
binary string
dataDir string
pkiDir string
port int // manager HTTP port (for verify script callback)
}
func NewManager(binary, dataDir string) *Manager {
@@ -36,9 +37,15 @@ func NewManager(binary, dataDir string) *Manager {
binary: binary,
dataDir: dataDir,
pkiDir: filepath.Join(dataDir, "pki"),
port: 8089,
}
}
// SetPort 设置 manager 的 HTTP 端口,供 verify.sh 回调使用。
func (m *Manager) SetPort(port int) {
m.port = port
}
// PKIPath 返回实例的 PKI 目录(用于存放 issued/private 等子目录)。
func (m *Manager) PKIPath(instanceName string) string {
return filepath.Join(m.dataDir, "instances", instanceName, "pki")
@@ -162,6 +169,18 @@ func (m *Manager) WriteServerConf(in *model.Instance, extraDir string) error {
// 仍允许 bypass-dhcp 保留局域网 DNS
conf.WriteString("push \"redirect-gateway def1 bypass-dhcp\"\n")
}
if in.AuthMode == model.AuthCertPassword {
// 双因素认证:证书 + 用户名密码
// OpenVPN 以 via-env 方式调用 verify 脚本,环境变量:
// username=<VPN用户名(=证书CN)>
// password=<用户输入的明文密码>
// 脚本退出码 0 = 允许,非 0 = 拒绝
verifyScript := filepath.Join(m.InstanceDir(in.Name), "verify.sh")
conf.WriteString("script-security 2\n")
conf.WriteString("auth-user-pass-verify \"" + verifyScript + "\" via-env\n")
conf.WriteString("verify-client-cert require\n")
_ = m.WriteVerifyScriptWithPort(in.Name, verifyScript, m.port)
}
if in.Extra != "" {
conf.WriteString("\n# --- custom ---\n")
conf.WriteString(in.Extra + "\n")
@@ -283,6 +302,10 @@ func (m *Manager) GenerateClientOVPN(in *model.Instance, username, remoteHost st
b.WriteString("auth " + orDefault(in.AuthDigest, "SHA256") + "\n")
b.WriteString("remote-cert-tls server\n")
b.WriteString("verb 3\n")
// 双因素模式:客户端需要输入用户名密码
if in.AuthMode == model.AuthCertPassword {
b.WriteString("auth-user-pass\n")
}
b.WriteString("\n<ca>\n")
b.WriteString(string(caCrt))
b.WriteString("\n</ca>\n")
@@ -469,6 +492,53 @@ func (m *Manager) InstanceDir(name string) string {
return filepath.Join(m.dataDir, "instances", name)
}
// WriteVerifyScript 生成 auth-user-pass-verify 认证脚本。
// OpenVPN 以 via-env 方式调用此脚本:
// 环境变量 username = 证书 CN
// 环境变量 password = 用户输入的明文密码
//
// 脚本通过 curl 调用 manager 的 /api/vpn/verify 接口验证密码,
// manager 用 Go bcrypt 库验证,无需 Python 模块或 htpasswd。
func (m *Manager) WriteVerifyScript(instanceName, scriptPath string) error {
// 内嵌占位符,实际端口由 WriteServerConf 调用前从 config 获取
return m.WriteVerifyScriptWithPort(instanceName, scriptPath, 8089)
}
// WriteVerifyScriptWithPort 生成带指定端口的认证脚本。
func (m *Manager) WriteVerifyScriptWithPort(instanceName, scriptPath string, port int) error {
script := fmt.Sprintf(`#!/bin/bash
# OpenVPN auth-user-pass-verify script (via-env).
# Generated by openvpn-manager — do NOT edit manually.
# Calls manager API to verify bcrypt password. Exit 0=allow, 1=deny.
set -e
PORT=%d
INSTANCE="%s"
user="${username:-}"
pass="${password:-}"
if [ -z "$user" ] || [ -z "$pass" ]; then
exit 1
fi
CODE=$(curl -sf -o /dev/null -w "%%{http_code}" \
--connect-timeout 3 --max-time 5 \
-X POST "http://127.0.0.1:${PORT}/api/vpn/verify" \
-H "Content-Type: application/json" \
-d "{\"instance\":\"${INSTANCE}\",\"username\":\"${user}\",\"password\":\"${pass}\"}" \
2>/dev/null || echo "000")
if [ "$CODE" = "200" ]; then
exit 0
else
exit 1
fi
`, port, instanceName)
if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil {
return err
}
return os.WriteFile(scriptPath, []byte(script), 0o755)
}
// SetIptablesForInstance 应用一个实例的默认 FORWARD 策略。
// 在白名单模式下,我们希望"默认拒绝 tun->eth 的转发",再由 client-connect 脚本按需放行。
// 这个函数在实例启动时调用一次。
+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>