// client.go 面向桌面/移动客户端的终端用户 API(/api/client/*)。 // // 与管理后台 API 完全隔离:独立的令牌表(只存哈希)、不携带管理角色、 // 只暴露当前拨号用户被授权的 WireGuard 线路与自己名下的设备。 // 设备私钥只在客户端生成与保管,服务端仅登记公钥(外部 Peer)。 package api import ( "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "net" "net/http" "strings" "sync" "time" "github.com/gin-gonic/gin" "github.com/go-ldap/ldap/v3" "github.com/pquerna/otp/totp" "golang.org/x/crypto/bcrypt" "vpn-platform/backend/internal/ldaputil" "vpn-platform/backend/internal/model" "vpn-platform/backend/internal/service/wireguard" "vpn-platform/backend/internal/sessionmgr" "vpn-platform/backend/internal/store" ) const ( clientTokenTTL = 30 * 24 * time.Hour clientTokenRenewBelow = 15 * 24 * time.Hour // 剩余不足一半时滑动续期 maxDevicesPerLine = 5 // 每用户每条线路的在线设备上限 ) var clientMFAMu sync.Mutex // ---- 令牌 ---- func clientTokenHash(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } // 会话只授予本次真正完成密码和 MFA 校验的拨号记录;修改凭据后旧授权立即失效。 func credentialFingerprint(user *model.VPNUser) string { sum := sha256.Sum256([]byte(fmt.Sprintf("%d\x00%s\x00%s\x00%s\x00%s\x00%t", user.ID, user.Password, user.LDAPDN, user.MFASecret, user.MFAPin, user.MFAEnabled))) return hex.EncodeToString(sum[:]) } func grantedUsers(users []model.VPNUser) map[uint]string { grants := make(map[uint]string, len(users)) for i := range users { grants[users[i].ID] = credentialFingerprint(&users[i]) } return grants } func currentGrantedUsers(sess *model.ClientSession) []model.VPNUser { var grants map[uint]string if json.Unmarshal([]byte(sess.Grants), &grants) != nil || len(grants) == 0 { return nil } ids := make([]uint, 0, len(grants)) for id := range grants { ids = append(ids, id) } var users []model.VPNUser if store.DB.Where("id IN ? AND username = ? AND enabled = ?", ids, sess.Username, true).Find(&users).Error != nil { return nil } valid := users[:0] for i := range users { if grants[users[i].ID] == credentialFingerprint(&users[i]) { valid = append(valid, users[i]) } } return valid } func revokeClientSessionsForUser(user *model.VPNUser) error { var sessions []model.ClientSession if err := store.DB.Where("username = ?", user.Username).Find(&sessions).Error; err != nil { return err } for _, session := range sessions { var grants map[uint]string if json.Unmarshal([]byte(session.Grants), &grants) != nil { continue } if _, ok := grants[user.ID]; ok { if err := store.DB.Delete(&session).Error; err != nil { return err } } } return nil } func issueClientToken(username, deviceName, platform string, users []model.VPNUser) (string, time.Time, error) { grants, err := json.Marshal(grantedUsers(users)) if err != nil { return "", time.Time{}, err } raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", time.Time{}, err } token := base64.RawURLEncoding.EncodeToString(raw) expire := time.Now().Add(clientTokenTTL) sess := model.ClientSession{ TokenHash: clientTokenHash(token), Username: username, Grants: string(grants), DeviceName: deviceName, Platform: platform, ExpireAt: expire, } if err := store.DB.Create(&sess).Error; err != nil { return "", time.Time{}, err } return token, expire, nil } // clientAuth 校验客户端令牌;与管理后台 authMiddleware 互不相通。 func clientAuth() gin.HandlerFunc { return func(c *gin.Context) { token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") if token == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "缺少客户端令牌"}) return } var sess model.ClientSession if err := store.DB.Where("token_hash = ?", clientTokenHash(token)).First(&sess).Error; err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "令牌无效或已注销"}) return } if time.Now().After(sess.ExpireAt) { store.DB.Delete(&sess) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"}) return } users := currentGrantedUsers(&sess) if len(users) == 0 { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "线路授权已失效,请重新登录"}) return } if time.Until(sess.ExpireAt) < clientTokenRenewBelow { sess.ExpireAt = time.Now().Add(clientTokenTTL) store.DB.Model(&sess).Update("expire_at", sess.ExpireAt) } c.Set("client_username", sess.Username) c.Set("client_users", users) c.Set("client_token_hash", sess.TokenHash) c.Next() } } // ---- 拨号用户认证 ---- // verifyVPNUserPassword 与 OpenVPN 拨号校验语义一致:本地用户走 bcrypt, // LDAP 用户用其登记的 DN 直接 bind。 func verifyVPNUserPassword(user *model.VPNUser, password string) error { switch user.Source { case "local": if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil { return fmt.Errorf("密码错误") } return nil case "ldap": if user.LDAPDN == "" { return fmt.Errorf("LDAP 用户缺少 DN") } cfg, err := ldapSettings() if err != nil { return err } dialURL, err := ldaputil.DialURL(cfg.URL) if err != nil { return err } conn, err := ldap.DialURL(dialURL) if err != nil { return err } defer conn.Close() if err := conn.Bind(user.LDAPDN, password); err != nil { return fmt.Errorf("密码错误") } return nil } return fmt.Errorf("未知认证来源") } // verifyVPNUserOTP 校验拨号用户的 TOTP 动态口令(MFASecret 加密保存)。 func verifyVPNUserOTP(user *model.VPNUser, otp string) bool { if !user.MFAEnabled { return true } if otp == "" { return false } clientMFAMu.Lock() defer clientMFAMu.Unlock() if store.DB.First(user, user.ID).Error != nil || !user.MFAEnabled { return false } now := time.Now() if user.MFALockedUntil != nil && now.Before(*user.MFALockedUntil) { return false } failure := func() bool { failures := user.MFAFailCount + 1 updates := map[string]interface{}{"mfa_fail_count": failures} if failures >= 5 { until := now.Add(5 * time.Minute) updates["mfa_locked_until"] = &until } store.DB.Model(user).Updates(updates) return false } if user.MFAHasPin { pin, err := store.DecryptSecret(user.MFAPin) if err != nil || pin == "" || !strings.HasPrefix(otp, pin) { return failure() } otp = strings.TrimPrefix(otp, pin) } secret, err := store.DecryptSecret(user.MFASecret) if err != nil || secret == "" { return failure() } counter := now.Unix() / 30 if !totp.Validate(strings.TrimSpace(otp), secret) || counter <= user.MFALastCounter { return failure() } store.DB.Model(user).Updates(map[string]interface{}{"mfa_last_counter": counter, "mfa_last_used_at": now, "mfa_fail_count": 0, "mfa_locked_until": nil}) return true } // authorizedLines 返回该拨号用户密码验证通过(且 OTP 满足)的 WireGuard 线路。 func authorizedLines(username, password, otp string) ([]model.VPNUser, bool, error) { var users []model.VPNUser if err := store.DB.Where("username = ? AND enabled = ?", username, true).Find(&users).Error; err != nil { return nil, false, err } var passed []model.VPNUser mfaRequired := false for i := range users { u := &users[i] if verifyVPNUserPassword(u, password) != nil { continue } if u.MFAEnabled { mfaRequired = true if otp == "" || !verifyVPNUserOTP(u, otp) { continue } } if t, err := store.GetTunnel(u.TunnelID); err != nil || t.Protocol != "wireguard" { continue } passed = append(passed, *u) } return passed, mfaRequired, nil } type clientLine struct { TunnelID uint `json:"tunnel_id"` Name string `json:"name"` Remark string `json:"remark"` State string `json:"state"` Endpoint string `json:"endpoint"` ExpireAt *time.Time `json:"expire_at"` } // linesForUsers 把授权拨号记录映射为 WireGuard 线路列表。 func linesForUsers(users []model.VPNUser) []clientLine { seen := map[uint]bool{} lines := []clientLine{} for _, u := range users { if seen[u.TunnelID] { continue } seen[u.TunnelID] = true t, err := store.GetTunnel(u.TunnelID) if err != nil || t.Protocol != "wireguard" || (t.ExpireAt != nil && time.Now().After(*t.ExpireAt)) { continue } line := clientLine{TunnelID: t.ID, Name: t.Name, Remark: t.Remark, State: t.State, ExpireAt: t.ExpireAt} if cfg, err := store.TunnelConfig(t); err == nil { line.Endpoint = wireguardEndpoint(cfg) } lines = append(lines, line) } return lines } func wireguardEndpoint(cfg map[string]interface{}) string { if ep, ok := cfg["public_endpoint"].(string); ok && ep != "" { return ep } port := 51820 if p, ok := cfg["listen_port"].(float64); ok && p > 0 { port = int(p) } return fmt.Sprintf(":%d", port) // 未配置公网地址时仅回传端口,由客户端提示管理员补全 } // ---- 处理器 ---- type clientLoginReq struct { Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required"` OTP string `json:"otp"` DeviceName string `json:"device_name"` Platform string `json:"platform"` } func (s *Server) clientLogin(c *gin.Context) { var req clientLoginReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "参数错误"}) return } req.Username = strings.TrimSpace(req.Username) lockKey := "client|" + c.ClientIP() + "|" + req.Username if remaining := loginLockRemaining(lockKey); remaining > 0 { c.JSON(429, gin.H{"error": fmt.Sprintf("尝试过于频繁,请 %d 秒后重试", int(remaining.Seconds()))}) return } passed, mfaRequired, err := authorizedLines(req.Username, req.Password, req.OTP) if err != nil { c.JSON(500, gin.H{"error": "认证服务异常"}) return } if len(passed) == 0 { recordLoginFail(lockKey) sessionmgr.LogConnection(0, req.Username, "auth_fail", c.ClientIP(), "客户端登录失败") if mfaRequired && req.OTP == "" { c.JSON(401, gin.H{"error": "该账号已启用动态口令", "mfa_required": true}) return } c.JSON(401, gin.H{"error": "用户名或密码错误"}) return } resetLoginFail(lockKey) lines := linesForUsers(passed) if len(lines) == 0 { c.JSON(403, gin.H{"error": "该账号未被授权任何 WireGuard 线路"}) return } token, expire, err := issueClientToken(req.Username, req.DeviceName, req.Platform, passed) if err != nil { c.JSON(500, gin.H{"error": "签发令牌失败"}) return } displayName := passed[0].DisplayName c.JSON(200, gin.H{ "token": token, "expires_at": expire, "username": req.Username, "display_name": displayName, "lines": lines, }) } func (s *Server) clientLogout(c *gin.Context) { store.DB.Where("token_hash = ?", c.GetString("client_token_hash")).Delete(&model.ClientSession{}) c.JSON(200, gin.H{"ok": true}) } func (s *Server) clientLines(c *gin.Context) { users, _ := c.Get("client_users") c.JSON(200, gin.H{"lines": linesForUsers(users.([]model.VPNUser))}) } // clientAuthorizedTunnel 校验当前客户端用户对该 WireGuard 线路有拨号授权。 func clientAuthorizedTunnel(users []model.VPNUser, tunnelID uint) (*model.Tunnel, *model.VPNUser, error) { var authorized *model.VPNUser for i := range users { if users[i].TunnelID == tunnelID { authorized = &users[i] break } } if authorized == nil { return nil, nil, fmt.Errorf("没有该线路的接入授权") } t, err := store.GetTunnel(tunnelID) if err != nil { return nil, nil, fmt.Errorf("线路不存在") } if t.Protocol != "wireguard" { return nil, nil, fmt.Errorf("客户端目前仅支持 WireGuard 线路") } if t.ExpireAt != nil && time.Now().After(*t.ExpireAt) { return nil, nil, fmt.Errorf("线路已过期") } return t, authorized, nil } // allocateClientAddress 在专线网段内为设备分配第一个空闲地址(/32,仅 IPv4)。 func allocateClientAddress(serverCIDR string, used map[string]bool) (string, error) { ip, ipnet, err := net.ParseCIDR(serverCIDR) if err != nil { return "", fmt.Errorf("专线网段配置无效: %s", serverCIDR) } ip = ip.To4() if ip == nil { return "", fmt.Errorf("客户端自动分配目前仅支持 IPv4 网段") } ones, bits := ipnet.Mask.Size() if bits-ones < 2 { return "", fmt.Errorf("专线网段过小,无法分配地址") } base := ip.Mask(ipnet.Mask) total := 1 << uint(bits-ones) // 跳过网络地址(0)与广播地址(total-1) for i := 1; i < total-1; i++ { candidate := make(net.IP, 4) copy(candidate, base) for b, v := 3, i; b >= 0 && v > 0; b-- { candidate[b] += byte(v & 0xff) v >>= 8 } if !used[candidate.String()] { return candidate.String() + "/32", nil } } return "", fmt.Errorf("专线网段地址已分配完") } type clientRegisterDeviceReq struct { TunnelID uint `json:"tunnel_id" binding:"required"` Name string `json:"name" binding:"required"` Platform string `json:"platform"` PublicKey string `json:"public_key" binding:"required"` } func (s *Server) clientRegisterDevice(c *gin.Context) { var req clientRegisterDeviceReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "参数错误"}) return } username := c.GetString("client_username") req.Name = strings.TrimSpace(req.Name) if req.Name == "" || len(req.Name) > 64 || strings.ContainsAny(req.Name, "\r\n") { c.JSON(400, gin.H{"error": "设备名不能为空、不超过 64 字符且不含换行"}) return } if !wireguard.IsWireGuardKey(req.PublicKey) { c.JSON(400, gin.H{"error": "public_key 不是合法的 WireGuard 公钥"}) return } granted, _ := c.Get("client_users") t, vpnUser, err := clientAuthorizedTunnel(granted.([]model.VPNUser), req.TunnelID) if err != nil { c.JSON(403, gin.H{"error": err.Error()}) return } mu := lockTunnel(t.ID) defer mu.Unlock() var active int64 store.DB.Model(&model.ClientDevice{}). Where("vpn_user_id = ? AND revoked = ?", vpnUser.ID, false). Count(&active) if active >= maxDevicesPerLine { c.JSON(400, gin.H{"error": fmt.Sprintf("每条线路最多登记 %d 台设备,请先在客户端或管理端撤销闲置设备", maxDevicesPerLine)}) return } var dup int64 store.DB.Model(&model.ClientDevice{}).Where("public_key = ?", req.PublicKey).Count(&dup) if dup > 0 { c.JSON(400, gin.H{"error": "该设备密钥已登记过;请在本机重新生成密钥后再注册"}) return } wg := wireguard.New() used, err := wg.UsedClientAddresses(t) if err != nil { c.JSON(500, gin.H{"error": "读取地址占用失败: " + err.Error()}) return } cfg, err := store.TunnelConfig(t) if err != nil { c.JSON(500, gin.H{"error": "读取线路配置失败"}) return } endpoint, configured := cfg["public_endpoint"].(string) if !configured || strings.TrimSpace(endpoint) == "" { c.JSON(400, gin.H{"error": "请管理员先为 WireGuard 专线配置公网端点"}) return } addr, err := allocateClientAddress(fmt.Sprint(cfg["address"]), used) if err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } device := model.ClientDevice{ TunnelID: t.ID, VPNUserID: vpnUser.ID, Username: username, Name: req.Name, Platform: req.Platform, PublicKey: req.PublicKey, ClientAddress: addr, } if err := store.DB.Create(&device).Error; err != nil { c.JSON(500, gin.H{"error": "保存设备失败"}) return } device.PeerID = fmt.Sprintf("dev-%d", device.ID) if _, err := wg.RegisterDevicePeer(t, device.PeerID, device.Name, device.PublicKey, device.ClientAddress); err != nil { // 已回滚的登记直接删除数据库记录;若 Peer 仍在,保留记录供管理员处理。 if _, lookupErr := wg.DeviceConnectionParams(t, device.PeerID); lookupErr != nil { store.DB.Delete(&device) } else { store.DB.Model(&device).Update("peer_id", device.PeerID) store.Audit(username, "client_device_cleanup_failed", t.Name, err.Error()) } c.JSON(500, gin.H{"error": "登记设备 Peer 失败: " + err.Error()}) return } store.DB.Model(&device).Update("peer_id", device.PeerID) store.Audit(username, "client_register_device", t.Name, fmt.Sprintf("设备 %q 分配 %s", device.Name, device.ClientAddress)) sessionmgr.LogConnection(t.ID, username, "connect", c.ClientIP(), fmt.Sprintf("客户端设备 %q 登记", device.Name)) params, err := wg.DeviceConnectionParams(t, device.PeerID) if err != nil { c.JSON(500, gin.H{"error": "读取连接参数失败: " + err.Error()}) return } params["device_id"] = device.ID params["device_name"] = device.Name params["tunnel_id"] = t.ID params["tunnel_name"] = t.Name c.JSON(200, params) } func (s *Server) clientListDevices(c *gin.Context) { var devices []model.ClientDevice users, _ := c.Get("client_users") ids := make([]uint, 0, len(users.([]model.VPNUser))) for _, u := range users.([]model.VPNUser) { ids = append(ids, u.ID) } store.DB.Where("vpn_user_id IN ?", ids).Order("id desc").Find(&devices) c.JSON(200, gin.H{"devices": devices}) } // clientDeviceConfig 重新拉取设备连接参数(端点/密钥轮换后客户端自愈)。 func (s *Server) clientDeviceConfig(c *gin.Context) { device, err := s.mustClientDevice(c) if err != nil { return } t, err := store.GetTunnel(device.TunnelID) if err != nil { c.JSON(404, gin.H{"error": "线路已删除"}) return } params, err := wireguard.New().DeviceConnectionParams(t, device.PeerID) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } params["device_id"] = device.ID params["device_name"] = device.Name params["tunnel_id"] = t.ID params["tunnel_name"] = t.Name c.JSON(200, params) } // clientDeviceStatus 返回服务端观测到的该设备真实握手与流量。 func (s *Server) clientDeviceStatus(c *gin.Context) { device, err := s.mustClientDevice(c) if err != nil { return } t, err := store.GetTunnel(device.TunnelID) if err != nil { c.JSON(404, gin.H{"error": "线路已删除"}) return } proto, err := s.mgr.Get("wireguard") if err != nil { c.JSON(500, gin.H{"error": "协议引擎不可用"}) return } st, err := proto.Status(t) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } for _, p := range st.Peers { if p.PublicKey != device.PublicKey { continue } updates := map[string]interface{}{"rx_bytes": p.RxBytes, "tx_bytes": p.TxBytes} if p.LatestHandshakeAt > 0 { seen := time.Unix(p.LatestHandshakeAt, 0) updates["last_seen_at"] = &seen } store.DB.Model(device).Updates(updates) c.JSON(200, gin.H{ "running": st.Running, "connected": p.Connected, "latest_handshake_at": p.LatestHandshakeAt, "rx_bytes": p.RxBytes, "tx_bytes": p.TxBytes, }) return } c.JSON(200, gin.H{"running": st.Running, "connected": false, "detail": "网关尚未观测到该设备握手"}) } func (s *Server) clientRevokeDevice(c *gin.Context) { device, err := s.mustClientDevice(c) if err != nil { return } if err := s.revokeDevice(device, c.GetString("client_username")); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"ok": true}) } func (s *Server) mustClientDevice(c *gin.Context) (*model.ClientDevice, error) { var device model.ClientDevice if err := store.DB.First(&device, c.Param("id")).Error; err != nil { c.JSON(404, gin.H{"error": "设备不存在"}) return nil, err } users, _ := c.Get("client_users") allowed := false for _, u := range users.([]model.VPNUser) { if u.ID == device.VPNUserID && u.TunnelID == device.TunnelID { allowed = true break } } if !allowed || device.Username != c.GetString("client_username") { c.JSON(403, gin.H{"error": "只能操作本人设备"}) return nil, fmt.Errorf("forbidden") } if device.Revoked { c.JSON(400, gin.H{"error": "设备已撤销"}) return nil, fmt.Errorf("revoked") } if _, err := store.GetTunnel(device.TunnelID); err != nil { c.JSON(404, gin.H{"error": "线路不存在"}) return nil, err } return &device, nil } // revokeDevice 撤销设备:先从网关移除 Peer(配置 + 运行时),再标记撤销。 func (s *Server) revokeDevice(device *model.ClientDevice, operator string) error { t, err := store.GetTunnel(device.TunnelID) if err == nil && t.Protocol == "wireguard" && device.PeerID != "" { mu := lockTunnel(t.ID) defer mu.Unlock() if err := wireguard.New().RemoveDevicePeer(t, device.PeerID); err != nil { return err } } if err := store.DB.Model(device).Update("revoked", true).Error; err != nil { return err } store.Audit(operator, "client_revoke_device", fmt.Sprintf("线路 %d", device.TunnelID), fmt.Sprintf("撤销用户 %s 的设备 %q(%s)", device.Username, device.Name, device.ClientAddress)) return nil } // ---- 管理端:客户端设备管理 ---- func (s *Server) adminListClientDevices(c *gin.Context) { type row struct { model.ClientDevice TunnelName string `json:"tunnel_name"` } var devices []model.ClientDevice store.DB.Order("id desc").Find(&devices) rows := make([]row, 0, len(devices)) for _, d := range devices { r := row{ClientDevice: d} if t, err := store.GetTunnel(d.TunnelID); err == nil { r.TunnelName = t.Name } rows = append(rows, r) } c.JSON(200, gin.H{"devices": rows}) } func (s *Server) adminRevokeClientDevice(c *gin.Context) { var device model.ClientDevice if err := store.DB.First(&device, c.Param("id")).Error; err != nil { c.JSON(404, gin.H{"error": "设备不存在"}) return } if device.Revoked { c.JSON(400, gin.H{"error": "设备已撤销"}) return } if err := s.revokeDevice(&device, c.GetString("username")); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"ok": true}) }