c155c6e629
- Make everything private - Drop unused AuthTime field - Use %q format string instead of "%s" - Only rely on GetRawAttributeValues in AttributeUnchangedSinceLogin Signed-off-by: Monis Khan <mok@vmware.com>
1837 lines
71 KiB
Go
1837 lines
71 KiB
Go
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package upstreamldap
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"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/user"
|
|
|
|
"go.pinniped.dev/internal/authenticators"
|
|
"go.pinniped.dev/internal/certauthority"
|
|
"go.pinniped.dev/internal/crypto/ptls"
|
|
"go.pinniped.dev/internal/endpointaddr"
|
|
"go.pinniped.dev/internal/mocks/mockldapconn"
|
|
"go.pinniped.dev/internal/oidc/provider"
|
|
"go.pinniped.dev/internal/testutil"
|
|
"go.pinniped.dev/internal/testutil/tlsserver"
|
|
)
|
|
|
|
const (
|
|
testHost = "ldap.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 = "some-upstream-uid-attribute"
|
|
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 = "some-upstream-uid-value"
|
|
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 *ProviderConfig)) *ProviderConfig {
|
|
config := &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: TLS,
|
|
BindUsername: testBindUsername,
|
|
BindPassword: testBindPassword,
|
|
UserSearch: UserSearchConfig{
|
|
Base: testUserSearchBase,
|
|
Filter: testUserSearchFilter,
|
|
UsernameAttribute: testUserSearchUsernameAttribute,
|
|
UIDAttribute: testUserSearchUIDAttribute,
|
|
},
|
|
GroupSearch: 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 *authenticators.Response)) *authenticators.Response {
|
|
u := &user.DefaultInfo{
|
|
Name: testUserSearchResultUsernameAttributeValue,
|
|
UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)),
|
|
Groups: []string{testGroupSearchResultGroupNameAttributeValue1, testGroupSearchResultGroupNameAttributeValue2},
|
|
}
|
|
response := &authenticators.Response{User: u, DN: testUserSearchResultDNValue, ExtraRefreshAttributes: map[string]string{}}
|
|
if editFunc != nil {
|
|
editFunc(response)
|
|
}
|
|
return response
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
username string
|
|
password string
|
|
providerConfig *ProviderConfig
|
|
searchMocks func(conn *mockldapconn.MockConn)
|
|
bindEndUserMocks func(conn *mockldapconn.MockConn)
|
|
dialError error
|
|
wantError string
|
|
wantToSkipDial bool
|
|
wantAuthResponse *authenticators.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: "when the user search filter is already wrapped by parenthesis then it is not wrapped again",
|
|
username: testUpstreamUsername,
|
|
password: testUpstreamPassword,
|
|
providerConfig: providerConfig(func(p *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 *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 *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 *authenticators.Response) {
|
|
info := r.User.(*user.DefaultInfo)
|
|
info.Groups = nil
|
|
}),
|
|
},
|
|
{
|
|
name: "when the UsernameAttribute is dn and there is a user search filter provided",
|
|
username: testUpstreamUsername,
|
|
password: testUpstreamPassword,
|
|
providerConfig: providerConfig(func(p *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 *authenticators.Response) {
|
|
info := r.User.(*user.DefaultInfo)
|
|
info.Name = testUserSearchResultDNValue
|
|
}),
|
|
},
|
|
{
|
|
name: "when the UIDAttribute is dn",
|
|
username: testUpstreamUsername,
|
|
password: testUpstreamPassword,
|
|
providerConfig: providerConfig(func(p *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 *authenticators.Response) {
|
|
info := r.User.(*user.DefaultInfo)
|
|
info.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 *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 *authenticators.Response) {
|
|
info := r.User.(*user.DefaultInfo)
|
|
info.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2}
|
|
}),
|
|
},
|
|
{
|
|
name: "when the GroupNameAttribute is dn",
|
|
username: testUpstreamUsername,
|
|
password: testUpstreamPassword,
|
|
providerConfig: providerConfig(func(p *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 *authenticators.Response) {
|
|
info := r.User.(*user.DefaultInfo)
|
|
info.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2}
|
|
}),
|
|
},
|
|
{
|
|
name: "when the GroupNameAttribute is cn",
|
|
username: testUpstreamUsername,
|
|
password: testUpstreamPassword,
|
|
providerConfig: providerConfig(func(p *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 *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 group search Filter is blank it uses a default search filter of member={}",
|
|
username: testUpstreamUsername,
|
|
password: testUpstreamPassword,
|
|
providerConfig: providerConfig(func(p *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: &authenticators.Response{
|
|
User: &user.DefaultInfo{
|
|
Name: testUserSearchResultUsernameAttributeValue,
|
|
UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)),
|
|
Groups: []string{"a", "b", "c"},
|
|
},
|
|
DN: testUserSearchResultDNValue,
|
|
ExtraRefreshAttributes: map[string]string{},
|
|
},
|
|
},
|
|
{
|
|
name: "requesting additional refresh related attributes",
|
|
username: testUpstreamUsername,
|
|
password: testUpstreamPassword,
|
|
providerConfig: providerConfig(func(p *ProviderConfig) {
|
|
p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes provider.StoredRefreshAttributes) error{
|
|
"some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes provider.StoredRefreshAttributes) error {
|
|
return nil
|
|
},
|
|
}
|
|
}),
|
|
searchMocks: func(conn *mockldapconn.MockConn) {
|
|
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
|
|
conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) {
|
|
r.Attributes = append(r.Attributes, "some-attribute-to-check-during-refresh")
|
|
})).Return(&ldap.SearchResult{
|
|
Entries: []*ldap.Entry{
|
|
{
|
|
DN: testUserSearchResultDNValue,
|
|
Attributes: []*ldap.EntryAttribute{
|
|
ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}),
|
|
ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}),
|
|
ldap.NewEntryAttribute("some-attribute-to-check-during-refresh", []string{"some-attribute-value"}),
|
|
},
|
|
},
|
|
},
|
|
}, 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 *authenticators.Response) {
|
|
r.ExtraRefreshAttributes = map[string]string{"some-attribute-to-check-during-refresh": "c29tZS1hdHRyaWJ1dGUtdmFsdWU"}
|
|
}),
|
|
},
|
|
{
|
|
name: "requesting additional refresh related attributes, but they aren't returned",
|
|
username: testUpstreamUsername,
|
|
password: testUpstreamPassword,
|
|
providerConfig: providerConfig(func(p *ProviderConfig) {
|
|
p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes provider.StoredRefreshAttributes) error{
|
|
"some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes provider.StoredRefreshAttributes) error {
|
|
return nil
|
|
},
|
|
}
|
|
}),
|
|
searchMocks: func(conn *mockldapconn.MockConn) {
|
|
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
|
|
conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) {
|
|
r.Attributes = append(r.Attributes, "some-attribute-to-check-during-refresh")
|
|
})).Return(exampleUserSearchResult, nil).Times(1)
|
|
conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize).
|
|
Return(exampleGroupSearchResult, nil).Times(1)
|
|
conn.EXPECT().Close().Times(1)
|
|
},
|
|
wantError: "found 0 values for attribute \"some-attribute-to-check-during-refresh\" while searching for user \"some-upstream-username\", but expected 1 result",
|
|
},
|
|
{
|
|
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 *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 = LDAPDialerFunc(func(ctx context.Context, addr endpointaddr.HostPort) (Conn, error) {
|
|
dialWasAttempted = true
|
|
require.Equal(t, tt.providerConfig.Host, addr.Endpoint())
|
|
if tt.dialError != nil {
|
|
return nil, tt.dialError
|
|
}
|
|
return conn, nil
|
|
})
|
|
|
|
ldapProvider := New(*tt.providerConfig)
|
|
|
|
authResponse, authenticated, err := ldapProvider.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 = ldapProvider.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 TestUpstreamRefresh(t *testing.T) {
|
|
pwdLastSetAttribute := "pwdLastSet"
|
|
expectedUserSearch := &ldap.SearchRequest{
|
|
BaseDN: testUserSearchResultDNValue,
|
|
Scope: ldap.ScopeBaseObject,
|
|
DerefAliases: ldap.NeverDerefAliases,
|
|
SizeLimit: 2,
|
|
TimeLimit: 90,
|
|
TypesOnly: false,
|
|
Filter: "(objectClass=*)",
|
|
Attributes: []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, pwdLastSetAttribute},
|
|
Controls: nil, // don't need paging because we set the SizeLimit so small
|
|
}
|
|
|
|
happyPathUserSearchResult := &ldap.SearchResult{
|
|
Entries: []*ldap.Entry{
|
|
{
|
|
DN: testUserSearchResultDNValue,
|
|
Attributes: []*ldap.EntryAttribute{
|
|
{
|
|
Name: testUserSearchUsernameAttribute,
|
|
Values: []string{testUserSearchResultUsernameAttributeValue},
|
|
},
|
|
{
|
|
Name: testUserSearchUIDAttribute,
|
|
ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)},
|
|
},
|
|
{
|
|
Name: pwdLastSetAttribute,
|
|
Values: []string{"132801740800000000"},
|
|
ByteValues: [][]byte{[]byte("132801740800000000")},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
providerConfig := &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: TLS,
|
|
BindUsername: testBindUsername,
|
|
BindPassword: testBindPassword,
|
|
UserSearch: UserSearchConfig{
|
|
Base: testUserSearchBase,
|
|
UIDAttribute: testUserSearchUIDAttribute,
|
|
UsernameAttribute: testUserSearchUsernameAttribute,
|
|
},
|
|
RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{
|
|
pwdLastSetAttribute: AttributeUnchangedSinceLogin(pwdLastSetAttribute),
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
providerConfig *ProviderConfig
|
|
setupMocks func(conn *mockldapconn.MockConn)
|
|
dialError error
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "happy path where searching the dn returns a single entry",
|
|
providerConfig: providerConfig,
|
|
setupMocks: func(conn *mockldapconn.MockConn) {
|
|
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
|
|
conn.EXPECT().Search(expectedUserSearch).Return(happyPathUserSearchResult, nil).Times(1)
|
|
conn.EXPECT().Close().Times(1)
|
|
},
|
|
},
|
|
{
|
|
name: "error where dial fails",
|
|
providerConfig: providerConfig,
|
|
dialError: errors.New("some dial error"),
|
|
wantErr: "error dialing host \"ldap.example.com:8443\": some dial error",
|
|
},
|
|
{
|
|
name: "error binding",
|
|
providerConfig: providerConfig,
|
|
setupMocks: func(conn *mockldapconn.MockConn) {
|
|
conn.EXPECT().Bind(testBindUsername, testBindPassword).Return(errors.New("some bind error")).Times(1)
|
|
conn.EXPECT().Close().Times(1)
|
|
},
|
|
wantErr: "error binding as \"cn=some-bind-username,dc=pinniped,dc=dev\" before user search: some bind error",
|
|
},
|
|
{
|
|
name: "search result returns no entries",
|
|
providerConfig: providerConfig,
|
|
setupMocks: func(conn *mockldapconn.MockConn) {
|
|
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
|
|
conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{
|
|
Entries: []*ldap.Entry{},
|
|
}, nil).Times(1)
|
|
conn.EXPECT().Close().Times(1)
|
|
},
|
|
wantErr: "searching for user \"some-upstream-user-dn\" resulted in 0 search results, but expected 1 result",
|
|
},
|
|
{
|
|
name: "error searching",
|
|
providerConfig: providerConfig,
|
|
setupMocks: func(conn *mockldapconn.MockConn) {
|
|
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
|
|
conn.EXPECT().Search(expectedUserSearch).Return(nil, errors.New("some search error"))
|
|
conn.EXPECT().Close().Times(1)
|
|
},
|
|
wantErr: "error searching for user \"some-upstream-user-dn\": some search error",
|
|
},
|
|
{
|
|
name: "search result returns more than one entry",
|
|
providerConfig: providerConfig,
|
|
setupMocks: func(conn *mockldapconn.MockConn) {
|
|
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
|
|
conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{
|
|
Entries: []*ldap.Entry{
|
|
{
|
|
DN: testUserSearchResultDNValue,
|
|
Attributes: []*ldap.EntryAttribute{},
|
|
},
|
|
{
|
|
DN: "doesn't-matter",
|
|
Attributes: []*ldap.EntryAttribute{},
|
|
},
|
|
},
|
|
}, nil).Times(1)
|
|
conn.EXPECT().Close().Times(1)
|
|
},
|
|
wantErr: "searching for user \"some-upstream-user-dn\" resulted in 2 search results, but expected 1 result",
|
|
},
|
|
{
|
|
name: "search result has wrong uid",
|
|
providerConfig: providerConfig,
|
|
setupMocks: func(conn *mockldapconn.MockConn) {
|
|
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
|
|
conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{
|
|
Entries: []*ldap.Entry{
|
|
{
|
|
DN: testUserSearchResultDNValue,
|
|
Attributes: []*ldap.EntryAttribute{
|
|
{
|
|
Name: testUserSearchUsernameAttribute,
|
|
Values: []string{testUserSearchResultUsernameAttributeValue},
|
|
},
|
|
{
|
|
Name: testUserSearchUIDAttribute,
|
|
ByteValues: [][]byte{[]byte("wrong-uid")},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, nil).Times(1)
|
|
conn.EXPECT().Close().Times(1)
|
|
},
|
|
wantErr: "searching for user \"some-upstream-user-dn\" produced a different subject than the previous value. expected: \"ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&sub=c29tZS11cHN0cmVhbS11aWQtdmFsdWU\", actual: \"ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&sub=d3JvbmctdWlk\"",
|
|
},
|
|
{
|
|
name: "search result has wrong username",
|
|
providerConfig: providerConfig,
|
|
setupMocks: func(conn *mockldapconn.MockConn) {
|
|
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
|
|
conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{
|
|
Entries: []*ldap.Entry{
|
|
{
|
|
DN: testUserSearchResultDNValue,
|
|
Attributes: []*ldap.EntryAttribute{
|
|
{
|
|
Name: testUserSearchUsernameAttribute,
|
|
Values: []string{"wrong-username"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, nil).Times(1)
|
|
conn.EXPECT().Close().Times(1)
|
|
},
|
|
wantErr: "searching for user \"some-upstream-user-dn\" returned a different username than the previous value. expected: \"some-upstream-username-value\", actual: \"wrong-username\"",
|
|
},
|
|
{
|
|
name: "search result has no dn",
|
|
providerConfig: providerConfig,
|
|
setupMocks: func(conn *mockldapconn.MockConn) {
|
|
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
|
|
conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{
|
|
Entries: []*ldap.Entry{
|
|
{
|
|
Attributes: []*ldap.EntryAttribute{
|
|
{
|
|
Name: testUserSearchUsernameAttribute,
|
|
Values: []string{testUserSearchResultUsernameAttributeValue},
|
|
},
|
|
{
|
|
Name: testUserSearchUIDAttribute,
|
|
ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, nil).Times(1)
|
|
conn.EXPECT().Close().Times(1)
|
|
},
|
|
wantErr: "searching for user with original DN \"some-upstream-user-dn\" resulted in search result without DN",
|
|
},
|
|
{
|
|
name: "search result has 0 values for username attribute",
|
|
providerConfig: providerConfig,
|
|
setupMocks: func(conn *mockldapconn.MockConn) {
|
|
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
|
|
conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{
|
|
Entries: []*ldap.Entry{
|
|
{
|
|
DN: testUserSearchResultDNValue,
|
|
Attributes: []*ldap.EntryAttribute{
|
|
{
|
|
Name: testUserSearchUsernameAttribute,
|
|
Values: []string{},
|
|
},
|
|
{
|
|
Name: testUserSearchUIDAttribute,
|
|
ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, nil).Times(1)
|
|
conn.EXPECT().Close().Times(1)
|
|
},
|
|
wantErr: "found 0 values for attribute \"some-upstream-username-attribute\" while searching for user \"some-upstream-user-dn\", but expected 1 result",
|
|
},
|
|
{
|
|
name: "search result has more than one value for username attribute",
|
|
providerConfig: providerConfig,
|
|
setupMocks: func(conn *mockldapconn.MockConn) {
|
|
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
|
|
conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{
|
|
Entries: []*ldap.Entry{
|
|
{
|
|
DN: testUserSearchResultDNValue,
|
|
Attributes: []*ldap.EntryAttribute{
|
|
{
|
|
Name: testUserSearchUsernameAttribute,
|
|
Values: []string{testUserSearchResultUsernameAttributeValue, "something-else"},
|
|
},
|
|
{
|
|
Name: testUserSearchUIDAttribute,
|
|
ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, nil).Times(1)
|
|
conn.EXPECT().Close().Times(1)
|
|
},
|
|
wantErr: "found 2 values for attribute \"some-upstream-username-attribute\" while searching for user \"some-upstream-user-dn\", but expected 1 result",
|
|
},
|
|
{
|
|
name: "search result has 0 values for uid attribute",
|
|
providerConfig: providerConfig,
|
|
setupMocks: func(conn *mockldapconn.MockConn) {
|
|
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
|
|
conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{
|
|
Entries: []*ldap.Entry{
|
|
{
|
|
DN: testUserSearchResultDNValue,
|
|
Attributes: []*ldap.EntryAttribute{
|
|
{
|
|
Name: testUserSearchUsernameAttribute,
|
|
Values: []string{testUserSearchResultUsernameAttributeValue},
|
|
},
|
|
{
|
|
Name: testUserSearchUIDAttribute,
|
|
ByteValues: [][]byte{},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, nil).Times(1)
|
|
conn.EXPECT().Close().Times(1)
|
|
},
|
|
wantErr: "found 0 values for attribute \"some-upstream-uid-attribute\" while searching for user \"some-upstream-user-dn\", but expected 1 result",
|
|
},
|
|
{
|
|
name: "search result has 2 values for uid attribute",
|
|
providerConfig: providerConfig,
|
|
setupMocks: func(conn *mockldapconn.MockConn) {
|
|
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
|
|
conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{
|
|
Entries: []*ldap.Entry{
|
|
{
|
|
DN: testUserSearchResultDNValue,
|
|
Attributes: []*ldap.EntryAttribute{
|
|
{
|
|
Name: testUserSearchUsernameAttribute,
|
|
Values: []string{testUserSearchResultUsernameAttributeValue},
|
|
},
|
|
{
|
|
Name: testUserSearchUIDAttribute,
|
|
ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue), []byte("other-uid-value")},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, nil).Times(1)
|
|
conn.EXPECT().Close().Times(1)
|
|
},
|
|
wantErr: "found 2 values for attribute \"some-upstream-uid-attribute\" while searching for user \"some-upstream-user-dn\", but expected 1 result",
|
|
},
|
|
{
|
|
name: "search result has a changed pwdLastSet value",
|
|
providerConfig: providerConfig,
|
|
setupMocks: func(conn *mockldapconn.MockConn) {
|
|
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
|
|
conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{
|
|
Entries: []*ldap.Entry{
|
|
{
|
|
DN: testUserSearchResultDNValue,
|
|
Attributes: []*ldap.EntryAttribute{
|
|
{
|
|
Name: testUserSearchUsernameAttribute,
|
|
Values: []string{testUserSearchResultUsernameAttributeValue},
|
|
},
|
|
{
|
|
Name: testUserSearchUIDAttribute,
|
|
ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)},
|
|
},
|
|
{
|
|
Name: pwdLastSetAttribute,
|
|
ByteValues: [][]byte{[]byte("132801740800000001")},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, nil).Times(1)
|
|
conn.EXPECT().Close().Times(1)
|
|
},
|
|
wantErr: "validation for attribute \"pwdLastSet\" failed during upstream refresh: value for attribute \"pwdLastSet\" has changed since initial value at login",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
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
|
|
providerConfig.Dialer = LDAPDialerFunc(func(ctx context.Context, addr endpointaddr.HostPort) (Conn, error) {
|
|
dialWasAttempted = true
|
|
require.Equal(t, providerConfig.Host, addr.Endpoint())
|
|
if tt.dialError != nil {
|
|
return nil, tt.dialError
|
|
}
|
|
|
|
return conn, nil
|
|
})
|
|
|
|
initialPwdLastSetEncoded := base64.RawURLEncoding.EncodeToString([]byte("132801740800000000"))
|
|
ldapProvider := New(*providerConfig)
|
|
subject := "ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&sub=c29tZS11cHN0cmVhbS11aWQtdmFsdWU"
|
|
err := ldapProvider.PerformRefresh(context.Background(), provider.StoredRefreshAttributes{
|
|
Username: testUserSearchResultUsernameAttributeValue,
|
|
Subject: subject,
|
|
DN: testUserSearchResultDNValue,
|
|
AdditionalAttributes: map[string]string{pwdLastSetAttribute: initialPwdLastSetEncoded},
|
|
})
|
|
if tt.wantErr != "" {
|
|
require.Error(t, err)
|
|
require.Equal(t, tt.wantErr, err.Error())
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
require.Equal(t, true, dialWasAttempted)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTestConnection(t *testing.T) {
|
|
providerConfig := func(editFunc func(p *ProviderConfig)) *ProviderConfig {
|
|
config := &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: TLS,
|
|
BindUsername: testBindUsername,
|
|
BindPassword: testBindPassword,
|
|
UserSearch: UserSearchConfig{}, // not used by TestConnection
|
|
}
|
|
if editFunc != nil {
|
|
editFunc(config)
|
|
}
|
|
return config
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
providerConfig *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 *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 = LDAPDialerFunc(func(ctx context.Context, addr endpointaddr.HostPort) (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 := ProviderConfig{
|
|
Name: "original-provider-name",
|
|
Host: testHost,
|
|
CABundle: []byte("some-ca-bundle"),
|
|
BindUsername: testBindUsername,
|
|
BindPassword: testBindPassword,
|
|
UserSearch: 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(ProviderConfig{
|
|
Host: "ldap.example.com:1234",
|
|
UserSearch: 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(ProviderConfig{
|
|
Host: "ldap.example.com",
|
|
UserSearch: 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) {
|
|
testServer := tlsserver.TLSTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
|
func(server *httptest.Server) {
|
|
tlsserver.RecordTLSHello(server)
|
|
recordFunc := server.TLS.GetConfigForClient
|
|
server.TLS.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
|
|
_, _ = recordFunc(info)
|
|
r, err := http.NewRequestWithContext(info.Context(), http.MethodGet, "/this-is-ldap", nil)
|
|
require.NoError(t, err)
|
|
tlsserver.AssertTLS(t, r, ptls.DefaultLDAP)
|
|
return nil, nil
|
|
}
|
|
})
|
|
parsedURL, err := url.Parse(testServer.URL)
|
|
require.NoError(t, err)
|
|
testServerHostAndPort := parsedURL.Host
|
|
testServerCABundle := tlsserver.TLSTestServerCA(testServer)
|
|
|
|
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 LDAPConnectionProtocol
|
|
caBundle []byte
|
|
context context.Context
|
|
wantError string
|
|
}{
|
|
{
|
|
name: "happy path",
|
|
host: testServerHostAndPort,
|
|
caBundle: testServerCABundle,
|
|
connProto: TLS,
|
|
context: context.Background(),
|
|
},
|
|
{
|
|
name: "server cert name does not match the address to which the client connected",
|
|
host: testServerWithBadCertNameAddr,
|
|
caBundle: caForTestServerWithBadCertName.Bundle(),
|
|
connProto: 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: 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: 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: testServerCABundle,
|
|
connProto: 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: testServerCABundle,
|
|
connProto: 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: 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: testServerCABundle,
|
|
connProto: 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: testServerCABundle,
|
|
connProto: 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: 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(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(ptls.DefaultLDAP(nil))
|
|
require.EqualError(t, err, `LDAP Result Code 200 "Network Error": ldap: already encrypted`)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAttributeUnchangedSinceLogin(t *testing.T) {
|
|
initialVal := "some-attribute-value"
|
|
changedVal := "some-different-attribute-value"
|
|
attributeName := "some-attribute-name"
|
|
tests := []struct {
|
|
name string
|
|
entry *ldap.Entry
|
|
wantResult bool
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "happy path where value has not changed since login",
|
|
entry: &ldap.Entry{
|
|
DN: "some-dn",
|
|
Attributes: []*ldap.EntryAttribute{
|
|
{
|
|
Name: attributeName,
|
|
Values: []string{initialVal},
|
|
ByteValues: [][]byte{[]byte(initialVal)},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "password has been reset since login",
|
|
entry: &ldap.Entry{
|
|
DN: "some-dn",
|
|
Attributes: []*ldap.EntryAttribute{
|
|
{
|
|
Name: attributeName,
|
|
Values: []string{changedVal},
|
|
ByteValues: [][]byte{[]byte(changedVal)},
|
|
},
|
|
},
|
|
},
|
|
wantErr: "value for attribute \"some-attribute-name\" has changed since initial value at login",
|
|
},
|
|
{
|
|
name: "no value for attribute attribute",
|
|
entry: &ldap.Entry{
|
|
DN: "some-dn",
|
|
Attributes: []*ldap.EntryAttribute{},
|
|
},
|
|
wantErr: "expected to find 1 value for \"some-attribute-name\" attribute, but found 0",
|
|
},
|
|
{
|
|
name: "too many values for attribute",
|
|
entry: &ldap.Entry{
|
|
DN: "some-dn",
|
|
Attributes: []*ldap.EntryAttribute{
|
|
{
|
|
Name: attributeName,
|
|
ByteValues: [][]byte{[]byte("val1"), []byte("val2")},
|
|
},
|
|
},
|
|
},
|
|
wantErr: "expected to find 1 value for \"some-attribute-name\" attribute, but found 2",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
tt := test
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
initialValRawEncoded := base64.RawURLEncoding.EncodeToString([]byte(initialVal))
|
|
err := AttributeUnchangedSinceLogin(attributeName)(tt.entry, provider.StoredRefreshAttributes{AdditionalAttributes: map[string]string{attributeName: initialValRawEncoded}})
|
|
if tt.wantErr != "" {
|
|
require.Error(t, err)
|
|
require.Equal(t, tt.wantErr, err.Error())
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
})
|
|
}
|
|
}
|