package auth import ( "context" "errors" "fmt" "strings" "sync" "sync/atomic" "time" "github.com/go-ldap/ldap/v3" ) // MatchingRuleInChain ist die AD-Erweiterung LDAP_MATCHING_RULE_IN_CHAIN. // Sie löst verschachtelte Gruppenmitgliedschaften in einer einzigen Abfrage auf. const MatchingRuleInChain = "1.2.840.113556.1.4.1941" const defaultTimeout = 8 * time.Second // FailoverHook wird gerufen, wenn ein Domain Controller übersprungen wird. type FailoverHook func(server string, err error) // Options konfiguriert den AD-Authenticator. type Options struct { Servers []string Port int TLSMode string // "ldaps" oder "starttls" BaseDN string BindUser string BindPassword string VPNGroup string CAFile string Timeout time.Duration // Dial ist injizierbar; nil = echte LDAP-Verbindung. Dial func(ctx context.Context, server string) (conn, error) // OnFailover kann auch nachträglich über SetFailoverHook gesetzt werden. OnFailover FailoverHook } // AD authentifiziert gegen Active Directory. type AD struct { opts Options // failover wird atomar gehalten, weil der Hook erst nach dem Öffnen des // Audit-Logs gesetzt wird, withConn ihn aber sperrfrei lesen muss. failover atomic.Pointer[FailoverHook] mu sync.RWMutex groupDN string } // NewAD prüft die Optionen und liefert den Authenticator. // Der Gruppen-DN wird erst durch ResolveGroupDN aufgelöst und danach gecacht. func NewAD(opts Options) (*AD, error) { if len(opts.Servers) == 0 { return nil, errors.New("ad: keine Domain Controller konfiguriert") } if opts.BindUser == "" || opts.BindPassword == "" { return nil, errors.New("ad: Service-Account (bind_user/bind_password) fehlt") } if opts.BaseDN == "" { return nil, errors.New("ad: Base-DN ist leer") } if opts.VPNGroup == "" { return nil, errors.New("ad: vpn_group fehlt") } if opts.Timeout <= 0 { opts.Timeout = defaultTimeout } if opts.Port == 0 { opts.Port = 636 } if opts.Dial == nil { opts.Dial = realDialer(opts.Port, opts.TLSMode, opts.CAFile, opts.Timeout) } a := &AD{opts: opts} if opts.OnFailover != nil { a.SetFailoverHook(opts.OnFailover) } return a, nil } // SetFailoverHook setzt den Failover-Rückruf, auch noch nach dem Start. // Das ist nötig, weil der Audit-Logger erst nach der Startprüfung geöffnet wird. func (a *AD) SetFailoverHook(fn FailoverHook) { if fn == nil { a.failover.Store(nil) return } a.failover.Store(&fn) } func (a *AD) notifyFailover(server string, err error) { if p := a.failover.Load(); p != nil { (*p)(server, err) } } // GroupDN liefert den zwischengespeicherten Gruppen-DN. func (a *AD) GroupDN() string { a.mu.RLock() defer a.mu.RUnlock() return a.groupDN } // withConn verbindet sich zum ersten erreichbaren DC, bindet den // Service-Account und ruft fn. func (a *AD) withConn(ctx context.Context, fn func(c conn) error) error { var lastErr error for _, server := range a.opts.Servers { c, err := a.opts.Dial(ctx, server) if err != nil { lastErr = fmt.Errorf("%s: %w", server, err) a.notifyFailover(server, err) continue } if err := c.Bind(a.opts.BindUser, a.opts.BindPassword); err != nil { c.Close() // Ein falsches Dienstkonto-Passwort ist ein Konfigurationsfehler // und wird nicht durch Failover kaschiert. return fmt.Errorf("Service-Bind an %s fehlgeschlagen: %w", server, err) } defer c.Close() return fn(c) } return fmt.Errorf("kein Domain Controller erreichbar: %w", lastErr) } // searchOne führt eine Suche aus und liefert höchstens einen Eintrag. func searchOne(c conn, baseDN, filter string, attrs []string, timeout time.Duration) (*ldap.Entry, error) { req := ldap.NewSearchRequest( baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, int(timeout.Seconds()), false, filter, attrs, nil) res, err := c.Search(req) if err != nil { // Referrals in Single-Domain-Umgebungen ignorieren. if ldap.IsErrorWithCode(err, ldap.LDAPResultReferral) { return nil, nil } return nil, err } if len(res.Entries) == 0 { return nil, nil } return res.Entries[0], nil } // resolveGroupDN sucht den DN der VPN-Gruppe über ihren sAMAccountName oder CN. func (a *AD) resolveGroupDN(c conn) (string, error) { name := ldap.EscapeFilter(a.opts.VPNGroup) filter := fmt.Sprintf("(&(objectClass=group)(|(sAMAccountName=%s)(cn=%s)))", name, name) e, err := searchOne(c, a.opts.BaseDN, filter, []string{"distinguishedName"}, a.opts.Timeout) if err != nil { return "", fmt.Errorf("Suche nach Gruppe %q: %w", a.opts.VPNGroup, err) } if e == nil { return "", fmt.Errorf("Gruppe %q wurde unter %s nicht gefunden", a.opts.VPNGroup, a.opts.BaseDN) } return e.DN, nil } // ResolveGroupDN löst den Gruppen-DN auf und legt ihn im Cache ab. // Wird beim Start und von check aufgerufen. func (a *AD) ResolveGroupDN(ctx context.Context) (string, error) { var dn string err := a.withConn(ctx, func(c conn) error { var err error dn, err = a.resolveGroupDN(c) return err }) if err != nil { return "", err } a.mu.Lock() a.groupDN = dn a.mu.Unlock() return dn, nil } // groupDNOrResolve liefert den gecachten DN oder löst ihn nach. func (a *AD) groupDNOrResolve(c conn) (string, error) { if dn := a.GroupDN(); dn != "" { return dn, nil } dn, err := a.resolveGroupDN(c) if err != nil { return "", err } a.mu.Lock() a.groupDN = dn a.mu.Unlock() return dn, nil } // findUser sucht den Benutzer über sAMAccountName oder userPrincipalName. func (a *AD) findUser(c conn, input string) (*ldap.Entry, error) { esc := ldap.EscapeFilter(input) filter := fmt.Sprintf("(&(objectClass=user)(|(sAMAccountName=%s)(userPrincipalName=%s)))", esc, esc) return searchOne(c, a.opts.BaseDN, filter, []string{"sAMAccountName", "distinguishedName"}, a.opts.Timeout) } // inGroup prüft die Mitgliedschaft inkl. verschachtelter Gruppen. func (a *AD) inGroup(c conn, sam, groupDN string) (bool, error) { filter := fmt.Sprintf("(&(sAMAccountName=%s)(memberOf:%s:=%s))", ldap.EscapeFilter(sam), MatchingRuleInChain, ldap.EscapeFilter(groupDN)) e, err := searchOne(c, a.opts.BaseDN, filter, []string{"distinguishedName"}, a.opts.Timeout) if err != nil { return false, fmt.Errorf("Gruppenprüfung für %q: %w", sam, err) } return e != nil, nil } // canonicalName liest sAMAccountName aus dem Verzeichnis, niemals aus der Eingabe. func canonicalName(e *ldap.Entry) string { return strings.ToLower(strings.TrimSpace(e.GetAttributeValue("sAMAccountName"))) } // Authenticate führt den Zwei-Schritt-Bind aus und prüft die Gruppenmitgliedschaft. func (a *AD) Authenticate(ctx context.Context, username, password string) (*Identity, error) { username = strings.TrimSpace(username) // Ein leeres Passwort würde als anonymer Bind durchgehen — hart ablehnen, // bevor überhaupt gesucht wird. if username == "" || password == "" { return nil, &Error{Reason: ReasonInvalidCredentials, Err: errors.New("Benutzername oder Passwort war leer")} } var id *Identity err := a.withConn(ctx, func(c conn) error { groupDN, err := a.groupDNOrResolve(c) if err != nil { return err } userEntry, err := a.findUser(c, username) if err != nil { return err } if userEntry == nil { return &Error{Reason: ReasonUserNotFound, Err: errors.New("kein Verzeichniseintrag zur Eingabe gefunden")} } // Schritt 2: Bind mit dem gefundenen DN und dem eingegebenen Passwort // auf einer eigenen Verbindung, damit die Service-Verbindung // gebunden bleibt. if err := a.bindAs(ctx, userEntry.DN, password); err != nil { return &Error{Reason: ReasonFromLDAPError(err), Err: err} } sam := canonicalName(userEntry) if sam == "" { return &Error{Reason: ReasonUserNotFound, Err: fmt.Errorf("Eintrag %s hat keinen sAMAccountName", userEntry.DN)} } ok, err := a.inGroup(c, sam, groupDN) if err != nil { return err } if !ok { return &Error{Reason: ReasonNotInVPNGroup, Err: fmt.Errorf("%s ist nicht Mitglied von %s", sam, a.opts.VPNGroup)} } id = &Identity{Username: sam, Groups: []string{a.opts.VPNGroup}} return nil }) if err != nil { var ae *Error if errors.As(err, &ae) { return nil, ae } return nil, &Error{Reason: ReasonBackendUnavailable, Err: err} } return id, nil } // bindAs öffnet eine frische Verbindung und bindet als der angegebene DN. func (a *AD) bindAs(ctx context.Context, dn, password string) error { var lastErr error for _, server := range a.opts.Servers { c, err := a.opts.Dial(ctx, server) if err != nil { lastErr = err a.notifyFailover(server, err) continue } err = c.Bind(dn, password) c.Close() return err } return fmt.Errorf("kein Domain Controller für Benutzer-Bind erreichbar: %w", lastErr) } // LookupResult ist das Ergebnis von check --test-auth. type LookupResult struct { DN string SAMAccountName string InVPNGroup bool } // Lookup spielt Suche und Gruppenprüfung ohne Passwort-Bind durch. func (a *AD) Lookup(ctx context.Context, username string) (*LookupResult, error) { var res *LookupResult err := a.withConn(ctx, func(c conn) error { groupDN, err := a.groupDNOrResolve(c) if err != nil { return err } e, err := a.findUser(c, strings.TrimSpace(username)) if err != nil { return err } if e == nil { return fmt.Errorf("Benutzer %q wurde unter %s nicht gefunden", username, a.opts.BaseDN) } sam := canonicalName(e) ok, err := a.inGroup(c, sam, groupDN) if err != nil { return err } res = &LookupResult{DN: e.DN, SAMAccountName: sam, InVPNGroup: ok} return nil }) return res, err } var _ Authenticator = (*AD)(nil)