Files
IdP/apps/ldap-auth/main.go
2026-06-24 20:15:19 +03:00

613 lines
18 KiB
Go

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) {
resolvedHost, err := resolveHostAddress(target.host)
if err != nil {
return nil, fmt.Errorf("DNS (%s): %w", target.host, err)
}
address := fmt.Sprintf("%s:%d", resolvedHost, 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)
}
if strings.Contains(lastErr.Error(), "server misbehaving") || strings.Contains(lastErr.Error(), "no such host") || strings.Contains(lastErr.Error(), "lookup ") || strings.Contains(lastErr.Error(), "DNS (") {
return nil, fmt.Errorf("не удалось разрешить имя LDAP-сервера (DNS). Добавьте в .env: LDAP_EXTRA_HOSTS=DC-1.mvkug.local:<IP> и LDAP_DNS_SERVERS=<IP>, затем пересоздайте ldap-auth: %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...")
}