feat(auth): AD-Authenticator mit Zwei-Schritt-Bind, DC-Failover und verschachtelter Gruppenprüfung
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
parent
2eb69bdcfe
commit
99ee8758cc
8 changed files with 908 additions and 1 deletions
9
go.mod
9
go.mod
|
|
@ -2,4 +2,11 @@ module git.ravensburg.dev/cabele/opnsense-portal
|
||||||
|
|
||||||
go 1.26.5
|
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
|
||||||
|
)
|
||||||
|
|
|
||||||
10
go.sum
10
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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
||||||
330
internal/auth/ad.go
Normal file
330
internal/auth/ad.go
Normal file
|
|
@ -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)
|
||||||
296
internal/auth/ad_test.go
Normal file
296
internal/auth/ad_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
internal/auth/addata.go
Normal file
47
internal/auth/addata.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
72
internal/auth/addata_test.go
Normal file
72
internal/auth/addata_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
67
internal/auth/auth.go
Normal file
67
internal/auth/auth.go
Normal file
|
|
@ -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 }
|
||||||
78
internal/auth/dial.go
Normal file
78
internal/auth/dial.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue