first commit
This commit is contained in:
20
apps/ldap-auth/Dockerfile
Normal file
20
apps/ldap-auth/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
COPY apps/ldap-auth/go.mod apps/ldap-auth/go.sum ./
|
||||
RUN go mod download
|
||||
COPY apps/ldap-auth ./
|
||||
RUN go build -o /out/ldap-auth .
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
WORKDIR /app
|
||||
RUN adduser -D -H ldap
|
||||
COPY --from=builder /out/ldap-auth /app/ldap-auth
|
||||
|
||||
ENV PORT="8086"
|
||||
|
||||
EXPOSE 8086
|
||||
|
||||
USER ldap
|
||||
CMD ["/app/ldap-auth"]
|
||||
14
apps/ldap-auth/go.mod
Normal file
14
apps/ldap-auth/go.mod
Normal file
@@ -0,0 +1,14 @@
|
||||
module lendry.ru/id/ldap-auth
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.12
|
||||
|
||||
require github.com/go-ldap/ldap/v3 v3.4.12
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
golang.org/x/crypto v0.36.0 // indirect
|
||||
)
|
||||
36
apps/ldap-auth/go.sum
Normal file
36
apps/ldap-auth/go.sum
Normal file
@@ -0,0 +1,36 @@
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
604
apps/ldap-auth/main.go
Normal file
604
apps/ldap-auth/main.go
Normal file
@@ -0,0 +1,604 @@
|
||||
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...")
|
||||
}
|
||||
Reference in New Issue
Block a user