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

+78 -6
View File
@@ -452,9 +452,9 @@ func (s *Server) createVPNUser(c *gin.Context) {
return
}
switch t.Protocol {
case "openvpn", "l2tp", "ikev2":
case "openvpn", "l2tp", "ikev2", "wireguard":
default:
c.JSON(400, gin.H{"error": "该协议不支持拨号用户(支持 OpenVPN / L2TP / IKEv2)"})
c.JSON(400, gin.H{"error": "该协议不支持拨号用户(支持 OpenVPN / L2TP / IKEv2 / WireGuard)"})
return
}
if req.PlatformUserID != 0 {
@@ -586,6 +586,14 @@ func (s *Server) batchDeleteVPNUsers(c *gin.Context) {
store.DB.Where("id IN ?", req.IDs).Find(&users)
tunnels := map[uint]bool{}
for _, user := range users {
if err := s.revokeClientDevicesForUser(&user, c.GetString("username")); err != nil {
c.JSON(500, gin.H{"error": "撤销客户端设备失败,请重试:" + err.Error()})
return
}
if err := revokeClientSessionsForUser(&user); err != nil {
c.JSON(500, gin.H{"error": "注销客户端会话失败:" + err.Error()})
return
}
revokeOpenVPNCert(&user)
tunnels[user.TunnelID] = true
}
@@ -642,6 +650,16 @@ func (s *Server) updateVPNUser(c *gin.Context) {
return
}
user.DisplayName, user.Email = req.DisplayName, req.Email
if (req.Enabled != nil && !*req.Enabled && user.Enabled) || req.Password != "" {
if err := s.revokeClientDevicesForUser(&user, c.GetString("username")); err != nil {
c.JSON(500, gin.H{"error": "撤销客户端设备失败,账号未修改:" + err.Error()})
return
}
if err := revokeClientSessionsForUser(&user); err != nil {
c.JSON(500, gin.H{"error": "注销客户端会话失败:" + err.Error()})
return
}
}
if req.Enabled != nil {
if !*req.Enabled && user.Enabled {
revokeOpenVPNCert(&user)
@@ -665,6 +683,14 @@ func (s *Server) deleteVPNUser(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error == nil {
if err := s.revokeClientDevicesForUser(&user, c.GetString("username")); err != nil {
c.JSON(500, gin.H{"error": "撤销客户端设备失败,用户未删除:" + err.Error()})
return
}
if err := revokeClientSessionsForUser(&user); err != nil {
c.JSON(500, gin.H{"error": "注销客户端会话失败:" + err.Error()})
return
}
revokeOpenVPNCert(&user)
tunnelID := user.TunnelID
store.DB.Delete(&model.VPNUser{}, id)
@@ -691,7 +717,21 @@ func revokeOpenVPNCert(user *model.VPNUser) {
}
}
// vpnUserMFASetup 为 OpenVPN 拨号用户生成 TOTP 密钥(待确认)。
// WireGuard Peer 长期有效,用户凭据或授权变化前必须先从网关撤销对应设备。
func (s *Server) revokeClientDevicesForUser(user *model.VPNUser, operator string) error {
var devices []model.ClientDevice
if err := store.DB.Where("vpn_user_id = ? AND revoked = ?", user.ID, false).Find(&devices).Error; err != nil {
return err
}
for i := range devices {
if err := s.revokeDevice(&devices[i], operator); err != nil {
return err
}
}
return nil
}
// vpnUserMFASetup 为 OpenVPN/WireGuard 拨号用户生成 TOTP 密钥(待确认)。
func (s *Server) vpnUserMFASetup(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
@@ -700,13 +740,17 @@ func (s *Server) vpnUserMFASetup(c *gin.Context) {
return
}
t, err := store.GetTunnel(user.TunnelID)
if err != nil || t.Protocol != "openvpn" {
c.JSON(400, gin.H{"error": "仅 OpenVPN 拨号用户支持 MFA"})
if err != nil || (t.Protocol != "openvpn" && t.Protocol != "wireguard") {
c.JSON(400, gin.H{"error": "仅 OpenVPN / WireGuard 拨号用户支持 MFA"})
return
}
issuer := "OPSX-OpenVPN"
if t.Protocol == "wireguard" {
issuer = "OPSX VPN"
}
// TOTP 标准参数(RFC 6238):SHA1、30s、6 位、base32,兼容主流 Authenticator
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "OPSX-OpenVPN",
Issuer: issuer,
AccountName: fmt.Sprintf("%s@%s", user.Username, t.Name),
Period: 30,
Digits: otp.DigitsSix,
@@ -775,6 +819,14 @@ func (s *Server) vpnUserMFAEnable(c *gin.Context) {
user.MFASecret = secret
user.MFABackupCodes = backups
user.MFAEnabled = true
if err := s.revokeClientDevicesForUser(&user, c.GetString("username")); err != nil {
c.JSON(500, gin.H{"error": "撤销已有客户端设备失败,MFA 未启用:" + err.Error()})
return
}
if err := revokeClientSessionsForUser(&user); err != nil {
c.JSON(500, gin.H{"error": "注销客户端会话失败:" + err.Error()})
return
}
if err := store.DB.Save(&user).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
@@ -790,6 +842,14 @@ func (s *Server) vpnUserMFADisable(c *gin.Context) {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
if err := s.revokeClientDevicesForUser(&user, c.GetString("username")); err != nil {
c.JSON(500, gin.H{"error": "撤销客户端设备失败,MFA 未关闭:" + err.Error()})
return
}
if err := revokeClientSessionsForUser(&user); err != nil {
c.JSON(500, gin.H{"error": "注销客户端会话失败:" + err.Error()})
return
}
store.DB.Model(&user).Updates(map[string]interface{}{
"mfa_enabled": false, "mfa_secret": "", "mfa_backup_codes": "",
"mfa_pin": "", "mfa_has_pin": false, "mfa_fail_count": 0, "mfa_locked_until": nil,
@@ -838,6 +898,18 @@ func (s *Server) vpnUserMFAPin(c *gin.Context) {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
if req.Pin != "" && (len(req.Pin) < 4 || len(req.Pin) > 32 || strings.ContainsAny(req.Pin, " \t\r\n")) {
c.JSON(400, gin.H{"error": "PIN 长度需 4-32 位且不能包含空白字符"})
return
}
if err := s.revokeClientDevicesForUser(&user, c.GetString("username")); err != nil {
c.JSON(500, gin.H{"error": "撤销客户端设备失败:" + err.Error()})
return
}
if err := revokeClientSessionsForUser(&user); err != nil {
c.JSON(500, gin.H{"error": "注销客户端会话失败:" + err.Error()})
return
}
if err := openvpn.SetUserMFAPin(&user, req.Pin); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return