package main import ( "crypto/tls" "encoding/json" "errors" "fmt" "log" "net/http" "os" "os/signal" "strings" "syscall" "time" "github.com/go-ldap/ldap/v3" ) type ldapConfig struct { Host string `json:"host"` Port int `json:"port"` UseLdaps bool `json:"useLdaps"` SkipTlsVerify bool `json:"skipTlsVerify"` Domain string `json:"domain"` BaseDn string `json:"baseDn"` BindDn string `json:"bindDn"` BindPassword string `json:"bindPassword"` UserFilter string `json:"userFilter"` UsernameAttr string `json:"usernameAttr"` EmailAttr string `json:"emailAttr"` DisplayNameAttr string `json:"displayNameAttr"` } type ldapTarget struct { host string port int useLdaps bool } type authenticateRequest struct { Config ldapConfig `json:"config"` Username string `json:"username"` Password string `json:"password"` } type authenticateResponse struct { Success bool `json:"success"` Dn string `json:"dn,omitempty"` Username string `json:"username,omitempty"` Email string `json:"email,omitempty"` DisplayName string `json:"displayName,omitempty"` Error string `json:"error,omitempty"` } func env(key, fallback string) string { if value := os.Getenv(key); value != "" { return value } return fallback } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func resolvePort(port int, useLdaps bool) int { if useLdaps { if port == 640 || port == 389 || port == 0 { return 636 } return port } if port == 0 { return 389 } return port } func parseTargets(cfg ldapConfig) ([]ldapTarget, error) { raw := strings.TrimSpace(cfg.Host) if raw == "" { return nil, errors.New("не указан LDAP host") } parts := strings.Split(raw, ",") targets := make([]ldapTarget, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } useLdaps := cfg.UseLdaps host := part port := cfg.Port if strings.Contains(part, "://") { if strings.HasPrefix(strings.ToLower(part), "ldaps://") { useLdaps = true } if strings.HasPrefix(strings.ToLower(part), "ldap://") { useLdaps = false } withoutScheme := part if idx := strings.Index(withoutScheme, "://"); idx >= 0 { withoutScheme = withoutScheme[idx+3:] } if slash := strings.Index(withoutScheme, "/"); slash >= 0 { withoutScheme = withoutScheme[:slash] } if colon := strings.LastIndex(withoutScheme, ":"); colon > 0 && colon < len(withoutScheme)-1 { host = withoutScheme[:colon] var parsedPort int _, scanErr := fmt.Sscanf(withoutScheme[colon+1:], "%d", &parsedPort) if scanErr == nil && parsedPort > 0 { port = parsedPort } } else { host = withoutScheme } } host = strings.TrimSpace(host) if host == "" { continue } targets = append(targets, ldapTarget{ host: host, port: resolvePort(port, useLdaps), useLdaps: useLdaps, }) } if len(targets) == 0 { return nil, errors.New("не указан LDAP host") } return targets, nil } func connectTarget(target ldapTarget, skipTlsVerify bool) (*ldap.Conn, error) { address := fmt.Sprintf("%s:%d", target.host, target.port) if target.useLdaps { tlsConfig := &tls.Config{ ServerName: target.host, InsecureSkipVerify: skipTlsVerify, } return ldap.DialURL(fmt.Sprintf("ldaps://%s", address), ldap.DialWithTLSConfig(tlsConfig)) } return ldap.DialURL(fmt.Sprintf("ldap://%s", address)) } func connect(cfg ldapConfig) (*ldap.Conn, error) { targets, err := parseTargets(cfg) if err != nil { return nil, err } var lastErr error for _, target := range targets { conn, dialErr := connectTarget(target, cfg.SkipTlsVerify) if dialErr == nil { return conn, nil } lastErr = fmt.Errorf("%s:%d: %w", target.host, target.port, dialErr) } if lastErr != nil { if strings.Contains(lastErr.Error(), "connection refused") { return nil, fmt.Errorf("не удалось подключиться к LDAP (connection refused). Проверьте host, порт (636 для LDAPS) и доступность контейнера ldap-auth до AD: %w", lastErr) } return nil, fmt.Errorf("не удалось подключиться к LDAP: %w", lastErr) } return nil, errors.New("не удалось подключиться к LDAP") } func resolveBindDN(cfg ldapConfig) string { bindDn := strings.TrimSpace(cfg.BindDn) if bindDn == "" { return bindDn } if strings.Contains(bindDn, "=") || strings.Contains(bindDn, `\`) || strings.Contains(bindDn, "@") { return bindDn } domain := strings.TrimSpace(cfg.Domain) if domain != "" { return bindDn + "@" + domain } return bindDn } func normalizeUserFilter(filter string) string { filter = strings.ReplaceAll(filter, "{{username}}", "{username}") filter = strings.ReplaceAll(filter, "{user}", "{username}") filter = strings.TrimSpace(filter) for strings.HasPrefix(filter, "((") && strings.HasSuffix(filter, "))") && len(filter) > 4 { filter = filter[1 : len(filter)-1] } return filter } func fetchDefaultNamingContext(conn *ldap.Conn) string { request := ldap.NewSearchRequest( "", ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false, "(objectClass=*)", []string{"defaultNamingContext"}, nil, ) result, err := conn.Search(request) if err != nil || len(result.Entries) == 0 { return "" } return strings.TrimSpace(result.Entries[0].GetAttributeValue("defaultNamingContext")) } func isNoSuchObject(err error) bool { ldapErr, ok := err.(*ldap.Error) return ok && ldapErr.ResultCode == 32 } func swapRdnPrefix(baseDn, fromPrefix, toPrefix string) string { upper := strings.ToUpper(baseDn) fromUpper := strings.ToUpper(fromPrefix) if !strings.HasPrefix(upper, fromUpper) { return "" } rest := baseDn[len(fromPrefix):] comma := strings.Index(rest, ",") if comma < 0 { return "" } name := strings.TrimSpace(rest[:comma]) suffix := strings.TrimSpace(rest[comma+1:]) if name == "" || suffix == "" { return "" } return toPrefix + name + "," + suffix } func buildSearchBaseCandidates(configured string, defaultNamingContext string) []string { configured = strings.TrimSpace(configured) seen := map[string]struct{}{} out := make([]string, 0, 4) add := func(dn string) { dn = strings.TrimSpace(dn) if dn == "" { return } key := strings.ToLower(dn) if _, exists := seen[key]; exists { return } seen[key] = struct{}{} out = append(out, dn) } add(configured) add(swapRdnPrefix(configured, "CN=", "OU=")) add(swapRdnPrefix(configured, "OU=", "CN=")) add(defaultNamingContext) return out } func searchUser(conn *ldap.Conn, baseDn, filter string, attrs []string) (*ldap.SearchResult, error) { request := ldap.NewSearchRequest( baseDn, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 15, false, filter, attrs, nil, ) return conn.Search(request) } func findMatchingContainers(conn *ldap.Conn, domainRoot, nameHint string) []string { nameHint = strings.TrimSpace(nameHint) if domainRoot == "" || nameHint == "" { return nil } escaped := ldap.EscapeFilter(nameHint) filter := fmt.Sprintf("(|(ou=%s)(cn=%s)(name=%s))", escaped, escaped, escaped) result, err := conn.Search(ldap.NewSearchRequest( domainRoot, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 5, 10, false, filter, []string{"dn"}, nil, )) if err != nil || len(result.Entries) == 0 { return nil } dns := make([]string, 0, len(result.Entries)) for _, entry := range result.Entries { dns = append(dns, entry.DN) } return dns } func extractNameHint(baseDn string) string { parts := strings.Split(baseDn, ",") if len(parts) == 0 { return "" } first := strings.TrimSpace(parts[0]) if eq := strings.Index(first, "="); eq > 0 { return strings.TrimSpace(first[eq+1:]) } return "" } func formatSearchError(baseDn string, defaultNamingContext string, alternatives []string, err error) error { if isNoSuchObject(err) { hint := fmt.Sprintf("Base DN «%s» не существует в Active Directory", baseDn) if ouVariant := swapRdnPrefix(baseDn, "CN=", "OU="); ouVariant != "" && !strings.EqualFold(ouVariant, baseDn) { hint += fmt.Sprintf(". Попробуйте OU вместо CN: %s", ouVariant) } if defaultNamingContext != "" { hint += fmt.Sprintf(". Корень домена: %s", defaultNamingContext) } if len(alternatives) > 0 { hint += fmt.Sprintf(". Найдены похожие контейнеры: %s", strings.Join(alternatives, "; ")) } else { hint += ". Укажите OU или контейнер, где находятся пользователи" } return errors.New(hint) } return fmt.Errorf("ошибка поиска пользователя в «%s»: %w", baseDn, err) } type discoverResponse struct { Success bool `json:"success"` DomainRoot string `json:"domainRoot,omitempty"` ConfiguredBaseDn string `json:"configuredBaseDn,omitempty"` SuggestedBaseDn string `json:"suggestedBaseDn,omitempty"` TopLevelContainers []string `json:"topLevelContainers,omitempty"` MatchingContainers []string `json:"matchingContainers,omitempty"` SearchBaseCandidates []string `json:"searchBaseCandidates,omitempty"` Error string `json:"error,omitempty"` } func discover(cfg ldapConfig) (discoverResponse, error) { if strings.TrimSpace(cfg.BaseDn) == "" { return discoverResponse{}, errors.New("не указан Base DN") } bindDn := resolveBindDN(cfg) if bindDn == "" { return discoverResponse{}, errors.New("не указан Bind DN") } conn, err := connect(cfg) if err != nil { return discoverResponse{}, err } defer conn.Close() if err := conn.Bind(bindDn, cfg.BindPassword); err != nil { return discoverResponse{}, fmt.Errorf("ошибка bind сервисной учётной записи (%s): %w", bindDn, err) } domainRoot := fetchDefaultNamingContext(conn) nameHint := extractNameHint(cfg.BaseDn) candidates := buildSearchBaseCandidates(cfg.BaseDn, domainRoot) matching := findMatchingContainers(conn, domainRoot, nameHint) topLevel := make([]string, 0, 16) if domainRoot != "" { result, searchErr := conn.Search(ldap.NewSearchRequest( domainRoot, ldap.ScopeSingleLevel, ldap.NeverDerefAliases, 0, 10, false, "(|(objectClass=organizationalUnit)(objectClass=container)(objectClass=builtinDomain))", []string{"dn", "name", "ou"}, nil, )) if searchErr == nil { for _, entry := range result.Entries { topLevel = append(topLevel, entry.DN) } } } suggested := cfg.BaseDn if len(matching) > 0 { suggested = matching[0] } else if ouVariant := swapRdnPrefix(cfg.BaseDn, "CN=", "OU="); ouVariant != "" { suggested = ouVariant } else if domainRoot != "" { suggested = domainRoot } return discoverResponse{ Success: true, DomainRoot: domainRoot, ConfiguredBaseDn: cfg.BaseDn, SuggestedBaseDn: suggested, TopLevelContainers: topLevel, MatchingContainers: matching, SearchBaseCandidates: candidates, }, nil } func firstAttr(entry *ldap.Entry, names ...string) string { for _, name := range names { if name == "" { continue } values := entry.GetAttributeValues(name) if len(values) > 0 && strings.TrimSpace(values[0]) != "" { return strings.TrimSpace(values[0]) } } return "" } func authenticate(cfg ldapConfig, username, password string) (authenticateResponse, error) { username = strings.TrimSpace(username) password = strings.TrimSpace(password) if username == "" || password == "" { return authenticateResponse{}, errors.New("укажите логин и пароль") } if strings.TrimSpace(cfg.BaseDn) == "" { return authenticateResponse{}, errors.New("не указан Base DN") } bindDn := resolveBindDN(cfg) if bindDn == "" { return authenticateResponse{}, errors.New("не указан Bind DN") } filterTemplate := normalizeUserFilter(cfg.UserFilter) if filterTemplate == "" { filterTemplate = "(|(sAMAccountName={username})(userPrincipalName={username})(mail={username})(uid={username}))" } filter := strings.ReplaceAll(filterTemplate, "{username}", ldap.EscapeFilter(username)) conn, err := connect(cfg) if err != nil { return authenticateResponse{}, err } defer conn.Close() if err := conn.Bind(bindDn, cfg.BindPassword); err != nil { return authenticateResponse{}, fmt.Errorf("ошибка bind сервисной учётной записи (%s): %w", bindDn, err) } defaultNamingContext := fetchDefaultNamingContext(conn) searchAttrs := []string{"dn", cfg.UsernameAttr, cfg.EmailAttr, cfg.DisplayNameAttr, "cn", "mail", "uid", "sAMAccountName", "userPrincipalName"} searchBases := buildSearchBaseCandidates(cfg.BaseDn, defaultNamingContext) var entry *ldap.Entry var lastSearchErr error var lastBaseDn string for _, baseDn := range searchBases { result, searchErr := searchUser(conn, baseDn, filter, searchAttrs) if searchErr != nil { lastSearchErr = searchErr lastBaseDn = baseDn if isNoSuchObject(searchErr) { continue } return authenticateResponse{}, formatSearchError(baseDn, defaultNamingContext, nil, searchErr) } if len(result.Entries) > 0 { entry = result.Entries[0] break } } if entry == nil { alternatives := findMatchingContainers(conn, defaultNamingContext, extractNameHint(cfg.BaseDn)) if lastSearchErr != nil { return authenticateResponse{}, formatSearchError(lastBaseDn, defaultNamingContext, alternatives, lastSearchErr) } hint := fmt.Sprintf("пользователь «%s» не найден в LDAP", username) if len(alternatives) > 0 { hint += fmt.Sprintf(". Проверьте Base DN — возможные контейнеры: %s", strings.Join(alternatives, "; ")) } else { hint += fmt.Sprintf(". Проверьте Base DN (%s) и фильтр поиска", cfg.BaseDn) } return authenticateResponse{}, errors.New(hint) } userDn := entry.DN if err := conn.Bind(userDn, password); err != nil { return authenticateResponse{}, errors.New("неверный логин или пароль LDAP") } usernameAttr := cfg.UsernameAttr if usernameAttr == "" { usernameAttr = "sAMAccountName" } emailAttr := cfg.EmailAttr if emailAttr == "" { emailAttr = "mail" } displayNameAttr := cfg.DisplayNameAttr if displayNameAttr == "" { displayNameAttr = "displayName" } resolvedUsername := firstAttr(entry, usernameAttr, "sAMAccountName", "uid", "userPrincipalName") if resolvedUsername == "" { resolvedUsername = username } email := firstAttr(entry, emailAttr, "mail", "userPrincipalName") displayName := firstAttr(entry, displayNameAttr, "cn", "displayName") if displayName == "" { displayName = resolvedUsername } return authenticateResponse{ Success: true, Dn: userDn, Username: resolvedUsername, Email: email, DisplayName: displayName, }, nil } func main() { mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) mux.HandleFunc("/discover", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, discoverResponse{Success: false, Error: "Метод не поддерживается"}) return } var req authenticateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, discoverResponse{Success: false, Error: "Некорректное тело запроса"}) return } result, err := discover(req.Config) if err != nil { writeJSON(w, http.StatusBadRequest, discoverResponse{Success: false, Error: err.Error()}) return } writeJSON(w, http.StatusOK, result) }) mux.HandleFunc("/authenticate", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, authenticateResponse{Success: false, Error: "Метод не поддерживается"}) return } var req authenticateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, authenticateResponse{Success: false, Error: "Некорректное тело запроса"}) return } result, err := authenticate(req.Config, req.Username, req.Password) if err != nil { writeJSON(w, http.StatusUnauthorized, authenticateResponse{Success: false, Error: err.Error()}) return } writeJSON(w, http.StatusOK, result) }) server := &http.Server{ Addr: ":" + env("PORT", "8086"), Handler: mux, ReadHeaderTimeout: 10 * time.Second, } go func() { log.Printf("LDAP Auth запущен на %s", server.Addr) if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("Ошибка HTTP сервера: %v", err) } }() stop := make(chan os.Signal, 1) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) <-stop log.Println("Остановка LDAP Auth...") }