Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
330 lines
9.5 KiB
Go
330 lines
9.5 KiB
Go
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)
|