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:
Carsten Abele 2026-08-14 09:11:38 +02:00
parent 2eb69bdcfe
commit 99ee8758cc
8 changed files with 908 additions and 1 deletions

78
internal/auth/dial.go Normal file
View 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
}
}