From 8be6add7bc5d1fee36ccc3dc900c5cffe59b9e38 Mon Sep 17 00:00:00 2001 From: cnbugs Date: Sun, 9 Aug 2026 22:59:32 +0800 Subject: [PATCH] Add cert+password dual-factor VPN authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/cmd/server/main.go | 1 + backend/internal/api/router.go | 60 +++++++++++++++++++++++++ backend/internal/model/model.go | 15 ++++++- backend/internal/service/service.go | 41 +++++++++++++++++ backend/internal/store/store.go | 1 + backend/pkg/openvpn/manager.go | 70 +++++++++++++++++++++++++++++ frontend/src/api/index.js | 1 + frontend/src/views/Users.vue | 52 ++++++++++++++++++--- 8 files changed, 235 insertions(+), 6 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 3e6aa61..679b8d0 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) } diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 51c6038..a912957 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -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) { diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index fba67a2..faf5b92 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -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 即吊销/禁用 diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index 9161b44..97b1f25 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -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/,client-connect 脚本读取后推送 route。 // 文件内容: // - 第 1 行 ifconfig-push (固定 IP) diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index a5576b1..e1d78b5 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -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) } } diff --git a/backend/pkg/openvpn/manager.go b/backend/pkg/openvpn/manager.go index 1c217a5..bc1858a 100644 --- a/backend/pkg/openvpn/manager.go +++ b/backend/pkg/openvpn/manager.go @@ -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= + // 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\n") b.WriteString(string(caCrt)) b.WriteString("\n\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 脚本按需放行。 // 这个函数在实例启动时调用一次。 diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index b50b117..2b3bb81 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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||'')}`, } diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue index c14adea..8c4af33 100644 --- a/frontend/src/views/Users.vue +++ b/frontend/src/views/Users.vue @@ -18,6 +18,11 @@ + + + \ No newline at end of file +