Files
archivdms/internal/ldapauth/ldapauth.go
T
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

240 lines
8.2 KiB
Go

// Package ldapauth implements the LDAP bind/search authentication flow against
// a per-tenant directory (config from internal/ldapstore). It uses
// github.com/go-ldap/ldap/v3 (pure Go, CGO_ENABLED=0 compatible).
//
// Flow (Authenticate):
// 1. Connect over LDAPS or StartTLS (cleartext LDAP is rejected).
// 2. Service-bind with bind_dn + decrypted bind password.
// 3. Search base_dn with user_filter, loginName escaped per RFC 4515 to
// prevent LDAP filter injection; expect exactly one entry.
// 4. Re-bind as the found user DN with the user-supplied password
// (this is the actual credential check — no fallback to a local password).
// 5. Optionally search the group tree to decide admin group membership,
// which the caller maps to a role.
package ldapauth
import (
"context"
"crypto/tls"
"fmt"
"net"
"strings"
"time"
"github.com/go-ldap/ldap/v3"
"archivdms/internal/ldapstore"
)
// Result is the outcome of a successful authentication.
type Result struct {
// Username is the attr_username value from the directory (used as the
// local username / ldap_uid on JIT provisioning).
Username string
// Email is the attr_email value.
Email string
// DisplayName is the attr_name value.
DisplayName string
// UserDN is the distinguished name the user bound with.
UserDN string
// IsAdmin is true when admin_group_dn is configured and the user is a
// member of it — mapped by the caller to domain_admin.
IsAdmin bool
}
// Authenticator performs LDAP authentication. It is stateless apart from a
// dial timeout, so a single instance can be shared across requests.
type Authenticator struct {
dialTimeout time.Duration
}
// New returns an Authenticator with the given dial/connect timeout (<=0 uses
// a 10s default).
func New(dialTimeout time.Duration) *Authenticator {
if dialTimeout <= 0 {
dialTimeout = 10 * time.Second
}
return &Authenticator{dialTimeout: dialTimeout}
}
// connect opens a TLS-protected LDAP connection according to cfg.UseTLS.
func (a *Authenticator) connect(cfg *ldapstore.Config) (*ldap.Conn, error) {
tlsCfg := &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
dialer := &net.Dialer{Timeout: a.dialTimeout}
switch cfg.UseTLS {
case ldapstore.TLSModeLDAPS:
conn, err := ldap.DialURL("ldaps://"+addr, ldap.DialWithTLSConfig(tlsCfg), ldap.DialWithDialer(dialer))
if err != nil {
return nil, fmt.Errorf("ldapauth: dial ldaps: %w", err)
}
return conn, nil
case ldapstore.TLSModeStartTLS:
conn, err := ldap.DialURL("ldap://"+addr, ldap.DialWithDialer(dialer))
if err != nil {
return nil, fmt.Errorf("ldapauth: dial ldap: %w", err)
}
if err := conn.StartTLS(tlsCfg); err != nil {
conn.Close()
return nil, fmt.Errorf("ldapauth: starttls: %w", err)
}
return conn, nil
default:
return nil, fmt.Errorf("ldapauth: cleartext LDAP not permitted (use_tls=%q)", cfg.UseTLS)
}
}
// TestConnection performs only the service-bind and a base-DN search — it does
// NOT attempt a user login. Returns the round-trip latency.
func (a *Authenticator) TestConnection(ctx context.Context, cfg *ldapstore.Config, bindPassword string) (time.Duration, error) {
start := time.Now()
conn, err := a.connect(cfg)
if err != nil {
return 0, err
}
defer conn.Close()
conn.SetTimeout(a.dialTimeout)
if err := conn.Bind(cfg.BindDN, bindPassword); err != nil {
return 0, fmt.Errorf("ldapauth: service bind failed: %w", err)
}
// Minimal base-scope search to confirm base_dn is reachable/valid.
req := ldap.NewSearchRequest(
cfg.BaseDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, int(a.dialTimeout.Seconds()), false,
"(objectClass=*)", []string{"dn"}, nil,
)
if _, err := conn.Search(req); err != nil {
return 0, fmt.Errorf("ldapauth: base search failed: %w", err)
}
return time.Since(start), nil
}
// Authenticate runs the full bind/search/re-bind flow.
func (a *Authenticator) Authenticate(ctx context.Context, cfg *ldapstore.Config, bindPassword, loginName, userPassword string) (*Result, error) {
if userPassword == "" {
// Prevent LDAP "unauthenticated bind" (empty password = anonymous success).
return nil, fmt.Errorf("ldapauth: empty password")
}
conn, err := a.connect(cfg)
if err != nil {
return nil, err
}
defer conn.Close()
conn.SetTimeout(a.dialTimeout)
// 1) Service bind.
if err := conn.Bind(cfg.BindDN, bindPassword); err != nil {
return nil, fmt.Errorf("ldapauth: service bind failed: %w", err)
}
// 2) Search for the user. loginName escaped against filter injection.
filter := strings.ReplaceAll(cfg.UserFilter, "%s", ldap.EscapeFilter(loginName))
attrs := []string{"dn", cfg.AttrUsername, cfg.AttrEmail, cfg.AttrName}
searchReq := ldap.NewSearchRequest(
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, int(a.dialTimeout.Seconds()), false,
filter, attrs, nil,
)
sr, err := conn.Search(searchReq)
if err != nil {
return nil, fmt.Errorf("ldapauth: user search failed: %w", err)
}
if len(sr.Entries) == 0 {
return nil, fmt.Errorf("ldapauth: user not found")
}
if len(sr.Entries) > 1 {
return nil, fmt.Errorf("ldapauth: user filter not unique (%d entries)", len(sr.Entries))
}
entry := sr.Entries[0]
res := &Result{
UserDN: entry.DN,
Username: firstNonEmpty(entry.GetAttributeValue(cfg.AttrUsername), loginName),
Email: entry.GetAttributeValue(cfg.AttrEmail),
DisplayName: entry.GetAttributeValue(cfg.AttrName),
}
// 3) Re-bind as the user to verify the password.
if err := conn.Bind(entry.DN, userPassword); err != nil {
return nil, fmt.Errorf("ldapauth: invalid credentials")
}
// 4) Group membership for admin role mapping. Re-bind as service account
// first (the user account may lack read rights on the group tree).
if cfg.AdminGroupDN != "" {
if err := conn.Bind(cfg.BindDN, bindPassword); err != nil {
return nil, fmt.Errorf("ldapauth: re-bind for group search failed: %w", err)
}
isAdmin, err := a.isAdminMember(conn, cfg, res)
if err != nil {
return nil, err
}
res.IsAdmin = isAdmin
}
return res, nil
}
// isAdminMember checks whether the authenticated user belongs to admin_group_dn.
// Two strategies are supported:
// - group_base_dn + group_filter set: search the group tree with a filter
// where %s is replaced by the user DN (escaped), then check whether the
// admin_group_dn is among the returned group DNs.
// - otherwise: a base-scope search of admin_group_dn testing the standard
// member/uniqueMember/memberUid attributes against the user.
func (a *Authenticator) isAdminMember(conn *ldap.Conn, cfg *ldapstore.Config, res *Result) (bool, error) {
if cfg.GroupBaseDN != "" && cfg.GroupFilter != "" {
filter := cfg.GroupFilter
filter = strings.ReplaceAll(filter, "%d", ldap.EscapeFilter(res.UserDN))
filter = strings.ReplaceAll(filter, "%s", ldap.EscapeFilter(res.Username))
req := ldap.NewSearchRequest(
cfg.GroupBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, int(a.dialTimeout.Seconds()), false,
filter, []string{"dn"}, nil,
)
sr, err := conn.Search(req)
if err != nil {
return false, fmt.Errorf("ldapauth: group search failed: %w", err)
}
for _, e := range sr.Entries {
if strings.EqualFold(strings.TrimSpace(e.DN), strings.TrimSpace(cfg.AdminGroupDN)) {
return true, nil
}
}
return false, nil
}
// Fallback: inspect the admin group entry directly.
req := ldap.NewSearchRequest(
cfg.AdminGroupDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, int(a.dialTimeout.Seconds()), false,
"(objectClass=*)", []string{"member", "uniqueMember", "memberUid"}, nil,
)
sr, err := conn.Search(req)
if err != nil {
return false, fmt.Errorf("ldapauth: admin group lookup failed: %w", err)
}
if len(sr.Entries) == 0 {
return false, nil
}
e := sr.Entries[0]
for _, dn := range append(e.GetAttributeValues("member"), e.GetAttributeValues("uniqueMember")...) {
if strings.EqualFold(strings.TrimSpace(dn), strings.TrimSpace(res.UserDN)) {
return true, nil
}
}
for _, uid := range e.GetAttributeValues("memberUid") {
if strings.EqualFold(strings.TrimSpace(uid), strings.TrimSpace(res.Username)) {
return true, nil
}
}
return false, nil
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}