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:
@@ -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) {
|
||||
|
||||
@@ -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 即吊销/禁用
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user