auth.ldap checks a username and password against the domain controllers over LDAPS or StartTLS: found with a service account, checked by binding as the person. Groups, nested ones included, give staff roles and places in the tree. Accounts are tied to the objectGUID; the leader reads the directory every 15 minutes, suspends whom it disabled or removed and reinstates them after, and refuses a sync that would suspend most. Entra ID sends no groups for someone in more than 200, only where to fetch them; that was read as none and took an administrator's role away. It is now left as it was, and logged. An account made by SCIM or OIDC can be reinstated again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
131 lines
4.3 KiB
Go
131 lines
4.3 KiB
Go
package directory
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-ldap/ldap/v3"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
|
)
|
|
|
|
// fakeConn is a directory with one person in it.
|
|
type fakeConn struct {
|
|
binds []string
|
|
filters []string
|
|
password string // the person's
|
|
people int // how many the person search finds
|
|
}
|
|
|
|
func (f *fakeConn) Bind(user, password string) error {
|
|
f.binds = append(f.binds, user)
|
|
if strings.HasPrefix(user, "CN=Ada") && password != f.password {
|
|
return &ldap.Error{ResultCode: ldap.LDAPResultInvalidCredentials}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeConn) Search(r *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
|
f.filters = append(f.filters, r.Filter)
|
|
var out []*ldap.Entry
|
|
for i := range f.people {
|
|
e := ldap.NewEntry("CN=Ada,OU=Staff,DC=corp,DC=example", map[string][]string{
|
|
"mail": {"ada@corp.example"}, "displayName": {"Ada"}, "userAccountControl": {"512"},
|
|
})
|
|
e.Attributes = append(e.Attributes, &ldap.EntryAttribute{Name: "objectGUID", ByteValues: [][]byte{guidBytes(byte(i))}})
|
|
out = append(out, e)
|
|
}
|
|
return &ldap.SearchResult{Entries: out}, nil
|
|
}
|
|
|
|
func (f *fakeConn) SearchWithPaging(r *ldap.SearchRequest, _ uint32) (*ldap.SearchResult, error) {
|
|
f.filters = append(f.filters, r.Filter)
|
|
return &ldap.SearchResult{Entries: []*ldap.Entry{
|
|
ldap.NewEntry("CN=ml-team,OU=Groups,DC=corp,DC=example", map[string][]string{"cn": {"ml-team"}}),
|
|
}}, nil
|
|
}
|
|
|
|
func (f *fakeConn) Close() error { return nil }
|
|
|
|
func guidBytes(last byte) []byte {
|
|
return []byte{0x78, 0x56, 0x34, 0x12, 0x34, 0x12, 0x78, 0x56, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, last}
|
|
}
|
|
|
|
func fakeLDAP(t *testing.T, c *fakeConn) *LDAP {
|
|
t.Helper()
|
|
pw := filepath.Join(t.TempDir(), "bind")
|
|
_ = os.WriteFile(pw, []byte("svc-secret\n"), 0o600)
|
|
d, err := NewLDAP(config.LDAP{URLs: []string{"ldaps://dc1"}, BindDN: "CN=llmgw,OU=Svc,DC=corp,DC=example",
|
|
BindPasswordFile: pw, BaseDN: "OU=Staff,DC=corp,DC=example", UserFilter: "(memberOf=CN=llm,DC=corp,DC=example)"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d.dial = func(string) (ldapConn, error) { return c, nil }
|
|
return d
|
|
}
|
|
|
|
func TestAPersonIsCheckedByBindingAsThemAndTheirGroupsReadNested(t *testing.T) {
|
|
c := &fakeConn{password: "ad-secret", people: 1}
|
|
d := fakeLDAP(t, c)
|
|
id, err := d.Authenticate(t.Context(), `CORP\ada*)(x=`, "ad-secret")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if id.Email != "ada@corp.example" || id.ID != "12345678-1234-5678-9abc-def012345600" ||
|
|
!slicesHas(id.Groups, "ml-team") || !slicesHas(id.Groups, "CN=ml-team,OU=Groups,DC=corp,DC=example") {
|
|
t.Errorf("identity %+v", id)
|
|
}
|
|
// The name is escaped into the filter, the domain part dropped; the
|
|
// user filter is ANDed in; disabled accounts are not found; groups are
|
|
// read with the in-chain rule.
|
|
find, groups := c.filters[0], c.filters[1]
|
|
if !strings.Contains(find, `(sAMAccountName=ada\2a\29\28x=)`) || !strings.Contains(find, "(memberOf=CN=llm,DC=corp,DC=example)") ||
|
|
!strings.Contains(find, "1.2.840.113556.1.4.803:=2") || !strings.Contains(groups, inChain) {
|
|
t.Errorf("filters %q", c.filters)
|
|
}
|
|
// Service account, then the person, then the service account again.
|
|
if len(c.binds) != 3 || c.binds[1] != "CN=Ada,OU=Staff,DC=corp,DC=example" {
|
|
t.Errorf("binds %v", c.binds)
|
|
}
|
|
}
|
|
|
|
func TestWrongPasswordsEmptyOnesAndAmbiguousNamesAreRefusedAlike(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
people int
|
|
password string
|
|
}{{1, "wrong"}, {1, ""}, {0, "ad-secret"}, {2, "ad-secret"}} {
|
|
d := fakeLDAP(t, &fakeConn{password: "ad-secret", people: tc.people})
|
|
if _, err := d.Authenticate(t.Context(), "ada", tc.password); !errors.Is(err, ErrBadCredentials) {
|
|
t.Errorf("%d found, password %q: %v", tc.people, tc.password, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlaintextLDAPIsRefusedWithoutStartTLS(t *testing.T) {
|
|
base := config.LDAP{BindDN: "CN=x", BindPasswordFile: "/f", BaseDN: "DC=corp"}
|
|
for _, c := range []struct {
|
|
urls []string
|
|
start bool
|
|
ok bool
|
|
}{{[]string{"ldaps://dc1"}, false, true}, {[]string{"ldap://dc1"}, false, false},
|
|
{[]string{"ldap://dc1"}, true, true}, {[]string{"http://dc1"}, true, false}} {
|
|
l := base
|
|
l.URLs, l.StartTLS = c.urls, c.start
|
|
if err := l.Validate(); (err == nil) != c.ok {
|
|
t.Errorf("%v start_tls=%v: %v", c.urls, c.start, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func slicesHas(xs []string, x string) bool {
|
|
for _, v := range xs {
|
|
if v == x {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|