From 99ee8758cc8cd55727347d4120de7e630524d131 Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 09:11:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20AD-Authenticator=20mit=20Zwei-Sch?= =?UTF-8?q?ritt-Bind,=20DC-Failover=20und=20verschachtelter=20Gruppenpr?= =?UTF-8?q?=C3=BCfung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 --- go.mod | 9 +- go.sum | 10 ++ internal/auth/ad.go | 330 +++++++++++++++++++++++++++++++++++ internal/auth/ad_test.go | 296 +++++++++++++++++++++++++++++++ internal/auth/addata.go | 47 +++++ internal/auth/addata_test.go | 72 ++++++++ internal/auth/auth.go | 67 +++++++ internal/auth/dial.go | 78 +++++++++ 8 files changed, 908 insertions(+), 1 deletion(-) create mode 100644 internal/auth/ad.go create mode 100644 internal/auth/ad_test.go create mode 100644 internal/auth/addata.go create mode 100644 internal/auth/addata_test.go create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/dial.go diff --git a/go.mod b/go.mod index 967871b..0ede722 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,11 @@ module git.ravensburg.dev/cabele/opnsense-portal go 1.26.5 -require gopkg.in/yaml.v3 v3.0.1 // indirect +require ( + github.com/Azure/go-ntlmssp v0.1.1 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect + github.com/go-ldap/ldap/v3 v3.4.14 // indirect + github.com/google/uuid v1.6.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index 4bc0337..309dba2 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,13 @@ +github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= +github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= +github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ= +github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs= +github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA= +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= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/ad.go b/internal/auth/ad.go new file mode 100644 index 0000000..7d28c8a --- /dev/null +++ b/internal/auth/ad.go @@ -0,0 +1,330 @@ +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) diff --git a/internal/auth/ad_test.go b/internal/auth/ad_test.go new file mode 100644 index 0000000..7c41272 --- /dev/null +++ b/internal/auth/ad_test.go @@ -0,0 +1,296 @@ +package auth + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/go-ldap/ldap/v3" +) + +// fakeConn ist ein LDAP-Server-Ersatz. bindErr bildet Bind-Ergebnisse pro +// DN ab; searches protokolliert alle Filter für Injection-Assertions. +type fakeConn struct { + bindErr map[string]error + entries map[string][]*ldap.Entry // Filter-Substring -> Ergebnis + searches []string + closed bool + dialErr error +} + +func (f *fakeConn) Bind(dn, pw string) error { + if err, ok := f.bindErr[dn+"|"+pw]; ok { + return err + } + if err, ok := f.bindErr[dn]; ok { + return err + } + return nil +} + +func (f *fakeConn) Search(req *ldap.SearchRequest) (*ldap.SearchResult, error) { + f.searches = append(f.searches, req.Filter) + // Echtes AD vergleicht sAMAccountName ohne Rücksicht auf Groß-/Kleinschreibung; + // der Fake muss das nachbilden, sonst testet er strenger als die Wirklichkeit. + filter := strings.ToLower(req.Filter) + for needle, entries := range f.entries { + if strings.Contains(filter, strings.ToLower(needle)) { + return &ldap.SearchResult{Entries: entries}, nil + } + } + return &ldap.SearchResult{}, nil +} + +func (f *fakeConn) Close() error { f.closed = true; return nil } + +func entry(dn string, attrs map[string][]string) *ldap.Entry { + e := &ldap.Entry{DN: dn} + for name, vals := range attrs { + e.Attributes = append(e.Attributes, &ldap.EntryAttribute{Name: name, Values: vals}) + } + return e +} + +const ( + userDN = "CN=Max Mueller,OU=Users,DC=firma,DC=local" + groupDN = "CN=VPN-Users,OU=Groups,DC=firma,DC=local" +) + +// stdEntries liefert die Standardantworten: Gruppen-Auflösung, User-Suche, +// Mitgliedschaftsprüfung. +func stdEntries(inGroup bool) map[string][]*ldap.Entry { + m := map[string][]*ldap.Entry{ + "objectClass=group": {entry(groupDN, nil)}, + "sAMAccountName=mmueller)(userPrincipalName": { + entry(userDN, map[string][]string{"sAMAccountName": {"MMueller"}}), + }, + } + if inGroup { + m["1.2.840.113556.1.4.1941"] = []*ldap.Entry{entry(userDN, nil)} + } + return m +} + +// dialSequence liefert eine Dial-Funktion, die die übergebenen Verbindungen +// der Reihe nach ausgibt und danach bei der letzten bleibt. +func dialSequence(conns []*fakeConn) func(context.Context, string) (conn, error) { + i := 0 + return func(ctx context.Context, server string) (conn, error) { + c := conns[min(i, len(conns)-1)] + i++ + if c.dialErr != nil { + return nil, c.dialErr + } + return c, nil + } +} + +func baseOptions() Options { + return Options{ + Servers: []string{"dc01.firma.local", "dc02.firma.local"}, + Port: 636, + TLSMode: "ldaps", + BaseDN: "DC=firma,DC=local", + BindUser: "svc@firma.local", + BindPassword: "svc-pw", + VPNGroup: "VPN-Users", + Timeout: time.Second, + } +} + +func newAD(t *testing.T, conns ...*fakeConn) *AD { + t.Helper() + opts := baseOptions() + opts.Dial = dialSequence(conns) + a, err := NewAD(opts) + if err != nil { + t.Fatalf("NewAD: %v", err) + } + return a +} + +func TestAuthenticateSuccessCanonicalisesUsername(t *testing.T) { + c := &fakeConn{entries: stdEntries(true)} + a := newAD(t, c) + + id, err := a.Authenticate(context.Background(), " MMueller ", "geheim") + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + // Kanonisch = sAMAccountName aus dem Verzeichnis, lowercase. + if id.Username != "mmueller" { + t.Errorf("Username = %q, want mmueller", id.Username) + } + if !id.HasGroup("VPN-Users") { + t.Errorf("Groups = %v, muss die VPN-Gruppe enthalten", id.Groups) + } +} + +func TestAuthenticateRejectsNonMember(t *testing.T) { + c := &fakeConn{entries: stdEntries(false)} + a := newAD(t, c) + + _, err := a.Authenticate(context.Background(), "mmueller", "geheim") + var ae *Error + if !errors.As(err, &ae) || ae.Reason != ReasonNotInVPNGroup { + t.Fatalf("err = %v, want Reason %q", err, ReasonNotInVPNGroup) + } +} + +func TestAuthenticateMapsBindFailure(t *testing.T) { + c := &fakeConn{entries: stdEntries(true)} + c.bindErr = map[string]error{ + userDN + "|falsch": errors.New( + "LDAP Result Code 49 \"Invalid Credentials\": AcceptSecurityContext error, data 532, v4563"), + } + a := newAD(t, c) + + _, err := a.Authenticate(context.Background(), "mmueller", "falsch") + var ae *Error + if !errors.As(err, &ae) || ae.Reason != ReasonPasswordExpired { + t.Fatalf("err = %v, want Reason %q", err, ReasonPasswordExpired) + } +} + +func TestAuthenticateUnknownUserReportsGenericReason(t *testing.T) { + c := &fakeConn{entries: map[string][]*ldap.Entry{"objectClass=group": {entry(groupDN, nil)}}} + a := newAD(t, c) + + _, err := a.Authenticate(context.Background(), "gibtsnicht", "egal") + var ae *Error + if !errors.As(err, &ae) || ae.Reason != ReasonUserNotFound { + t.Fatalf("err = %v, want Reason %q", err, ReasonUserNotFound) + } + if ae.UserVisible() { + t.Error("unbekannter Benutzer darf keine spezifische Meldung erzeugen") + } +} + +func TestAuthenticateRejectsEmptyPassword(t *testing.T) { + c := &fakeConn{entries: stdEntries(true)} + a := newAD(t, c) + // Leeres Passwort wäre ein anonymer Bind und würde fälschlich gelingen. + _, err := a.Authenticate(context.Background(), "mmueller", "") + var ae *Error + if !errors.As(err, &ae) || ae.Reason != ReasonInvalidCredentials { + t.Fatalf("err = %v, want Reason %q", err, ReasonInvalidCredentials) + } + for _, f := range c.searches { + if strings.Contains(f, "mmueller") { + t.Error("bei leerem Passwort darf gar keine Suche stattfinden") + } + } +} + +func TestAuthenticateEscapesFilterInput(t *testing.T) { + c := &fakeConn{entries: stdEntries(true)} + a := newAD(t, c) + + _, _ = a.Authenticate(context.Background(), "evil)(objectClass=*", "pw") + for _, f := range c.searches { + if strings.Contains(f, "evil)(objectClass=*") { + t.Fatalf("unescapte Benutzereingabe im Filter: %q", f) + } + } + var found bool + for _, f := range c.searches { + if strings.Contains(strings.ToLower(f), `\29\28`) { // ")(" escaped + found = true + } + } + if !found { + t.Errorf("Eingabe wurde nicht escapt; Filter: %v", c.searches) + } +} + +func TestFailoverToSecondDC(t *testing.T) { + dead := &fakeConn{dialErr: errors.New("connection refused")} + alive := &fakeConn{entries: stdEntries(true)} + var failedOver []string + + opts := baseOptions() + opts.OnFailover = func(server string, err error) { failedOver = append(failedOver, server) } + opts.Dial = dialSequence([]*fakeConn{dead, alive}) + a, err := NewAD(opts) + if err != nil { + t.Fatal(err) + } + if _, err := a.Authenticate(context.Background(), "mmueller", "geheim"); err != nil { + t.Fatalf("Failover muss gelingen: %v", err) + } + if len(failedOver) == 0 || failedOver[0] != "dc01.firma.local" { + t.Errorf("OnFailover = %v, want dc01.firma.local", failedOver) + } +} + +func TestAllDCsDownIsBackendUnavailable(t *testing.T) { + dead := &fakeConn{dialErr: errors.New("connection refused")} + a := newAD(t, dead) + _, err := a.Authenticate(context.Background(), "mmueller", "geheim") + var ae *Error + if !errors.As(err, &ae) || ae.Reason != ReasonBackendUnavailable { + t.Fatalf("err = %v, want Reason %q", err, ReasonBackendUnavailable) + } +} + +func TestLookupForTestAuth(t *testing.T) { + c := &fakeConn{entries: stdEntries(true)} + a := newAD(t, c) + res, err := a.Lookup(context.Background(), "MMueller") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if res.DN != userDN || res.SAMAccountName != "mmueller" || !res.InVPNGroup { + t.Errorf("Lookup = %+v", res) + } +} + +func TestConnectionsAreAlwaysClosed(t *testing.T) { + c := &fakeConn{entries: stdEntries(true)} + a := newAD(t, c) + if _, err := a.Authenticate(context.Background(), "mmueller", "geheim"); err != nil { + t.Fatal(err) + } + if !c.closed { + t.Error("Verbindung muss geschlossen werden") + } +} + +func TestSetFailoverHookIsUsed(t *testing.T) { + dead := &fakeConn{dialErr: errors.New("connection refused")} + alive := &fakeConn{entries: stdEntries(true)} + + opts := baseOptions() + opts.Dial = dialSequence([]*fakeConn{dead, alive}) + a, err := NewAD(opts) + if err != nil { + t.Fatal(err) + } + var seen []string + a.SetFailoverHook(func(server string, err error) { seen = append(seen, server) }) + if _, err := a.Authenticate(context.Background(), "mmueller", "geheim"); err != nil { + t.Fatal(err) + } + if len(seen) == 0 || seen[0] != "dc01.firma.local" { + t.Fatalf("Failover-Hook = %v", seen) + } +} + +func TestServiceBindFailureIsNotMaskedByFailover(t *testing.T) { + // Ein falsches Dienstkonto-Passwort ist ein Konfigurationsfehler. + // Es darf nicht als "nächster DC probieren" durchgehen. + c := &fakeConn{ + entries: stdEntries(true), + bindErr: map[string]error{"svc@firma.local": errors.New("LDAP Result Code 49")}, + } + a := newAD(t, c) + _, err := a.Authenticate(context.Background(), "mmueller", "geheim") + if err == nil { + t.Fatal("fehlerhafter Service-Bind muss zum Fehler führen") + } + if !strings.Contains(err.Error(), "Service-Bind") { + t.Errorf("Fehler muss den Service-Bind benennen: %v", err) + } +} diff --git a/internal/auth/addata.go b/internal/auth/addata.go new file mode 100644 index 0000000..00237af --- /dev/null +++ b/internal/auth/addata.go @@ -0,0 +1,47 @@ +package auth + +import ( + "regexp" + "strings" +) + +// dataCodeRe findet den AD-spezifischen data-Code im Text eines +// LDAP-Result-Code-49-Fehlers, z. B. "... error, data 52e, v4563". +var dataCodeRe = regexp.MustCompile(`(?i)\bdata\s+([0-9a-f]{3,4})\b`) + +// dataCodeReasons bildet AD-data-Codes auf Audit-Reasons ab. +// 525 (kein solcher User) wird bewusst wie ein falsches Passwort behandelt, +// damit die Fehlermeldung keine Benutzerexistenz verrät. +var dataCodeReasons = map[string]string{ + "525": ReasonInvalidCredentials, + "52e": ReasonInvalidCredentials, + "530": ReasonInvalidCredentials, // Anmeldung außerhalb erlaubter Zeiten + "531": ReasonInvalidCredentials, // Anmeldung an dieser Station nicht erlaubt + "532": ReasonPasswordExpired, + "533": ReasonAccountDisabled, + "701": ReasonAccountDisabled, // Konto abgelaufen + "773": ReasonPasswordChangeRequired, + "775": ReasonAccountLocked, +} + +// ReasonFromLDAPError leitet den Audit-Reason aus einem LDAP-Bind-Fehler ab. +// Fehler ohne erkennbaren data-Code gelten als Backend-Problem, nicht als +// falsches Passwort — sonst würde ein DC-Ausfall als Fehlanmeldung gezählt +// und den Benutzer über den Rate-Limiter aussperren. +func ReasonFromLDAPError(err error) string { + if err == nil { + return "" + } + msg := err.Error() + if m := dataCodeRe.FindStringSubmatch(msg); m != nil { + if reason, ok := dataCodeReasons[strings.ToLower(m[1])]; ok { + return reason + } + // data-Code vorhanden, aber unbekannt: es war eine echte Ablehnung. + return ReasonInvalidCredentials + } + if strings.Contains(msg, "Result Code 49") || strings.Contains(msg, "Invalid Credentials") { + return ReasonInvalidCredentials + } + return ReasonBackendUnavailable +} diff --git a/internal/auth/addata_test.go b/internal/auth/addata_test.go new file mode 100644 index 0000000..8f8ad9d --- /dev/null +++ b/internal/auth/addata_test.go @@ -0,0 +1,72 @@ +package auth + +import ( + "errors" + "fmt" + "testing" +) + +func TestReasonFromLDAPError(t *testing.T) { + // Formulierung wie sie AD in LDAP-Result-Code 49 liefert. + const tmpl = "LDAP Result Code 49 \"Invalid Credentials\": 80090308: LdapErr: " + + "DSID-0C0903A9, comment: AcceptSecurityContext error, data %s, v4563" + + cases := map[string]string{ + "52e": ReasonInvalidCredentials, + "533": ReasonAccountDisabled, + "775": ReasonAccountLocked, + "532": ReasonPasswordExpired, + "773": ReasonPasswordChangeRequired, + "525": ReasonInvalidCredentials, // user not found -> generisch, keine Enumeration + "701": ReasonAccountDisabled, // account expired + "999": ReasonInvalidCredentials, // unbekannter Code -> generisch + } + for code, want := range cases { + err := errors.New(fmt.Sprintf(tmpl, code)) + if got := ReasonFromLDAPError(err); got != want { + t.Errorf("data %s: got %q, want %q", code, got, want) + } + } +} + +func TestReasonFromLDAPErrorIsCaseInsensitive(t *testing.T) { + err := errors.New("AcceptSecurityContext error, DATA 52E, v4563") + if got := ReasonFromLDAPError(err); got != ReasonInvalidCredentials { + t.Errorf("got %q, want %q", got, ReasonInvalidCredentials) + } +} + +func TestReasonFromLDAPErrorWithoutDataCode(t *testing.T) { + if got := ReasonFromLDAPError(errors.New("connection refused")); got != ReasonBackendUnavailable { + t.Errorf("got %q, want %q", got, ReasonBackendUnavailable) + } + if got := ReasonFromLDAPError(nil); got != "" { + t.Errorf("nil-Fehler muss leeren Reason liefern, got %q", got) + } +} + +func TestErrorUserVisibleOnlyForExpiredPassword(t *testing.T) { + if !(&Error{Reason: ReasonPasswordExpired}).UserVisible() { + t.Error("abgelaufenes Passwort ist die einzige spezifische Meldung") + } + for _, r := range []string{ + ReasonInvalidCredentials, ReasonAccountDisabled, ReasonAccountLocked, + ReasonNotInVPNGroup, ReasonPasswordChangeRequired, ReasonBackendUnavailable, + } { + if (&Error{Reason: r}).UserVisible() { + t.Errorf("Reason %q darf keine spezifische Meldung erzeugen", r) + } + } +} + +func TestErrorUnwraps(t *testing.T) { + inner := errors.New("boom") + err := &Error{Reason: ReasonBackendUnavailable, Err: inner} + if !errors.Is(err, inner) { + t.Error("Error muss den inneren Fehler durchreichen") + } + var authErr *Error + if !errors.As(error(err), &authErr) { + t.Error("errors.As muss *Error finden") + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..7aa5634 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,67 @@ +// Package auth kapselt die Benutzerauthentifizierung hinter einem schmalen +// Interface, damit später weitere Backends (OIDC/Entra ID) ergänzt werden können. +package auth + +import ( + "context" + "fmt" + "strings" +) + +// Identity ist die kanonische Identität eines authentifizierten Benutzers. +type Identity struct { + // Username ist der aus dem Verzeichnis gelesene sAMAccountName in + // Kleinschreibung — niemals die Benutzereingabe. + Username string + // Groups enthält die Gruppen, die für Rollenentscheidungen relevant sind. + // v1 füllt hier nur die VPN-Gruppe; das Feld hält den Weg zu einer + // späteren VPN-Portal-Admins-Gruppe offen. + Groups []string +} + +// HasGroup prüft Gruppenzugehörigkeit ohne Rücksicht auf Groß-/Kleinschreibung. +func (i *Identity) HasGroup(name string) bool { + for _, g := range i.Groups { + if strings.EqualFold(g, name) { + return true + } + } + return false +} + +// Authenticator prüft Zugangsdaten und liefert eine kanonische Identität. +type Authenticator interface { + Authenticate(ctx context.Context, username, password string) (*Identity, error) +} + +// Reason-Codes landen unverändert im Audit-Log. +const ( + ReasonInvalidCredentials = "invalid_credentials" + ReasonAccountDisabled = "account_disabled" + ReasonAccountLocked = "account_locked" + ReasonPasswordExpired = "password_expired" + ReasonPasswordChangeRequired = "password_change_required" + ReasonNotInVPNGroup = "not_in_vpn_group" + ReasonUserNotFound = "user_not_found" + ReasonBackendUnavailable = "backend_unavailable" +) + +// Error trägt den Audit-Reason und den technischen Ursprungsfehler. +type Error struct { + Reason string + Err error +} + +func (e *Error) Error() string { + if e.Err == nil { + return e.Reason + } + return fmt.Sprintf("%s: %v", e.Reason, e.Err) +} + +func (e *Error) Unwrap() error { return e.Err } + +// UserVisible meldet, ob dem Benutzer eine spezifische statt der generischen +// Fehlermeldung gezeigt werden darf. Nur das abgelaufene Passwort ist eine +// Ausnahme — alles andere wäre ein Enumerationsorakel. +func (e *Error) UserVisible() bool { return e.Reason == ReasonPasswordExpired } diff --git a/internal/auth/dial.go b/internal/auth/dial.go new file mode 100644 index 0000000..7edbf9f --- /dev/null +++ b/internal/auth/dial.go @@ -0,0 +1,78 @@ +package auth + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "os" + "strconv" + "time" + + "github.com/go-ldap/ldap/v3" +) + +// conn ist der Ausschnitt der LDAP-Verbindung, den der Authenticator braucht. +// Das Interface existiert, damit Tests ohne echten Verzeichnisdienst laufen. +type conn interface { + Bind(username, password string) error + Search(req *ldap.SearchRequest) (*ldap.SearchResult, error) + Close() error +} + +// ldapConn adaptiert *ldap.Conn an conn (Close hat dort keine Fehlerrückgabe). +type ldapConn struct{ *ldap.Conn } + +func (c ldapConn) Close() error { c.Conn.Close(); return nil } + +// tlsConfigFor baut die TLS-Konfiguration. Verifikation ist immer aktiv — +// für LDAP gibt es bewusst keine Insecure-Option. +func tlsConfigFor(server, caFile string) (*tls.Config, error) { + cfg := &tls.Config{ServerName: server, MinVersion: tls.VersionTLS12} + if caFile == "" { + return cfg, nil + } + pem, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("ad.ca_file %s: %w", caFile, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("ad.ca_file %s enthält kein gültiges PEM-Zertifikat", caFile) + } + cfg.RootCAs = pool + return cfg, nil +} + +// realDialer erzeugt die Dial-Funktion für den Produktivbetrieb. +func realDialer(port int, tlsMode, caFile string, timeout time.Duration) func(context.Context, string) (conn, error) { + return func(ctx context.Context, server string) (conn, error) { + tlsCfg, err := tlsConfigFor(server, caFile) + if err != nil { + return nil, err + } + addr := net.JoinHostPort(server, strconv.Itoa(port)) + dialer := &net.Dialer{Timeout: timeout} + + var c *ldap.Conn + if tlsMode == "starttls" { + c, err = ldap.DialURL("ldap://"+addr, ldap.DialWithDialer(dialer)) + if err != nil { + return nil, fmt.Errorf("Verbindung zu %s: %w", addr, err) + } + if err := c.StartTLS(tlsCfg); err != nil { + c.Close() + return nil, fmt.Errorf("StartTLS zu %s: %w", addr, err) + } + } else { + c, err = ldap.DialURL("ldaps://"+addr, + ldap.DialWithDialer(dialer), ldap.DialWithTLSConfig(tlsCfg)) + if err != nil { + return nil, fmt.Errorf("LDAPS-Verbindung zu %s: %w", addr, err) + } + } + c.SetTimeout(timeout) + return ldapConn{c}, nil + } +}