ContainerImage.Pinniped/test/integration/ldap_client_test.go

617 lines
21 KiB
Go
Raw Normal View History

// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package integration
import (
"context"
"fmt"
"io"
"io/ioutil"
"net"
"os"
"os/exec"
"path"
"strings"
"testing"
"time"
"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/upstreamldap"
)
// Unlike most other integration tests, you can run this test with no special setup, as long as you have Docker.
// It does not depend on Kubernetes.
func TestLDAPSearch(t *testing.T) {
ctx, cancelFunc := context.WithCancel(context.Background())
t.Cleanup(func() {
cancelFunc() // this will send SIGKILL to the docker process, just in case
})
port := localhostPort(t)
caBundle := dockerRunLDAPServer(ctx, t, port)
provider := func(editFunc func(p *upstreamldap.Provider)) *upstreamldap.Provider {
provider := &upstreamldap.Provider{
Name: "test-ldap-provider",
Host: "127.0.0.1:" + port,
CABundle: caBundle,
BindUsername: "cn=admin,dc=pinniped,dc=dev",
BindPassword: "password",
UserSearch: &upstreamldap.UserSearch{
Base: "ou=users,dc=pinniped,dc=dev",
Filter: "", // defaults to UsernameAttribute={}, i.e. "cn={}" in this case
UsernameAttribute: "cn",
UIDAttribute: "uidNumber",
},
}
if editFunc != nil {
editFunc(provider)
}
return provider
}
pinnyPassword := "password123" // from the LDIF file below
wallyPassword := "password456" // from the LDIF file below
tests := []struct {
name string
username string
password string
provider *upstreamldap.Provider
wantError string
wantAuthResponse *authenticator.Response
wantUnauthenticated bool
}{
{
name: "happy path",
username: "pinny",
password: pinnyPassword,
provider: provider(nil),
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{}},
},
},
{
name: "happy path as a different user",
username: "wally",
password: wallyPassword,
provider: provider(nil),
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "wally", UID: "1001", Groups: []string{}},
},
},
{
name: "using a different user search base",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.Base = "dc=pinniped,dc=dev" }),
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{}},
},
},
{
name: "when the user search filter is already wrapped by parenthesis",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.Filter = "(cn={})" }),
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{}},
},
},
{
name: "when the UsernameAttribute is dn and a user search filter is provided",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) {
p.UserSearch.UsernameAttribute = "dn"
p.UserSearch.Filter = "cn={}"
}),
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "cn=pinny,ou=users,dc=pinniped,dc=dev", UID: "1000", Groups: []string{}},
},
},
{
name: "when the user search filter allows for different ways of logging in and the first one is used",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) {
p.UserSearch.Filter = "(|(cn={})(mail={}))"
}),
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{}},
},
},
{
name: "when the user search filter allows for different ways of logging in and the second one is used",
username: "pinny.ldap@example.com",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) {
p.UserSearch.Filter = "(|(cn={})(mail={}))"
}),
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{}},
},
},
{
name: "when the UIDAttribute is dn",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.UIDAttribute = "dn" }),
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "cn=pinny,ou=users,dc=pinniped,dc=dev", Groups: []string{}},
},
},
{
name: "when the UIDAttribute is sn",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.UIDAttribute = "sn" }),
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "Seal", Groups: []string{}},
},
},
{
name: "when the UsernameAttribute is sn",
username: "seAl", // note that this is not case-sensitive! sn=Seal
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.UsernameAttribute = "sn" }),
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "Seal", UID: "1000", Groups: []string{}}, // note that the final answer is case-sensitive
},
},
{
name: "when the UsernameAttribute is dn and there is no user search filter provided",
username: "cn=pinny,ou=users,dc=pinniped,dc=dev",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) {
p.UserSearch.UsernameAttribute = "dn"
p.UserSearch.Filter = ""
}),
wantError: `must specify UserSearch Filter when UserSearch UsernameAttribute is "dn"`,
},
{
name: "when the bind user username is not a valid DN",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.BindUsername = "invalid-dn" }),
wantError: `error binding as "invalid-dn" before user search: LDAP Result Code 34 "Invalid DN Syntax": invalid DN`,
},
{
name: "when the bind user username is wrong",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.BindUsername = "cn=wrong,dc=pinniped,dc=dev" }),
wantError: `error binding as "cn=wrong,dc=pinniped,dc=dev" before user search: LDAP Result Code 49 "Invalid Credentials": `,
},
{
name: "when the bind user password is wrong",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.BindPassword = "wrong-password" }),
wantError: `error binding as "cn=admin,dc=pinniped,dc=dev" before user search: LDAP Result Code 49 "Invalid Credentials": `,
},
{
name: "when the end user password is wrong",
username: "pinny",
password: "wrong-pinny-password",
provider: provider(nil),
wantUnauthenticated: true,
},
{
name: "when the end user username is wrong",
username: "wrong-username",
password: pinnyPassword,
provider: provider(nil),
wantUnauthenticated: true,
},
{
name: "when the user search filter does not compile",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.Filter = "*" }),
wantError: `error searching for user "pinny": LDAP Result Code 201 "Filter Compile Error": ldap: error parsing filter`,
},
{
name: "when there are too many search results for the user",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) {
p.UserSearch.Filter = "objectClass=*" // overly broad search filter
}),
wantError: `error searching for user "pinny": LDAP Result Code 4 "Size Limit Exceeded": `,
},
{
name: "when the server is unreachable",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.Host = "127.0.0.1:27534" }), // hopefully this port is not in use on the host running tests
wantError: `error dialing host "127.0.0.1:27534": LDAP Result Code 200 "Network Error": dial tcp 127.0.0.1:27534: connect: connection refused`,
},
{
name: "when the server is not parsable",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.Host = "too:many:ports" }),
wantError: `error dialing host "too:many:ports": LDAP Result Code 200 "Network Error": address too:many:ports: too many colons in address`,
},
{
name: "when the CA bundle is not parsable",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.CABundle = []byte("invalid-pem") }),
wantError: fmt.Sprintf(`error dialing host "127.0.0.1:%s": LDAP Result Code 200 "Network Error": could not parse CA bundle`, port),
},
{
name: "when the CA bundle does not cause the host to be trusted",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.CABundle = nil }),
wantError: fmt.Sprintf(`error dialing host "127.0.0.1:%s": LDAP Result Code 200 "Network Error": x509: certificate signed by unknown authority`, port),
},
{
name: "when the UsernameAttribute attribute has multiple values in the entry",
username: "wally.ldap@example.com",
password: wallyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.UsernameAttribute = "mail" }),
wantError: `found 2 values for attribute "mail" while searching for user "wally.ldap@example.com", but expected 1 result`,
},
{
name: "when the UIDAttribute attribute has multiple values in the entry",
username: "wally",
password: wallyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.UIDAttribute = "mail" }),
wantError: `found 2 values for attribute "mail" while searching for user "wally", but expected 1 result`,
},
{
name: "when the UsernameAttribute attribute is not found in the entry",
username: "wally",
password: wallyPassword,
provider: provider(func(p *upstreamldap.Provider) {
p.UserSearch.Filter = "cn={}"
p.UserSearch.UsernameAttribute = "attr-does-not-exist"
}),
wantError: `found 0 values for attribute "attr-does-not-exist" while searching for user "wally", but expected 1 result`,
},
{
name: "when the UIDAttribute attribute is not found in the entry",
username: "wally",
password: wallyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.UIDAttribute = "attr-does-not-exist" }),
wantError: `found 0 values for attribute "attr-does-not-exist" while searching for user "wally", but expected 1 result`,
},
{
name: "when the UsernameAttribute has the wrong case",
username: "Seal",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.UsernameAttribute = "SN" }), // this is case-sensitive
wantError: `found 0 values for attribute "SN" while searching for user "Seal", but expected 1 result`,
},
{
name: "when the UIDAttribute has the wrong case",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.UIDAttribute = "SN" }), // this is case-sensitive
wantError: `found 0 values for attribute "SN" while searching for user "pinny", but expected 1 result`,
},
{
name: "when the UsernameAttribute is DN and has the wrong case",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) {
p.UserSearch.UsernameAttribute = "DN" // dn must be lower-case
p.UserSearch.Filter = "cn={}"
}),
wantError: `found 0 values for attribute "DN" while searching for user "pinny", but expected 1 result`,
},
{
name: "when the UIDAttribute is DN and has the wrong case",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) {
p.UserSearch.UIDAttribute = "DN" // dn must be lower-case
}),
wantError: `found 0 values for attribute "DN" while searching for user "pinny", but expected 1 result`,
},
{
name: "when the search base is invalid",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.Base = "invalid-base" }),
wantError: `error searching for user "pinny": LDAP Result Code 34 "Invalid DN Syntax": invalid DN`,
},
{
name: "when the search base does not exist",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.Base = "ou=does-not-exist,dc=pinniped,dc=dev" }),
wantError: `error searching for user "pinny": LDAP Result Code 32 "No Such Object": `,
},
{
name: "when the search base causes no search results",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.Base = "ou=groups,dc=pinniped,dc=dev" }),
wantUnauthenticated: true,
},
{
name: "when there is no username specified",
username: "",
password: pinnyPassword,
provider: provider(nil),
wantUnauthenticated: true,
},
{
name: "when there is no password specified",
username: "pinny",
password: "",
provider: provider(nil),
wantError: `error binding for user "pinny" using provided password against DN "cn=pinny,ou=users,dc=pinniped,dc=dev": LDAP Result Code 206 "Empty password not allowed by the client": ldap: empty password not allowed by the client`,
},
{
name: "when the user has no password in their entry",
username: "olive",
password: "anything",
provider: provider(nil),
wantUnauthenticated: true,
},
}
for _, test := range tests {
tt := test
t.Run(tt.name, func(t *testing.T) {
authResponse, authenticated, err := tt.provider.AuthenticateUser(ctx, tt.username, tt.password)
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 localhostPort(t *testing.T) string {
t.Helper()
unusedPortGrabbingListener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
recentlyClaimedHostAndPort := unusedPortGrabbingListener.Addr().String()
require.NoError(t, unusedPortGrabbingListener.Close())
splitHostAndPort := strings.Split(recentlyClaimedHostAndPort, ":")
require.Len(t, splitHostAndPort, 2)
return splitHostAndPort[1]
}
func dockerRunLDAPServer(ctx context.Context, t *testing.T, hostPort string) []byte {
t.Helper()
_, err := exec.LookPath("docker")
require.NoError(t, err)
ca, err := certauthority.New("Test LDAP CA", time.Hour*24)
require.NoError(t, err)
certPEM, keyPEM, err := ca.IssueServerCertPEM(nil, []net.IP{net.ParseIP("127.0.0.1")}, time.Hour*24)
require.NoError(t, err)
tempDir, err := ioutil.TempDir("", "pinniped-test-*")
require.NoError(t, err)
t.Cleanup(func() {
err := os.Remove(tempDir)
require.NoError(t, err)
})
writeToNewTempFile(t, tempDir, "cert.pem", certPEM)
writeToNewTempFile(t, tempDir, "key.pem", keyPEM)
writeToNewTempFile(t, tempDir, "ca.pem", ca.Bundle())
writeToNewTempFile(t, tempDir, "test.ldif", []byte(testLDIF))
dockerArgs := []string{
"run",
"-e", "BITNAMI_DEBUG=true",
"-e", "LDAP_ADMIN_USERNAME=admin",
"-e", "LDAP_ADMIN_PASSWORD=password",
"-e", "LDAP_ENABLE_TLS=yes",
"-e", "LDAP_TLS_CERT_FILE=/inputs/cert.pem",
"-e", "LDAP_TLS_KEY_FILE=/inputs/key.pem",
"-e", "LDAP_TLS_CA_FILE=/inputs/ca.pem",
"-e", "LDAP_CUSTOM_LDIF_DIR=/inputs",
"-e", "LDAP_ROOT=dc=pinniped,dc=dev",
"-v", tempDir + ":/inputs",
"-p", hostPort + ":1636",
"-m", "64m",
"--rm", // automatically delete the container when finished
"docker.io/bitnami/openldap",
}
t.Log("Starting:", "docker", strings.Join(dockerArgs, " "))
cmd := exec.CommandContext(ctx, "docker", dockerArgs...)
var stdoutBuf, stderrBuf syncBuffer
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
cmd.Stdout = io.MultiWriter(os.Stdout, &stdoutBuf)
cmd.Stderr = io.MultiWriter(os.Stderr, &stderrBuf)
err = cmd.Start()
require.NoError(t, err)
t.Cleanup(func() {
// docker requires an interrupt signal to end the container.
// This t.Cleanup is registered after the one that cancels the context, so this one will happen first.
err := cmd.Process.Signal(os.Interrupt)
require.NoError(t, err)
time.Sleep(time.Second) // give a moment before we move on, because we'll send SIGKILL in a later t.Cleanup
})
earlyTerminationCh := make(chan bool, 1)
go func() {
err = cmd.Wait()
earlyTerminationCh <- true
}()
terminatedEarly := false
require.Eventually(t, func() bool {
t.Log("Waiting for slapd to start...")
// This substring is contained in the last line of output before the server starts.
if strings.Contains(stderrBuf.String(), " slapd starting\n") {
return true
}
select {
case <-earlyTerminationCh:
terminatedEarly = true
return true
default: // ignore when this non-blocking read found no message
}
return false
}, 2*time.Minute, time.Second)
require.Falsef(t, terminatedEarly, "docker command ended sooner than expected")
t.Log("Detected LDAP server has started successfully")
return ca.Bundle()
}
func writeToNewTempFile(t *testing.T, dir string, filename string, contents []byte) {
t.Helper()
filePath := path.Join(dir, filename)
err := ioutil.WriteFile(filePath, contents, 0600)
require.NoError(t, err)
t.Cleanup(func() {
err := os.Remove(filePath)
require.NoError(t, err)
})
}
var testLDIF = `
# ** CAUTION: Blank lines separate entries in the LDIF format! Do not remove them! ***
# Here's a good explanation of LDIF:
# https://www.digitalocean.com/community/tutorials/how-to-use-ldif-files-to-make-changes-to-an-openldap-system
# pinniped.dev (organization, root)
dn: dc=pinniped,dc=dev
objectClass: dcObject
objectClass: organization
dc: pinniped
o: example
# users, pinniped.dev (organization unit)
dn: ou=users,dc=pinniped,dc=dev
objectClass: organizationalUnit
ou: users
# groups, pinniped.dev (organization unit)
dn: ou=groups,dc=pinniped,dc=dev
objectClass: organizationalUnit
ou: groups
# beach-groups, groups, pinniped.dev (organization unit)
dn: ou=beach-groups,ou=groups,dc=pinniped,dc=dev
objectClass: organizationalUnit
ou: beach-groups
# pinny, users, pinniped.dev (user)
dn: cn=pinny,ou=users,dc=pinniped,dc=dev
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
cn: pinny
sn: Seal
givenName: Pinny
mail: pinny.ldap@example.com
userPassword: password123
uid: pinny
uidNumber: 1000
gidNumber: 1000
homeDirectory: /home/pinny
loginShell: /bin/bash
gecos: pinny-the-seal
# wally, users, pinniped.dev
dn: cn=wally,ou=users,dc=pinniped,dc=dev
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
cn: wally
sn: Walrus
givenName: Wally
mail: wally.ldap@example.com
mail: wally.alternate@example.com
userPassword: password456
uid: wally
uidNumber: 1001
gidNumber: 1001
homeDirectory: /home/wally
loginShell: /bin/bash
gecos: wally-the-walrus
# olive, users, pinniped.dev (user without password)
dn: cn=olive,ou=users,dc=pinniped,dc=dev
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
cn: olive
sn: Boston Terrier
givenName: Olive
mail: olive.ldap@example.com
uid: olive
uidNumber: 1002
gidNumber: 1002
homeDirectory: /home/olive
loginShell: /bin/bash
gecos: olive-the-dog
# ball-game-players, beach-groups, groups, pinniped.dev (group of users)
dn: cn=ball-game-players,ou=beach-groups,ou=groups,dc=pinniped,dc=dev
cn: ball-game-players
objectClass: groupOfNames
member: cn=pinny,ou=users,dc=pinniped,dc=dev
member: cn=olive,ou=users,dc=pinniped,dc=dev
# seals, groups, pinniped.dev (group of users)
dn: cn=seals,ou=groups,dc=pinniped,dc=dev
cn: seals
objectClass: groupOfNames
member: cn=pinny,ou=users,dc=pinniped,dc=dev
# walruses, groups, pinniped.dev (group of users)
dn: cn=walruses,ou=groups,dc=pinniped,dc=dev
cn: walruses
objectClass: groupOfNames
member: cn=wally,ou=users,dc=pinniped,dc=dev
# pinnipeds, users, pinniped.dev (group of groups)
dn: cn=pinnipeds,ou=groups,dc=pinniped,dc=dev
cn: pinnipeds
objectClass: groupOfNames
member: cn=seals,ou=groups,dc=pinniped,dc=dev
member: cn=walruses,ou=groups,dc=pinniped,dc=dev
# mammals, groups, pinniped.dev (group of both groups and users)
dn: cn=mammals,ou=groups,dc=pinniped,dc=dev
cn: mammals
objectClass: groupOfNames
member: cn=pinninpeds,ou=groups,dc=pinniped,dc=dev
member: cn=olive,ou=users,dc=pinniped,dc=dev
`