ContainerImage.Pinniped/internal/upstreamldap/upstreamldap_test.go

458 lines
15 KiB
Go
Raw Normal View History

// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package upstreamldap
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"testing"
"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/mocks/mockldapconn"
"go.pinniped.dev/internal/testutil"
)
const (
testHost = "ldap.example.com:8443"
testBindUsername = "some-bind-username"
testBindPassword = "some-bind-password"
testUpstreamUsername = "some-upstream-username"
testUpstreamPassword = "some-upstream-password"
testUserSearchBase = "some-upstream-base-dn"
testUserSearchFilter = "some-filter={}-and-more-filter={}"
testUserSearchUsernameAttribute = "some-upstream-username-attribute"
testUserSearchUIDAttribute = "some-upstream-uid-attribute"
testSearchResultDNValue = "some-upstream-user-dn"
testSearchResultUsernameAttributeValue = "some-upstream-username-value"
testSearchResultUIDAttributeValue = "some-upstream-uid-value"
)
var (
testUserSearchFilterInterpolated = fmt.Sprintf("some-filter=%s-and-more-filter=%s", testUpstreamUsername, testUpstreamUsername)
)
func TestAuthenticateUser(t *testing.T) {
provider := func(editFunc func(p *Provider)) *Provider {
provider := &Provider{
Host: testHost,
BindUsername: testBindUsername,
BindPassword: testBindPassword,
UserSearch: &UserSearch{
Base: testUserSearchBase,
Filter: testUserSearchFilter,
UsernameAttribute: testUserSearchUsernameAttribute,
UIDAttribute: testUserSearchUIDAttribute,
},
}
if editFunc != nil {
editFunc(provider)
}
return provider
}
expectedSearch := func(editFunc func(r *ldap.SearchRequest)) *ldap.SearchRequest {
request := &ldap.SearchRequest{
BaseDN: testUserSearchBase,
Scope: ldap.ScopeWholeSubtree,
DerefAliases: ldap.DerefAlways,
SizeLimit: 2,
TimeLimit: 90,
TypesOnly: false,
Filter: testUserSearchFilterInterpolated,
Attributes: []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute},
Controls: nil,
}
if editFunc != nil {
editFunc(request)
}
return request
}
tests := []struct {
name string
username string
password string
provider *Provider
setupMocks func(conn *mockldapconn.MockConn)
dialError error
wantError string
wantAuthResponse *authenticator.Response
}{
{
name: "happy path",
username: testUpstreamUsername,
password: testUpstreamPassword,
provider: provider(nil),
setupMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedSearch(nil)).Return(&ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: testSearchResultDNValue,
Attributes: []*ldap.EntryAttribute{
ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testSearchResultUsernameAttributeValue}),
ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testSearchResultUIDAttributeValue}),
},
},
},
Referrals: []string{}, // note that we are not following referrals at this time
Controls: []ldap.Control{}, // TODO are there any response controls that we need to be able to handle?
}, nil).Times(1)
conn.EXPECT().Bind(testSearchResultDNValue, testUpstreamPassword).Times(1)
conn.EXPECT().Close().Times(1)
},
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: testSearchResultUsernameAttributeValue,
Groups: []string{}, // We don't support group search yet. Coming soon!
UID: testSearchResultUIDAttributeValue,
},
},
},
{
name: "when the UsernameAttribute is dn",
username: testUpstreamUsername,
password: testUpstreamPassword,
provider: provider(func(p *Provider) {
p.UserSearch.UsernameAttribute = "dn"
}),
setupMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedSearch(func(r *ldap.SearchRequest) {
r.Attributes = []string{testUserSearchUIDAttribute}
})).Return(&ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: testSearchResultDNValue,
Attributes: []*ldap.EntryAttribute{
ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testSearchResultUIDAttributeValue}),
},
},
},
}, nil).Times(1)
conn.EXPECT().Bind(testSearchResultDNValue, testUpstreamPassword).Times(1)
conn.EXPECT().Close().Times(1)
},
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: testSearchResultDNValue,
Groups: []string{}, // We don't support group search yet. Coming soon!
UID: testSearchResultUIDAttributeValue,
},
},
},
{
name: "when the UIDAttribute is dn",
username: testUpstreamUsername,
password: testUpstreamPassword,
provider: provider(func(p *Provider) {
p.UserSearch.UIDAttribute = "dn"
}),
setupMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedSearch(func(r *ldap.SearchRequest) {
r.Attributes = []string{testUserSearchUsernameAttribute}
})).Return(&ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: testSearchResultDNValue,
Attributes: []*ldap.EntryAttribute{
ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testSearchResultUsernameAttributeValue}),
},
},
},
}, nil).Times(1)
conn.EXPECT().Bind(testSearchResultDNValue, testUpstreamPassword).Times(1)
conn.EXPECT().Close().Times(1)
},
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: testSearchResultUsernameAttributeValue,
Groups: []string{}, // We don't support group search yet. Coming soon!
UID: testSearchResultDNValue,
},
},
},
{
name: "when Filter is blank it derives a search filter from the UsernameAttribute",
username: testUpstreamUsername,
password: testUpstreamPassword,
provider: provider(func(p *Provider) {
p.UserSearch.Filter = ""
}),
setupMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedSearch(func(r *ldap.SearchRequest) {
r.Filter = testUserSearchUsernameAttribute + "=" + testUpstreamUsername
})).Return(&ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: testSearchResultDNValue,
Attributes: []*ldap.EntryAttribute{
ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testSearchResultUsernameAttributeValue}),
ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testSearchResultUIDAttributeValue}),
},
},
},
}, nil).Times(1)
conn.EXPECT().Bind(testSearchResultDNValue, testUpstreamPassword).Times(1)
conn.EXPECT().Close().Times(1)
},
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: testSearchResultUsernameAttributeValue,
Groups: []string{}, // We don't support group search yet. Coming soon!
UID: testSearchResultUIDAttributeValue,
},
},
},
{
name: "when the username has special LDAP search filter characters then they must be properly escaped in the search filter",
username: `a&b|c(d)e\f*g`,
password: testUpstreamPassword,
provider: provider(nil),
setupMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedSearch(func(r *ldap.SearchRequest) {
r.Filter = fmt.Sprintf("some-filter=%s-and-more-filter=%s", `a&b|c\28d\29e\5cf\2ag`, `a&b|c\28d\29e\5cf\2ag`)
})).Return(&ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: testSearchResultDNValue,
Attributes: []*ldap.EntryAttribute{
ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testSearchResultUsernameAttributeValue}),
ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testSearchResultUIDAttributeValue}),
},
},
},
}, nil).Times(1)
conn.EXPECT().Bind(testSearchResultDNValue, testUpstreamPassword).Times(1)
conn.EXPECT().Close().Times(1)
},
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: testSearchResultUsernameAttributeValue,
Groups: []string{}, // We don't support group search yet. Coming soon!
UID: testSearchResultUIDAttributeValue,
},
},
},
// TODO are LDAP attribute names case sensitive? do we need any special handling for case?
{
name: "when dial fails",
provider: provider(nil),
dialError: errors.New("some dial error"),
wantError: fmt.Sprintf(`error dialing host "%s": some dial error`, testHost),
},
}
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.provider.Dialer = LDAPDialerFunc(func(ctx context.Context, hostAndPort string) (Conn, error) {
dialWasAttempted = true
require.Equal(t, tt.provider.Host, hostAndPort)
if tt.dialError != nil {
return nil, tt.dialError
}
return conn, nil
})
authResponse, authenticated, err := tt.provider.AuthenticateUser(context.Background(), tt.username, tt.password)
require.True(t, dialWasAttempted, "AuthenticateUser was supposed to try to dial, but didn't")
if tt.wantError != "" {
require.EqualError(t, err, tt.wantError)
require.False(t, authenticated)
require.Nil(t, authResponse)
} else {
require.NoError(t, err)
require.True(t, authenticated)
require.Equal(t, tt.wantAuthResponse, authResponse)
}
})
}
}
func TestGetURL(t *testing.T) {
require.Equal(t, "ldaps://ldap.example.com:1234", (&Provider{Host: "ldap.example.com:1234"}).GetURL())
require.Equal(t, "ldaps://ldap.example.com", (&Provider{Host: "ldap.example.com"}).GetURL())
}
// 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
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
caBundle []byte
context context.Context
wantError string
}{
{
name: "happy path",
host: testServerHostAndPort,
caBundle: []byte(testServerCABundle),
context: context.Background(),
},
{
name: "invalid CA bundle",
host: testServerHostAndPort,
caBundle: []byte("not a ca bundle"),
context: context.Background(),
wantError: `LDAP Result Code 200 "Network Error": could not parse CA bundle`,
},
{
name: "missing CA bundle when it is required because the host is not using a trusted CA",
host: testServerHostAndPort,
caBundle: nil,
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),
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),
context: alreadyCancelledContext,
wantError: fmt.Sprintf(`LDAP Result Code 200 "Network Error": dial tcp %s: operation was canceled`, testServerHostAndPort),
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
provider := &Provider{
Host: test.host,
CABundle: test.caBundle,
Dialer: nil, // this test is for the default (production) dialer
}
conn, err := provider.dial(test.context)
if conn != nil {
defer conn.Close()
}
if test.wantError != "" {
require.Nil(t, conn)
require.EqualError(t, err, test.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`)
}
})
}
}
// Test various cases of host and port parsing.
func TestHostAndPortWithDefaultPort(t *testing.T) {
tests := []struct {
name string
hostAndPort string
defaultPort string
wantError string
wantHostAndPort string
}{
{
name: "host already has port",
hostAndPort: "host.example.com:99",
defaultPort: "42",
wantHostAndPort: "host.example.com:99",
},
{
name: "host does not have port",
hostAndPort: "host.example.com",
defaultPort: "42",
wantHostAndPort: "host.example.com:42",
},
{
name: "host does not have port and default port is empty",
hostAndPort: "host.example.com",
defaultPort: "",
wantHostAndPort: "host.example.com",
},
{
name: "IPv6 host already has port",
hostAndPort: "[::1%lo0]:80",
defaultPort: "42",
wantHostAndPort: "[::1%lo0]:80",
},
{
name: "IPv6 host does not have port",
hostAndPort: "[::1%lo0]",
defaultPort: "42",
wantHostAndPort: "[::1%lo0]:42",
},
{
name: "IPv6 host does not have port and default port is empty",
hostAndPort: "[::1%lo0]",
defaultPort: "",
wantHostAndPort: "[::1%lo0]",
},
{
name: "host is not valid",
hostAndPort: "host.example.com:port1:port2",
defaultPort: "42",
wantError: "address host.example.com:port1:port2: too many colons in address",
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
hostAndPort, err := hostAndPortWithDefaultPort(test.hostAndPort, test.defaultPort)
if test.wantError != "" {
require.EqualError(t, err, test.wantError)
} else {
require.NoError(t, err)
}
require.Equal(t, test.wantHostAndPort, hostAndPort)
})
}
}