Documentation
¶
Overview ¶
Package ldaptest is an in-memory LDAP directory for tests.
It speaks enough of the protocol to be worth testing against: real filter parsing, real scopes, real bind semantics — including the one that matters most, where a bind with an empty password succeeds as an unauthenticated bind exactly as a conforming server would. A fake that quietly rejected the empty password would make the test that proves this library never sends one pass for the wrong reason.
It is not a server. Nothing here listens on a socket; a Directory hands out an ldapauth.Dialer whose connections talk to the map directly. For tests against a real directory, see the integration suite.
What it will tell you ¶
dir.Binds() every bind, in order, with the password it carried dir.Filters() every search filter, as it went out dir.Searches() the full search requests dir.Conns() how many connections were opened and closed dir.PagedSearches() how many used a paged results control
Directory.Binds records the password on purpose: the point of recording it is to let a test assert that an empty one was never sent.
Making things go wrong ¶
dir.SetDown(url, true) dialling fails, and live connections break dir.DialHook = ... fail, count, or delay a dial dir.BindHook = ... the same for binds dir.SearchHook = ... and for searches dir.ReferralHook = ... answer a search with referrals, as a forest does dir.BindControlHook = ... answer a bind with the password policy control
Directory.SetDown also breaks connections that were already open, which is what a server going down actually does and what a connection pool has to survive.
What it is not ¶
It is not a validator. It will not check schemas, enforce access controls, or complain about an entry with no objectClass. Those are exactly the differences that make something work in tests and fail in production, which is what the integration suite is for.
dir := ldaptest.New(
ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
With("uid", "alice").
With("mail", "alice@example.com"),
)
auth, err := ldapauth.New(
ldapauth.WithURL("ldap://memory"),
ldapauth.WithDialer(dir.Dialer()),
ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),
)
Index ¶
- Constants
- type Bind
- type Directory
- func (d *Directory) Add(entries ...*Entry) *Directory
- func (d *Directory) Binds() []Bind
- func (d *Directory) Conns() (opened, closed int)
- func (d *Directory) Dialer() ldapauth.Dialer
- func (d *Directory) Filters() []string
- func (d *Directory) PagedSearches() int
- func (d *Directory) Reset()
- func (d *Directory) Searches() []*ldap.SearchRequest
- func (d *Directory) SetDown(url string, down bool)
- type Entry
Examples ¶
Constants ¶
const ChainOID = "1.2.840.113556.1.4.1941"
ChainOID is LDAP_MATCHING_RULE_IN_CHAIN, the Active Directory matching rule for transitive group membership.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Bind ¶
type Bind struct {
// DN is the distinguished name the bind was attempted as. It is empty
// for an anonymous bind.
DN string
// Password is what was presented. It is recorded on purpose: the
// point of recording it is to let a test assert that an empty one was
// never sent.
Password string
// URL is the server the connection was opened to.
URL string
}
A Bind is one recorded bind attempt. The password is recorded because the point of recording it is to let a test assert that an empty one was never sent.
type Directory ¶
type Directory struct {
// BindHook, if set, runs before a bind is evaluated. Returning an
// error makes the bind fail with that error.
BindHook func(dn, password string) error
// SearchHook, if set, runs before a search is evaluated.
SearchHook func(request *ldap.SearchRequest) error
// DialHook, if set, runs before a connection is created.
DialHook func(url string) error
// BindControlHook, if set, returns the controls a bind should be
// answered with.
//
// It is how a test produces the password policy control that
// OpenLDAP and 389 Directory Server use to say *why* a credential
// was refused — an expired password, a locked account, one that must
// be changed after a reset. Active Directory says the same thing in
// the error text and needs nothing here.
BindControlHook func(dn string) []ldap.Control
// TimeoutHook, if set, is called every time an operation timeout is
// set on a connection. It is how a test sees the timeout the
// authenticator narrowed a connection to, which is otherwise
// invisible and is exactly where one request's deadline can leak
// into the next.
TimeoutHook func(timeout time.Duration)
// ReferralHook, if set, returns the referral URLs a search should be
// answered with. Return none for an ordinary search.
//
// A referral is what a multi-domain Active Directory forest answers
// when the base DN names a partition that does not hold the object,
// and it is the failure mode most likely to be mistaken for "no such
// user". A fake with no way to produce one is a fake that cannot test
// the handling.
ReferralHook func(request *ldap.SearchRequest) []string
// contains filtered or unexported fields
}
Directory is an in-memory directory. The zero value is not usable; call New. A Directory is safe for concurrent use.
func New ¶
New builds a directory holding the given entries.
Example ¶
A directory in a map, with no server anywhere.
package main
import (
"context"
"fmt"
ldapauth "github.com/ctolon/ldap-authenticator"
"github.com/ctolon/ldap-authenticator/ldaptest"
)
func main() {
dir := ldaptest.New(
ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
With("uid", "alice").
With("mail", "alice@example.com").
With("objectClass", "inetOrgPerson"),
)
auth, err := ldapauth.New(
ldapauth.WithURL("ldap://in-memory"),
ldapauth.WithDialer(dir.Dialer()),
ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),
ldapauth.WithEmailAttribute("mail"),
)
if err != nil {
fmt.Println(err)
return
}
defer auth.Close()
identity, err := auth.Authenticate(context.Background(), "alice", "s3cret")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(identity.GetEmail())
}
Output: alice@example.com
func (*Directory) Add ¶
Add adds entries, replacing any that already exist under the same DN.
Replacing rather than appending is what a directory does, and it is the behaviour a test wants: appending a second entry with the same DN would leave the first one shadowing it in every search, and the test would fail for a reason that has nothing to do with what it was testing.
func (*Directory) Binds ¶
Binds returns every bind the directory has seen, in order.
Example ¶
The empty-password bind is modelled faithfully: a conforming server answers it with success, which is why a library that sends one is a library that authenticates anybody.
package main
import (
"context"
"errors"
"fmt"
ldapauth "github.com/ctolon/ldap-authenticator"
"github.com/ctolon/ldap-authenticator/ldaptest"
)
func main() {
dir := ldaptest.New(
ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
With("uid", "alice").
With("objectClass", "inetOrgPerson"),
)
auth, err := ldapauth.New(
ldapauth.WithURL("ldap://in-memory"),
ldapauth.WithDialer(dir.Dialer()),
ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),
)
if err != nil {
fmt.Println(err)
return
}
defer auth.Close()
_, err = auth.Authenticate(context.Background(), "alice", "")
fmt.Println(errors.Is(err, ldapauth.ErrEmptyPassword))
fmt.Println("binds reaching the directory:", len(dir.Binds()))
}
Output: true binds reaching the directory: 0
func (*Directory) Conns ¶
Conns reports how many connections have been opened and closed. A test that wants to prove the pool is pooling asserts on the first; one that wants to prove it is not leaking asserts they are equal at the end.
func (*Directory) Dialer ¶
Dialer returns a dialer that connects to this directory whatever URL it is given, unless that URL has been marked down with SetDown.
func (*Directory) Filters ¶
Filters returns the filter string of every search, which is usually the only part a test cares about.
Example ¶
What went to the directory, which is usually the thing a security test wants to assert on.
package main
import (
"context"
"fmt"
ldapauth "github.com/ctolon/ldap-authenticator"
"github.com/ctolon/ldap-authenticator/ldaptest"
)
func main() {
dir := ldaptest.New(
ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
With("uid", "alice").
With("objectClass", "inetOrgPerson"),
ldaptest.User("cn=svc,dc=example,dc=com", "svc-password").
With("objectClass", "person"),
)
auth, err := ldapauth.New(
ldapauth.WithURL("ldap://in-memory"),
ldapauth.WithDialer(dir.Dialer()),
ldapauth.WithServiceAccount("cn=svc,dc=example,dc=com", "svc-password"),
ldapauth.WithUserSearch("ou=people,dc=example,dc=com", "(uid={{username}})"),
)
if err != nil {
fmt.Println(err)
return
}
defer auth.Close()
// A username that is trying something.
_, _ = auth.Authenticate(context.Background(), "*)(uid=*", "anything")
fmt.Println(dir.Filters()[0])
}
Output: (uid=\2a\29\28uid=\2a)
func (*Directory) PagedSearches ¶
PagedSearches reports how many searches were issued with a paged results control, for a test that wants to know whether WithPagingSize reached the wire.
func (*Directory) Reset ¶
func (d *Directory) Reset()
Reset clears the recorded binds and searches, leaving the entries alone.
func (*Directory) Searches ¶
func (d *Directory) Searches() []*ldap.SearchRequest
Searches returns every search request the directory has seen, in order.
func (*Directory) SetDown ¶
SetDown makes dialling a URL fail, or stop failing. Use it to take a server out from under a running authenticator.
Example ¶
Taking a server away, which is what a failover test needs. Connections already open to it break, as they would.
package main
import (
"context"
"fmt"
ldapauth "github.com/ctolon/ldap-authenticator"
"github.com/ctolon/ldap-authenticator/ldaptest"
)
func main() {
dir := ldaptest.New(
ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
With("uid", "alice").
With("objectClass", "inetOrgPerson"),
)
dir.SetDown("ldap://dc1", true)
auth, err := ldapauth.New(
ldapauth.WithURLs("ldap://dc1", "ldap://dc2"),
ldapauth.WithDialer(dir.Dialer()),
ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),
)
if err != nil {
fmt.Println(err)
return
}
defer auth.Close()
if _, err := auth.Authenticate(context.Background(), "alice", "s3cret"); err != nil {
fmt.Println(err)
return
}
fmt.Println("bound on", dir.Binds()[0].URL)
}
Output: bound on ldap://dc2
type Entry ¶
type Entry struct {
// DN is the entry's distinguished name.
DN string
// Password is what a simple bind as this entry must present. An entry
// with no password cannot be bound as.
Password string
// Attributes are the entry's attributes, keyed by name.
Attributes map[string][]string
}
Entry is one directory entry.