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:
@@ -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 脚本按需放行。
|
||||
// 这个函数在实例启动时调用一次。
|
||||
|
||||
Reference in New Issue
Block a user