From 4ef6685802515cde67464bcdcaff4e16d54fb8ec Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Thu, 18 Apr 2024 09:52:30 +0200 Subject: [PATCH] Add end-to-end test for LDAP * use LDAP fake server package * expose config from YAML function * update go repository and deps list --- WORKSPACE | 6 +- config/config.go | 4 + deps.bzl | 34 +++++ go.mod | 1 + go.sum | 2 + ldap/BUILD.bazel | 12 +- ldap/ldap_test.go | 312 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 368 insertions(+), 3 deletions(-) create mode 100644 ldap/ldap_test.go diff --git a/WORKSPACE b/WORKSPACE index 67522959..563b5ea0 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -126,14 +126,16 @@ go_repository( go_repository( name = "in_gopkg_asn1_ber_v1", - commit = "f715ec2f112d1e4195b827ad68cf44017a3ef2b1", importpath = "gopkg.in/asn1-ber.v1", + sum = "h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM=", + version = "v1.0.0-20181015200546-f715ec2f112d", ) go_repository( name = "in_gopkg_ldap_v3", - commit = "9f0d712775a0973b7824a1585a86a4ea1d5263d9", importpath = "gopkg.in/ldap.v3", + sum = "h1:YKRHW/2sIl05JsCtx/5ZuUueFuJyoj/6+DGXe3wp6ro=", + version = "v3.0.3", ) gazelle_dependencies() diff --git a/config/config.go b/config/config.go index bfdcb6fd..6cfc9860 100644 --- a/config/config.go +++ b/config/config.go @@ -291,6 +291,10 @@ func newFromYaml(data []byte) (*Config, error) { return &c, nil } +func NewConfigFromYaml(data []byte) (*Config, error) { + return newFromYaml(data) +} + func validateConfig(c *Config) error { if c.Dir == "" { return errors.New("The 'dir' flag/key is required") diff --git a/deps.bzl b/deps.bzl index b811a3d6..09aa4a4a 100644 --- a/deps.bzl +++ b/deps.bzl @@ -28,6 +28,13 @@ def go_dependencies(): sum = "h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc=", version = "v0.0.0-20211218093645-b94a6e3cc137", ) + go_repository( + name = "com_github_alexbrainman_sspi", + importpath = "github.com/alexbrainman/sspi", + sum = "h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA=", + version = "v0.0.0-20210105120005-909beea2cc74", + ) + go_repository( name = "com_github_andybalholm_brotli", importpath = "github.com/andybalholm/brotli", @@ -65,6 +72,13 @@ def go_dependencies(): sum = "h1:QSdcrd/UFJv6Bp/CfoVf2SrENpFn9P6Yh8yb+xNhYMM=", version = "v0.4.1", ) + go_repository( + name = "com_github_azure_go_ntlmssp", + importpath = "github.com/Azure/go-ntlmssp", + sum = "h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=", + version = "v0.0.0-20221128193559-754e69321358", + ) + go_repository( name = "com_github_azuread_microsoft_authentication_library_for_go", importpath = "github.com/AzureAD/microsoft-authentication-library-for-go", @@ -230,6 +244,13 @@ def go_dependencies(): sum = "h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=", version = "v1.9.1", ) + go_repository( + name = "com_github_go_asn1_ber_asn1_ber", + importpath = "github.com/go-asn1-ber/asn1-ber", + sum = "h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=", + version = "v1.5.5", + ) + go_repository( name = "com_github_go_chi_chi_v4", importpath = "github.com/go-chi/chi/v4", @@ -243,6 +264,12 @@ def go_dependencies(): sum = "h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=", version = "v0.2.1", ) + go_repository( + name = "com_github_go_ldap_ldap_v3", + importpath = "github.com/go-ldap/ldap/v3", + sum = "h1:ert95MdbiG7aWo/oPYp9btL3KJlMPKnP58r09rI8T+A=", + version = "v3.4.6", + ) go_repository( name = "com_github_go_logfmt_logfmt", @@ -390,6 +417,13 @@ def go_dependencies(): sum = "h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk=", version = "v1.1.3", ) + go_repository( + name = "com_github_jonasscharpf_godap", + importpath = "github.com/JonasScharpf/godap", + sum = "h1:7L5zT1awL4RZeLtT4vp+BlRoTrFBbRtMFOZMQCqub7I=", + version = "v0.0.0-20240417153024-2d460c2776c0", + ) + go_repository( name = "com_github_josharian_intern", importpath = "github.com/josharian/intern", diff --git a/go.mod b/go.mod index d9cb6c76..436d3de0 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1 // indirect + github.com/JonasScharpf/godap v0.0.0-20240417153024-2d460c2776c0 // indirect github.com/aws/aws-sdk-go v1.44.256 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect diff --git a/go.sum b/go.sum index 2269afc4..2eb5fe37 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1 h1:BWe8a+f/t+7KY7zH2mqygeUD0t8hNFXe08p1Pb3/jKE= github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4= +github.com/JonasScharpf/godap v0.0.0-20240417153024-2d460c2776c0 h1:7L5zT1awL4RZeLtT4vp+BlRoTrFBbRtMFOZMQCqub7I= +github.com/JonasScharpf/godap v0.0.0-20240417153024-2d460c2776c0/go.mod h1:K5gGJQ/vwxHEUWBYv7ciUOgw94MYrGCoy5JX/hjSJkY= github.com/abbot/go-http-auth v0.4.1-0.20220112235402-e1cee1c72f2f h1:R2ZVGCZzU95oXFJxncosHS9LsX8N4/MYUdGGWOb2cFk= github.com/abbot/go-http-auth v0.4.1-0.20220112235402-e1cee1c72f2f/go.mod h1:l2P3JyHa+fjy5Bxol6y1u2o4DV/mv3QMBdBu2cNR53w= github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= diff --git a/ldap/BUILD.bazel b/ldap/BUILD.bazel index 8476c4b4..eb05ec8b 100644 --- a/ldap/BUILD.bazel +++ b/ldap/BUILD.bazel @@ -1,4 +1,4 @@ -load("@io_bazel_rules_go//go:def.bzl", "go_library") +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") go_library( name = "go_default_library", @@ -11,3 +11,13 @@ go_library( "@in_gopkg_ldap_v3//:go_default_library", ], ) + +go_test( + name = "go_default_test", + srcs = ["ldap_test.go"], + embed = [":go_default_library"], + deps = [ + "//config:go_default_library", + "@com_github_jonasscharpf_godap//godap:go_default_library", + ], +) diff --git a/ldap/ldap_test.go b/ldap/ldap_test.go new file mode 100644 index 00000000..958223c4 --- /dev/null +++ b/ldap/ldap_test.go @@ -0,0 +1,312 @@ +package ldap + +import ( + "context" + b64 "encoding/base64" + "fmt" + "io" + "log" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/JonasScharpf/godap/godap" + simplesearch "github.com/JonasScharpf/godap/godap" + "github.com/abbot/go-http-auth" + config "github.com/buchgr/bazel-remote/v2/config" +) + +func loadYamlConfig(data []byte) *config.Config { + cfg, err := config.NewConfigFromYaml(data) + if err != nil { + log.Fatal(err) + } + return cfg +} + +func loadFakeLdapConfig() *config.Config { + yaml := `host: localhost +port: 8080 +dir: /opt/cache-dir +max_size: 100 +ldap: + url: ldap://127.0.0.99:10000/ + base_dn: OU=My Users,DC=example,DC=com + username_attribute: uid + bind_user: CN=read-only-admin,OU=My Users,DC=example,DC=com + bind_password: 1234 + cache_time: 3600s + groups: + - CN=bazel-users,OU=Groups,OU=My Users,DC=example,DC=com + - CN=other-users,OU=Groups2,OU=Alien Users,DC=foo,DC=org +` + + return loadYamlConfig([]byte(yaml)) +} + +var usersPasswords = map[string]string{ + "CN=read-only-admin,OU=My Users,DC=example,DC=com": "1234", + "user": "password", + "cn=user,OU=My Users,DC=example,DC=com": "password", +} + +func verifyUserPass(username string, password string) bool { + log.Printf("Looking for username '%s' with password '%s'", username, password) + wantPass, hasUser := usersPasswords[username] + if !hasUser { + log.Printf("No such user '%s'", username) + return false + } + if wantPass == password { + log.Println("Password and username are valid") + return true + } + log.Printf("Invalid password for username '%s'", username) + return false +} + +func startLdapServer() { + hs := make([]godap.LDAPRequestHandler, 0) + + // use a LDAPBindFuncHandler to provide a callback function to respond + // to bind requests + hs = append(hs, &godap.LDAPBindFuncHandler{ + LDAPBindFunc: func(binddn string, bindpw []byte) bool { + return verifyUserPass(binddn, string(bindpw)) + }, + }) + + // use a LDAPSimpleSearchFuncHandler to reply to search queries + hs = append(hs, &simplesearch.LDAPSimpleSearchFuncHandler{ + LDAPSimpleSearchFunc: func(req *godap.LDAPSimpleSearchRequest) []*godap.LDAPSimpleSearchResultEntry { + ret := make([]*godap.LDAPSimpleSearchResultEntry, 0, 1) + + if req.FilterAttr == "uid" { + userPassword := b64.StdEncoding.EncodeToString([]byte(req.FilterValue)) + + ret = append(ret, &simplesearch.LDAPSimpleSearchResultEntry{ + DN: "cn=" + req.FilterValue + "," + req.BaseDN, + Attrs: map[string]interface{}{ + "cn": req.FilterValue, + "sn": req.FilterValue, + "uid": req.FilterValue, + "userPassword": userPassword, + "homeDirectory": "/home/" + req.FilterValue, + "objectClass": []string{ + "top", + "posixAccount", + "inetOrgPerson", + }, + }, + Skip: false, + }) + } else if req.FilterAttr == "searchFingerprint" { + // a non-simple search request has been received and should be + // processed. For simplicity, as this is just a fake LDAP + // server simple but really bad assumptions are done onwards. + // If the first query element is "pass" a response is sent, + // otherwise not. By this a user can be available/found or not + filterValues := strings.Split(req.FilterValue, ";") + passOrFail := filterValues[0] + user := filterValues[1] + userPassword := b64.StdEncoding.EncodeToString([]byte(user)) + // TODO add user with this password to the mapping + + if passOrFail == "pass" { + log.Println("Simulate 'query match'") + ret = append(ret, &simplesearch.LDAPSimpleSearchResultEntry{ + DN: "cn=" + user + "," + req.BaseDN, + Attrs: map[string]interface{}{ + "cn": user, + "sn": user, + "uid": user, + "userPassword": userPassword, + "homeDirectory": "/home/" + user, + "objectClass": []string{ + "top", + "posixAccount", + "inetOrgPerson", + }, + }, + Skip: false, + }) + } else { + log.Println("Simulate 'no query match'") + // "skip" this one in the LDAP processing step to mock an + // empty response + ret = append(ret, &simplesearch.LDAPSimpleSearchResultEntry{ + DN: "cn=" + user + "," + req.BaseDN, + Attrs: map[string]interface{}{ + "cn": user, + }, + Skip: true, + }) + } + } + + return ret + }, + }) + + s := &godap.LDAPServer{ + Handlers: hs, + } + + // start the LDAP server and wait for a short time to bring it up, + // connection would be refused otherwise + go s.ListenAndServe("127.0.0.99:10000") + time.Sleep(50 * time.Millisecond) +} + +func startHttpServer(ldapAuth auth.AuthenticatorInterface, addr string, timeout time.Duration) (*http.Server, *sync.WaitGroup) { + mux := http.NewServeMux() + + mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/" { + http.NotFound(w, req) + return + } + fmt.Fprintf(w, "Unrestricted") + }) + mux.HandleFunc("/secret", ldapAuthWrapper( + func(w http.ResponseWriter, req *http.Request) { + fmt.Fprintf(w, "Logged in") + }, + ldapAuth, + )) + + srv := &http.Server{ + Addr: addr, + Handler: mux, + } + + log.Printf("Starting HTTP server on %s for %s", addr, timeout) + + httpServerExitDone := &sync.WaitGroup{} + httpServerExitDone.Add(1) + go func() { + defer httpServerExitDone.Done() + + // always returns error. ErrServerClosed on graceful close + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + log.Fatal("HTTP server error:", err) + } + }() + + // start the HTTP server and wait for a short time to bring it up, + // connection would be refused otherwise + time.Sleep(50 * time.Millisecond) + + return srv, httpServerExitDone +} + +func TestNewConnection(t *testing.T) { + cfg := loadFakeLdapConfig() + ldapAuthenticator, ldap_err := New(cfg.LDAP) + + if ldapAuthenticator != nil { + t.Fatal("No connection should be established to", cfg.LDAP.URL) + } + if ldap_err == nil { + t.Fatal("An error should raise while connecting to", cfg.LDAP.URL) + } + + startLdapServer() + + ldapAuthenticator, ldap_err = New(cfg.LDAP) + + if ldapAuthenticator == nil { + t.Fatal("Connection should be established to", cfg.LDAP.URL) + } + if ldap_err != nil { + t.Fatal("No error should raise while connecting to", cfg.LDAP.URL) + } + + // set an invalid bind password + cfg.LDAP.BindPassword = "asdf" + ldapAuthenticator, ldap_err = New(cfg.LDAP) + + if ldapAuthenticator != nil { + t.Fatal("No connection should be established with", cfg.LDAP.BindPassword) + } + if ldap_err == nil { + t.Fatal("An error should raise while connecting with", cfg.LDAP.BindPassword) + } +} + +func TestAuth(t *testing.T) { + cfg := loadFakeLdapConfig() + var ldapAuthenticator auth.AuthenticatorInterface + var ldap_err error + var httpServerAddr string = "127.0.0.99:4000" + var httpServerTimeout time.Duration = 5 * time.Second + // allow the onwards used user to successfully login + cfg.LDAP.UsernameAttribute = "pass" + + startLdapServer() + + ldapAuthenticator, ldap_err = New(cfg.LDAP) + + if ldapAuthenticator == nil { + t.Fatal("Connection should be established to", cfg.LDAP.URL) + } + if ldap_err != nil { + t.Fatal("No error should raise while connecting to", cfg.LDAP.URL) + } + + srv, httpServerExitDone := startHttpServer(ldapAuthenticator, httpServerAddr, httpServerTimeout) + + pageContent := crawlHttpPage("http://" + httpServerAddr) + if pageContent != "Unrestricted" { + t.Fatal("No content received from root page, expected 'Unrestricted'") + } + + securePageContent := crawlHttpPage("http://"+httpServerAddr+"/secret", "user", usersPasswords["user"]) + if securePageContent != "Logged in" { + t.Fatal("No content received from '/secret' page, expected 'Logged in'") + } + + // uncomment this sleep for manual testing and HTTP/LDAP interaction + // time.Sleep(60 * time.Second) + + if err := srv.Shutdown(context.Background()); err != nil { + log.Fatal("HTTP shutdown error:", err) + } + + // wait for started goroutine to stop + httpServerExitDone.Wait() + log.Println("HTTP server shutdown completed") +} + +func ldapAuthWrapper(handler http.HandlerFunc, authenticator auth.AuthenticatorInterface) http.HandlerFunc { + return auth.JustCheck(authenticator, handler) +} + +func crawlHttpPage(params ...string) string { + client := http.Client{Timeout: 1 * time.Second} + + req, err := http.NewRequest(http.MethodGet, params[0], http.NoBody) + if err != nil { + log.Fatal(err) + } + + if len(params) == 3 { + req.SetBasicAuth(params[1], params[2]) + } + + res, err := client.Do(req) + if err != nil { + log.Fatal(err) + } + + defer res.Body.Close() + + resBody, err := io.ReadAll(res.Body) + if err != nil { + log.Fatal(err) + } + + return string(resBody) +}