From 7696f4256d2382d03d2cb4afa5876e1a1fd7f576 Mon Sep 17 00:00:00 2001 From: Margo Crawford Date: Thu, 15 Jul 2021 16:33:42 -0700 Subject: [PATCH] Move defaulting of ad username and uid attributes to controller Now the controller uses upstreamldap so there is less duplication, since they are very similar. Signed-off-by: Ryan Richard --- .../active_directory_upstream_watcher.go | 22 +- .../active_directory_upstream_watcher_test.go | 49 +- internal/upstreamad/upstreamad.go | 567 ------- internal/upstreamad/upstreamad_test.go | 1377 ----------------- 4 files changed, 61 insertions(+), 1954 deletions(-) delete mode 100644 internal/upstreamad/upstreamad.go delete mode 100644 internal/upstreamad/upstreamad_test.go diff --git a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go index 3d611710..0394bdad 100644 --- a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go +++ b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go @@ -27,7 +27,6 @@ import ( "go.pinniped.dev/internal/controllerlib" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/plog" - "go.pinniped.dev/internal/upstreamad" "go.pinniped.dev/internal/upstreamldap" ) @@ -43,6 +42,10 @@ const ( reasonActiveDirectoryConnectionError = "ActiveDirectoryConnectionError" noTLSConfigurationMessage = "no TLS configuration provided" loadedTLSConfigurationMessage = "loaded TLS configuration" + + // Default values for active directory config + defaultActiveDirectoryUsernameAttributeName = "sAMAccountName" + defaultActiveDirectoryUIDAttributeName = "objectGUID" ) // UpstreamActiveDirectoryIdentityProviderICache is a thread safe cache that holds a list of validated upstream LDAP IDP configurations. @@ -158,14 +161,23 @@ func (c *activeDirectoryWatcherController) Sync(ctx controllerlib.Context) error func (c *activeDirectoryWatcherController) validateUpstream(ctx context.Context, upstream *v1alpha1.ActiveDirectoryIdentityProvider) (p provider.UpstreamLDAPIdentityProviderI, requeue bool) { spec := upstream.Spec + usernameAttribute := spec.UserSearch.Attributes.Username + if len(usernameAttribute) == 0 { + usernameAttribute = defaultActiveDirectoryUsernameAttributeName + } + uidAttribute := spec.UserSearch.Attributes.UID + if len(uidAttribute) == 0 { + uidAttribute = defaultActiveDirectoryUIDAttributeName + } + config := &upstreamldap.ProviderConfig{ Name: upstream.Name, Host: spec.Host, UserSearch: upstreamldap.UserSearchConfig{ Base: spec.UserSearch.Base, Filter: spec.UserSearch.Filter, - UsernameAttribute: spec.UserSearch.Attributes.Username, - UIDAttribute: spec.UserSearch.Attributes.UID, + UsernameAttribute: usernameAttribute, + UIDAttribute: uidAttribute, }, GroupSearch: upstreamldap.GroupSearchConfig{ Base: spec.GroupSearch.Base, @@ -198,12 +210,12 @@ func (c *activeDirectoryWatcherController) validateUpstream(ctx context.Context, requeue = true case finishedConfigCondition != nil && finishedConfigCondition.Status != v1alpha1.ConditionTrue: // Error but load it into the cache anyway, treating this condition failure more like a warning. - p = upstreamad.New(*config) + p = upstreamldap.New(*config) // Try again hoping that the condition will improve. requeue = true default: // Fully validated provider, so load it into the cache. - p = upstreamad.New(*config) + p = upstreamldap.New(*config) requeue = false } diff --git a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go index c14c4390..95468c2a 100644 --- a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go @@ -12,8 +12,6 @@ import ( "testing" "time" - "go.pinniped.dev/internal/upstreamad" - "github.com/go-ldap/ldap/v3" "github.com/golang/mock/gomock" "github.com/stretchr/testify/require" @@ -139,7 +137,7 @@ func TestActiveDirectoryUpstreamWatcherControllerFilterActiveDirectoryIdentityPr } } -// Wrap the func into a struct so the test can do deep equal assertions on instances of upstreamad.Provider. +// Wrap the func into a struct so the test can do deep equal assertions on instances of upstreamldap.Provider. type comparableDialer struct { upstreamldap.LDAPDialerFunc } @@ -850,6 +848,47 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }}, wantValidatedSettings: map[string]validatedSettings{testName: {BindSecretResourceVersion: "4242", LDAPConnectionProtocol: upstreamldap.TLS}}, }, + { + name: "when the input activedirectoryidentityprovider leaves user attributes blank, provide default values", + inputUpstreams: []runtime.Object{editedValidUpstream(func(upstream *v1alpha1.ActiveDirectoryIdentityProvider) { + upstream.Spec.UserSearch.Attributes = v1alpha1.ActiveDirectoryIdentityProviderUserSearchAttributes{} + })}, + inputSecrets: []runtime.Object{validBindUserSecret("4242")}, + setupMocks: func(conn *mockldapconn.MockConn) { + // Should perform a test dial and bind. + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantResultingCache: []*upstreamldap.ProviderConfig{ + { + Name: testName, + Host: testHost, + ConnectionProtocol: upstreamldap.TLS, + CABundle: testCABundle, + BindUsername: testBindUsername, + BindPassword: testBindPassword, + UserSearch: upstreamldap.UserSearchConfig{ + Base: testUserSearchBase, + Filter: testUserSearchFilter, + UsernameAttribute: "sAMAccountName", + UIDAttribute: "objectGUID", + }, + GroupSearch: upstreamldap.GroupSearchConfig{ + Base: testGroupSearchBase, + Filter: testGroupSearchFilter, + GroupNameAttribute: testGroupNameAttrName, + }, + }, + }, + wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ + Phase: "Ready", + Conditions: allConditionsTrue(1234, "4242"), + }, + }}, + wantValidatedSettings: map[string]validatedSettings{testName: {BindSecretResourceVersion: "4242", LDAPConnectionProtocol: upstreamldap.TLS}}, + }, } for _, tt := range tests { @@ -863,7 +902,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { kubeInformers := informers.NewSharedInformerFactory(fakeKubeClient, 0) cache := provider.NewDynamicUpstreamIDPProvider() cache.SetActiveDirectoryIdentityProviders([]provider.UpstreamLDAPIdentityProviderI{ - upstreamad.New(upstreamldap.ProviderConfig{Name: "initial-entry"}), + upstreamldap.New(upstreamldap.ProviderConfig{Name: "initial-entry"}), }) ctrl := gomock.NewController(t) @@ -917,7 +956,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { actualIDPList := cache.GetActiveDirectoryIdentityProviders() require.Equal(t, len(tt.wantResultingCache), len(actualIDPList)) for i := range actualIDPList { - actualIDP := actualIDPList[i].(*upstreamad.Provider) + actualIDP := actualIDPList[i].(*upstreamldap.Provider) copyOfExpectedValueForResultingCache := *tt.wantResultingCache[i] // copy before edit to avoid race because these tests are run in parallel // The dialer that was passed in to the controller's constructor should always have been // passed through to the provider. diff --git a/internal/upstreamad/upstreamad.go b/internal/upstreamad/upstreamad.go deleted file mode 100644 index 5b2f34fa..00000000 --- a/internal/upstreamad/upstreamad.go +++ /dev/null @@ -1,567 +0,0 @@ -// Copyright 2021 the Pinniped contributors. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Package upstreamad implements an active directory specific abstraction of upstream LDAP IDP interactions. -package upstreamad - -import ( - "context" - "crypto/tls" - "crypto/x509" - "encoding/base64" - "errors" - "fmt" - "net" - "net/url" - "sort" - "strings" - "time" - - "github.com/go-ldap/ldap/v3" - "github.com/gofrs/uuid" - "k8s.io/apiserver/pkg/authentication/authenticator" - "k8s.io/apiserver/pkg/authentication/user" - "k8s.io/utils/trace" - - "go.pinniped.dev/internal/authenticators" - "go.pinniped.dev/internal/endpointaddr" - "go.pinniped.dev/internal/oidc/provider" - "go.pinniped.dev/internal/plog" - "go.pinniped.dev/internal/upstreamldap" -) - -const ( - ldapsScheme = "ldaps" - distinguishedNameAttributeName = "dn" - objectGUIDAttributeName = "objectGUID" - sAMAccountNameAttributeName = "sAMAccountName" - searchFilterInterpolationLocationMarker = "{}" - groupSearchPageSize = uint32(250) - defaultLDAPPort = uint16(389) - defaultLDAPSPort = uint16(636) -) - -// UserSearchConfig contains information about how to search for users in the upstream active directory IDP. -type UserSearchConfig struct { - // Base is the base DN to use for the user search in the upstream active directory IDP. - Base string - - // Filter is the filter to use for the user search in the upstream active directory IDP. - Filter string - - // UsernameAttribute is the attribute in the LDAP entry from which the username should be - // retrieved. Empty means to use 'sAMAccountName'. - UsernameAttribute string - - // UIDAttribute is the attribute in the LDAP entry from which the user's unique ID should be - // retrieved. Empty means to use 'objectGUID'. - UIDAttribute string -} - -// GroupSearchConfig contains information about how to search for group membership for users in the upstream active directory IDP. -type GroupSearchConfig struct { - // Base is the base DN to use for the group search in the upstream active directory IDP. Empty means to skip group search - // entirely, in which case authenticated users will not belong to any groups from the upstream active directory IDP. - Base string - - // Filter is the filter to use for the group search in the upstream active directory IDP. Empty means to use `member={}`. - Filter string - - // GroupNameAttribute is the attribute in the LDAP group entry from which the group name should be - // retrieved. Empty means to use 'cn'. - GroupNameAttribute string -} - -type Provider struct { - c upstreamldap.ProviderConfig -} - -var _ provider.UpstreamLDAPIdentityProviderI = &Provider{} -var _ authenticators.UserAuthenticator = &Provider{} - -// Create a Provider. The config is not a pointer to ensure that a copy of the config is created, -// making the resulting Provider use an effectively read-only configuration. -func New(config upstreamldap.ProviderConfig) *Provider { - return &Provider{c: config} -} - -// A reader for the config. Returns a copy of the config to keep the underlying config read-only. -func (p *Provider) GetConfig() upstreamldap.ProviderConfig { - return p.c -} - -func (p *Provider) dial(ctx context.Context) (upstreamldap.Conn, error) { - tlsAddr, err := endpointaddr.Parse(p.c.Host, defaultLDAPSPort) - if err != nil { - return nil, ldap.NewError(ldap.ErrorNetwork, err) - } - - startTLSAddr, err := endpointaddr.Parse(p.c.Host, defaultLDAPPort) - if err != nil { - return nil, ldap.NewError(ldap.ErrorNetwork, err) - } - - // Choose how and where to dial based on TLS vs. StartTLS config option. - var dialFunc upstreamldap.LDAPDialerFunc - var addr endpointaddr.HostPort - switch { - case p.c.ConnectionProtocol == upstreamldap.TLS: - dialFunc = p.dialTLS - addr = tlsAddr - case p.c.ConnectionProtocol == upstreamldap.StartTLS: - dialFunc = p.dialStartTLS - addr = startTLSAddr - default: - return nil, ldap.NewError(ldap.ErrorNetwork, fmt.Errorf("did not specify valid ConnectionProtocol")) - } - - // Override the real dialer for testing purposes sometimes. - if p.c.Dialer != nil { - dialFunc = p.c.Dialer.Dial - } - - return dialFunc(ctx, addr) -} - -// dialTLS is a default implementation of the Dialer, used when Dialer is nil and ConnectionProtocol is TLS. -// Unfortunately, the go-ldap library does not seem to support dialing with a context.Context, -// so we implement it ourselves, heavily inspired by ldap.DialURL. -func (p *Provider) dialTLS(ctx context.Context, addr endpointaddr.HostPort) (upstreamldap.Conn, error) { - tlsConfig, err := p.tlsConfig() - if err != nil { - return nil, ldap.NewError(ldap.ErrorNetwork, err) - } - - dialer := &tls.Dialer{NetDialer: netDialer(), Config: tlsConfig} - c, err := dialer.DialContext(ctx, "tcp", addr.Endpoint()) - if err != nil { - return nil, ldap.NewError(ldap.ErrorNetwork, err) - } - - conn := ldap.NewConn(c, true) - conn.Start() - return conn, nil -} - -// dialTLS is a default implementation of the Dialer, used when Dialer is nil and ConnectionProtocol is StartTLS. -// Unfortunately, the go-ldap library does not seem to support dialing with a context.Context, -// so we implement it ourselves, heavily inspired by ldap.DialURL. -func (p *Provider) dialStartTLS(ctx context.Context, addr endpointaddr.HostPort) (upstreamldap.Conn, error) { - tlsConfig, err := p.tlsConfig() - if err != nil { - return nil, ldap.NewError(ldap.ErrorNetwork, err) - } - - // Unfortunately, this seems to be required for StartTLS, even though it is not needed for regular TLS. - tlsConfig.ServerName = addr.Host - - c, err := netDialer().DialContext(ctx, "tcp", addr.Endpoint()) - if err != nil { - return nil, ldap.NewError(ldap.ErrorNetwork, err) - } - - conn := ldap.NewConn(c, false) - conn.Start() - err = conn.StartTLS(tlsConfig) - if err != nil { - return nil, err - } - - return conn, nil -} - -func netDialer() *net.Dialer { - return &net.Dialer{Timeout: time.Minute} -} - -func (p *Provider) tlsConfig() (*tls.Config, error) { - var rootCAs *x509.CertPool - if p.c.CABundle != nil { - rootCAs = x509.NewCertPool() - if !rootCAs.AppendCertsFromPEM(p.c.CABundle) { - return nil, fmt.Errorf("could not parse CA bundle") - } - } - return &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: rootCAs}, nil -} - -// A name for this upstream provider. -func (p *Provider) GetName() string { - return p.c.Name -} - -// Return a URL which uniquely identifies this LDAP provider, e.g. "ldaps://host.example.com:1234?base=user-search-base". -// This URL is not used for connecting to the provider, but rather is used for creating a globally unique user -// identifier by being combined with the user's UID, since user UIDs are only unique within one provider. -func (p *Provider) GetURL() *url.URL { - u := &url.URL{Scheme: ldapsScheme, Host: p.c.Host} - q := u.Query() - q.Set("base", p.c.UserSearch.Base) - u.RawQuery = q.Encode() - return u -} - -// TestConnection provides a method for testing the connection and bind settings. It performs a dial and bind -// and returns any errors that we encountered. -func (p *Provider) TestConnection(ctx context.Context) error { - err := p.validateConfig() - if err != nil { - return err - } - - conn, err := p.dial(ctx) - if err != nil { - return fmt.Errorf(`error dialing host "%s": %w`, p.c.Host, err) - } - defer conn.Close() - - err = conn.Bind(p.c.BindUsername, p.c.BindPassword) - if err != nil { - return fmt.Errorf(`error binding as "%s": %w`, p.c.BindUsername, err) - } - - return nil -} - -// DryRunAuthenticateUser provides a method for testing all of the Provider settings in a kind of dry run of -// authentication for a given end user's username. It runs the same logic as AuthenticateUser except it does -// not bind as that user, so it does not test their password. It returns the same values that a real call to -// AuthenticateUser with the correct password would return. -func (p *Provider) DryRunAuthenticateUser(ctx context.Context, username string) (*authenticator.Response, bool, error) { - endUserBindFunc := func(conn upstreamldap.Conn, foundUserDN string) error { - // Act as if the end user bind always succeeds. - return nil - } - return p.authenticateUserImpl(ctx, username, endUserBindFunc) -} - -// Authenticate an end user and return their mapped username, groups, and UID. Implements authenticators.UserAuthenticator. -func (p *Provider) AuthenticateUser(ctx context.Context, username, password string) (*authenticator.Response, bool, error) { - endUserBindFunc := func(conn upstreamldap.Conn, foundUserDN string) error { - return conn.Bind(foundUserDN, password) - } - return p.authenticateUserImpl(ctx, username, endUserBindFunc) -} - -func (p *Provider) authenticateUserImpl(ctx context.Context, username string, bindFunc func(conn upstreamldap.Conn, foundUserDN string) error) (*authenticator.Response, bool, error) { - t := trace.FromContext(ctx).Nest("slow ldap authenticate user attempt", trace.Field{Key: "providerName", Value: p.GetName()}) - defer t.LogIfLong(500 * time.Millisecond) // to help users debug slow LDAP searches - - err := p.validateConfig() - if err != nil { - p.traceAuthFailure(t, err) - return nil, false, err - } - - if len(username) == 0 { - // Empty passwords are already handled by go-ldap. - p.traceAuthFailure(t, fmt.Errorf("empty username")) - return nil, false, nil - } - - conn, err := p.dial(ctx) - if err != nil { - p.traceAuthFailure(t, err) - return nil, false, fmt.Errorf(`error dialing host "%s": %w`, p.c.Host, err) - } - defer conn.Close() - - err = conn.Bind(p.c.BindUsername, p.c.BindPassword) - if err != nil { - p.traceAuthFailure(t, err) - return nil, false, fmt.Errorf(`error binding as "%s" before user search: %w`, p.c.BindUsername, err) - } - - mappedUsername, mappedUID, mappedGroupNames, err := p.searchAndBindUser(conn, username, bindFunc) - if err != nil { - p.traceAuthFailure(t, err) - return nil, false, err - } - if len(mappedUsername) == 0 || len(mappedUID) == 0 { - // Couldn't find the username or couldn't bind using the password. - p.traceAuthFailure(t, fmt.Errorf("bad username or password")) - return nil, false, nil - } - - response := &authenticator.Response{ - User: &user.DefaultInfo{ - Name: mappedUsername, - UID: mappedUID, - Groups: mappedGroupNames, - }, - } - p.traceAuthSuccess(t) - return response, true, nil -} - -func (p *Provider) searchGroupsForUserDN(conn upstreamldap.Conn, userDN string) ([]string, error) { - searchResult, err := conn.SearchWithPaging(p.groupSearchRequest(userDN), groupSearchPageSize) - if err != nil { - return nil, fmt.Errorf(`error searching for group memberships for user with DN %q: %w`, userDN, err) - } - - groupAttributeName := p.c.GroupSearch.GroupNameAttribute - if len(groupAttributeName) == 0 { - groupAttributeName = distinguishedNameAttributeName - } - - groups := []string{} - for _, groupEntry := range searchResult.Entries { - if len(groupEntry.DN) == 0 { - return nil, fmt.Errorf(`searching for group memberships for user with DN %q resulted in search result without DN`, userDN) - } - mappedGroupName, err := p.getSearchResultAttributeValue(groupAttributeName, groupEntry, userDN) - if err != nil { - return nil, fmt.Errorf(`error searching for group memberships for user with DN %q: %w`, userDN, err) - } - groups = append(groups, mappedGroupName) - } - - return groups, nil -} - -func (p *Provider) validateConfig() error { - // TODO if user search base is nil then host must be an IP address? - if p.usernameAttribute() == distinguishedNameAttributeName && len(p.c.UserSearch.Filter) == 0 { - // LDAP search filters do not allow searching by DN, so we would have no reasonable default for Filter. - return fmt.Errorf(`must specify UserSearch Filter when UserSearch UsernameAttribute is "dn"`) - } - return nil -} - -func (p *Provider) searchAndBindUser(conn upstreamldap.Conn, username string, bindFunc func(conn upstreamldap.Conn, foundUserDN string) error) (string, string, []string, error) { - searchResult, err := conn.Search(p.userSearchRequest(username)) - if err != nil { - plog.All(`error searching for user`, - "upstreamName", p.GetName(), - "username", username, - "err", err, - ) - return "", "", nil, fmt.Errorf(`error searching for user: %w`, err) - } - if len(searchResult.Entries) == 0 { - if plog.Enabled(plog.LevelAll) { - plog.All("error finding user: user not found (if this username is valid, please check the user search configuration)", - "upstreamName", p.GetName(), - "username", username, - ) - } else { - plog.Debug("error finding user: user not found (cowardly avoiding printing username because log level is not 'all')", "upstreamName", p.GetName()) - } - return "", "", nil, nil - } - - // At this point, we have matched at least one entry, so we can be confident that the username is not actually - // someone's password mistakenly entered into the username field, so we can log it without concern. - if len(searchResult.Entries) > 1 { - return "", "", nil, fmt.Errorf(`searching for user "%s" resulted in %d search results, but expected 1 result`, - username, len(searchResult.Entries), - ) - } - userEntry := searchResult.Entries[0] - if len(userEntry.DN) == 0 { - return "", "", nil, fmt.Errorf(`searching for user "%s" resulted in search result without DN`, username) - } - - mappedUsername, err := p.getSearchResultAttributeValue(p.usernameAttribute(), userEntry, username) - if err != nil { - return "", "", nil, err - } - - mappedUID, err := p.getSearchResultAttributeRawValueEncoded(p.uidAttribute(), userEntry, username) - if err != nil { - return "", "", nil, err - } - - mappedGroupNames := []string{} - if len(p.c.GroupSearch.Base) > 0 { - mappedGroupNames, err = p.searchGroupsForUserDN(conn, userEntry.DN) - if err != nil { - return "", "", nil, err - } - } - sort.Strings(mappedGroupNames) - - // Caution: Note that any other LDAP commands after this bind will be run as this user instead of as the configured BindUsername! - err = bindFunc(conn, userEntry.DN) - if err != nil { - plog.DebugErr("error binding for user (if this is not the expected dn for this username, please check the user search configuration)", - err, "upstreamName", p.GetName(), "username", username, "dn", userEntry.DN) - ldapErr := &ldap.Error{} - if errors.As(err, &ldapErr) && ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials { - return "", "", nil, nil - } - return "", "", nil, fmt.Errorf(`error binding for user "%s" using provided password against DN "%s": %w`, username, userEntry.DN, err) - } - - return mappedUsername, mappedUID, mappedGroupNames, nil -} - -func (p *Provider) userSearchRequest(username string) *ldap.SearchRequest { - // See https://ldap.com/the-ldap-search-operation for general documentation of LDAP search options. - return &ldap.SearchRequest{ - BaseDN: p.userSearchBase(), - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - SizeLimit: 2, - TimeLimit: 90, - TypesOnly: false, - Filter: p.userSearchFilter(username), - Attributes: p.userSearchRequestedAttributes(), - Controls: nil, // this could be used to enable paging, but we're already limiting the result max size - } -} - -func (p *Provider) userSearchBase() string { - if len(p.c.UserSearch.Base) == 0 { - return "" - } - return p.c.UserSearch.Base -} - -func (p *Provider) groupSearchRequest(userDN string) *ldap.SearchRequest { - // See https://ldap.com/the-ldap-search-operation for general documentation of LDAP search options. - return &ldap.SearchRequest{ - BaseDN: p.c.GroupSearch.Base, - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - SizeLimit: 0, // unlimited size because we will search with paging - TimeLimit: 90, - TypesOnly: false, - Filter: p.groupSearchFilter(userDN), - Attributes: p.groupSearchRequestedAttributes(), - Controls: nil, // nil because ldap.SearchWithPaging() will set the appropriate controls for us - } -} - -func (p *Provider) userSearchRequestedAttributes() []string { - attributes := []string{} - if p.usernameAttribute() != distinguishedNameAttributeName { - attributes = append(attributes, p.usernameAttribute()) - } - if p.uidAttribute() != distinguishedNameAttributeName { - attributes = append(attributes, p.uidAttribute()) - } - return attributes -} - -func (p *Provider) groupSearchRequestedAttributes() []string { - switch p.c.GroupSearch.GroupNameAttribute { - case "": - return []string{} - case distinguishedNameAttributeName: - return []string{} - default: - return []string{p.c.GroupSearch.GroupNameAttribute} - } -} - -func (p *Provider) usernameAttribute() string { - if len(p.c.UserSearch.UsernameAttribute) == 0 { - return sAMAccountNameAttributeName - } - return p.c.UserSearch.UsernameAttribute -} - -func (p *Provider) uidAttribute() string { - if len(p.c.UserSearch.UIDAttribute) == 0 { - return objectGUIDAttributeName - } - return p.c.UserSearch.UIDAttribute -} - -func (p *Provider) userSearchFilter(username string) string { - safeUsername := p.escapeUsernameForSearchFilter(username) - if len(p.c.UserSearch.Filter) == 0 { - return fmt.Sprintf("(%s=%s)", p.usernameAttribute(), safeUsername) - } - return interpolateSearchFilter(p.c.UserSearch.Filter, safeUsername) -} - -func (p *Provider) groupSearchFilter(userDN string) string { - if len(p.c.GroupSearch.Filter) == 0 { - return fmt.Sprintf("(member=%s)", userDN) - } - return interpolateSearchFilter(p.c.GroupSearch.Filter, userDN) -} - -func interpolateSearchFilter(filterFormat, valueToInterpolateIntoFilter string) string { - filter := strings.ReplaceAll(filterFormat, searchFilterInterpolationLocationMarker, valueToInterpolateIntoFilter) - if strings.HasPrefix(filter, "(") && strings.HasSuffix(filter, ")") { - return filter - } - return "(" + filter + ")" -} - -func (p *Provider) escapeUsernameForSearchFilter(username string) string { - // The username is end user input, so it should be escaped before being included in a search to prevent query injection. - return ldap.EscapeFilter(username) -} - -// Returns the (potentially) binary data of the attribute's value, base64 URL encoded. -func (p *Provider) getSearchResultAttributeRawValueEncoded(attributeName string, entry *ldap.Entry, username string) (string, error) { - if attributeName == distinguishedNameAttributeName { - return base64.RawURLEncoding.EncodeToString([]byte(entry.DN)), nil - } - - attributeValues := entry.GetRawAttributeValues(attributeName) - - if len(attributeValues) != 1 { - return "", fmt.Errorf(`found %d values for attribute "%s" while searching for user "%s", but expected 1 result`, - len(attributeValues), attributeName, username, - ) - } - - attributeValue := attributeValues[0] - if len(attributeValue) == 0 { - return "", fmt.Errorf(`found empty value for attribute "%s" while searching for user "%s", but expected value to be non-empty`, - attributeName, username, - ) - } - - if attributeName == objectGUIDAttributeName { - uuidEntry, err := uuid.FromBytes(attributeValue) - if err != nil { - return "", fmt.Errorf("Error decoding UID: %s", err.Error()) - } - return uuidEntry.String(), nil - } - - return base64.RawURLEncoding.EncodeToString(attributeValue), nil -} - -func (p *Provider) getSearchResultAttributeValue(attributeName string, entry *ldap.Entry, username string) (string, error) { - if attributeName == distinguishedNameAttributeName { - return entry.DN, nil - } - - attributeValues := entry.GetAttributeValues(attributeName) - - if len(attributeValues) != 1 { - return "", fmt.Errorf(`found %d values for attribute "%s" while searching for user "%s", but expected 1 result`, - len(attributeValues), attributeName, username, - ) - } - - attributeValue := attributeValues[0] - if len(attributeValue) == 0 { - return "", fmt.Errorf(`found empty value for attribute "%s" while searching for user "%s", but expected value to be non-empty`, - attributeName, username, - ) - } - - return attributeValue, nil -} - -func (p *Provider) traceAuthFailure(t *trace.Trace, err error) { - t.Step("authentication failed", - trace.Field{Key: "authenticated", Value: false}, - trace.Field{Key: "reason", Value: err.Error()}, - ) -} - -func (p *Provider) traceAuthSuccess(t *trace.Trace) { - t.Step("authentication succeeded", - trace.Field{Key: "authenticated", Value: true}, - ) -} diff --git a/internal/upstreamad/upstreamad_test.go b/internal/upstreamad/upstreamad_test.go deleted file mode 100644 index 579bddd2..00000000 --- a/internal/upstreamad/upstreamad_test.go +++ /dev/null @@ -1,1377 +0,0 @@ -// Copyright 2021 the Pinniped contributors. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package upstreamad - -import ( - "context" - "crypto/tls" - "encoding/base64" - "errors" - "fmt" - "net" - "net/http" - "net/url" - "testing" - "time" - - "github.com/go-ldap/ldap/v3" - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/require" - "k8s.io/apiserver/pkg/authentication/authenticator" - "k8s.io/apiserver/pkg/authentication/user" - - "go.pinniped.dev/internal/certauthority" - "go.pinniped.dev/internal/endpointaddr" - "go.pinniped.dev/internal/mocks/mockldapconn" - "go.pinniped.dev/internal/testutil" - "go.pinniped.dev/internal/upstreamldap" -) - -const ( - testHost = "activedirectory.example.com:8443" - testBindUsername = "cn=some-bind-username,dc=pinniped,dc=dev" - testBindPassword = "some-bind-password" - testUpstreamUsername = "some-upstream-username" - testUpstreamPassword = "some-upstream-password" - testUserSearchBase = "some-upstream-user-base-dn" - testGroupSearchBase = "some-upstream-group-base-dn" - testUserSearchFilter = "some-user-filter={}-and-more-filter={}" - testGroupSearchFilter = "some-group-filter={}-and-more-filter={}" - testUserSearchUsernameAttribute = "some-upstream-username-attribute" - testUserSearchUIDAttribute = "objectGUID" - testGroupSearchGroupNameAttribute = "some-upstream-group-name-attribute" - testUserSearchResultDNValue = "some-upstream-user-dn" - testGroupSearchResultDNValue1 = "some-upstream-group-dn1" - testGroupSearchResultDNValue2 = "some-upstream-group-dn2" - testUserSearchResultUsernameAttributeValue = "some-upstream-username-value" - testUserSearchResultUIDAttributeValue = "\x12>Eg\xe8\x9b\x12\u04e4VBf\x14\x17@\x00" // binary representation of 123e4567-e89b-12d3-a456-426614174000 - testGroupSearchResultGroupNameAttributeValue1 = "some-upstream-group-name-value1" - testGroupSearchResultGroupNameAttributeValue2 = "some-upstream-group-name-value2" - - expectedGroupSearchPageSize = uint32(250) -) - -var ( - testUserSearchFilterInterpolated = fmt.Sprintf("(some-user-filter=%s-and-more-filter=%s)", testUpstreamUsername, testUpstreamUsername) - testGroupSearchFilterInterpolated = fmt.Sprintf("(some-group-filter=%s-and-more-filter=%s)", testUserSearchResultDNValue, testUserSearchResultDNValue) -) - -func TestEndUserAuthentication(t *testing.T) { - providerConfig := func(editFunc func(p *upstreamldap.ProviderConfig)) *upstreamldap.ProviderConfig { - config := &upstreamldap.ProviderConfig{ - Name: "some-provider-name", - Host: testHost, - CABundle: nil, // this field is only used by the production dialer, which is replaced by a mock for this test - ConnectionProtocol: upstreamldap.TLS, - BindUsername: testBindUsername, - BindPassword: testBindPassword, - UserSearch: upstreamldap.UserSearchConfig{ - Base: testUserSearchBase, - Filter: testUserSearchFilter, - UsernameAttribute: testUserSearchUsernameAttribute, - UIDAttribute: testUserSearchUIDAttribute, - }, - GroupSearch: upstreamldap.GroupSearchConfig{ - Base: testGroupSearchBase, - Filter: testGroupSearchFilter, - GroupNameAttribute: testGroupSearchGroupNameAttribute, - }, - } - if editFunc != nil { - editFunc(config) - } - return config - } - - expectedUserSearch := func(editFunc func(r *ldap.SearchRequest)) *ldap.SearchRequest { - request := &ldap.SearchRequest{ - BaseDN: testUserSearchBase, - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - SizeLimit: 2, - TimeLimit: 90, - TypesOnly: false, - Filter: testUserSearchFilterInterpolated, - Attributes: []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute}, - Controls: nil, // don't need paging because we set the SizeLimit so small - } - if editFunc != nil { - editFunc(request) - } - return request - } - - expectedGroupSearch := func(editFunc func(r *ldap.SearchRequest)) *ldap.SearchRequest { - request := &ldap.SearchRequest{ - BaseDN: testGroupSearchBase, - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - SizeLimit: 0, // unlimited size because we will search with paging - TimeLimit: 90, - TypesOnly: false, - Filter: testGroupSearchFilterInterpolated, - Attributes: []string{testGroupSearchGroupNameAttribute}, - Controls: nil, // nil because ldap.SearchWithPaging() will set the appropriate controls for us - } - if editFunc != nil { - editFunc(request) - } - return request - } - - exampleUserSearchResult := &ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testUserSearchResultDNValue, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), - ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), - }, - }, - }, - } - - exampleGroupSearchResult := &ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testGroupSearchResultDNValue1, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{testGroupSearchResultGroupNameAttributeValue1}), - }, - }, - { - DN: testGroupSearchResultDNValue2, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{testGroupSearchResultGroupNameAttributeValue2}), - }, - }, - }, - Referrals: []string{}, // note that we are not following referrals at this time - Controls: []ldap.Control{}, - } - - // The auth response which matches the exampleUserSearchResult and exampleGroupSearchResult. - expectedAuthResponse := func(editFunc func(r *user.DefaultInfo)) *authenticator.Response { - u := &user.DefaultInfo{ - Name: testUserSearchResultUsernameAttributeValue, - UID: "123e4567-e89b-12d3-a456-426614174000", - Groups: []string{testGroupSearchResultGroupNameAttributeValue1, testGroupSearchResultGroupNameAttributeValue2}, - } - if editFunc != nil { - editFunc(u) - } - return &authenticator.Response{User: u} - } - - tests := []struct { - name string - username string - password string - providerConfig *upstreamldap.ProviderConfig - searchMocks func(conn *mockldapconn.MockConn) - bindEndUserMocks func(conn *mockldapconn.MockConn) - dialError error - wantError string - wantToSkipDial bool - wantAuthResponse *authenticator.Response - wantUnauthenticated bool - skipDryRunAuthenticateUser bool // tests about when the end user bind fails don't make sense for DryRunAuthenticateUser() - }{ - { - name: "happy path", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(exampleGroupSearchResult, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: expectedAuthResponse(nil), - }, - { - name: "default as much as possible", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: &upstreamldap.ProviderConfig{ - Name: "some-provider-name", - Host: testHost, - CABundle: nil, // this field is only used by the production dialer, which is replaced by a mock for this test - ConnectionProtocol: upstreamldap.TLS, - BindUsername: testBindUsername, - BindPassword: testBindPassword, - // no user search... that's all defaulted. - GroupSearch: upstreamldap.GroupSearchConfig{ - Base: testGroupSearchBase, - Filter: testGroupSearchFilter, - GroupNameAttribute: testGroupSearchGroupNameAttribute, - }, - }, - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { - r.Filter = "(" + sAMAccountNameAttributeName + "=" + testUpstreamUsername + ")" - r.Attributes = []string{sAMAccountNameAttributeName, testUserSearchUIDAttribute} - r.BaseDN = "" - })).Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testUserSearchResultDNValue, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(sAMAccountNameAttributeName, []string{testUserSearchResultUsernameAttributeValue}), - ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), - }, - }, - }, - }, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(exampleGroupSearchResult, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: expectedAuthResponse(nil), - }, - { - name: "when the user search filter is already wrapped by parenthesis then it is not wrapped again", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(func(p *upstreamldap.ProviderConfig) { - p.UserSearch.Filter = "(" + testUserSearchFilter + ")" - }), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(exampleGroupSearchResult, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: expectedAuthResponse(nil), - }, - { - name: "when the group search filter is already wrapped by parenthesis then it is not wrapped again", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(func(p *upstreamldap.ProviderConfig) { - p.GroupSearch.Filter = "(" + testGroupSearchFilter + ")" - }), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(exampleGroupSearchResult, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: expectedAuthResponse(nil), - }, - { - name: "when the group search base is empty then skip the group search entirely", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(func(p *upstreamldap.ProviderConfig) { - p.GroupSearch.Base = "" // this configuration means that the user does not want group search to happen - }), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { - r.Groups = []string{} - }), - }, - { - name: "when the UsernameAttribute is dn and there is a user search filter provided", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(func(p *upstreamldap.ProviderConfig) { - p.UserSearch.UsernameAttribute = "dn" - }), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { - r.Attributes = []string{testUserSearchUIDAttribute} - })).Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testUserSearchResultDNValue, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), - }, - }, - }, - }, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(exampleGroupSearchResult, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { - r.Name = testUserSearchResultDNValue - }), - }, - { - name: "when the UIDAttribute is dn", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(func(p *upstreamldap.ProviderConfig) { - p.UserSearch.UIDAttribute = "dn" - }), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { - r.Attributes = []string{testUserSearchUsernameAttribute} - })).Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testUserSearchResultDNValue, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), - }, - }, - }, - }, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(exampleGroupSearchResult, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { - r.UID = base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultDNValue)) - }), - }, - { - name: "when the GroupNameAttribute is empty then it defaults to dn", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(func(p *upstreamldap.ProviderConfig) { - p.GroupSearch.GroupNameAttribute = "" // blank means to use dn - }), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) { - r.Attributes = []string{} - }), expectedGroupSearchPageSize). - Return(exampleGroupSearchResult, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { - r.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2} - }), - }, - { - name: "when the GroupNameAttribute is dn", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(func(p *upstreamldap.ProviderConfig) { - p.GroupSearch.GroupNameAttribute = "dn" - }), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) { - r.Attributes = []string{} - }), expectedGroupSearchPageSize). - Return(exampleGroupSearchResult, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { - r.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2} - }), - }, - { - name: "when the GroupNameAttribute is cn", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(func(p *upstreamldap.ProviderConfig) { - p.GroupSearch.GroupNameAttribute = "cn" - }), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) { - r.Attributes = []string{"cn"} - }), expectedGroupSearchPageSize). - Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testGroupSearchResultDNValue1, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute("cn", []string{testGroupSearchResultGroupNameAttributeValue1}), - }, - }, - { - DN: testGroupSearchResultDNValue2, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute("cn", []string{testGroupSearchResultGroupNameAttributeValue2}), - }, - }, - }, - Referrals: []string{}, // note that we are not following referrals at this time - Controls: []ldap.Control{}, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: expectedAuthResponse(nil), - }, - { - name: "when user search Filter is blank it derives a search filter from the UsernameAttribute", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(func(p *upstreamldap.ProviderConfig) { - p.UserSearch.Filter = "" - }), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { - r.Filter = "(" + testUserSearchUsernameAttribute + "=" + testUpstreamUsername + ")" - })).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(exampleGroupSearchResult, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: expectedAuthResponse(nil), - }, - { - name: "when user search Filter and user attribute is blank it defaults to sAMAccountName={}", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(func(p *upstreamldap.ProviderConfig) { - p.UserSearch.Filter = "" - p.UserSearch.UsernameAttribute = "" - }), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { - r.Filter = "(" + sAMAccountNameAttributeName + "=" + testUpstreamUsername + ")" - r.Attributes = []string{sAMAccountNameAttributeName, testUserSearchUIDAttribute} - })).Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testUserSearchResultDNValue, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(sAMAccountNameAttributeName, []string{testUserSearchResultUsernameAttributeValue}), - ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), - }, - }, - }, - }, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(exampleGroupSearchResult, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: expectedAuthResponse(nil), - }, - { - name: "when group search Filter is blank it uses a default search filter of member={}", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(func(p *upstreamldap.ProviderConfig) { - p.GroupSearch.Filter = "" - }), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) { - r.Filter = "(member=" + testUserSearchResultDNValue + ")" - }), expectedGroupSearchPageSize). - Return(exampleGroupSearchResult, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: expectedAuthResponse(nil), - }, - { - name: "when the username has special LDAP search filter characters then they must be properly escaped in the search filter, because the username is end-user input", - username: `a&b|c(d)e\f*g`, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { - r.Filter = fmt.Sprintf("(some-user-filter=%s-and-more-filter=%s)", `a&b|c\28d\29e\5cf\2ag`, `a&b|c\28d\29e\5cf\2ag`) - })).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(exampleGroupSearchResult, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: expectedAuthResponse(nil), - }, - { - name: "group names are sorted to make the result more stable/predictable", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testGroupSearchResultDNValue1, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{"c"}), - }, - }, - { - DN: testGroupSearchResultDNValue2, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{"a"}), - }, - }, - { - DN: testGroupSearchResultDNValue2, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{"b"}), - }, - }, - }, - Referrals: []string{}, // note that we are not following referrals at this time - Controls: []ldap.Control{}, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: &authenticator.Response{ - User: &user.DefaultInfo{ - Name: testUserSearchResultUsernameAttributeValue, - UID: "123e4567-e89b-12d3-a456-426614174000", - Groups: []string{"a", "b", "c"}, - }, - }, - }, - { - name: "when dial fails", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - dialError: errors.New("some dial error"), - wantError: fmt.Sprintf(`error dialing host "%s": some dial error`, testHost), - }, - { - name: "when the UsernameAttribute is dn and there is not a user search filter provided", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(func(p *upstreamldap.ProviderConfig) { - p.UserSearch.UsernameAttribute = "dn" - p.UserSearch.Filter = "" - }), - wantToSkipDial: true, - wantError: `must specify UserSearch Filter when UserSearch UsernameAttribute is "dn"`, - }, - { - name: "when binding as the bind user returns an error", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Return(errors.New("some bind error")).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: fmt.Sprintf(`error binding as "%s" before user search: some bind error`, testBindUsername), - }, - { - name: "when searching for the user returns an error", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(nil, errors.New("some user search error")).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: `error searching for user: some user search error`, - }, - { - name: "when searching for the user's groups returns an error", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(nil, errors.New("some group search error")).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: fmt.Sprintf(`error searching for group memberships for user with DN "%s": some group search error`, testUserSearchResultDNValue), - }, - { - name: "when searching for the user returns no results", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{}, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantUnauthenticated: true, - }, - { - name: "when searching for the user returns multiple results", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - {DN: testUserSearchResultDNValue}, - {DN: "some-other-dn"}, - }, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: fmt.Sprintf(`searching for user "%s" resulted in 2 search results, but expected 1 result`, testUpstreamUsername), - }, - { - name: "when searching for the user returns a user without a DN", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - {DN: ""}, - }, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: fmt.Sprintf(`searching for user "%s" resulted in search result without DN`, testUpstreamUsername), - }, - { - name: "when searching for the user's groups returns a group without a DN", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testGroupSearchResultDNValue1, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{testGroupSearchResultGroupNameAttributeValue1}), - }, - }, - { - DN: "", - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{testGroupSearchResultGroupNameAttributeValue2}), - }, - }, - }, - Referrals: []string{}, // note that we are not following referrals at this time - Controls: []ldap.Control{}, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: fmt.Sprintf( - `searching for group memberships for user with DN "%s" resulted in search result without DN`, - testUserSearchResultDNValue), - }, - { - name: "when searching for the user returns a user without an expected username attribute", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testUserSearchResultDNValue, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), - }, - }, - }, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: fmt.Sprintf( - `found 0 values for attribute "%s" while searching for user "%s", but expected 1 result`, - testUserSearchUsernameAttribute, testUpstreamUsername), - }, - { - name: "when searching for the group memberships returns a group without an expected group name attribute", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testGroupSearchResultDNValue1, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{testGroupSearchResultGroupNameAttributeValue1}), - }, - }, - { - DN: testGroupSearchResultDNValue1, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute("unrelated attribute", []string{"anything"}), - }, - }, - }, - Referrals: []string{}, // note that we are not following referrals at this time - Controls: []ldap.Control{}, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: fmt.Sprintf( - `error searching for group memberships for user with DN "%s": found 0 values for attribute "%s" while searching for user "%s", but expected 1 result`, - testUserSearchResultDNValue, testGroupSearchGroupNameAttribute, testUserSearchResultDNValue), - }, - { - name: "when searching for the user returns a user with too many values for the expected username attribute", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testUserSearchResultDNValue, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{ - testUserSearchResultUsernameAttributeValue, - "unexpected-additional-value", - }), - ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), - }, - }, - }, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: fmt.Sprintf( - `found 2 values for attribute "%s" while searching for user "%s", but expected 1 result`, - testUserSearchUsernameAttribute, testUpstreamUsername), - }, - { - name: "when searching for the group memberships returns a group with too many values for the expected group name attribute", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testGroupSearchResultDNValue1, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{testGroupSearchResultGroupNameAttributeValue1}), - }, - }, - { - DN: testGroupSearchResultDNValue1, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{ - testGroupSearchResultGroupNameAttributeValue1, - "unexpected-additional-value", - }), - }, - }, - }, - Referrals: []string{}, // note that we are not following referrals at this time - Controls: []ldap.Control{}, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: fmt.Sprintf( - `error searching for group memberships for user with DN "%s": found 2 values for attribute "%s" while searching for user "%s", but expected 1 result`, - testUserSearchResultDNValue, testGroupSearchGroupNameAttribute, testUserSearchResultDNValue), - }, - { - name: "when searching for the user returns a user with an empty value for the expected username attribute", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testUserSearchResultDNValue, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{""}), - ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), - }, - }, - }, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: fmt.Sprintf( - `found empty value for attribute "%s" while searching for user "%s", but expected value to be non-empty`, - testUserSearchUsernameAttribute, testUpstreamUsername), - }, - { - name: "when searching for the group memberships returns a group with an empty value for for the expected group name attribute", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testGroupSearchResultDNValue1, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{testGroupSearchResultGroupNameAttributeValue1}), - }, - }, - { - DN: testGroupSearchResultDNValue1, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{""}), - }, - }, - }, - Referrals: []string{}, // note that we are not following referrals at this time - Controls: []ldap.Control{}, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: fmt.Sprintf( - `error searching for group memberships for user with DN "%s": found empty value for attribute "%s" while searching for user "%s", but expected value to be non-empty`, - testUserSearchResultDNValue, testGroupSearchGroupNameAttribute, testUserSearchResultDNValue), - }, - { - name: "when searching for the user returns a user without an expected UID attribute", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testUserSearchResultDNValue, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), - }, - }, - }, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: fmt.Sprintf(`found 0 values for attribute "%s" while searching for user "%s", but expected 1 result`, testUserSearchUIDAttribute, testUpstreamUsername), - }, - { - name: "when searching for the user returns a user with too many values for the expected UID attribute", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testUserSearchResultDNValue, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), - ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{ - testUserSearchResultUIDAttributeValue, - "unexpected-additional-value", - }), - }, - }, - }, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: fmt.Sprintf(`found 2 values for attribute "%s" while searching for user "%s", but expected 1 result`, testUserSearchUIDAttribute, testUpstreamUsername), - }, - { - name: "when searching for the user returns a user with an empty value for the expected UID attribute", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: testUserSearchResultDNValue, - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), - ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{""}), - }, - }, - }, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: fmt.Sprintf(`found empty value for attribute "%s" while searching for user "%s", but expected value to be non-empty`, testUserSearchUIDAttribute, testUpstreamUsername), - }, - { - name: "when binding as the found user returns an error", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(exampleGroupSearchResult, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Return(errors.New("some bind error")).Times(1) - }, - skipDryRunAuthenticateUser: true, - wantError: fmt.Sprintf(`error binding for user "%s" using provided password against DN "%s": some bind error`, testUpstreamUsername, testUserSearchResultDNValue), - }, - { - name: "when binding as the found user returns a specific invalid credentials error", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). - Return(exampleGroupSearchResult, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantUnauthenticated: true, - skipDryRunAuthenticateUser: true, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - err := &ldap.Error{ - Err: errors.New("some bind error"), - ResultCode: ldap.LDAPResultInvalidCredentials, - } - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Return(err).Times(1) - }, - }, - { - name: "when no username is specified", - username: "", - password: testUpstreamPassword, - providerConfig: providerConfig(nil), - wantToSkipDial: true, - wantUnauthenticated: true, - }, - } - - for _, test := range tests { - tt := test - t.Run(tt.name, func(t *testing.T) { - ctrl := gomock.NewController(t) - t.Cleanup(ctrl.Finish) - - conn := mockldapconn.NewMockConn(ctrl) - if tt.searchMocks != nil { - tt.searchMocks(conn) - } - if tt.bindEndUserMocks != nil { - tt.bindEndUserMocks(conn) - } - - dialWasAttempted := false - tt.providerConfig.Dialer = upstreamldap.LDAPDialerFunc(func(ctx context.Context, addr endpointaddr.HostPort) (upstreamldap.Conn, error) { - dialWasAttempted = true - require.Equal(t, tt.providerConfig.Host, addr.Endpoint()) - if tt.dialError != nil { - return nil, tt.dialError - } - return conn, nil - }) - - provider := New(*tt.providerConfig) - - authResponse, authenticated, err := provider.AuthenticateUser(context.Background(), tt.username, tt.password) - require.Equal(t, !tt.wantToSkipDial, dialWasAttempted) - switch { - case tt.wantError != "": - require.EqualError(t, err, tt.wantError) - require.False(t, authenticated) - require.Nil(t, authResponse) - case tt.wantUnauthenticated: - require.NoError(t, err) - require.False(t, authenticated) - require.Nil(t, authResponse) - default: - require.NoError(t, err) - require.True(t, authenticated) - require.Equal(t, tt.wantAuthResponse, authResponse) - } - - // DryRunAuthenticateUser() should have the same behavior as AuthenticateUser() except that it does not bind - // as the end user to confirm their password. Since it should behave the same, all of the same test cases - // apply, except for those which are specifically testing what happens when the end user bind fails. - if tt.skipDryRunAuthenticateUser { - return // move on to the next test - } - - // Reset some variables to get ready to call DryRunAuthenticateUser(). - dialWasAttempted = false - conn = mockldapconn.NewMockConn(ctrl) - if tt.searchMocks != nil { - tt.searchMocks(conn) - } - // Skip tt.bindEndUserMocks since DryRunAuthenticateUser() never binds as the end user. - - authResponse, authenticated, err = provider.DryRunAuthenticateUser(context.Background(), tt.username) - require.Equal(t, !tt.wantToSkipDial, dialWasAttempted) - switch { - case tt.wantError != "": - require.EqualError(t, err, tt.wantError) - require.False(t, authenticated) - require.Nil(t, authResponse) - case tt.wantUnauthenticated: - require.NoError(t, err) - require.False(t, authenticated) - require.Nil(t, authResponse) - default: - require.NoError(t, err) - require.True(t, authenticated) - require.Equal(t, tt.wantAuthResponse, authResponse) - } - }) - } -} - -func TestTestConnection(t *testing.T) { - providerConfig := func(editFunc func(p *upstreamldap.ProviderConfig)) *upstreamldap.ProviderConfig { - config := &upstreamldap.ProviderConfig{ - Name: "some-provider-name", - Host: testHost, - CABundle: nil, // this field is only used by the production dialer, which is replaced by a mock for this test - ConnectionProtocol: upstreamldap.TLS, - BindUsername: testBindUsername, - BindPassword: testBindPassword, - UserSearch: upstreamldap.UserSearchConfig{}, // not used by TestConnection - } - if editFunc != nil { - editFunc(config) - } - return config - } - - tests := []struct { - name string - providerConfig *upstreamldap.ProviderConfig - setupMocks func(conn *mockldapconn.MockConn) - dialError error - wantError string - wantToSkipDial bool - }{ - { - name: "happy path", - providerConfig: providerConfig(nil), - setupMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Close().Times(1) - }, - }, - { - name: "when dial fails", - providerConfig: providerConfig(nil), - dialError: errors.New("some dial error"), - wantError: fmt.Sprintf(`error dialing host "%s": some dial error`, testHost), - }, - { - name: "when binding as the bind user returns an error", - providerConfig: providerConfig(nil), - setupMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Return(errors.New("some bind error")).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: fmt.Sprintf(`error binding as "%s": some bind error`, testBindUsername), - }, - { - name: "when the config is invalid", - providerConfig: providerConfig(func(p *upstreamldap.ProviderConfig) { - // This particular combination of options is not allowed. - p.UserSearch.UsernameAttribute = "dn" - p.UserSearch.Filter = "" - }), - wantToSkipDial: true, - wantError: `must specify UserSearch Filter when UserSearch UsernameAttribute is "dn"`, - }, - } - - for _, test := range tests { - tt := test - t.Run(tt.name, func(t *testing.T) { - ctrl := gomock.NewController(t) - t.Cleanup(ctrl.Finish) - - conn := mockldapconn.NewMockConn(ctrl) - if tt.setupMocks != nil { - tt.setupMocks(conn) - } - - dialWasAttempted := false - tt.providerConfig.Dialer = upstreamldap.LDAPDialerFunc(func(ctx context.Context, addr endpointaddr.HostPort) (upstreamldap.Conn, error) { - dialWasAttempted = true - require.Equal(t, tt.providerConfig.Host, addr.Endpoint()) - if tt.dialError != nil { - return nil, tt.dialError - } - return conn, nil - }) - - provider := New(*tt.providerConfig) - err := provider.TestConnection(context.Background()) - - require.Equal(t, !tt.wantToSkipDial, dialWasAttempted) - - switch { - case tt.wantError != "": - require.EqualError(t, err, tt.wantError) - default: - require.NoError(t, err) - } - }) - } -} - -func TestGetConfig(t *testing.T) { - c := upstreamldap.ProviderConfig{ - Name: "original-provider-name", - Host: testHost, - CABundle: []byte("some-ca-bundle"), - BindUsername: testBindUsername, - BindPassword: testBindPassword, - UserSearch: upstreamldap.UserSearchConfig{ - Base: testUserSearchBase, - Filter: testUserSearchFilter, - UsernameAttribute: testUserSearchUsernameAttribute, - UIDAttribute: testUserSearchUIDAttribute, - }, - } - p := New(c) - require.Equal(t, c, p.c) - require.Equal(t, c, p.GetConfig()) - - // The original config can be changed without impacting the provider, since the provider made a copy of the config. - c.Name = "changed-name" - require.Equal(t, "original-provider-name", p.c.Name) - - // The return value of GetConfig can be modified without impacting the provider, since it is a copy of the config. - returnedConfig := p.GetConfig() - returnedConfig.Name = "changed-name" - require.Equal(t, "original-provider-name", p.c.Name) -} - -func TestGetURL(t *testing.T) { - require.Equal(t, - "ldaps://ldap.example.com:1234?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev", - New(upstreamldap.ProviderConfig{ - Host: "ldap.example.com:1234", - UserSearch: upstreamldap.UserSearchConfig{Base: "ou=users,dc=pinniped,dc=dev"}, - }).GetURL().String()) - - require.Equal(t, - "ldaps://ldap.example.com?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev", - New(upstreamldap.ProviderConfig{ - Host: "ldap.example.com", - UserSearch: upstreamldap.UserSearchConfig{Base: "ou=users,dc=pinniped,dc=dev"}, - }).GetURL().String()) -} - -// Testing of host parsing, TLS negotiation, and CA bundle, etc. for the production code's dialer. -func TestRealTLSDialing(t *testing.T) { - testServerCABundle, testServerURL := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) {}) - parsedURL, err := url.Parse(testServerURL) - require.NoError(t, err) - testServerHostAndPort := parsedURL.Host - - caForTestServerWithBadCertName, err := certauthority.New("Test CA", time.Hour) - require.NoError(t, err) - wrongIP := net.ParseIP("10.2.3.4") - cert, err := caForTestServerWithBadCertName.IssueServerCert([]string{"wrong-dns-name"}, []net.IP{wrongIP}, time.Hour) - require.NoError(t, err) - testServerWithBadCertNameAddr := testutil.TLSTestServerWithCert(t, func(w http.ResponseWriter, r *http.Request) {}, cert) - - unusedPortGrabbingListener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - recentlyClaimedHostAndPort := unusedPortGrabbingListener.Addr().String() - require.NoError(t, unusedPortGrabbingListener.Close()) - - alreadyCancelledContext, cancelFunc := context.WithCancel(context.Background()) - cancelFunc() // cancel it immediately - - tests := []struct { - name string - host string - connProto upstreamldap.LDAPConnectionProtocol - caBundle []byte - context context.Context - wantError string - }{ - { - name: "happy path", - host: testServerHostAndPort, - caBundle: []byte(testServerCABundle), - connProto: upstreamldap.TLS, - context: context.Background(), - }, - { - name: "server cert name does not match the address to which the client connected", - host: testServerWithBadCertNameAddr, - caBundle: caForTestServerWithBadCertName.Bundle(), - connProto: upstreamldap.TLS, - context: context.Background(), - wantError: `LDAP Result Code 200 "Network Error": x509: certificate is valid for 10.2.3.4, not 127.0.0.1`, - }, - { - name: "invalid CA bundle with TLS", - host: testServerHostAndPort, - caBundle: []byte("not a ca bundle"), - connProto: upstreamldap.TLS, - context: context.Background(), - wantError: `LDAP Result Code 200 "Network Error": could not parse CA bundle`, - }, - { - name: "invalid CA bundle with StartTLS", - host: testServerHostAndPort, - caBundle: []byte("not a ca bundle"), - connProto: upstreamldap.StartTLS, - context: context.Background(), - wantError: `LDAP Result Code 200 "Network Error": could not parse CA bundle`, - }, - { - name: "invalid host with TLS", - host: "this:is:not:a:valid:hostname", - caBundle: []byte(testServerCABundle), - connProto: upstreamldap.TLS, - context: context.Background(), - wantError: `LDAP Result Code 200 "Network Error": host "this:is:not:a:valid:hostname" is not a valid hostname or IP address`, - }, - { - name: "invalid host with StartTLS", - host: "this:is:not:a:valid:hostname", - caBundle: []byte(testServerCABundle), - connProto: upstreamldap.StartTLS, - context: context.Background(), - wantError: `LDAP Result Code 200 "Network Error": host "this:is:not:a:valid:hostname" is not a valid hostname or IP address`, - }, - { - name: "missing CA bundle when it is required because the host is not using a trusted CA", - host: testServerHostAndPort, - caBundle: nil, - connProto: upstreamldap.TLS, - context: context.Background(), - wantError: `LDAP Result Code 200 "Network Error": x509: certificate signed by unknown authority`, - }, - { - name: "cannot connect to host", - // This is assuming that this port was not reclaimed by another app since the test setup ran. Seems safe enough. - host: recentlyClaimedHostAndPort, - caBundle: []byte(testServerCABundle), - connProto: upstreamldap.TLS, - context: context.Background(), - wantError: fmt.Sprintf(`LDAP Result Code 200 "Network Error": dial tcp %s: connect: connection refused`, recentlyClaimedHostAndPort), - }, - { - name: "pays attention to the passed context", - host: testServerHostAndPort, - caBundle: []byte(testServerCABundle), - connProto: upstreamldap.TLS, - context: alreadyCancelledContext, - wantError: fmt.Sprintf(`LDAP Result Code 200 "Network Error": dial tcp %s: operation was canceled`, testServerHostAndPort), - }, - { - name: "unsupported connection protocol", - host: testServerHostAndPort, - caBundle: []byte(testServerCABundle), - connProto: "bad usage of this type", - context: alreadyCancelledContext, - wantError: `LDAP Result Code 200 "Network Error": did not specify valid ConnectionProtocol`, - }, - } - for _, test := range tests { - tt := test - t.Run(tt.name, func(t *testing.T) { - provider := New(upstreamldap.ProviderConfig{ - Host: tt.host, - CABundle: tt.caBundle, - ConnectionProtocol: tt.connProto, - Dialer: nil, // this test is for the default (production) TLS dialer - }) - conn, err := provider.dial(tt.context) - if conn != nil { - defer conn.Close() - } - if tt.wantError != "" { - require.Nil(t, conn) - require.EqualError(t, err, tt.wantError) - } else { - require.NoError(t, err) - require.NotNil(t, conn) - - // Should be an instance of the real production LDAP client type. - // Can't test its methods here because we are not dialed to a real LDAP server. - require.IsType(t, &ldap.Conn{}, conn) - - // Indirectly checking that the Dialer method constructed the ldap.Conn with isTLS set to true, - // since this is always the correct behavior unless/until we want to support StartTLS. - err := conn.(*ldap.Conn).StartTLS(&tls.Config{}) - require.EqualError(t, err, `LDAP Result Code 200 "Network Error": ldap: already encrypted`) - } - }) - } -}