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
+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) {