diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index 5c9f7c75..abc7be80 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -5,16 +5,11 @@ package integration import ( "context" - "crypto/rand" "crypto/tls" - "crypto/x509" "encoding/base64" - "encoding/hex" "encoding/json" "fmt" "io/ioutil" - "math/big" - "net" "net/http" "net/http/httptest" "net/url" @@ -24,18 +19,15 @@ import ( "time" coreosoidc "github.com/coreos/go-oidc/v3/oidc" - "github.com/go-ldap/ldap/v3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" - "golang.org/x/text/encoding/unicode" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" "go.pinniped.dev/internal/certauthority" - "go.pinniped.dev/internal/crypto/ptls" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" @@ -1171,10 +1163,10 @@ func TestSupervisorLogin_Browser(t *testing.T) { return adIDP.Name }, createTestUser: func(t *testing.T) (string, string) { - return createFreshADTestUser(t, env) + return testlib.CreateFreshADTestUser(t, env) }, 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) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -1186,7 +1178,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { ) }, 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, // so skip wantDownstreamIDTokenSubjectToMatch @@ -1233,10 +1225,10 @@ func TestSupervisorLogin_Browser(t *testing.T) { return adIDP.Name }, createTestUser: func(t *testing.T) (string, string) { - return createFreshADTestUser(t, env) + return testlib.CreateFreshADTestUser(t, env) }, 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) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -1248,7 +1240,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { ) }, 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, // so skip wantDownstreamIDTokenSubjectToMatch @@ -1295,10 +1287,10 @@ func TestSupervisorLogin_Browser(t *testing.T) { return adIDP.Name }, createTestUser: func(t *testing.T) (string, string) { - return createFreshADTestUser(t, env) + return testlib.CreateFreshADTestUser(t, env) }, 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) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -1310,7 +1302,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { ) }, 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, // 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, "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)) - } - } -} diff --git a/test/integration/supervisor_warnings_test.go b/test/integration/supervisor_warnings_test.go index aea7d17b..65fa06e1 100644 --- a/test/integration/supervisor_warnings_test.go +++ b/test/integration/supervisor_warnings_test.go @@ -242,6 +242,122 @@ func TestSupervisorWarnings_Browser(t *testing.T) { 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) { if len(env.SupervisorUpstreamOIDC.ExpectedGroups) == 0 { diff --git a/test/testlib/activedirectory.go b/test/testlib/activedirectory.go new file mode 100644 index 00000000..b4440a99 --- /dev/null +++ b/test/testlib/activedirectory.go @@ -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)) + } + } +}