feat(client): add authorized WireGuard device enrollment and desktop connection

This commit is contained in:
opsx committed 2026-10-21 14:53:55 +08:00
1 parent d2f1f5040e
commit 6dd3c7dd45
25 files changed
+3274 -131

No files matched your search

+709
View File
@@ -0,0 +1,709 @@
// 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})
}