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