fix: 修复CSP白屏并优化LDAP配置

This commit is contained in:
Logan.Li committed 2026-07-25 22:28:16 +08:00
1 parent a14d9e62e6
commit c7f59f2cbf
12 files changed
+930 -70

No files matched your search

+48 -26
View File
@@ -6,7 +6,6 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"strconv"
"strings"
@@ -19,6 +18,7 @@ import (
"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"
@@ -85,27 +85,20 @@ 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://") {
// 安全默认:未显式指定协议时按 ldaps,避免口令明文过网
url = "ldaps://" + url
}
conn, err := ldap.DialURL(url)
dialURL, err := ldaputil.DialURL(cfg.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
}
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)
}
} 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 {
@@ -126,7 +119,12 @@ func (s *Server) authenticateLDAP(username, password string) (*model.User, error
return nil, err
}
defer conn.Close()
filter := fmt.Sprintf("(&%s(%s=%s))", cfg.UserFilter, cfg.UsernameAttribute, ldap.EscapeFilter(username))
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 用户不存在")
@@ -361,6 +359,12 @@ func (s *Server) saveLDAPConfig(c *gin.Context) {
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 =
@@ -373,17 +377,35 @@ func (s *Server) saveLDAPConfig(c *gin.Context) {
}
func (s *Server) testLDAP(c *gin.Context) {
cfg, err := ldapSettings()
if err == nil {
conn, connErr := ldapConnect(cfg)
if connErr == nil {
conn.Close()
c.JSON(200, gin.H{"ok": true})
return
}
err = connErr
var req struct {
URL string `json:"url"`
BindDN string `json:"bind_dn"`
BindPassword string `json:"bind_password"`
StartTLS bool `json:"starttls"`
}
c.JSON(400, gin.H{"error": "LDAP 连接失败: " + err.Error()})
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) {
+69
View File
@@ -0,0 +1,69 @@
// Package ldaputil normalizes LDAP server addresses supplied by administrators.
package ldaputil
import (
"fmt"
"net"
"net/url"
"strings"
)
// DialURL accepts an IP address or domain name, with an optional port. Bare
// addresses use LDAPS on port 636. Existing ldap:// and ldaps:// values remain
// supported for backward compatibility.
func DialURL(address string) (string, error) {
address = strings.TrimSpace(address)
if address == "" {
return "", fmt.Errorf("LDAP 服务器地址不能为空")
}
if strings.Contains(address, "://") {
u, err := url.Parse(address)
if err != nil || (u.Scheme != "ldap" && u.Scheme != "ldaps") || u.Host == "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return "", fmt.Errorf("LDAP 服务器地址格式无效")
}
port := "389"
if u.Scheme == "ldaps" {
port = "636"
}
host, err := withDefaultPort(u.Host, port)
if err != nil {
return "", err
}
u.Host = host
return u.String(), nil
}
host, err := withDefaultPort(address, "636")
if err != nil {
return "", err
}
return "ldaps://" + host, nil
}
func withDefaultPort(address, defaultPort string) (string, error) {
if strings.ContainsAny(address, " \t\r\n/?#@") {
return "", fmt.Errorf("LDAP 服务器地址格式无效")
}
if host, port, err := net.SplitHostPort(address); err == nil {
if host == "" || port == "" {
return "", fmt.Errorf("LDAP 服务器地址格式无效")
}
return address, nil
}
host := strings.TrimSuffix(strings.TrimPrefix(address, "["), "]")
if host == "" || (strings.Contains(host, ":") && net.ParseIP(host) == nil) {
return "", fmt.Errorf("LDAP 服务器地址格式无效")
}
return net.JoinHostPort(host, defaultPort), nil
}
// Hostname returns the hostname or IP for TLS ServerName selection.
func Hostname(dialURL string) string {
u, err := url.Parse(dialURL)
if err != nil {
return ""
}
return u.Hostname()
}
+32
View File
@@ -0,0 +1,32 @@
package ldaputil
import "testing"
func TestDialURL(t *testing.T) {
tests := []struct {
address string
want string
}{
{"8.217.205.92", "ldaps://8.217.205.92:636"},
{"ldap.opsx.vip", "ldaps://ldap.opsx.vip:636"},
{"ldap.opsx.vip:1636", "ldaps://ldap.opsx.vip:1636"},
{"2001:db8::1", "ldaps://[2001:db8::1]:636"},
{"ldap://ldap.opsx.vip", "ldap://ldap.opsx.vip:389"},
{"ldaps://ldap.opsx.vip", "ldaps://ldap.opsx.vip:636"},
{" ldaps://8.217.205.92 ", "ldaps://8.217.205.92:636"},
}
for _, tt := range tests {
got, err := DialURL(tt.address)
if err != nil || got != tt.want {
t.Errorf("DialURL(%q) = %q, %v; want %q", tt.address, got, err, tt.want)
}
}
}
func TestDialURLRejectsInvalidAddress(t *testing.T) {
for _, address := range []string{"", "ldap.opsx.vip/path", "ldap.opsx.vip:", "https://ldap.opsx.vip"} {
if _, err := DialURL(address); err == nil {
t.Errorf("DialURL(%q) succeeded; want error", address)
}
}
}
+5 -4
View File
@@ -28,6 +28,7 @@ import (
"gorm.io/gorm"
"vpn-platform/backend/internal/core"
"vpn-platform/backend/internal/ldaputil"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/sessionmgr"
@@ -903,11 +904,11 @@ func verifyDialPassword(user *model.VPNUser, password string) error {
if err := store.DB.Where("enabled = ?", true).First(&cfg).Error; err != nil {
return errors.New("LDAP 未启用")
}
url := cfg.URL
if !strings.HasPrefix(url, "ldap://") && !strings.HasPrefix(url, "ldaps://") {
url = "ldap://" + url
dialURL, err := ldaputil.DialURL(cfg.URL)
if err != nil {
return err
}
conn, err := ldap.DialURL(url)
conn, err := ldap.DialURL(dialURL)
if err != nil {
return err
}