605 lines
17 KiB
Go
605 lines
17 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) {
|
|
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...")
|
|
}
|