- backend: 新增 GRE 隧道实现(ip tunnel 点对点互联),binmgr 支持 iproute2 - backend: api/identity 调整,各协议 service(gre/ikev2/ipsec/l2tp/openvpn)统一改动 - backend: OpenVPN 认证辅助与用户口令同步逻辑完善 - frontend: 协议图标/向导/编辑器支持 GRE,VPN 用户页重构(234 行) - frontend: 新增 RouteListInput 路由列表输入组件,Dashboard/Layout 优化 - i18n: 中英文文案同步更新 - test: 新增 binmgr/common/gre 单测,openvpn 测试补充 - docs: README 补充 GRE 说明,默认监听端口 8080 → 18080 - chore: .gitignore 增加 opsx-vpn.previous(构建备份二进制)
1113 lines
34 KiB
Go
1113 lines
34 KiB
Go
package api
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"crypto/subtle"
|
||
"crypto/tls"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"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/ldaputil"
|
||
"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"
|
||
)
|
||
|
||
type pendingMFA struct {
|
||
secret string
|
||
backups []string
|
||
expires time.Time
|
||
}
|
||
|
||
var (
|
||
pendingMFAMu sync.Mutex
|
||
pendingMFAs = map[uint]pendingMFA{}
|
||
)
|
||
|
||
func requireRole(roles ...string) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
role := c.GetString("role")
|
||
for _, allowed := range roles {
|
||
if role == allowed {
|
||
c.Next()
|
||
return
|
||
}
|
||
}
|
||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足"})
|
||
}
|
||
}
|
||
|
||
func (s *Server) authenticateUser(username, password string) (*model.User, error) {
|
||
var user model.User
|
||
err := store.DB.Where("username = ?", username).First(&user).Error
|
||
if err == nil && user.Source != "ldap" {
|
||
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil {
|
||
return &user, nil
|
||
}
|
||
return nil, fmt.Errorf("invalid password")
|
||
}
|
||
if user.Source == "ldap" || err != nil {
|
||
return s.authenticateLDAP(username, password)
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
func ldapSettings() (*model.LDAPConfig, error) {
|
||
var cfg model.LDAPConfig
|
||
if err := store.DB.First(&cfg).Error; err != nil || !cfg.Enabled {
|
||
return nil, fmt.Errorf("LDAP 未启用")
|
||
}
|
||
plain, err := store.DecryptSecret(cfg.BindPassword)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("LDAP 配置密钥无效")
|
||
}
|
||
cfg.BindPassword = plain
|
||
if cfg.UsernameAttribute == "" {
|
||
cfg.UsernameAttribute = "uid"
|
||
}
|
||
if cfg.UserFilter == "" {
|
||
cfg.UserFilter = "(objectClass=inetOrgPerson)"
|
||
}
|
||
return &cfg, nil
|
||
}
|
||
|
||
func ldapConnect(cfg *model.LDAPConfig) (*ldap.Conn, error) {
|
||
dialURL, err := ldaputil.DialURL(cfg.URL)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
conn, err := ldap.DialURL(dialURL)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if cfg.StartTLS && strings.HasPrefix(dialURL, "ldap://") {
|
||
host := ldaputil.Hostname(dialURL)
|
||
if err := conn.StartTLS(&tls.Config{ServerName: host}); err != nil {
|
||
conn.Close()
|
||
return nil, fmt.Errorf("StartTLS 升级失败: %w", err)
|
||
}
|
||
}
|
||
if cfg.BindDN != "" {
|
||
if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil {
|
||
conn.Close()
|
||
return nil, err
|
||
}
|
||
}
|
||
return conn, nil
|
||
}
|
||
|
||
func (s *Server) authenticateLDAP(username, password string) (*model.User, error) {
|
||
cfg, err := ldapSettings()
|
||
if err != nil || password == "" {
|
||
return nil, fmt.Errorf("LDAP 认证不可用")
|
||
}
|
||
conn, err := ldapConnect(cfg)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer conn.Close()
|
||
filter := cfg.UserFilter
|
||
if strings.Contains(filter, "%s") {
|
||
filter = fmt.Sprintf(filter, ldap.EscapeFilter(username))
|
||
} else {
|
||
filter = fmt.Sprintf("(&%s(%s=%s))", filter, cfg.UsernameAttribute, ldap.EscapeFilter(username))
|
||
}
|
||
result, err := conn.Search(ldap.NewSearchRequest(cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 5, false, filter, []string{"dn", cfg.UsernameAttribute, "cn", "mail"}, nil))
|
||
if err != nil || len(result.Entries) != 1 {
|
||
return nil, fmt.Errorf("LDAP 用户不存在")
|
||
}
|
||
entry := result.Entries[0]
|
||
if err := conn.Bind(entry.DN, password); err != nil {
|
||
return nil, fmt.Errorf("LDAP 密码错误")
|
||
}
|
||
var user model.User
|
||
err = store.DB.Where("username = ?", username).First(&user).Error
|
||
if err != nil {
|
||
user = model.User{Username: username, Email: entry.GetAttributeValue("mail"), Source: "ldap", Role: "viewer", Enabled: true}
|
||
if err := store.DB.Create(&user).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return &user, nil
|
||
}
|
||
|
||
func verifyMFA(user *model.User, code string) error {
|
||
var device model.MFADevice
|
||
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(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
|
||
c.JSON(200, gin.H{"enabled": err == nil && device.Enabled})
|
||
}
|
||
|
||
func backupCodes() []string {
|
||
codes := make([]string, 10)
|
||
for i := range codes {
|
||
raw := make([]byte, 5)
|
||
_, _ = rand.Read(raw)
|
||
codes[i] = fmt.Sprintf("%X", raw)
|
||
}
|
||
return codes
|
||
}
|
||
|
||
func (s *Server) mfaSetup(c *gin.Context) {
|
||
var user model.User
|
||
if err := store.DB.First(&user, c.GetUint("user_id")).Error; err != nil {
|
||
c.JSON(404, gin.H{"error": "用户不存在"})
|
||
return
|
||
}
|
||
key, err := totp.Generate(totp.GenerateOpts{Issuer: "OPSX VPN", AccountName: user.Username})
|
||
if err != nil {
|
||
c.JSON(500, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
pendingMFAMu.Lock()
|
||
pendingMFAs[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(), "expires_in": 900})
|
||
}
|
||
|
||
func (s *Server) mfaEnable(c *gin.Context) {
|
||
var req struct {
|
||
Code string `json:"code"`
|
||
}
|
||
if c.ShouldBindJSON(&req) != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
userID := c.GetUint("user_id")
|
||
pendingMFAMu.Lock()
|
||
pending, ok := pendingMFAs[userID]
|
||
delete(pendingMFAs, userID)
|
||
pendingMFAMu.Unlock()
|
||
if !ok || time.Now().After(pending.expires) || !totp.Validate(req.Code, pending.secret) {
|
||
c.JSON(400, gin.H{"error": "验证码无效或设置已过期"})
|
||
return
|
||
}
|
||
secret, _ := store.EncryptSecret(pending.secret)
|
||
raw, _ := json.Marshal(pending.backups)
|
||
backups, _ := store.EncryptSecret(string(raw))
|
||
device := model.MFADevice{UserID: userID, Secret: secret, BackupCodes: backups, Enabled: true}
|
||
store.DB.Where("user_id = ?", userID).Assign(device).FirstOrCreate(&device)
|
||
store.Audit(c.GetString("username"), "enable_mfa", c.GetString("username"), "")
|
||
c.JSON(200, gin.H{"ok": true, "backup_codes": pending.backups})
|
||
}
|
||
|
||
func (s *Server) mfaDisable(c *gin.Context) {
|
||
store.DB.Where("user_id = ?", c.GetUint("user_id")).Delete(&model.MFADevice{})
|
||
store.Audit(c.GetString("username"), "disable_mfa", c.GetString("username"), "")
|
||
c.JSON(200, gin.H{"ok": true})
|
||
}
|
||
|
||
func (s *Server) listUsers(c *gin.Context) {
|
||
var users []model.User
|
||
store.DB.Order("id").Find(&users)
|
||
c.JSON(200, users)
|
||
}
|
||
|
||
func (s *Server) createUser(c *gin.Context) {
|
||
var req struct {
|
||
Username, Password, Email, Role string
|
||
Enabled bool `json:"enabled"`
|
||
}
|
||
if c.ShouldBindJSON(&req) != nil || req.Username == "" || len(req.Password) < 8 {
|
||
c.JSON(400, gin.H{"error": "用户名及至少 8 位的密码必填"})
|
||
return
|
||
}
|
||
if req.Role == "" {
|
||
req.Role = "viewer"
|
||
}
|
||
if req.Role != "admin" && req.Role != "operator" && req.Role != "viewer" {
|
||
c.JSON(400, gin.H{"error": "角色无效"})
|
||
return
|
||
}
|
||
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||
user := model.User{Username: req.Username, Password: string(hash), Email: req.Email, Role: req.Role, Source: "local", Enabled: req.Enabled}
|
||
if err := store.DB.Create(&user).Error; err != nil {
|
||
c.JSON(400, gin.H{"error": "用户创建失败:" + err.Error()})
|
||
return
|
||
}
|
||
store.Audit(c.GetString("username"), "create_user", user.Username, "")
|
||
c.JSON(200, user)
|
||
}
|
||
|
||
func (s *Server) updateUser(c *gin.Context) {
|
||
id, _ := strconv.Atoi(c.Param("id"))
|
||
var user model.User
|
||
if store.DB.First(&user, id).Error != nil {
|
||
c.JSON(404, gin.H{"error": "用户不存在"})
|
||
return
|
||
}
|
||
var req struct {
|
||
Email, Role, Password string
|
||
Enabled *bool `json:"enabled"`
|
||
}
|
||
if c.ShouldBindJSON(&req) != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
if req.Email != "" {
|
||
user.Email = req.Email
|
||
}
|
||
if req.Role != "" {
|
||
user.Role = req.Role
|
||
}
|
||
if req.Enabled != nil {
|
||
user.Enabled = *req.Enabled
|
||
}
|
||
if req.Password != "" {
|
||
if len(req.Password) < 8 {
|
||
c.JSON(400, gin.H{"error": "密码至少 8 位"})
|
||
return
|
||
}
|
||
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||
user.Password = string(hash)
|
||
user.MustChangePassword = true // 管理员重置后强制改密
|
||
}
|
||
store.DB.Save(&user)
|
||
c.JSON(200, user)
|
||
}
|
||
|
||
func (s *Server) deleteUser(c *gin.Context) {
|
||
id, _ := strconv.Atoi(c.Param("id"))
|
||
if uint(id) == c.GetUint("user_id") {
|
||
c.JSON(400, gin.H{"error": "不能删除当前用户"})
|
||
return
|
||
}
|
||
store.DB.Delete(&model.User{}, id)
|
||
c.JSON(200, gin.H{"ok": true})
|
||
}
|
||
|
||
func (s *Server) getLDAPConfig(c *gin.Context) {
|
||
var cfg model.LDAPConfig
|
||
if store.DB.First(&cfg).Error != nil {
|
||
c.JSON(200, gin.H{"enabled": false})
|
||
return
|
||
}
|
||
c.JSON(200, cfg)
|
||
}
|
||
|
||
func (s *Server) saveLDAPConfig(c *gin.Context) {
|
||
var req struct {
|
||
URL string `json:"url"`
|
||
BindDN string `json:"bind_dn"`
|
||
BindPassword string `json:"bind_password"`
|
||
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 == "")) {
|
||
c.JSON(400, gin.H{"error": "LDAP 地址与 Base DN 必填"})
|
||
return
|
||
}
|
||
if req.Enabled {
|
||
if _, err := ldaputil.DialURL(req.URL); err != nil {
|
||
c.JSON(400, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
}
|
||
var cfg model.LDAPConfig
|
||
store.DB.First(&cfg)
|
||
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)
|
||
}
|
||
store.DB.Save(&cfg)
|
||
c.JSON(200, gin.H{"ok": true})
|
||
}
|
||
|
||
func (s *Server) testLDAP(c *gin.Context) {
|
||
var req struct {
|
||
URL string `json:"url"`
|
||
BindDN string `json:"bind_dn"`
|
||
BindPassword string `json:"bind_password"`
|
||
StartTLS bool `json:"starttls"`
|
||
}
|
||
if c.ShouldBindJSON(&req) != nil || strings.TrimSpace(req.URL) == "" {
|
||
c.JSON(400, gin.H{"error": "LDAP 服务器地址必填"})
|
||
return
|
||
}
|
||
cfg := &model.LDAPConfig{URL: strings.TrimSpace(req.URL), BindDN: strings.TrimSpace(req.BindDN), BindPassword: req.BindPassword, StartTLS: req.StartTLS}
|
||
if cfg.BindPassword == "" {
|
||
var saved model.LDAPConfig
|
||
if store.DB.First(&saved).Error == nil && saved.BindPassword != "" {
|
||
plain, err := store.DecryptSecret(saved.BindPassword)
|
||
if err != nil {
|
||
c.JSON(400, gin.H{"error": "LDAP 配置密钥无效"})
|
||
return
|
||
}
|
||
cfg.BindPassword = plain
|
||
}
|
||
}
|
||
conn, err := ldapConnect(cfg)
|
||
if err != nil {
|
||
c.JSON(400, gin.H{"error": "LDAP 连接失败: " + err.Error()})
|
||
return
|
||
}
|
||
conn.Close()
|
||
c.JSON(200, gin.H{"ok": true})
|
||
}
|
||
|
||
func (s *Server) listVPNUsers(c *gin.Context) {
|
||
var users []model.VPNUser
|
||
q := store.DB.Order("id desc")
|
||
if id := c.Query("tunnel_id"); id != "" {
|
||
q = q.Where("tunnel_id = ?", id)
|
||
}
|
||
q.Find(&users)
|
||
c.JSON(200, users)
|
||
}
|
||
func (s *Server) createVPNUser(c *gin.Context) {
|
||
var req struct {
|
||
TunnelID uint `json:"tunnel_id"`
|
||
PlatformUserID uint `json:"platform_user_id"`
|
||
Username string `json:"username"`
|
||
DisplayName string `json:"display_name"`
|
||
Email string `json:"email"`
|
||
Source string `json:"source"`
|
||
LDAPDN string `json:"ldap_dn"`
|
||
Password string `json:"password"`
|
||
Enabled *bool `json:"enabled"`
|
||
}
|
||
if c.ShouldBindJSON(&req) != nil || req.TunnelID == 0 || (req.Username == "" && req.PlatformUserID == 0) {
|
||
c.JSON(400, gin.H{"error": "隧道和用户名必填"})
|
||
return
|
||
}
|
||
if req.Source == "" {
|
||
req.Source = "local"
|
||
}
|
||
if req.Source != "local" && req.Source != "ldap" {
|
||
c.JSON(400, gin.H{"error": "来源必须为 local 或 ldap"})
|
||
return
|
||
}
|
||
// 手工创建仅支持独立本地拨号账号。LDAP 身份必须从用户管理批量导入,
|
||
// 这样可以复用平台身份源,并由服务端查找、固定对应的 LDAP DN。
|
||
if req.PlatformUserID == 0 && req.Source == "ldap" {
|
||
c.JSON(400, gin.H{"error": "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
|
||
}
|
||
if req.PlatformUserID != 0 {
|
||
if t.Protocol != "openvpn" {
|
||
c.JSON(400, gin.H{"error": "平台用户导入仅支持 OpenVPN;L2TP/IKEv2 需要独立拨号密码"})
|
||
return
|
||
}
|
||
var account model.User
|
||
if err := store.DB.First(&account, req.PlatformUserID).Error; err != nil {
|
||
c.JSON(404, gin.H{"error": "平台用户不存在"})
|
||
return
|
||
}
|
||
if !account.Enabled {
|
||
c.JSON(400, gin.H{"error": "平台用户已禁用,无法导入"})
|
||
return
|
||
}
|
||
req.Username, req.Email, req.Source = account.Username, account.Email, account.Source
|
||
if req.DisplayName == "" {
|
||
req.DisplayName = account.Username
|
||
}
|
||
if req.Source == "ldap" {
|
||
dn, err := findLDAPUserDN(account.Username)
|
||
if err != nil {
|
||
c.JSON(400, gin.H{"error": "LDAP 平台用户导入失败: " + err.Error()})
|
||
return
|
||
}
|
||
req.LDAPDN = dn
|
||
}
|
||
}
|
||
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 req.PlatformUserID != 0 {
|
||
var account model.User
|
||
if err := store.DB.First(&account, req.PlatformUserID).Error; err != nil || account.Password == "" {
|
||
c.JSON(400, gin.H{"error": "平台本地用户密码不可用"})
|
||
return
|
||
}
|
||
user.Password = account.Password
|
||
} else if len(req.Password) < 8 {
|
||
c.JSON(400, gin.H{"error": "本地拨号用户密码至少 8 位"})
|
||
return
|
||
} else {
|
||
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
|
||
}
|
||
if err := store.DB.Create(&user).Error; err != nil {
|
||
c.JSON(400, gin.H{"error": "创建失败:" + err.Error()})
|
||
return
|
||
}
|
||
_ = refreshDialAuth(t)
|
||
c.JSON(200, user)
|
||
}
|
||
|
||
// importPlatformVPNUsers imports selected platform local/LDAP accounts into an
|
||
// OpenVPN tunnel. L2TP/IKEv2 use CHAP/EAP and require a dedicated dial-in
|
||
// secret, so they intentionally remain manual-user only.
|
||
func (s *Server) importPlatformVPNUsers(c *gin.Context) {
|
||
var req struct {
|
||
TunnelID uint `json:"tunnel_id"`
|
||
PlatformUserIDs []uint `json:"platform_user_ids"`
|
||
}
|
||
if c.ShouldBindJSON(&req) != nil || req.TunnelID == 0 || len(req.PlatformUserIDs) == 0 {
|
||
c.JSON(400, gin.H{"error": "请选择目标 OpenVPN 隧道和至少一个平台用户"})
|
||
return
|
||
}
|
||
t, err := store.GetTunnel(req.TunnelID)
|
||
if err != nil {
|
||
c.JSON(404, gin.H{"error": "隧道不存在"})
|
||
return
|
||
}
|
||
if t.Protocol != "openvpn" {
|
||
c.JSON(400, gin.H{"error": "平台用户批量导入仅支持 OpenVPN;L2TP/IKEv2 请创建独立拨号账号"})
|
||
return
|
||
}
|
||
var accounts []model.User
|
||
if err := store.DB.Where("id IN ? AND enabled = ?", req.PlatformUserIDs, true).Find(&accounts).Error; err != nil {
|
||
c.JSON(500, gin.H{"error": "读取平台用户失败: " + err.Error()})
|
||
return
|
||
}
|
||
created, skipped := 0, []string{}
|
||
for _, account := range accounts {
|
||
var exists int64
|
||
store.DB.Model(&model.VPNUser{}).Where("tunnel_id = ? AND username = ?", t.ID, account.Username).Count(&exists)
|
||
if exists > 0 {
|
||
skipped = append(skipped, account.Username+"(已存在)")
|
||
continue
|
||
}
|
||
user := model.VPNUser{TunnelID: t.ID, Username: account.Username, DisplayName: account.Username, Email: account.Email, Source: account.Source, Enabled: true}
|
||
if account.Source == "ldap" {
|
||
dn, err := findLDAPUserDN(account.Username)
|
||
if err != nil {
|
||
skipped = append(skipped, account.Username+"(LDAP DN 获取失败)")
|
||
continue
|
||
}
|
||
user.LDAPDN = dn
|
||
} else {
|
||
user.Password = account.Password
|
||
}
|
||
if err := store.DB.Create(&user).Error; err != nil {
|
||
skipped = append(skipped, account.Username+"(创建失败)")
|
||
continue
|
||
}
|
||
created++
|
||
}
|
||
store.Audit(c.GetString("username"), "import_vpn_users", t.Name, fmt.Sprintf("成功 %d,跳过 %d", created, len(skipped)))
|
||
c.JSON(200, gin.H{"created": created, "skipped": skipped})
|
||
}
|
||
|
||
func (s *Server) batchDeleteVPNUsers(c *gin.Context) {
|
||
var req struct {
|
||
IDs []uint `json:"ids"`
|
||
}
|
||
if c.ShouldBindJSON(&req) != nil || len(req.IDs) == 0 {
|
||
c.JSON(400, gin.H{"error": "请选择要删除的拨号用户"})
|
||
return
|
||
}
|
||
var users []model.VPNUser
|
||
store.DB.Where("id IN ?", req.IDs).Find(&users)
|
||
tunnels := map[uint]bool{}
|
||
for _, user := range users {
|
||
revokeOpenVPNCert(&user)
|
||
tunnels[user.TunnelID] = true
|
||
}
|
||
if err := store.DB.Delete(&model.VPNUser{}, req.IDs).Error; err != nil {
|
||
c.JSON(500, gin.H{"error": "批量删除失败: " + err.Error()})
|
||
return
|
||
}
|
||
for tunnelID := range tunnels {
|
||
if t, err := store.GetTunnel(tunnelID); err == nil {
|
||
_ = refreshDialAuth(t)
|
||
}
|
||
}
|
||
store.Audit(c.GetString("username"), "batch_delete_vpn_users", "VPN 拨号用户", fmt.Sprintf("删除 %d 个", len(users)))
|
||
c.JSON(200, gin.H{"deleted": len(users)})
|
||
}
|
||
|
||
// findLDAPUserDN resolves a platform LDAP account to the DN required by the
|
||
// OpenVPN authentication helper. The password is never read or stored here.
|
||
func findLDAPUserDN(username string) (string, error) {
|
||
cfg, err := ldapSettings()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
conn, err := ldapConnect(cfg)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer conn.Close()
|
||
filter := cfg.UserFilter
|
||
if strings.Contains(filter, "%s") {
|
||
filter = fmt.Sprintf(filter, ldap.EscapeFilter(username))
|
||
} else {
|
||
filter = fmt.Sprintf("(&%s(%s=%s))", filter, cfg.UsernameAttribute, ldap.EscapeFilter(username))
|
||
}
|
||
result, err := conn.Search(ldap.NewSearchRequest(cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 5, false, filter, []string{"dn"}, nil))
|
||
if err != nil || len(result.Entries) != 1 {
|
||
return "", fmt.Errorf("未找到 LDAP 用户 %q", username)
|
||
}
|
||
return result.Entries[0].DN, nil
|
||
}
|
||
func (s *Server) updateVPNUser(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 {
|
||
DisplayName, Email, Password string
|
||
Enabled *bool `json:"enabled"`
|
||
}
|
||
if c.ShouldBindJSON(&req) != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
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),
|
||
})
|
||
}
|