feat: 全面优化 — UI/UX重构 + 流量审计与会话管理 + 协议部署
UI/UX 优化: - 登录页分屏布局 + 可访问性修复 + 内联错误提示 - 侧栏菜单分组(专线运维/资源管理/系统) + 图标修正 + 子路由高亮 - Dashboard 统计卡渐变图标 + 空状态引导 + 统计卡可点击 - 创建向导: 依赖检测门控 + 高级折叠 + 必填校验 + WireGuard推荐标 - 专线列表: 空状态CTA + 操作列「更多」下拉 + 状态筛选 + SSE日志流 - 协议品牌SVG图标(ProtocolIcon组件)替换emoji - 设计令牌系统(tokens.css)统一颜色/间距/圆角/阴影 功能增强: - 在线会话管理(sessionmgr): 跨协议会话同步 + 踢下线 + 连接审计日志 - 流量配额引擎(trafficmgr): 配额检查 + 超额动作(block/warn/throttle) + 周期重置 - 异常流量告警: 突发流量检测(>90MB/15s) + 审计记录 - 节点SSH凭证管理: 密码/私钥认证 + 即时测试连接 + AES-GCM加密存储 - 协议二进制管理(binmgr): FRP/OpenVPN GitHub下载 + 系统包安装引导(apt/yum/dnf) - 右上角修改密码弹窗 + MFA独立验证页 + 密码强度提示 - 用户管理: 批量导入 + LDAP浏览导入 + 平台用户导入VPN拨号用户 - 404路由 + IPSec对接页可达性修复 - MFA备用码持久化弹窗(替代Toast) - 轮询可见性守卫 + ECharts resize修复 后端: - 3张新表: VPNSession/ConnectionLog/TrafficPolicy - monitor增强: 会话同步+配额检查+异常告警+到期审计 - changePassword统一8位+新旧不同校验 - openvpn-otp文案专业化(移除抄袭感) - SSE日志流(token降级query参数)
This commit is contained in:
1 parent
58c036c34c
commit
1e2f8ef24f
43 files changed
+6783
-460
No files matched your search
@@ -2,8 +2,11 @@ package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -12,10 +15,14 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service/ikev2"
|
||||
"vpn-platform/backend/internal/service/l2tp"
|
||||
"vpn-platform/backend/internal/service/openvpn"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
@@ -80,12 +87,26 @@ func ldapSettings() (*model.LDAPConfig, error) {
|
||||
func ldapConnect(cfg *model.LDAPConfig) (*ldap.Conn, error) {
|
||||
url := cfg.URL
|
||||
if !strings.HasPrefix(url, "ldap://") && !strings.HasPrefix(url, "ldaps://") {
|
||||
url = "ldap://" + url
|
||||
// 安全默认:未显式指定协议时按 ldaps,避免口令明文过网
|
||||
url = "ldaps://" + url
|
||||
}
|
||||
conn, err := ldap.DialURL(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.StartTLS && strings.HasPrefix(url, "ldap://") {
|
||||
host := strings.TrimPrefix(url, "ldap://")
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
if err := conn.StartTLS(&tls.Config{ServerName: host}); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("StartTLS 升级失败: %w", err)
|
||||
}
|
||||
} else if strings.HasPrefix(url, "ldap://") {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("明文 ldap:// 存在口令泄露风险,请改用 ldaps:// 或开启 StartTLS")
|
||||
}
|
||||
if cfg.BindDN != "" {
|
||||
if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil {
|
||||
conn.Close()
|
||||
@@ -130,16 +151,48 @@ func verifyMFA(user *model.User, code string) error {
|
||||
if err := store.DB.Where("user_id = ? AND enabled = ?", user.ID, true).First(&device).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return fmt.Errorf("需要有效的 MFA 验证码")
|
||||
}
|
||||
secret, err := store.DecryptSecret(device.Secret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("MFA 配置无效")
|
||||
}
|
||||
if totp.Validate(strings.TrimSpace(code), secret) {
|
||||
if totp.Validate(code, secret) {
|
||||
return nil
|
||||
}
|
||||
// 备用码一次性消费(丢失验证器时恢复)
|
||||
if consumeAdminBackupCode(&device, code) {
|
||||
store.Audit(user.Username, "mfa_backup_used", user.Username, "使用备用码登录")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("需要有效的 MFA 验证码")
|
||||
}
|
||||
|
||||
func consumeAdminBackupCode(device *model.MFADevice, code string) bool {
|
||||
plain, err := store.DecryptSecret(device.BackupCodes)
|
||||
if err != nil || plain == "" {
|
||||
return false
|
||||
}
|
||||
var codes []string
|
||||
if json.Unmarshal([]byte(plain), &codes) != nil {
|
||||
return false
|
||||
}
|
||||
normalized := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(code), " ", ""))
|
||||
for i, bc := range codes {
|
||||
if subtle.ConstantTimeCompare([]byte(strings.ToUpper(bc)), []byte(normalized)) == 1 {
|
||||
codes = append(codes[:i], codes[i+1:]...)
|
||||
raw, _ := json.Marshal(codes)
|
||||
if enc, err := store.EncryptSecret(string(raw)); err == nil {
|
||||
store.DB.Model(device).Update("backup_codes", enc)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) mfaStatus(c *gin.Context) {
|
||||
var device model.MFADevice
|
||||
err := store.DB.Where("user_id = ?", c.GetUint("user_id")).First(&device).Error
|
||||
@@ -268,6 +321,7 @@ func (s *Server) updateUser(c *gin.Context) {
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
user.Password = string(hash)
|
||||
user.MustChangePassword = true // 管理员重置后强制改密
|
||||
}
|
||||
store.DB.Save(&user)
|
||||
c.JSON(200, user)
|
||||
@@ -300,6 +354,7 @@ func (s *Server) saveLDAPConfig(c *gin.Context) {
|
||||
BaseDN string `json:"base_dn"`
|
||||
UserFilter string `json:"user_filter"`
|
||||
UsernameAttribute string `json:"username_attribute"`
|
||||
StartTLS bool `json:"starttls"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil || (req.Enabled && (req.URL == "" || req.BaseDN == "")) {
|
||||
@@ -308,7 +363,8 @@ func (s *Server) saveLDAPConfig(c *gin.Context) {
|
||||
}
|
||||
var cfg model.LDAPConfig
|
||||
store.DB.First(&cfg)
|
||||
cfg.URL, cfg.BindDN, cfg.BaseDN, cfg.UserFilter, cfg.UsernameAttribute, cfg.Enabled = req.URL, req.BindDN, req.BaseDN, req.UserFilter, req.UsernameAttribute, req.Enabled
|
||||
cfg.URL, cfg.BindDN, cfg.BaseDN, cfg.UserFilter, cfg.UsernameAttribute, cfg.StartTLS, cfg.Enabled =
|
||||
req.URL, req.BindDN, req.BaseDN, req.UserFilter, req.UsernameAttribute, req.StartTLS, req.Enabled
|
||||
if req.BindPassword != "" {
|
||||
cfg.BindPassword, _ = store.EncryptSecret(req.BindPassword)
|
||||
}
|
||||
@@ -361,17 +417,32 @@ func (s *Server) createVPNUser(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "来源必须为 local 或 ldap"})
|
||||
return
|
||||
}
|
||||
t, err := store.GetTunnel(req.TunnelID)
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{"error": "隧道不存在"})
|
||||
return
|
||||
}
|
||||
switch t.Protocol {
|
||||
case "openvpn", "l2tp", "ikev2":
|
||||
default:
|
||||
c.JSON(400, gin.H{"error": "该协议不支持拨号用户(支持 OpenVPN / L2TP / IKEv2)"})
|
||||
return
|
||||
}
|
||||
user := model.VPNUser{TunnelID: req.TunnelID, Username: req.Username, DisplayName: req.DisplayName, Email: req.Email, Source: req.Source, LDAPDN: req.LDAPDN, Enabled: true}
|
||||
if req.Enabled != nil {
|
||||
user.Enabled = *req.Enabled
|
||||
}
|
||||
if user.Source == "local" {
|
||||
if len(req.Password) < 8 {
|
||||
c.JSON(400, gin.H{"error": "本地拨号用户密码必填"})
|
||||
c.JSON(400, gin.H{"error": "本地拨号用户密码至少 8 位"})
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
user.Password = string(hash)
|
||||
// L2TP/IKEv2 需要可逆拨号密钥;OpenVPN 仅用 bcrypt,同时存一份便于统一导出说明。
|
||||
if enc, err := store.EncryptSecret(req.Password); err == nil {
|
||||
user.DialSecret = enc
|
||||
}
|
||||
} else if user.LDAPDN == "" {
|
||||
c.JSON(400, gin.H{"error": "LDAP 拨号用户必须填写 LDAP DN"})
|
||||
return
|
||||
@@ -380,6 +451,7 @@ func (s *Server) createVPNUser(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "创建失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
_ = refreshDialAuth(t)
|
||||
c.JSON(200, user)
|
||||
}
|
||||
func (s *Server) updateVPNUser(c *gin.Context) {
|
||||
@@ -399,17 +471,470 @@ func (s *Server) updateVPNUser(c *gin.Context) {
|
||||
}
|
||||
user.DisplayName, user.Email = req.DisplayName, req.Email
|
||||
if req.Enabled != nil {
|
||||
if !*req.Enabled && user.Enabled {
|
||||
revokeOpenVPNCert(&user)
|
||||
}
|
||||
user.Enabled = *req.Enabled
|
||||
}
|
||||
if req.Password != "" {
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
user.Password = string(hash)
|
||||
if enc, err := store.EncryptSecret(req.Password); err == nil {
|
||||
user.DialSecret = enc
|
||||
}
|
||||
}
|
||||
store.DB.Save(&user)
|
||||
if t, err := store.GetTunnel(user.TunnelID); err == nil {
|
||||
_ = refreshDialAuth(t)
|
||||
}
|
||||
c.JSON(200, user)
|
||||
}
|
||||
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 {
|
||||
revokeOpenVPNCert(&user)
|
||||
tunnelID := user.TunnelID
|
||||
store.DB.Delete(&model.VPNUser{}, id)
|
||||
if t, err := store.GetTunnel(tunnelID); err == nil {
|
||||
_ = refreshDialAuth(t)
|
||||
}
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
store.DB.Delete(&model.VPNUser{}, id)
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func revokeOpenVPNCert(user *model.VPNUser) {
|
||||
if user.Certificate == "" {
|
||||
return
|
||||
}
|
||||
t, err := store.GetTunnel(user.TunnelID)
|
||||
if err != nil || t.Protocol != "openvpn" {
|
||||
return
|
||||
}
|
||||
if err := openvpn.New().RevokeClientCertificate(t, user); err != nil {
|
||||
store.Audit("system", "revoke_cert_failed", user.Username, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// vpnUserMFASetup 为 OpenVPN 拨号用户生成 TOTP 密钥(待确认)。
|
||||
func (s *Server) vpnUserMFASetup(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
t, err := store.GetTunnel(user.TunnelID)
|
||||
if err != nil || t.Protocol != "openvpn" {
|
||||
c.JSON(400, gin.H{"error": "仅 OpenVPN 拨号用户支持 MFA"})
|
||||
return
|
||||
}
|
||||
// TOTP 标准参数(RFC 6238):SHA1、30s、6 位、base32,兼容主流 Authenticator
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: "OPSX-OpenVPN",
|
||||
AccountName: fmt.Sprintf("%s@%s", user.Username, t.Name),
|
||||
Period: 30,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
pendingMFAMu.Lock()
|
||||
// 用 user_id 偏移避免与管理端 MFA 冲突:拨号用户用高位 key 空间
|
||||
pendingMFAs[vpnUserPendingKey(user.ID)] = pendingMFA{
|
||||
secret: key.Secret(), backups: backupCodes(), expires: time.Now().Add(15 * time.Minute),
|
||||
}
|
||||
pendingMFAMu.Unlock()
|
||||
c.JSON(200, gin.H{
|
||||
"otpauth_url": key.URL(),
|
||||
"secret": key.Secret(),
|
||||
"period": 30,
|
||||
"digits": 6,
|
||||
"algorithm": "SHA1",
|
||||
"encoding": "base32",
|
||||
"expires_in": 900,
|
||||
"hint": "兼容主流 Authenticator 应用:扫描二维码或手动输入 base32 密钥",
|
||||
})
|
||||
}
|
||||
|
||||
func vpnUserPendingKey(vpnUserID uint) uint {
|
||||
// 管理端 MFA 用真实 user_id;拨号 MFA pending 用高位偏移隔离。
|
||||
return 1_000_000_000 + vpnUserID
|
||||
}
|
||||
|
||||
func (s *Server) vpnUserMFAEnable(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil || req.Code == "" {
|
||||
c.JSON(400, gin.H{"error": "请输入验证码"})
|
||||
return
|
||||
}
|
||||
pendingMFAMu.Lock()
|
||||
pending, ok := pendingMFAs[vpnUserPendingKey(user.ID)]
|
||||
delete(pendingMFAs, vpnUserPendingKey(user.ID))
|
||||
pendingMFAMu.Unlock()
|
||||
if !ok || time.Now().After(pending.expires) || !totp.Validate(strings.TrimSpace(req.Code), pending.secret) {
|
||||
c.JSON(400, gin.H{"error": "验证码无效或设置已过期,请重新扫描二维码"})
|
||||
return
|
||||
}
|
||||
secret, err := store.EncryptSecret(pending.secret)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "加密 MFA 密钥失败"})
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal(pending.backups)
|
||||
backups, err := store.EncryptSecret(string(raw))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "加密备用码失败"})
|
||||
return
|
||||
}
|
||||
user.MFASecret = secret
|
||||
user.MFABackupCodes = backups
|
||||
user.MFAEnabled = true
|
||||
if err := store.DB.Save(&user).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "enable_vpn_user_mfa", user.Username, "")
|
||||
c.JSON(200, gin.H{"ok": true, "backup_codes": pending.backups, "mfa_enabled": true})
|
||||
}
|
||||
|
||||
func (s *Server) vpnUserMFADisable(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"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,
|
||||
"mfa_last_counter": 0, "mfa_last_used_at": nil,
|
||||
})
|
||||
store.Audit(c.GetString("username"), "disable_vpn_user_mfa", user.Username, "")
|
||||
c.JSON(200, gin.H{"ok": true, "mfa_enabled": false})
|
||||
}
|
||||
|
||||
// vpnUserMFAStatus OTP Manager:查看拨号用户 MFA 状态。
|
||||
func (s *Server) vpnUserMFAStatus(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
locked := user.MFALockedUntil != nil && time.Now().Before(*user.MFALockedUntil)
|
||||
c.JSON(200, gin.H{
|
||||
"mfa_enabled": user.MFAEnabled,
|
||||
"mfa_has_pin": user.MFAHasPin,
|
||||
"mfa_fail_count": user.MFAFailCount,
|
||||
"mfa_locked": locked,
|
||||
"mfa_locked_until": user.MFALockedUntil,
|
||||
"mfa_last_used_at": user.MFALastUsedAt,
|
||||
"compatible": "TOTP 标准(兼容 Google/Microsoft Authenticator,sha1:base32:6:30s)",
|
||||
})
|
||||
}
|
||||
|
||||
// vpnUserMFAPin 设置或清除静态 PIN(连接时第二因子 = PIN+OTP)。
|
||||
func (s *Server) vpnUserMFAPin(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
if !user.MFAEnabled {
|
||||
c.JSON(400, gin.H{"error": "请先启用 MFA 再设置 PIN"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Pin string `json:"pin"` // 空字符串表示清除
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := openvpn.SetUserMFAPin(&user, req.Pin); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "set_vpn_user_mfa_pin", user.Username, "")
|
||||
c.JSON(200, gin.H{"ok": true, "mfa_has_pin": user.MFAHasPin})
|
||||
}
|
||||
|
||||
// vpnUserMFABackupCodes 重新生成备用码。
|
||||
func (s *Server) vpnUserMFABackupCodes(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
codes, err := openvpn.RegenerateBackupCodes(&user)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "regen_vpn_user_mfa_backup", user.Username, "")
|
||||
c.JSON(200, gin.H{"ok": true, "backup_codes": codes})
|
||||
}
|
||||
|
||||
// vpnUserMFAUnlock 清除 MFA 锁定(失败次数过多后)。
|
||||
func (s *Server) vpnUserMFAUnlock(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
if err := openvpn.UnlockUserMFA(&user); err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "unlock_vpn_user_mfa", user.Username, "")
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// vpnUserOTPSecretsLine 导出 TOTP 密钥行(标准 otp-secrets 格式,便于迁移与备份)。
|
||||
func (s *Server) vpnUserOTPSecretsLine(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
line, err := openvpn.GenerateOTPSecretsLine(&user)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"line": line,
|
||||
"format": "user server type:hash:encoding:key:pin:udid client",
|
||||
"note": "标准 otp-secrets 格式密钥行;密钥敏感,请妥善保管,切勿泄露",
|
||||
})
|
||||
}
|
||||
|
||||
// refreshDialAuth 在拨号用户变更后重写 L2TP/IKEv2 认证文件。
|
||||
func refreshDialAuth(t *model.Tunnel) error {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
switch t.Protocol {
|
||||
case "l2tp":
|
||||
return l2tp.New().RefreshAuth(t)
|
||||
case "ikev2":
|
||||
return ikev2.New().RefreshAuth(t)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 用户批量导入 / LDAP 导入 ----
|
||||
|
||||
// listLDAPUsers 浏览 LDAP 目录中的用户列表,供导入选择。复用 ldapSettings + ldapConnect。
|
||||
func (s *Server) listLDAPUsers(c *gin.Context) {
|
||||
cfg, err := ldapSettings()
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "LDAP 未启用或配置不完整:" + err.Error()})
|
||||
return
|
||||
}
|
||||
conn, err := ldapConnect(cfg)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "LDAP 连接失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 搜索 BaseDN 下所有匹配用户过滤器的条目
|
||||
attrs := []string{"dn", cfg.UsernameAttribute, "cn", "mail"}
|
||||
result, err := conn.Search(ldap.NewSearchRequest(
|
||||
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
cfg.UserFilter, attrs, nil,
|
||||
))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "LDAP 搜索失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询本地已有的 LDAP 用户名,标记已导入
|
||||
var existingNames []string
|
||||
store.DB.Model(&model.User{}).Where("source = ?", "ldap").Pluck("username", &existingNames)
|
||||
existingMap := map[string]bool{}
|
||||
for _, n := range existingNames {
|
||||
existingMap[n] = true
|
||||
}
|
||||
|
||||
type ldapUser struct {
|
||||
DN string `json:"dn"`
|
||||
UID string `json:"uid"`
|
||||
CN string `json:"cn"`
|
||||
Mail string `json:"mail"`
|
||||
Imported bool `json:"imported"`
|
||||
}
|
||||
users := make([]ldapUser, 0, len(result.Entries))
|
||||
for _, entry := range result.Entries {
|
||||
uid := entry.GetAttributeValue(cfg.UsernameAttribute)
|
||||
if uid == "" {
|
||||
continue
|
||||
}
|
||||
users = append(users, ldapUser{
|
||||
DN: entry.DN,
|
||||
UID: uid,
|
||||
CN: entry.GetAttributeValue("cn"),
|
||||
Mail: entry.GetAttributeValue("mail"),
|
||||
Imported: existingMap[uid],
|
||||
})
|
||||
}
|
||||
c.JSON(200, users)
|
||||
}
|
||||
|
||||
// batchCreateUsers 批量创建本地用户。
|
||||
func (s *Server) batchCreateUsers(c *gin.Context) {
|
||||
var req struct {
|
||||
Users []struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
} `json:"users"`
|
||||
DefaultRole string `json:"default_role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if req.DefaultRole == "" {
|
||||
req.DefaultRole = "viewer"
|
||||
}
|
||||
type failInfo struct {
|
||||
Username string `json:"username"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
var success, skipped, failed int
|
||||
var fails []failInfo
|
||||
for _, u := range req.Users {
|
||||
if u.Username == "" {
|
||||
failed++
|
||||
fails = append(fails, failInfo{Error: "用户名为空"})
|
||||
continue
|
||||
}
|
||||
// 检查是否已存在
|
||||
var count int64
|
||||
store.DB.Model(&model.User{}).Where("username = ?", u.Username).Count(&count)
|
||||
if count > 0 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
pwd := u.Password
|
||||
if len(pwd) < 8 {
|
||||
failed++
|
||||
fails = append(fails, failInfo{Username: u.Username, Error: "密码至少 8 位"})
|
||||
continue
|
||||
}
|
||||
role := u.Role
|
||||
if role == "" {
|
||||
role = req.DefaultRole
|
||||
}
|
||||
if role != "admin" && role != "operator" && role != "viewer" {
|
||||
role = "viewer"
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
|
||||
user := model.User{
|
||||
Username: u.Username, Password: string(hash), Email: u.Email,
|
||||
Role: role, Source: "local", Enabled: true,
|
||||
}
|
||||
if err := store.DB.Create(&user).Error; err != nil {
|
||||
failed++
|
||||
fails = append(fails, failInfo{Username: u.Username, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
store.Audit(c.GetString("username"), "batch_create_user", u.Username, "")
|
||||
success++
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"success": success, "skipped": skipped, "failed": failed,
|
||||
"errors": fails, "total": len(req.Users),
|
||||
})
|
||||
}
|
||||
|
||||
// importLDAPUsers 从 LDAP 导入选中的用户到本地 DB(source=ldap)。
|
||||
func (s *Server) importLDAPUsers(c *gin.Context) {
|
||||
var req struct {
|
||||
DNs []string `json:"dns"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
cfg, err := ldapSettings()
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "LDAP 未启用:" + err.Error()})
|
||||
return
|
||||
}
|
||||
conn, err := ldapConnect(cfg)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "LDAP 连接失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
type failInfo struct {
|
||||
DN string `json:"dn"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
var success, skipped, failed int
|
||||
var fails []failInfo
|
||||
for _, dn := range req.DNs {
|
||||
// 按 DN 查询单个条目
|
||||
result, err := conn.Search(ldap.NewSearchRequest(
|
||||
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false,
|
||||
"(objectClass=*)", []string{cfg.UsernameAttribute, "cn", "mail"}, nil,
|
||||
))
|
||||
if err != nil || len(result.Entries) == 0 {
|
||||
failed++
|
||||
fails = append(fails, failInfo{DN: dn, Error: "查询失败"})
|
||||
continue
|
||||
}
|
||||
entry := result.Entries[0]
|
||||
uid := entry.GetAttributeValue(cfg.UsernameAttribute)
|
||||
if uid == "" {
|
||||
failed++
|
||||
fails = append(fails, failInfo{DN: dn, Error: "无 uid 属性"})
|
||||
continue
|
||||
}
|
||||
// 检查本地是否已存在
|
||||
var count int64
|
||||
store.DB.Model(&model.User{}).Where("username = ?", uid).Count(&count)
|
||||
if count > 0 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
user := model.User{
|
||||
Username: uid, Email: entry.GetAttributeValue("mail"),
|
||||
Role: "viewer", Source: "ldap", Enabled: true,
|
||||
}
|
||||
if err := store.DB.Create(&user).Error; err != nil {
|
||||
failed++
|
||||
fails = append(fails, failInfo{DN: dn, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
store.Audit(c.GetString("username"), "import_ldap_user", uid, dn)
|
||||
success++
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"success": success, "skipped": skipped, "failed": failed,
|
||||
"errors": fails, "total": len(req.DNs),
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user