Files
llmgw/internal/directory/ldap.go
T
Ilker ManapandClaude Opus 5.5 ac25a9136a Sign-in is set up from the console, and a directory tried before it is kept
People → Sign-in shows single sign-on, Active Directory and SCIM, and
the directory's last sync. E sets up Entra ID from the tenant and client
IDs; D sets up Active Directory, tried from the control plane first:
each server bound, people who may sign in, members of each mapped group.
The secrets go into the store as store:auth/..., sealed by the master
key, and the CA into the settings, so no control plane needs a file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 12:55:27 +02:00

414 lines
13 KiB
Go

package directory
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/binary"
"encoding/pem"
"errors"
"fmt"
"os"
"strconv"
"strings"
"github.com/go-ldap/ldap/v3"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/secrets"
)
// LDAP is a directory reached over LDAP, written for Active Directory: its
// attribute names, its disabled flag, and its matching rule for nested
// groups (LDAP_MATCHING_RULE_IN_CHAIN).
type LDAP struct {
cfg config.LDAP
tls *tls.Config
dial func(url string) (ldapConn, error)
// password, when set, is the service account's in place of the one
// bind_password_file names: settings tried before they are kept.
password string
}
// WithPassword has the service account bind with pw rather than read it.
func (d *LDAP) WithPassword(pw string) *LDAP {
d.password = pw
return d
}
func (d *LDAP) servicePassword(ctx context.Context) (string, error) {
if d.password != "" {
return d.password, nil
}
pw, err := secrets.Read(ctx, d.cfg.BindPasswordFile)
if err != nil {
return "", fmt.Errorf("auth.ldap.bind_password_file: %w", err)
}
return strings.TrimSpace(string(pw)), nil
}
// ldapConn is the part of a connection used here, so tests need no server.
type ldapConn interface {
Bind(user, password string) error
Search(*ldap.SearchRequest) (*ldap.SearchResult, error)
SearchWithPaging(*ldap.SearchRequest, uint32) (*ldap.SearchResult, error)
Close() error
}
// inChain is AD's matching rule that follows nested group membership.
const inChain = "1.2.840.113556.1.4.1941"
// uacDisabled is userAccountControl's ACCOUNTDISABLE bit.
const uacDisabled = 0x2
var personAttrs = []string{"objectGUID", "sAMAccountName", "userPrincipalName", "mail", "displayName", "userAccountControl"}
// ErrBadCredentials is a person the directory does not know, or a password
// it does not take: said alike, so a form cannot tell which.
var ErrBadCredentials = errors.New("the directory does not take that name and password")
// NewLDAP prepares a directory from its configuration.
func NewLDAP(cfg config.LDAP) (*LDAP, error) {
tc := &tls.Config{MinVersion: tls.VersionTLS12}
ca, where := []byte(cfg.CA), "auth.ldap.ca"
if cfg.CA == "" && cfg.CAFile != "" {
var err error
if ca, err = os.ReadFile(cfg.CAFile); err != nil {
return nil, fmt.Errorf("auth.ldap.ca_file: %w", err)
}
where = "auth.ldap.ca_file " + cfg.CAFile
}
if len(ca) > 0 {
pool, err := certPool(ca)
if err != nil {
return nil, fmt.Errorf("%s: %w", where, err)
}
tc.RootCAs = pool
}
d := &LDAP{cfg: cfg, tls: tc}
d.dial = d.dialURL
return d, nil
}
// certPool is the certificates in pem, each of which must parse: a
// certificate Go refuses (a negative serial, as Samba's own has) is said,
// not skipped into an empty pool.
func certPool(data []byte) (*x509.CertPool, error) {
pool := x509.NewCertPool()
n := 0
for rest := data; ; {
var b *pem.Block
if b, rest = pem.Decode(rest); b == nil {
break
}
if b.Type != "CERTIFICATE" {
continue
}
c, err := x509.ParseCertificate(b.Bytes)
if err != nil {
return nil, err
}
pool.AddCert(c)
n++
}
if n == 0 {
return nil, errors.New("holds no certificate")
}
return pool, nil
}
func (d *LDAP) dialURL(u string) (ldapConn, error) {
c, err := ldap.DialURL(u, ldap.DialWithTLSConfig(d.tls))
if err != nil {
return nil, err
}
c.SetTimeout(d.cfg.RoundTrip())
if strings.HasPrefix(u, "ldap://") {
// Validate refuses ldap:// without start_tls: nothing is sent first.
if err := c.StartTLS(d.tls); err != nil {
c.Close()
return nil, fmt.Errorf("StartTLS: %w", err)
}
}
return c, nil
}
// service is a connection bound as the account that finds people, to the
// first server that answers.
func (d *LDAP) service(ctx context.Context) (ldapConn, error) {
pw, err := d.servicePassword(ctx)
if err != nil {
return nil, err
}
var errs []error
for _, u := range d.cfg.URLs {
c, err := d.dial(u)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", u, err))
continue
}
if err := c.Bind(d.cfg.BindDN, pw); err != nil {
c.Close()
// A server that refuses the service account refuses it
// everywhere: not tried on the next.
return nil, fmt.Errorf("binding as %s on %s: %w", d.cfg.BindDN, u, err)
}
return c, nil
}
return nil, fmt.Errorf("no directory server answered: %w", errors.Join(errs...))
}
// Authenticate checks a person's name and password against the directory
// and says who they are there, groups and all.
func (d *LDAP) Authenticate(ctx context.Context, login, password string) (Identity, error) {
login = strings.TrimSpace(login)
// An empty password is an unauthenticated bind, which succeeds: never
// one for a person.
if login == "" || password == "" {
return Identity{}, ErrBadCredentials
}
// DOMAIN\user, as Windows writes it.
if i := strings.LastIndexByte(login, '\\'); i >= 0 {
login = login[i+1:]
}
c, err := d.service(ctx)
if err != nil {
return Identity{}, err
}
defer c.Close()
q := ldap.EscapeFilter(login)
filter := "(&" + d.people(false) + "(|(sAMAccountName=" + q + ")(userPrincipalName=" + q + ")(mail=" + q + ")))"
res, err := c.Search(ldap.NewSearchRequest(d.cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
2, 0, false, filter, personAttrs, nil))
if err != nil {
return Identity{}, fmt.Errorf("finding %s: %w", login, err)
}
if len(res.Entries) != 1 {
// None, or two people one name matches: neither is let in.
return Identity{}, ErrBadCredentials
}
id := identity(res.Entries[0])
if err := c.Bind(id.DN, password); err != nil {
var le *ldap.Error
if errors.As(err, &le) && le.ResultCode == ldap.LDAPResultInvalidCredentials {
return Identity{}, ErrBadCredentials
}
return Identity{}, fmt.Errorf("checking %s's password: %w", login, err)
}
// Back to the service account to read groups, which a person may not.
pw, err := d.servicePassword(ctx)
if err != nil {
return Identity{}, err
}
if err := c.Bind(d.cfg.BindDN, pw); err != nil {
return Identity{}, err
}
if id.Groups, err = d.groupsOf(c, id.DN); err != nil {
return Identity{}, err
}
return id, nil
}
// groupsOf are a person's groups, nested ones included, by CN and DN.
func (d *LDAP) groupsOf(c ldapConn, dn string) ([]string, error) {
filter := "(&(objectClass=group)(member:" + inChain + ":=" + ldap.EscapeFilter(dn) + "))"
res, err := c.SearchWithPaging(ldap.NewSearchRequest(d.groupBase(), ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
0, 0, false, filter, []string{"cn"}, nil), 500)
if err != nil {
return nil, fmt.Errorf("reading the groups of %s: %w", dn, err)
}
var out []string
for _, e := range res.Entries {
out = append(out, e.GetAttributeValue("cn"), e.DN)
}
return out, nil
}
// People are everyone the directory has under the base, enabled or not,
// each with the mapped groups they are in: what a sync compares against.
func (d *LDAP) People(ctx context.Context, m Mapping) ([]Identity, error) {
c, err := d.service(ctx)
if err != nil {
return nil, err
}
defer c.Close()
res, err := c.SearchWithPaging(ldap.NewSearchRequest(d.cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
0, 0, false, d.people(true), personAttrs, nil), 500)
if err != nil {
return nil, fmt.Errorf("listing people: %w", err)
}
people := make([]Identity, 0, len(res.Entries))
at := map[string]int{}
for _, e := range res.Entries {
id := identity(e)
at[id.ID] = len(people)
people = append(people, id)
}
// Only the groups the mapping names matter: a query each, for its
// members nested ones included, rather than one per person.
for _, g := range m.Groups() {
dns, err := d.groupDNs(c, g)
if err != nil {
return nil, err
}
for _, gdn := range dns {
filter := "(&" + d.people(true) + "(memberOf:" + inChain + ":=" + ldap.EscapeFilter(gdn) + "))"
r, err := c.SearchWithPaging(ldap.NewSearchRequest(d.cfg.BaseDN, ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases, 0, 0, false, filter, []string{"objectGUID"}, nil), 500)
if err != nil {
return nil, fmt.Errorf("reading the members of %s: %w", gdn, err)
}
for _, e := range r.Entries {
if i, ok := at[guid(e.GetRawAttributeValue("objectGUID"))]; ok {
people[i].Groups = append(people[i].Groups, g, gdn)
}
}
}
}
return people, nil
}
// Check is one step of trying a directory.
type Check struct {
Step string `json:"step"`
OK bool `json:"ok"`
Detail string `json:"detail"`
}
// Check tries the directory as configured, before its settings are kept:
// each server reached and the service account bound on it, how many people
// may sign in, and how many are in each group the mapping names.
func (d *LDAP) Check(ctx context.Context, m Mapping) []Check {
pw, err := d.servicePassword(ctx)
if err != nil {
return []Check{{Step: "password", Detail: err.Error()}}
}
var out []Check
var c ldapConn
for _, u := range d.cfg.URLs {
conn, err := d.dial(u)
if err == nil {
if err = conn.Bind(d.cfg.BindDN, pw); err != nil {
conn.Close()
}
}
if err != nil {
out = append(out, Check{Step: u, Detail: err.Error()})
continue
}
out = append(out, Check{Step: u, OK: true, Detail: "reached, bound as " + d.cfg.BindDN})
if c == nil {
c = conn
} else {
conn.Close()
}
}
if c == nil {
return out
}
defer c.Close()
res, err := c.SearchWithPaging(ldap.NewSearchRequest(d.cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
0, 0, false, d.people(false), []string{"objectGUID"}, nil), 500)
switch {
case err != nil:
out = append(out, Check{Step: "people", Detail: err.Error()})
case len(res.Entries) == 0:
out = append(out, Check{Step: "people", Detail: "no enabled user under base_dn matches user_filter: no one could sign in"})
default:
out = append(out, Check{Step: "people", OK: true,
Detail: fmt.Sprintf("%d enabled people under base_dn may sign in", len(res.Entries))})
}
for _, g := range m.Groups() {
dns, err := d.groupDNs(c, g)
if err != nil || len(dns) == 0 {
detail := "no group of that name under " + d.groupBase()
if err != nil {
detail = err.Error()
}
out = append(out, Check{Step: "group " + g, Detail: detail})
continue
}
n := 0
for _, gdn := range dns {
filter := "(&" + d.people(false) + "(memberOf:" + inChain + ":=" + ldap.EscapeFilter(gdn) + "))"
r, err := c.SearchWithPaging(ldap.NewSearchRequest(d.cfg.BaseDN, ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases, 0, 0, false, filter, []string{"objectGUID"}, nil), 500)
if err == nil {
n += len(r.Entries)
}
}
out = append(out, Check{Step: "group " + g, OK: true,
Detail: fmt.Sprintf("%d of the people who may sign in are in it, nested groups included", n)})
}
return out
}
// groupDNs are the groups a mapping's name means: a DN as it is, a CN
// looked up.
func (d *LDAP) groupDNs(c ldapConn, g string) ([]string, error) {
if strings.Contains(g, "=") {
return []string{g}, nil
}
res, err := c.Search(ldap.NewSearchRequest(d.groupBase(), ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
0, 0, false, "(&(objectClass=group)(cn="+ldap.EscapeFilter(g)+"))", []string{"cn"}, nil))
if err != nil {
return nil, fmt.Errorf("finding the group %s: %w", g, err)
}
var out []string
for _, e := range res.Entries {
out = append(out, e.DN)
}
return out, nil
}
// people is the filter for person accounts under the user filter; enabled
// ones only, unless all.
func (d *LDAP) people(all bool) string {
f := "(objectCategory=person)(objectClass=user)"
if !all {
f += "(!(userAccountControl:1.2.840.113556.1.4.803:=" + strconv.Itoa(uacDisabled) + "))"
}
return "(&" + f + d.cfg.UserFilter + ")"
}
// groupBase is where groups are searched: GroupBaseDN, else the domain root
// of BaseDN (its DC= parts).
func (d *LDAP) groupBase() string {
if d.cfg.GroupBaseDN != "" {
return d.cfg.GroupBaseDN
}
var dc []string
for _, rdn := range strings.Split(d.cfg.BaseDN, ",") {
if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rdn)), "DC=") {
dc = append(dc, strings.TrimSpace(rdn))
}
}
if len(dc) == 0 {
return d.cfg.BaseDN
}
return strings.Join(dc, ",")
}
func identity(e *ldap.Entry) Identity {
email := e.GetAttributeValue("mail")
if email == "" {
email = e.GetAttributeValue("userPrincipalName")
}
uac, _ := strconv.Atoi(e.GetAttributeValue("userAccountControl"))
return Identity{
ID: guid(e.GetRawAttributeValue("objectGUID")), DN: e.DN,
Email: email, Name: e.GetAttributeValue("displayName"),
Disabled: uac&uacDisabled != 0,
}
}
// guid is an objectGUID as Windows writes it: its first three parts
// little-endian. Another directory's id is kept as hex.
func guid(b []byte) string {
if len(b) != 16 {
return fmt.Sprintf("%x", b)
}
return fmt.Sprintf("%08x-%04x-%04x-%x-%x", binary.LittleEndian.Uint32(b[0:4]),
binary.LittleEndian.Uint16(b[4:6]), binary.LittleEndian.Uint16(b[6:8]), b[8:10], b[10:16])
}