// 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 "" }