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>
414 lines
13 KiB
Go
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])
|
|
}
|