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
+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 脚本按需放行。
// 这个函数在实例启动时调用一次。