Merge pull request #1043 from vmware-tanzu/active-directory-group-change-warning
Add group change warning test for Active Directory
This commit is contained in:
commit
ec74158ebc
@ -5,16 +5,11 @@ package integration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"math/big"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@ -24,18 +19,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
|
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
|
||||||
"github.com/go-ldap/ldap/v3"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
"golang.org/x/text/encoding/unicode"
|
|
||||||
v1 "k8s.io/api/core/v1"
|
v1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
|
||||||
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
|
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
|
||||||
idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
|
idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
|
||||||
"go.pinniped.dev/internal/certauthority"
|
"go.pinniped.dev/internal/certauthority"
|
||||||
"go.pinniped.dev/internal/crypto/ptls"
|
|
||||||
"go.pinniped.dev/internal/oidc"
|
"go.pinniped.dev/internal/oidc"
|
||||||
"go.pinniped.dev/internal/psession"
|
"go.pinniped.dev/internal/psession"
|
||||||
"go.pinniped.dev/internal/testutil"
|
"go.pinniped.dev/internal/testutil"
|
||||||
@ -1171,10 +1163,10 @@ func TestSupervisorLogin_Browser(t *testing.T) {
|
|||||||
return adIDP.Name
|
return adIDP.Name
|
||||||
},
|
},
|
||||||
createTestUser: func(t *testing.T) (string, string) {
|
createTestUser: func(t *testing.T) (string, string) {
|
||||||
return createFreshADTestUser(t, env)
|
return testlib.CreateFreshADTestUser(t, env)
|
||||||
},
|
},
|
||||||
deleteTestUser: func(t *testing.T, username string) {
|
deleteTestUser: func(t *testing.T, username string) {
|
||||||
deleteTestADUser(t, env, username)
|
testlib.DeleteTestADUser(t, env, username)
|
||||||
},
|
},
|
||||||
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) {
|
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) {
|
||||||
requestAuthorizationUsingCLIPasswordFlow(t,
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
||||||
@ -1186,7 +1178,7 @@ func TestSupervisorLogin_Browser(t *testing.T) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) {
|
breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) {
|
||||||
changeADTestUserPassword(t, env, username)
|
testlib.ChangeADTestUserPassword(t, env, username)
|
||||||
},
|
},
|
||||||
// we can't know the subject ahead of time because we created a new user and don't know their uid,
|
// we can't know the subject ahead of time because we created a new user and don't know their uid,
|
||||||
// so skip wantDownstreamIDTokenSubjectToMatch
|
// so skip wantDownstreamIDTokenSubjectToMatch
|
||||||
@ -1233,10 +1225,10 @@ func TestSupervisorLogin_Browser(t *testing.T) {
|
|||||||
return adIDP.Name
|
return adIDP.Name
|
||||||
},
|
},
|
||||||
createTestUser: func(t *testing.T) (string, string) {
|
createTestUser: func(t *testing.T) (string, string) {
|
||||||
return createFreshADTestUser(t, env)
|
return testlib.CreateFreshADTestUser(t, env)
|
||||||
},
|
},
|
||||||
deleteTestUser: func(t *testing.T, username string) {
|
deleteTestUser: func(t *testing.T, username string) {
|
||||||
deleteTestADUser(t, env, username)
|
testlib.DeleteTestADUser(t, env, username)
|
||||||
},
|
},
|
||||||
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) {
|
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) {
|
||||||
requestAuthorizationUsingCLIPasswordFlow(t,
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
||||||
@ -1248,7 +1240,7 @@ func TestSupervisorLogin_Browser(t *testing.T) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) {
|
breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) {
|
||||||
deactivateADTestUser(t, env, username)
|
testlib.DeactivateADTestUser(t, env, username)
|
||||||
},
|
},
|
||||||
// we can't know the subject ahead of time because we created a new user and don't know their uid,
|
// we can't know the subject ahead of time because we created a new user and don't know their uid,
|
||||||
// so skip wantDownstreamIDTokenSubjectToMatch
|
// so skip wantDownstreamIDTokenSubjectToMatch
|
||||||
@ -1295,10 +1287,10 @@ func TestSupervisorLogin_Browser(t *testing.T) {
|
|||||||
return adIDP.Name
|
return adIDP.Name
|
||||||
},
|
},
|
||||||
createTestUser: func(t *testing.T) (string, string) {
|
createTestUser: func(t *testing.T) (string, string) {
|
||||||
return createFreshADTestUser(t, env)
|
return testlib.CreateFreshADTestUser(t, env)
|
||||||
},
|
},
|
||||||
deleteTestUser: func(t *testing.T, username string) {
|
deleteTestUser: func(t *testing.T, username string) {
|
||||||
deleteTestADUser(t, env, username)
|
testlib.DeleteTestADUser(t, env, username)
|
||||||
},
|
},
|
||||||
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) {
|
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) {
|
||||||
requestAuthorizationUsingCLIPasswordFlow(t,
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
||||||
@ -1310,7 +1302,7 @@ func TestSupervisorLogin_Browser(t *testing.T) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) {
|
breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) {
|
||||||
lockADTestUser(t, env, username)
|
testlib.LockADTestUser(t, env, username)
|
||||||
},
|
},
|
||||||
// we can't know the subject ahead of time because we created a new user and don't know their uid,
|
// we can't know the subject ahead of time because we created a new user and don't know their uid,
|
||||||
// so skip wantDownstreamIDTokenSubjectToMatch
|
// so skip wantDownstreamIDTokenSubjectToMatch
|
||||||
@ -2157,153 +2149,3 @@ func expectSecurityHeaders(t *testing.T, response *http.Response, expectFositeTo
|
|||||||
assert.Equal(t, "no-cache", h.Get("Pragma"))
|
assert.Equal(t, "no-cache", h.Get("Pragma"))
|
||||||
assert.Equal(t, "0", h.Get("Expires"))
|
assert.Equal(t, "0", h.Get("Expires"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// create a fresh test user in AD to use for this test.
|
|
||||||
func createFreshADTestUser(t *testing.T, env *testlib.TestEnv) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
// dial tls
|
|
||||||
conn := dialTLS(t, env)
|
|
||||||
// bind
|
|
||||||
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
testUserName := "user-" + createRandomHexString(t, 7) // sAMAccountNames are limited to 20 characters, so this is as long as we can make it.
|
|
||||||
// create
|
|
||||||
userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
|
|
||||||
a := ldap.NewAddRequest(userDN, []ldap.Control{})
|
|
||||||
a.Attribute("objectClass", []string{"top", "person", "organizationalPerson", "user"})
|
|
||||||
a.Attribute("userPrincipalName", []string{fmt.Sprintf("%s@%s", testUserName, env.SupervisorUpstreamActiveDirectory.Domain)})
|
|
||||||
a.Attribute("sAMAccountName", []string{testUserName})
|
|
||||||
|
|
||||||
err = conn.Add(a)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// modify password and enable account
|
|
||||||
testUserPassword := createRandomASCIIString(t, 20)
|
|
||||||
enc := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder()
|
|
||||||
encodedTestUserPassword, err := enc.String("\"" + testUserPassword + "\"")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
m := ldap.NewModifyRequest(userDN, []ldap.Control{})
|
|
||||||
m.Replace("unicodePwd", []string{encodedTestUserPassword})
|
|
||||||
m.Replace("userAccountControl", []string{"512"})
|
|
||||||
err = conn.Modify(m)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated.
|
|
||||||
return testUserName, testUserPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
// deactivate the test user.
|
|
||||||
func deactivateADTestUser(t *testing.T, env *testlib.TestEnv, testUserName string) {
|
|
||||||
conn := dialTLS(t, env)
|
|
||||||
// bind
|
|
||||||
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
|
|
||||||
m := ldap.NewModifyRequest(userDN, []ldap.Control{})
|
|
||||||
m.Replace("userAccountControl", []string{"514"}) // normal user, account disabled
|
|
||||||
err = conn.Modify(m)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated.
|
|
||||||
}
|
|
||||||
|
|
||||||
// lock the test user's account by entering the wrong password a bunch of times.
|
|
||||||
func lockADTestUser(t *testing.T, env *testlib.TestEnv, testUserName string) {
|
|
||||||
userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
|
|
||||||
conn := dialTLS(t, env)
|
|
||||||
|
|
||||||
// our password policy allows 20 wrong attempts before locking the account, so do 21.
|
|
||||||
// these wrong password attempts could go to different domain controllers, but account
|
|
||||||
// lockout changes are urgently replicated, meaning that the domain controllers will be
|
|
||||||
// synced asap rather than in the usual 15 second interval.
|
|
||||||
// See https://docs.microsoft.com/en-us/previous-versions/windows/it-pro/windows-2000-server/cc961787(v=technet.10)#urgent-replication-of-account-lockout-changes
|
|
||||||
for i := 0; i <= 21; i++ {
|
|
||||||
err := conn.Bind(userDN, "not-the-right-password-"+fmt.Sprint(i))
|
|
||||||
require.Error(t, err) // this should be an error
|
|
||||||
}
|
|
||||||
|
|
||||||
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated.
|
|
||||||
}
|
|
||||||
|
|
||||||
// change the user's password to a new one.
|
|
||||||
func changeADTestUserPassword(t *testing.T, env *testlib.TestEnv, testUserName string) {
|
|
||||||
conn := dialTLS(t, env)
|
|
||||||
// bind
|
|
||||||
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
newTestUserPassword := createRandomASCIIString(t, 20)
|
|
||||||
enc := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder()
|
|
||||||
encodedTestUserPassword, err := enc.String(`"` + newTestUserPassword + `"`)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
|
|
||||||
m := ldap.NewModifyRequest(userDN, []ldap.Control{})
|
|
||||||
m.Replace("unicodePwd", []string{encodedTestUserPassword})
|
|
||||||
err = conn.Modify(m)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated.
|
|
||||||
// don't bother to return the new password... we won't be using it, just checking that it's changed.
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete the test user created for this test.
|
|
||||||
func deleteTestADUser(t *testing.T, env *testlib.TestEnv, testUserName string) {
|
|
||||||
t.Helper()
|
|
||||||
conn := dialTLS(t, env)
|
|
||||||
// bind
|
|
||||||
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
|
|
||||||
d := ldap.NewDelRequest(userDN, []ldap.Control{})
|
|
||||||
err = conn.Del(d)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func dialTLS(t *testing.T, env *testlib.TestEnv) *ldap.Conn {
|
|
||||||
t.Helper()
|
|
||||||
// dial tls
|
|
||||||
rootCAs := x509.NewCertPool()
|
|
||||||
success := rootCAs.AppendCertsFromPEM([]byte(env.SupervisorUpstreamActiveDirectory.CABundle))
|
|
||||||
require.True(t, success)
|
|
||||||
tlsConfig := ptls.DefaultLDAP(rootCAs)
|
|
||||||
dialer := &tls.Dialer{NetDialer: &net.Dialer{Timeout: time.Minute}, Config: tlsConfig}
|
|
||||||
c, err := dialer.DialContext(context.Background(), "tcp", env.SupervisorUpstreamActiveDirectory.Host)
|
|
||||||
require.NoError(t, err)
|
|
||||||
conn := ldap.NewConn(c, true)
|
|
||||||
conn.Start()
|
|
||||||
return conn
|
|
||||||
}
|
|
||||||
|
|
||||||
func createRandomHexString(t *testing.T, length int) string {
|
|
||||||
t.Helper()
|
|
||||||
bytes := make([]byte, length)
|
|
||||||
_, err := rand.Read(bytes)
|
|
||||||
require.NoError(t, err)
|
|
||||||
randomString := hex.EncodeToString(bytes)
|
|
||||||
return randomString
|
|
||||||
}
|
|
||||||
|
|
||||||
func createRandomASCIIString(t *testing.T, length int) string {
|
|
||||||
result := ""
|
|
||||||
for {
|
|
||||||
if len(result) >= length {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
num, err := rand.Int(rand.Reader, big.NewInt(int64(127)))
|
|
||||||
require.NoError(t, err)
|
|
||||||
n := num.Int64()
|
|
||||||
// Make sure that the number/byte/letter is inside
|
|
||||||
// the range of printable ASCII characters (excluding space and DEL)
|
|
||||||
if n > 32 && n < 127 {
|
|
||||||
result += string(rune(n))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -242,6 +242,122 @@ func TestSupervisorWarnings_Browser(t *testing.T) {
|
|||||||
|
|
||||||
t.Logf("second kubectl command took %s", time.Since(startTime2).String())
|
t.Logf("second kubectl command took %s", time.Since(startTime2).String())
|
||||||
})
|
})
|
||||||
|
t.Run("Active Directory group refresh flow", func(t *testing.T) {
|
||||||
|
if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) {
|
||||||
|
t.Skip("LDAP integration test requires connectivity to an LDAP server")
|
||||||
|
}
|
||||||
|
if env.SupervisorUpstreamActiveDirectory.Host == "" {
|
||||||
|
t.Skip("Active Directory hostname not specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedUsername, password := testlib.CreateFreshADTestUser(t, env)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
testlib.DeleteTestADUser(t, env, expectedUsername)
|
||||||
|
})
|
||||||
|
|
||||||
|
sAMAccountName := expectedUsername + "@" + env.SupervisorUpstreamActiveDirectory.Domain
|
||||||
|
setupClusterForEndToEndActiveDirectoryTest(t, sAMAccountName, env)
|
||||||
|
|
||||||
|
// Use a specific session cache for this test.
|
||||||
|
sessionCachePath := tempDir + "/ldap-test-refresh-sessions.yaml"
|
||||||
|
credentialCachePath := tempDir + "/ldap-test-refresh-credentials.yaml"
|
||||||
|
kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{
|
||||||
|
"get", "kubeconfig",
|
||||||
|
"--concierge-api-group-suffix", env.APIGroupSuffix,
|
||||||
|
"--concierge-authenticator-type", "jwt",
|
||||||
|
"--concierge-authenticator-name", authenticator.Name,
|
||||||
|
"--oidc-session-cache", sessionCachePath,
|
||||||
|
"--credential-cache", credentialCachePath,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Run "kubectl get namespaces" which should trigger a cli-based login.
|
||||||
|
start := time.Now()
|
||||||
|
kubectlCmd := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath)
|
||||||
|
kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...)
|
||||||
|
var kubectlStdoutPipe io.ReadCloser
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
// For some unknown reason this breaks the pty library on some MacOS machines.
|
||||||
|
// The problem doesn't reproduce for everyone, so this is just a workaround.
|
||||||
|
kubectlStdoutPipe, err = kubectlCmd.StdoutPipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
ptyFile, err := pty.Start(kubectlCmd)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the subprocess to print the username prompt, then type the user's username.
|
||||||
|
readFromFileUntilStringIsSeen(t, ptyFile, "Username: ")
|
||||||
|
_, err = ptyFile.WriteString(expectedUsername + "\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the subprocess to print the password prompt, then type the user's password.
|
||||||
|
readFromFileUntilStringIsSeen(t, ptyFile, "Password: ")
|
||||||
|
_, err = ptyFile.WriteString(password + "\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Read all of the remaining output from the subprocess until EOF.
|
||||||
|
t.Logf("waiting for kubectl to output namespace list")
|
||||||
|
// Read all output from the subprocess until EOF.
|
||||||
|
// Ignore any errors returned because there is always an error on linux.
|
||||||
|
kubectlPtyOutputBytes, _ := ioutil.ReadAll(ptyFile)
|
||||||
|
if kubectlStdoutPipe != nil {
|
||||||
|
// On non-MacOS check that stdout of the CLI contains the expected output.
|
||||||
|
kubectlStdOutOutputBytes, _ := ioutil.ReadAll(kubectlStdoutPipe)
|
||||||
|
requireKubectlGetNamespaceOutput(t, env, string(kubectlStdOutOutputBytes))
|
||||||
|
} else {
|
||||||
|
// On MacOS check that the pty (stdout+stderr+stdin) of the CLI contains the expected output.
|
||||||
|
requireKubectlGetNamespaceOutput(t, env, string(kubectlPtyOutputBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("first kubectl command took %s", time.Since(start).String())
|
||||||
|
|
||||||
|
// create an active directory group, and add our user to it.
|
||||||
|
groupName := testlib.CreateFreshADTestGroup(t, env)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
testlib.DeleteTestADUser(t, env, groupName)
|
||||||
|
})
|
||||||
|
testlib.AddTestUserToGroup(t, env, groupName, expectedUsername)
|
||||||
|
|
||||||
|
// remove the credential cache, which includes the cached cert, so it won't be reused and the refresh flow will be triggered.
|
||||||
|
err = os.Remove(credentialCachePath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx2, cancel2 := context.WithTimeout(ctx, 1*time.Minute)
|
||||||
|
defer cancel2()
|
||||||
|
|
||||||
|
// Run kubectl, which should work without any prompting for authentication.
|
||||||
|
kubectlCmd2 := exec.CommandContext(ctx2, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath)
|
||||||
|
kubectlCmd2.Env = append(os.Environ(), env.ProxyEnv()...)
|
||||||
|
startTime2 := time.Now()
|
||||||
|
var kubectlStdoutPipe2 io.ReadCloser
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
// For some unknown reason this breaks the pty library on some MacOS machines.
|
||||||
|
// The problem doesn't reproduce for everyone, so this is just a workaround.
|
||||||
|
kubectlStdoutPipe2, err = kubectlCmd2.StdoutPipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
ptyFile2, err := pty.Start(kubectlCmd2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Read all of the remaining output from the subprocess until EOF.
|
||||||
|
t.Logf("waiting for kubectl to output namespace list")
|
||||||
|
// Read all output from the subprocess until EOF.
|
||||||
|
// Ignore any errors returned because there is always an error on linux.
|
||||||
|
kubectlPtyOutputBytes2, _ := ioutil.ReadAll(ptyFile2)
|
||||||
|
if kubectlStdoutPipe2 != nil {
|
||||||
|
// On non-MacOS check that stdout of the CLI contains the expected output.
|
||||||
|
kubectlStdOutOutputBytes2, _ := ioutil.ReadAll(kubectlStdoutPipe2)
|
||||||
|
requireKubectlGetNamespaceOutput(t, env, string(kubectlStdOutOutputBytes2))
|
||||||
|
} else {
|
||||||
|
// On MacOS check that the pty (stdout+stderr+stdin) of the CLI contains the expected output.
|
||||||
|
requireKubectlGetNamespaceOutput(t, env, string(kubectlPtyOutputBytes2))
|
||||||
|
}
|
||||||
|
// the output should include a warning that a group has been added.
|
||||||
|
require.Contains(t, string(kubectlPtyOutputBytes2), fmt.Sprintf(`%sWarning:%s User %q has been added to the following groups: %q`+"\r\n", yellowColor, resetColor, sAMAccountName, []string{groupName + "@" + env.SupervisorUpstreamActiveDirectory.Domain}))
|
||||||
|
// there should not be a warning about being removed from groups, since we haven't done so.
|
||||||
|
require.NotContains(t, string(kubectlPtyOutputBytes2), "has been removed from")
|
||||||
|
|
||||||
|
t.Logf("second kubectl command took %s", time.Since(startTime2).String())
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("OIDC group refresh flow", func(t *testing.T) {
|
t.Run("OIDC group refresh flow", func(t *testing.T) {
|
||||||
if len(env.SupervisorUpstreamOIDC.ExpectedGroups) == 0 {
|
if len(env.SupervisorUpstreamOIDC.ExpectedGroups) == 0 {
|
||||||
|
220
test/testlib/activedirectory.go
Normal file
220
test/testlib/activedirectory.go
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
// Copyright 2022 the Pinniped contributors. All Rights Reserved.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
package testlib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/text/encoding/unicode"
|
||||||
|
|
||||||
|
"go.pinniped.dev/internal/crypto/ptls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateFreshADTestUser creates a fresh test user in AD to use for this test
|
||||||
|
// and returns their username and password.
|
||||||
|
func CreateFreshADTestUser(t *testing.T, env *TestEnv) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
// dial tls
|
||||||
|
conn := dialTLS(t, env)
|
||||||
|
// bind
|
||||||
|
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
testUserName := "user-" + createRandomHexString(t, 7) // sAMAccountNames are limited to 20 characters, so this is as long as we can make it.
|
||||||
|
// create
|
||||||
|
userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
|
||||||
|
a := ldap.NewAddRequest(userDN, []ldap.Control{})
|
||||||
|
a.Attribute("objectClass", []string{"top", "person", "organizationalPerson", "user"})
|
||||||
|
a.Attribute("userPrincipalName", []string{fmt.Sprintf("%s@%s", testUserName, env.SupervisorUpstreamActiveDirectory.Domain)})
|
||||||
|
a.Attribute("sAMAccountName", []string{testUserName})
|
||||||
|
|
||||||
|
err = conn.Add(a)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// modify password and enable account
|
||||||
|
testUserPassword := createRandomASCIIString(t, 20)
|
||||||
|
enc := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder()
|
||||||
|
encodedTestUserPassword, err := enc.String("\"" + testUserPassword + "\"")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
m := ldap.NewModifyRequest(userDN, []ldap.Control{})
|
||||||
|
m.Replace("unicodePwd", []string{encodedTestUserPassword})
|
||||||
|
m.Replace("userAccountControl", []string{"512"})
|
||||||
|
err = conn.Modify(m)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated.
|
||||||
|
return testUserName, testUserPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFreshADTestGroup creates a fresh test group in AD to use for this test
|
||||||
|
// and returns the group's name.
|
||||||
|
func CreateFreshADTestGroup(t *testing.T, env *TestEnv) string {
|
||||||
|
t.Helper()
|
||||||
|
// dial tls
|
||||||
|
conn := dialTLS(t, env)
|
||||||
|
// bind
|
||||||
|
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// group is domain local and a security group.
|
||||||
|
groupType := 0x00000004 | 0x80000000
|
||||||
|
// the group is modifiable.
|
||||||
|
instanceType := 0x00000004
|
||||||
|
testGroupName := "group-" + createRandomHexString(t, 7) // sAMAccountNames are limited to 20 characters, so this is as long as we can make it.
|
||||||
|
groupDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testGroupName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
|
||||||
|
a := ldap.NewAddRequest(groupDN, []ldap.Control{})
|
||||||
|
a.Attribute("objectClass", []string{"top", "group"})
|
||||||
|
a.Attribute("name", []string{testGroupName})
|
||||||
|
a.Attribute("sAMAccountName", []string{testGroupName})
|
||||||
|
a.Attribute("groupType", []string{fmt.Sprintf("%d", groupType)})
|
||||||
|
a.Attribute("instanceType", []string{fmt.Sprintf("%d", instanceType)})
|
||||||
|
err = conn.Add(a)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated.
|
||||||
|
return testGroupName
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTestUserToGroup adds a test user to a group within the test-users directory.
|
||||||
|
func AddTestUserToGroup(t *testing.T, env *TestEnv, testGroupName, testUserName string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
conn := dialTLS(t, env)
|
||||||
|
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
|
||||||
|
groupDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testGroupName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
|
||||||
|
|
||||||
|
r := ldap.NewModifyRequest(groupDN, []ldap.Control{})
|
||||||
|
r.Add("member", []string{userDN})
|
||||||
|
err = conn.Modify(r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated.
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateADTestUser deactivates the test user.
|
||||||
|
func DeactivateADTestUser(t *testing.T, env *TestEnv, testUserName string) {
|
||||||
|
conn := dialTLS(t, env)
|
||||||
|
// bind
|
||||||
|
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
|
||||||
|
m := ldap.NewModifyRequest(userDN, []ldap.Control{})
|
||||||
|
m.Replace("userAccountControl", []string{"514"}) // normal user, account disabled
|
||||||
|
err = conn.Modify(m)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated.
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockADTestUser locks the test user's account by entering the wrong password a bunch of times.
|
||||||
|
func LockADTestUser(t *testing.T, env *TestEnv, testUserName string) {
|
||||||
|
userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
|
||||||
|
conn := dialTLS(t, env)
|
||||||
|
|
||||||
|
// our password policy allows 20 wrong attempts before locking the account, so do 21.
|
||||||
|
// these wrong password attempts could go to different domain controllers, but account
|
||||||
|
// lockout changes are urgently replicated, meaning that the domain controllers will be
|
||||||
|
// synced asap rather than in the usual 15 second interval.
|
||||||
|
// See https://docs.microsoft.com/en-us/previous-versions/windows/it-pro/windows-2000-server/cc961787(v=technet.10)#urgent-replication-of-account-lockout-changes
|
||||||
|
for i := 0; i <= 21; i++ {
|
||||||
|
err := conn.Bind(userDN, "not-the-right-password-"+fmt.Sprint(i))
|
||||||
|
require.Error(t, err) // this should be an error
|
||||||
|
}
|
||||||
|
|
||||||
|
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeADTestUserPassword changes the user's password to a new one.
|
||||||
|
func ChangeADTestUserPassword(t *testing.T, env *TestEnv, testUserName string) {
|
||||||
|
conn := dialTLS(t, env)
|
||||||
|
// bind
|
||||||
|
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
newTestUserPassword := createRandomASCIIString(t, 20)
|
||||||
|
enc := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder()
|
||||||
|
encodedTestUserPassword, err := enc.String(`"` + newTestUserPassword + `"`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
|
||||||
|
m := ldap.NewModifyRequest(userDN, []ldap.Control{})
|
||||||
|
m.Replace("unicodePwd", []string{encodedTestUserPassword})
|
||||||
|
err = conn.Modify(m)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated.
|
||||||
|
// don't bother to return the new password... we won't be using it, just checking that it's changed.
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTestADUser deletes the test user created for this test.
|
||||||
|
func DeleteTestADUser(t *testing.T, env *TestEnv, testUserName string) {
|
||||||
|
t.Helper()
|
||||||
|
conn := dialTLS(t, env)
|
||||||
|
// bind
|
||||||
|
err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase)
|
||||||
|
d := ldap.NewDelRequest(userDN, []ldap.Control{})
|
||||||
|
err = conn.Del(d)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialTLS(t *testing.T, env *TestEnv) *ldap.Conn {
|
||||||
|
t.Helper()
|
||||||
|
// dial tls
|
||||||
|
rootCAs := x509.NewCertPool()
|
||||||
|
success := rootCAs.AppendCertsFromPEM([]byte(env.SupervisorUpstreamActiveDirectory.CABundle))
|
||||||
|
require.True(t, success)
|
||||||
|
tlsConfig := ptls.DefaultLDAP(rootCAs)
|
||||||
|
dialer := &tls.Dialer{NetDialer: &net.Dialer{Timeout: time.Minute}, Config: tlsConfig}
|
||||||
|
c, err := dialer.DialContext(context.Background(), "tcp", env.SupervisorUpstreamActiveDirectory.Host)
|
||||||
|
require.NoError(t, err)
|
||||||
|
conn := ldap.NewConn(c, true)
|
||||||
|
conn.Start()
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func createRandomHexString(t *testing.T, length int) string {
|
||||||
|
t.Helper()
|
||||||
|
bytes := make([]byte, length)
|
||||||
|
_, err := rand.Read(bytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
randomString := hex.EncodeToString(bytes)
|
||||||
|
return randomString
|
||||||
|
}
|
||||||
|
|
||||||
|
func createRandomASCIIString(t *testing.T, length int) string {
|
||||||
|
result := ""
|
||||||
|
for {
|
||||||
|
if len(result) >= length {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
num, err := rand.Int(rand.Reader, big.NewInt(int64(127)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
n := num.Int64()
|
||||||
|
// Make sure that the number/byte/letter is inside
|
||||||
|
// the range of printable ASCII characters (excluding space and DEL)
|
||||||
|
if n > 32 && n < 127 {
|
||||||
|
result += string(rune(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user