diff --git a/BUILD.bazel b/BUILD.bazel index c9b721e49..43fd8f123 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -26,6 +26,7 @@ go_library( deps = [ "//cache/disk:go_default_library", "//config:go_default_library", + "//ldap:go_default_library", "//server:go_default_library", "//utils/flags:go_default_library", "//utils/idle:go_default_library", diff --git a/README.md b/README.md index 16c07c278..7b774c28c 100644 --- a/README.md +++ b/README.md @@ -220,9 +220,10 @@ OPTIONS: [$BAZEL_REMOTE_TLS_KEY_FILE] --allow_unauthenticated_reads If authentication is enabled - (--htpasswd_file or --tls_ca_file), allow unauthenticated clients read - access. (default: false, ie if authentication is required, read-only - requests must also be authenticated) [$BAZEL_REMOTE_UNAUTHENTICATED_READS] + (--htpasswd_file, --tls_ca_file or --ldap.url), allow unauthenticated + clients read access. (default: false, i.e. if authentication is required, + read-only requests must also be authenticated) + [$BAZEL_REMOTE_UNAUTHENTICATED_READS] --idle_timeout value The maximum period of having received no request after which the server will shut itself down. (default: 0s, ie disabled) @@ -292,6 +293,34 @@ OPTIONS: Google credentials for the Google Cloud Storage proxy backend. [$BAZEL_REMOTE_GCS_JSON_CREDENTIALS_FILE] + --ldap.url value The LDAP URL which may include a port. LDAP over SSL + (LDAPs) is supported. + [$BAZEL_REMOTE_LDAP_URL] + + --ldap.base_dn value The distinguished name of the search base. + [$BAZEL_REMOTE_LDAP_BASE_DN] + + --ldap.bind_user value The user who is allowed to perform a search within + the base DN. If none is specified the connection and the search is + performed without an authentication. It is recommended to use a read-only + account. + [$BAZEL_REMOTE_LDAP_BIND_USER] + + --ldap.bind_password value The password of the bind user. + [$BAZEL_REMOTE_LDAP_BIND_PASSWORD] + + --ldap.username_attribute value The user attribute of a connecting user. + (default: "uid") + [$BAZEL_REMOTE_LDAP_USER_ATTRIBUTE] + + --ldap.groups value Filter clause for searching groups. This option can be + given multiple times and the groups are OR connected in the search query. + [$BAZEL_REMOTE_LDAP_GROUPS] + + --ldap.cache_time value The amount of time to cache a successful + authentication in seconds. (default 3600) + [$BAZEL_REMOTE_LDAP_CACHE_TIME] + --s3.endpoint value The S3/minio endpoint to use when using S3 proxy backend. [$BAZEL_REMOTE_S3_ENDPOINT] @@ -469,6 +498,17 @@ http_address: 0.0.0.0:8080 # Alternatively, you can use simple authentication: #htpasswd_file: path/to/.htpasswd +# At most one authentication mechanism can be used +#ldap: +# url: ldaps://ldap.example.com:636 +# base_dn: OU=My Users,DC=example,DC=com +# username_attribute: sAMAccountName # defaults to "uid" +# bind_user: ldapuser +# bind_password: ldappassword +# cache_time: 3600 # in seconds (default 1 hour) +# groups: +# - CN=bazel-users,OU=Groups,OU=My Users,DC=example,DC=com + # If tls_ca_file or htpasswd_file are specified, you can choose @@ -679,7 +719,10 @@ $ bazel build :bazel-remote ### Authentication bazel-remote defaults to allow unauthenticated access, but basic `.htpasswd` -style authentication and mutual TLS authentication are also supported. +style authentication, mutual TLS authentication and LDAP are also supported. +Please note that only one authentication mechanism can be used at a time. + +#### htpasswd In order to pass a `.htpasswd` and/or server key file(s) to the cache inside a docker container, you first need to mount the file in the @@ -698,6 +741,8 @@ $ docker run -v /path/to/cache/dir:/data \ --htpasswd_file /etc/bazel-remote/htpasswd --max_size 5 ``` +#### mTLS + If you prefer not using `.htpasswd` files it is also possible to authenticate with mTLS (also can be known as "authenticating client certificates"). You can do this by passing in the the cert/key the @@ -716,6 +761,24 @@ $ docker run -v /path/to/cache/dir:/data \ --max_size 5 ``` +#### LDAP + +LDAP is an additional authentication method for the cache. It can be used via +command line args, the config file or env variables. + +```bash +$ docker run -v /path/to/cache/dir:/data \ + -p 9090:8080 -p 9092:9092 buchgr/bazel-remote-cache \ + --ldap.url="ldaps://ldap.example.com:636" \ + --ldap.base_dn="OU=My Users,DC=example,DC=com" \ + --ldap.groups="CN=bazel-users,OU=Groups,OU=My Users,DC=example,DC=com" \ + --ldap.groups="CN=bazel-testers,OU=Groups,OU=My Users,DC=example,DC=com" \ + --ldap.cache_time=100 \ + --ldap.bind_user="cn=readonly.username,ou=readonly,OU=Other Users,DC=example,DC=com" \ + --ldap.bind_password="secret4Sure" \ + --max_size 5 +``` + ### Using bazel-remote with AWS Credential file authentication for S3 inside a docker container The following demonstrates how to configure a docker instance of bazel-remote to use an AWS S3 diff --git a/WORKSPACE b/WORKSPACE index 83146392f..41ee25b56 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -124,6 +124,13 @@ go_repository( version = "v1.3.5", ) +go_repository( + name = "in_gopkg_asn1_ber_v1", + importpath = "gopkg.in/asn1-ber.v1", + sum = "h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM=", + version = "v1.0.0-20181015200546-f715ec2f112d", +) + gazelle_dependencies() http_archive( diff --git a/config/config.go b/config/config.go index 51655d00d..8d8844652 100644 --- a/config/config.go +++ b/config/config.go @@ -38,6 +38,16 @@ type URLBackendConfig struct { CaFile string `yaml:"ca_file"` } +type LDAPConfig struct { + URL string `yaml:"url"` + BaseDN string `yaml:"base_dn"` + BindUser string `yaml:"bind_user"` + BindPassword string `yaml:"bind_password"` + UsernameAttribute string `yaml:"username_attribute"` + Groups []string `yaml:"groups,flow"` + CacheTime time.Duration `yaml:"cache_time"` +} + func (c *URLBackendConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { type Aux URLBackendConfig aux := &struct { @@ -89,6 +99,7 @@ type Config struct { StorageMode string `yaml:"storage_mode"` ZstdImplementation string `yaml:"zstd_implementation"` HtpasswdFile string `yaml:"htpasswd_file"` + LDAP *LDAPConfig `yaml:"ldap,omitempty"` MinTLSVersion string `yaml:"min_tls_version"` TLSCaFile string `yaml:"tls_ca_file"` TLSCertFile string `yaml:"tls_cert_file"` @@ -157,6 +168,7 @@ func newFromArgs(dir string, maxSize int, storageMode string, zstdImplementation hc *URLBackendConfig, grpcb *URLBackendConfig, gcs *GoogleCloudStorageConfig, + ldap *LDAPConfig, s3 *S3CloudStorageConfig, azblob *AzBlobStorageConfig, disableHTTPACValidation bool, @@ -192,6 +204,7 @@ func newFromArgs(dir string, maxSize int, storageMode string, zstdImplementation GoogleCloudStorage: gcs, HTTPBackend: hc, GRPCBackend: grpcb, + LDAP: ldap, IdleTimeout: idleTimeout, DisableHTTPACValidation: disableHTTPACValidation, DisableGRPCACDepsCheck: disableGRPCACDepsCheck, @@ -278,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") @@ -368,7 +385,7 @@ func validateConfig(c *Config) error { "and 'tls_cert_file' specified.") } - if c.AllowUnauthenticatedReads && c.TLSCaFile == "" && c.HtpasswdFile == "" { + if c.AllowUnauthenticatedReads && c.TLSCaFile == "" && c.HtpasswdFile == "" && c.LDAP == nil { return errors.New("AllowUnauthenticatedReads setting is only available when authentication is enabled") } @@ -450,6 +467,25 @@ func validateConfig(c *Config) error { } } + if c.HtpasswdFile != "" && c.TLSCaFile != "" && c.LDAP != nil { + return errors.New("One can specify at most one authentication mechanism") + } + + if c.LDAP != nil { + if c.LDAP.URL == "" { + return errors.New("The 'url' field is required for 'ldap'") + } + if c.LDAP.BaseDN == "" { + return errors.New("The 'base_dn' field is required for 'ldap'") + } + if c.LDAP.UsernameAttribute == "" { + c.LDAP.UsernameAttribute = "uid" + } + if c.LDAP.CacheTime <= 0 { + c.LDAP.CacheTime = 3600 + } + } + switch c.AccessLogLevel { case "none", "all": default: @@ -590,6 +626,19 @@ func get(ctx *cli.Context) (*Config, error) { } } + var ldap *LDAPConfig + if ctx.String("ldap.url") != "" { + ldap = &LDAPConfig{ + URL: ctx.String("ldap.url"), + BaseDN: ctx.String("ldap.base_dn"), + BindUser: ctx.String("ldap.bind_user"), + BindPassword: ctx.String("ldap.bind_password"), + UsernameAttribute: ctx.String("ldap.username_attribute"), + Groups: ctx.StringSlice("ldap.groups"), + CacheTime: ctx.Duration("ldap.cache_time"), + } + } + return newFromArgs( ctx.String("dir"), ctx.Int("max_size"), @@ -610,6 +659,7 @@ func get(ctx *cli.Context) (*Config, error) { hc, grpcb, gcs, + ldap, s3, azblob, ctx.Bool("disable_http_ac_validation"), diff --git a/config/config_test.go b/config/config_test.go index 429e87081..c0425470d 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -231,6 +231,57 @@ s3_proxy: } } +func TestValidLDAPConfig(t *testing.T) { + yaml := `host: localhost +port: 8080 +dir: /opt/cache-dir +max_size: 100 +ldap: + url: ldap://ldap.example.com + base_dn: OU=My Users,DC=example,DC=com + username_attribute: sAMAccountName + bind_user: ldapuser + bind_password: ldappassword + 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 +` + config, err := newFromYaml([]byte(yaml)) + if err != nil { + t.Fatal(err) + } + + expectedConfig := &Config{ + HTTPAddress: "localhost:8080", + Dir: "/opt/cache-dir", + MaxSize: 100, + StorageMode: "zstd", + ZstdImplementation: "go", + LDAP: &LDAPConfig{ + URL: "ldap://ldap.example.com", + BaseDN: "OU=My Users,DC=example,DC=com", + BindUser: "ldapuser", + BindPassword: "ldappassword", + UsernameAttribute: "sAMAccountName", + Groups: []string{"CN=bazel-users,OU=Groups,OU=My Users,DC=example,DC=com", "CN=other-users,OU=Groups2,OU=Alien Users,DC=foo,DC=org"}, + CacheTime: 3600 * time.Second, + }, + NumUploaders: 100, + MinTLSVersion: "1.0", + MaxQueuedUploads: 1000000, + MaxBlobSize: math.MaxInt64, + MaxProxyBlobSize: math.MaxInt64, + MetricsDurationBuckets: []float64{.5, 1, 2.5, 5, 10, 20, 40, 80, 160, 320}, + AccessLogLevel: "all", + LogTimezone: "UTC", + } + + if !cmp.Equal(config, expectedConfig) { + t.Fatalf("Expected '%+v' but got '%+v'", expectedConfig, config) + } +} + func TestValidProfiling(t *testing.T) { yaml := `host: localhost port: 1234 diff --git a/deps.bzl b/deps.bzl index 1e7c640f4..cbc9639d7 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:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=", + version = "v0.0.0-20231016080023-1a75b4708caa", + ) + 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", @@ -231,6 +245,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", @@ -244,6 +265,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:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ=", + version = "v3.4.8", + ) go_repository( name = "com_github_go_logfmt_logfmt", @@ -384,6 +411,18 @@ def go_dependencies(): sum = "h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=", version = "v1.8.0", ) + go_repository( + name = "com_github_gorilla_securecookie", + importpath = "github.com/gorilla/securecookie", + sum = "h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=", + version = "v1.1.1", + ) + go_repository( + name = "com_github_gorilla_sessions", + importpath = "github.com/gorilla/sessions", + sum = "h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=", + version = "v1.2.1", + ) go_repository( name = "com_github_grpc_ecosystem_go_grpc_prometheus", @@ -391,18 +430,69 @@ def go_dependencies(): sum = "h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho=", version = "v1.2.0", ) + go_repository( + name = "com_github_hashicorp_go_uuid", + importpath = "github.com/hashicorp/go-uuid", + sum = "h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=", + version = "v1.0.3", + ) + go_repository( name = "com_github_iris_contrib_schema", importpath = "github.com/iris-contrib/schema", sum = "h1:CPSBLyx2e91H2yJzPuhGuifVRnZBBJ3pCOMbOvPZaTw=", version = "v0.0.6", ) + go_repository( + name = "com_github_jcmturner_aescts_v2", + importpath = "github.com/jcmturner/aescts/v2", + sum = "h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=", + version = "v2.0.0", + ) + go_repository( + name = "com_github_jcmturner_dnsutils_v2", + importpath = "github.com/jcmturner/dnsutils/v2", + sum = "h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=", + version = "v2.0.0", + ) + go_repository( + name = "com_github_jcmturner_gofork", + importpath = "github.com/jcmturner/gofork", + sum = "h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=", + version = "v1.7.6", + ) + go_repository( + name = "com_github_jcmturner_goidentity_v6", + importpath = "github.com/jcmturner/goidentity/v6", + sum = "h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=", + version = "v6.0.1", + ) + go_repository( + name = "com_github_jcmturner_gokrb5_v8", + importpath = "github.com/jcmturner/gokrb5/v8", + sum = "h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=", + version = "v8.4.4", + ) + go_repository( + name = "com_github_jcmturner_rpc_v2", + importpath = "github.com/jcmturner/rpc/v2", + sum = "h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=", + version = "v2.0.3", + ) + go_repository( name = "com_github_joker_jade", importpath = "github.com/Joker/jade", 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 226b44973..bbaca1e5c 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,8 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.4.1 + github.com/JonasScharpf/godap v0.0.0-20240417153024-2d460c2776c0 + github.com/go-ldap/ldap/v3 v3.4.8 github.com/johannesboyne/gofakes3 v0.0.0-20230506070712-04da935ef877 github.com/valyala/gozstd v1.20.1 google.golang.org/genproto/googleapis/api v0.0.0-20240401170217-c3f982113cda @@ -41,12 +43,14 @@ require ( cloud.google.com/go/compute/metadata v0.2.3 // indirect cloud.google.com/go/longrunning v0.5.6 // indirect 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/aws/aws-sdk-go v1.44.256 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/snappy v0.0.4 // indirect @@ -71,6 +75,7 @@ require ( golang.org/x/net v0.24.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/tools v0.8.0 // indirect + gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index 568a3606d..a6eba50b8 100644 --- a/go.sum +++ b/go.sum @@ -12,10 +12,16 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0 h1:jp0dGvZ7ZK0mgqnTSClMxa5 github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.4.1 h1:QSdcrd/UFJv6Bp/CfoVf2SrENpFn9P6Yh8yb+xNhYMM= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.4.1/go.mod h1:eZ4g6GUvXiGulfIbbhh1Xr4XwUYaYaWMqzGD/284wCA= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +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-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/aws/aws-sdk-go v1.44.256 h1:O8VH+bJqgLDguqkH/xQBFz5o/YheeZqgcOYIgsTVWY4= github.com/aws/aws-sdk-go v1.44.256/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -31,9 +37,12 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= github.com/dnaeon/go-vcr v1.1.0 h1:ReYa/UBrRyQdant9B4fNHGoCNKw6qh6P0fsdGmZpR7c= -github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= +github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ= +github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk= github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= @@ -49,8 +58,25 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= @@ -64,7 +90,6 @@ github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02 github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -113,10 +138,15 @@ github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJ github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc= github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0= -github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= @@ -129,17 +159,25 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg= @@ -159,6 +197,9 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -166,6 +207,9 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -194,9 +238,10 @@ google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= @@ -204,5 +249,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ldap/BUILD.bazel b/ldap/BUILD.bazel new file mode 100644 index 000000000..0793e60c8 --- /dev/null +++ b/ldap/BUILD.bazel @@ -0,0 +1,23 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["ldap.go"], + importpath = "github.com/buchgr/bazel-remote/v2/ldap", + visibility = ["//visibility:public"], + deps = [ + "//config:go_default_library", + "@com_github_abbot_go_http_auth//:go_default_library", + "@com_github_go_ldap_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.go b/ldap/ldap.go new file mode 100644 index 000000000..51851e66b --- /dev/null +++ b/ldap/ldap.go @@ -0,0 +1,170 @@ +package ldap + +import ( + "context" + "encoding/base64" + "fmt" + "log" + "net/http" + "strings" + "sync" + "time" + + "github.com/buchgr/bazel-remote/v2/config" + + auth "github.com/abbot/go-http-auth" + ldap "github.com/go-ldap/ldap/v3" +) + +// Cache represents a cache of LDAP query results so that many concurrent +// requests don't DDoS the LDAP server. +type Cache struct { + *auth.BasicAuth + m sync.Map + config *config.LDAPConfig +} + +type cacheEntry struct { + sync.Mutex + // Poor man's enum; nil pointer means uninitialized + authed *bool +} + +func New(config *config.LDAPConfig) (*Cache, error) { + conn, err := ldap.DialURL(config.URL) + if err != nil { + return nil, err + } + defer conn.Close() + + // Test the configured bind credentials + if err = conn.Bind(config.BindUser, config.BindPassword); err != nil { + return nil, err + } + + return &Cache{ + config: config, + BasicAuth: &auth.BasicAuth{ + Realm: "Bazel remote cache", + }, + }, nil +} + +// Either query LDAP for a result or retrieve it from the cache +func (c *Cache) checkLdap(user, password string) bool { + k := [2]string{user, password} + v, _ := c.m.LoadOrStore(k, &cacheEntry{}) + ce := v.(*cacheEntry) + ce.Lock() + defer ce.Unlock() + if ce.authed != nil { + return *ce.authed + } + + // Not initialized; actually do the query and record the result + authed := c.query(user, password) + ce.authed = &authed + timeout := c.config.CacheTime * time.Second + // Don't cache a negative result for a long time; likely wrong password + if !authed { + timeout = 5 * time.Second + } + go func() { + <-time.After(timeout) + c.m.Delete(k) + }() + + return authed +} + +func (c *Cache) query(user, password string) bool { + // This should always succeed since it was tested at instantiation + conn, err := ldap.DialURL(c.config.URL) + if err != nil { + log.Fatal("No valid LDAP connection could be established:", err) + } + defer conn.Close() + + if err = conn.Bind(c.config.BindUser, c.config.BindPassword); err != nil { + log.Fatal("LDAP connection with username and password failed:", err) + } + + var groupsQuery strings.Builder + if len(c.config.Groups) != 0 { + groupsQuery.WriteString("(|") + for _, group := range c.config.Groups { + // memberOf only works with groupOfNames, not with POSIX + fmt.Fprintf(&groupsQuery, "(memberOf=%s)", group) + } + groupsQuery.WriteString(")") + } + + query := fmt.Sprintf("(&(%s=%s)%s)", c.config.UsernameAttribute, user, groupsQuery.String()) + + searchRequest := ldap.NewSearchRequest( + c.config.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + query, + []string{"cn", "dn"}, + nil, + ) + + sr, err := conn.Search(searchRequest) + if err != nil || len(sr.Entries) != 1 { + return false + } + + // Do they have the right credentials? + return conn.Bind(sr.Entries[0].DN, password) == nil +} + +// Below mostly copied from github.com/abbot/go-http-auth +// in order to "override" CheckAuth + +func (c *Cache) CheckAuth(r *http.Request) string { + s := strings.SplitN(r.Header.Get(c.Headers.V().Authorization), " ", 2) + if len(s) != 2 || s[0] != "Basic" { + return "" + } + + b, err := base64.StdEncoding.DecodeString(s[1]) + if err != nil { + return "" + } + pair := strings.SplitN(string(b), ":", 2) + if len(pair) != 2 { + return "" + } + user, password := pair[0], pair[1] + if !c.checkLdap(user, password) { + return "" + } + + return user +} + +func (c *Cache) Wrap(wrapped auth.AuthenticatedHandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if username := c.CheckAuth(r); username == "" { + c.RequireAuth(w, r) + } else { + ar := &auth.AuthenticatedRequest{Request: *r, Username: username} + wrapped(w, ar) + } + } +} + +func (c *Cache) NewContext(ctx context.Context, r *http.Request) context.Context { + type key int + // key of context.WithValue must be comparable and should not be of type + // string or any other built-in type to avoid collisions between packages + // using context + var infoKey key + info := &auth.Info{Username: c.CheckAuth(r), ResponseHeaders: make(http.Header)} + + info.Authenticated = info.Username != "" + if !info.Authenticated { + info.ResponseHeaders.Set(c.Headers.V().Authenticate, `Basic realm="`+c.Realm+`"`) + } + return context.WithValue(ctx, infoKey, info) +} diff --git a/ldap/ldap_test.go b/ldap/ldap_test.go new file mode 100644 index 000000000..098e01199 --- /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) +} diff --git a/main.go b/main.go index 9472701b8..060a94905 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "github.com/buchgr/bazel-remote/v2/cache/disk" "github.com/buchgr/bazel-remote/v2/config" + "github.com/buchgr/bazel-remote/v2/ldap" "github.com/buchgr/bazel-remote/v2/server" "github.com/buchgr/bazel-remote/v2/utils/flags" "github.com/buchgr/bazel-remote/v2/utils/idle" @@ -48,6 +49,8 @@ func main() { cli.HelpPrinterCustom = flags.HelpPrinter // Force the use of cli.HelpPrinterCustom. app.ExtraInfo = func() map[string]string { return map[string]string{} } + // ldap groups could contain "," and would be split + app.SliceFlagSeparator = ";" app.Flags = flags.GetCliFlags() app.Action = run @@ -242,6 +245,7 @@ func startHttpServer(c *config.Config, httpServer **http.Server, c.EnableACKeyInstanceMangling, checkClientCertForReads, checkClientCertForWrites, gitCommit) cacheHandler := h.CacheHandler + var ldapAuthenticator auth.AuthenticatorInterface var basicAuthenticator auth.BasicAuth if c.HtpasswdFile != "" { if c.AllowUnauthenticatedReads { @@ -250,6 +254,18 @@ func startHttpServer(c *config.Config, httpServer **http.Server, basicAuthenticator = auth.BasicAuth{Realm: c.HTTPAddress, Secrets: htpasswdSecrets} cacheHandler = basicAuthWrapper(cacheHandler, &basicAuthenticator) } + } else if c.LDAP != nil { + if c.AllowUnauthenticatedReads { + cacheHandler = unauthenticatedReadWrapper(cacheHandler, htpasswdSecrets, c.HTTPAddress) + } else { + var ldap_err error + if ldapAuthenticator, ldap_err = ldap.New(c.LDAP); ldap_err != nil { + log.Fatal("Failed to create LDAP connection: ", ldap_err) + } + cacheHandler = ldapAuthWrapper(cacheHandler, ldapAuthenticator) + } + } else { + log.Println("Neither HTTP_PASSWD nor LDAP used") } if c.IdleTimeout > 0 { @@ -267,6 +283,8 @@ func startHttpServer(c *config.Config, httpServer **http.Server, statusHandler = h.VerifyClientCertHandler(statusHandler).ServeHTTP } else if c.HtpasswdFile != "" { statusHandler = basicAuthWrapper(statusHandler, &basicAuthenticator) + } else if c.LDAP != nil { + statusHandler = ldapAuthWrapper(statusHandler, ldapAuthenticator) } } @@ -285,6 +303,8 @@ func startHttpServer(c *config.Config, httpServer **http.Server, middlewareHandler = h.VerifyClientCertHandler(middlewareHandler) } else if c.HtpasswdFile != "" { middlewareHandler = basicAuthWrapper(middlewareHandler.ServeHTTP, &basicAuthenticator) + } else if c.LDAP != nil { + middlewareHandler = ldapAuthWrapper(middlewareHandler.ServeHTTP, ldapAuthenticator) } } mux.Handle("/metrics", middlewareHandler) @@ -438,6 +458,10 @@ func basicAuthWrapper(handler http.HandlerFunc, authenticator *auth.BasicAuth) h return auth.JustCheck(authenticator, handler) } +func ldapAuthWrapper(handler http.HandlerFunc, authenticator auth.AuthenticatorInterface) http.HandlerFunc { + return auth.JustCheck(authenticator, handler) +} + // A http.HandlerFunc wrapper which requires successful basic // authentication for write requests, but allows unauthenticated // read requests. diff --git a/utils/flags/flags.go b/utils/flags/flags.go index 10e3b0d40..2beef5578 100644 --- a/utils/flags/flags.go +++ b/utils/flags/flags.go @@ -254,6 +254,51 @@ func GetCliFlags() []cli.Flag { Usage: "Path to a JSON file that contains Google credentials for the Google Cloud Storage proxy backend.", EnvVars: []string{"BAZEL_REMOTE_GCS_JSON_CREDENTIALS_FILE"}, }, + &cli.StringFlag{ + Name: "ldap.url", + Value: "", + Usage: "The LDAP URL which may include a port. LDAP over SSL (LDAPs) is supported.", + EnvVars: []string{"BAZEL_REMOTE_LDAP_URL"}, + }, + &cli.StringFlag{ + Name: "ldap.base_dn", + Value: "", + Usage: "The distinguished name of the search base.", + EnvVars: []string{"BAZEL_REMOTE_LDAP_BASE_DN"}, + }, + // to allow anonymous access do not require BindUser or BindPassword + &cli.StringFlag{ + Name: "ldap.bind_user", + Value: "", + Usage: "The user who is allowed to perform a search within the base DN. If none is specified the connection and the search is performed without an authentication. It is recommended to use a read-only account.", + EnvVars: []string{"BAZEL_REMOTE_LDAP_BIND_USER"}, + }, + &cli.StringFlag{ + Name: "ldap.bind_password", + Value: "", + Usage: "The password of the bind user.", + EnvVars: []string{"BAZEL_REMOTE_LDAP_BIND_PASSWORD"}, + }, + &cli.StringFlag{ + Name: "ldap.username_attribute", + Value: "uid", + Usage: "The user attribute of a connecting user.", + EnvVars: []string{"BAZEL_REMOTE_LDAP_USER_ATTRIBUTE"}, + }, + // https://cli.urfave.org/v2/examples/flags/#multiple-values-per-single-flag + &cli.StringSliceFlag{ + Name: "ldap.groups", + // setting a "Value" will no longer respect the "SliceFlagSeparator" + // https://github.com/urfave/cli/issues/1878 + Usage: "Filter clause for searching groups. This option can be given multiple times and the groups are OR connected in the search query.", + EnvVars: []string{"BAZEL_REMOTE_LDAP_GROUPS"}, + }, + &cli.IntFlag{ + Name: "ldap.cache_time", + Value: 3600, + Usage: "The amount of time to cache a successful authentication in seconds.", + EnvVars: []string{"BAZEL_REMOTE_LDAP_CACHE_TIME"}, + }, &cli.StringFlag{ Name: "s3.endpoint", Value: "",