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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user