diff --git a/Makefile b/Makefile index ab8c3388..388b4180 100644 --- a/Makefile +++ b/Makefile @@ -22,9 +22,9 @@ test: go test -v -race -cover -coverprofile=coverage.txt -covermode=atomic ./... .PHONY: check -check: +check: .bazel/golangcilint.yaml golangci-lint --version || curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s v1.17.1 - golangci-lint run --enable-all ./cmd/... ./pkg/... + golangci-lint --config .bazel/golangcilint.yaml run --enable-all ./cmd/... ./pkg/... docs/docs.go: swag -v || go install github.com/swaggo/swag/cmd/swag diff --git a/README.md b/README.md index bfe630a0..cbaa2e11 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ * Conforms to [OCI distribution spec](https://github.com/opencontainers/distribution-spec) APIs * Uses [OCI storage layout](https://github.com/opencontainers/image-spec/blob/master/image-layout.md) for storage layout * TLS support -* *Basic* and TLS mutual authentication -* Swagger based documentation +* Authentication via TLS mutual authentication and HTTP *BASIC* (local _htpasswd_ and LDAP) * Doesn't require _root_ privileges +* Swagger based documentation # Building diff --git a/WORKSPACE b/WORKSPACE index be22e457..986659ae 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -1008,3 +1008,108 @@ go_repository( sum = "h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=", version = "v0.0.0-20190717185122-a985d3407aa7", ) + +go_repository( + name = "com_github_boombuler_barcode", + importpath = "github.com/boombuler/barcode", + sum = "h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=", + version = "v1.0.1-0.20190219062509-6c824513bacc", +) + +go_repository( + name = "com_github_docopt_docopt_go", + importpath = "github.com/docopt/docopt-go", + sum = "h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=", + version = "v0.0.0-20180111231733-ee0de3bc6815", +) + +go_repository( + name = "com_github_geertjohan_yubigo", + importpath = "github.com/GeertJohan/yubigo", + sum = "h1:KA/G9j1p6mBmMihAZwmpnS6t8WsToyVlvF2v5VgJIcY=", + version = "v0.0.0-20190829090426-2d4089dc8789", +) + +go_repository( + name = "com_github_glauth_glauth", + importpath = "github.com/glauth/glauth", + sum = "h1:2Rl5vTPWlchM4P+VCUtHbD7U3wFcoLYZiTwYad2QCOM=", + version = "v1.1.1", +) + +go_repository( + name = "com_github_jtblin_go_ldap_client", + importpath = "github.com/jtblin/go-ldap-client", + sum = "h1:XDpFOMOZq0u0Ar4F0p/wklqQXp/AMV1pTF5T5bDoUfQ=", + version = "v0.0.0-20170223121919-b73f66626b33", +) + +go_repository( + name = "com_github_nmcclain_asn1_ber", + importpath = "github.com/nmcclain/asn1-ber", + sum = "h1:D9EvfGQvlkKaDr2CRKN++7HbSXbefUNDrPq60T+g24s=", + version = "v0.0.0-20170104154839-2661553a0484", +) + +go_repository( + name = "com_github_nmcclain_ldap", + importpath = "github.com/nmcclain/ldap", + sum = "h1:SNpbw8iNcHdnboQsLB5wkRAgCSqWXplItrd8Xxu+9Dc=", + version = "v0.0.0-20190703182433-09931d85c0ff", +) + +go_repository( + name = "com_github_op_go_logging", + importpath = "github.com/op/go-logging", + sum = "h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=", + version = "v0.0.0-20160315200505-970db520ece7", +) + +go_repository( + name = "com_github_pquerna_otp", + importpath = "github.com/pquerna/otp", + sum = "h1:/A3+Jn+cagqayeR3iHs/L62m5ue7710D35zl1zJ1kok=", + version = "v1.2.0", +) + +go_repository( + name = "com_github_samuel_go_ldap", + importpath = "github.com/samuel/go-ldap", + sum = "h1:1iey3/nAwh5WYP9DGAH6vZGyBhCbRZ0fkX33LO138Fg=", + version = "v0.0.0-20150819063227-09b1a56d2755", +) + +go_repository( + name = "com_github_vjeantet_ldapserver", + importpath = "github.com/vjeantet/ldapserver", + sum = "h1:VWE8ZC9ER1YIfx0V0QgZGdG4UB/nGeaSmKxesfVuheo=", + version = "v0.0.0-20170919170217-479fece7c5f1", +) + +go_repository( + name = "in_gopkg_amz_v1", + importpath = "gopkg.in/amz.v1", + sum = "h1:FMrsB0OTjHsPDA1NM7AhRmmZzkBPu3iGdxK/5MFfBmk=", + version = "v1.0.0-20150111123259-ad23e96a31d2", +) + +go_repository( + name = "in_gopkg_asn1_ber_v1", + importpath = "gopkg.in/asn1-ber.v1", + sum = "h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM=", + version = "v1.0.0-20181015200546-f715ec2f112d", +) + +go_repository( + name = "in_gopkg_ldap_v2", + importpath = "gopkg.in/ldap.v2", + sum = "h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU=", + version = "v2.5.1", +) + +go_repository( + name = "com_github_getlantern_deepcopy", + importpath = "github.com/getlantern/deepcopy", + sum = "h1:yU/FENpkHYISWsQrbr3pcZOBj0EuRjPzNc1+dTCLu44=", + version = "v0.0.0-20160317154340-7f45deb8130a", +) diff --git a/errors/errors.go b/errors/errors.go index 31217201..4e0a102a 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -16,4 +16,7 @@ var ( ErrBadBlobDigest = errors.New("blob: bad blob digest") ErrUnknownCode = errors.New("error: unknown error code") ErrBadCACert = errors.New("tls: invalid ca cert") + ErrBadUser = errors.New("ldap: non-existent user") + ErrEntriesExceeded = errors.New("ldap: too many entries returned") + ErrLDAPConfig = errors.New("config: invalid LDAP configuration") ) diff --git a/examples/config-example.json b/examples/config-example.json index dd111839..70b66348 100644 --- a/examples/config-example.json +++ b/examples/config-example.json @@ -12,6 +12,17 @@ "key":"test/data/server.key" }, "auth": { + "ldap": { + "address":"ldap.example.org", + "port":389, + "startTLS":false, + "baseDN":"ou=Users,dc=example,dc=org", + "userAttribute":"uid", + "bindDN":"cn=ldap-searcher,ou=Users,dc=example,dc=org", + "bindPassword":"ldap-searcher-password", + "skipVerify":false, + "subtreeSearch":true + }, "htpasswd": { "path": "test/data/htpasswd" }, diff --git a/examples/config-example.yaml b/examples/config-example.yaml index 1a0fab66..ceb67161 100644 --- a/examples/config-example.yaml +++ b/examples/config-example.yaml @@ -11,6 +11,16 @@ http: cert: test/data/server.cert key: test/data/server.key auth: + ldap: + address: ldap.example.org + port: 389 + startTLS: false + baseDN: ou=Users,dc=example,dc=org + userAttribute: uid + bindDN: cn=ldap-searcher,ou=Users,dc=example,dc=org + bindPassword: ldap-searcher-password + skipVerify: false + subtreeSearch: true htpasswd: path: test/data/htpasswd failDelay: 5 diff --git a/go.mod b/go.mod index 083be3cc..6771df71 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +4,17 @@ go 1.12 require ( github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc + github.com/getlantern/deepcopy v0.0.0-20160317154340-7f45deb8130a github.com/go-chi/chi v4.0.2+incompatible // indirect github.com/gofrs/uuid v3.2.0+incompatible github.com/gorilla/mux v1.7.3 github.com/json-iterator/go v1.1.6 + github.com/jtblin/go-ldap-client v0.0.0-20170223121919-b73f66626b33 github.com/mitchellh/mapstructure v1.1.2 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.1 // indirect + github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 // indirect + github.com/nmcclain/ldap v0.0.0-20190703182433-09931d85c0ff github.com/opencontainers/distribution-spec v1.0.0-rc0 github.com/opencontainers/go-digest v1.0.0-rc1 github.com/opencontainers/image-spec v1.0.1 @@ -26,5 +30,7 @@ require ( golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456 // indirect golang.org/x/text v0.3.2 // indirect golang.org/x/tools v0.0.0-20190827205025-b29f5f60c37a // indirect + gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect + gopkg.in/ldap.v2 v2.5.1 gopkg.in/resty.v1 v1.12.0 ) diff --git a/go.sum b/go.sum index e04e9819..47f65d81 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZm github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/getlantern/deepcopy v0.0.0-20160317154340-7f45deb8130a h1:yU/FENpkHYISWsQrbr3pcZOBj0EuRjPzNc1+dTCLu44= +github.com/getlantern/deepcopy v0.0.0-20160317154340-7f45deb8130a/go.mod h1:AEugkNu3BjBxyz958nJ5holD9PRjta6iprcoUauDbU4= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs= github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= @@ -72,6 +74,8 @@ github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANyt github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/json-iterator/go v1.1.6 h1:MrUvLMLTMxbqFJ9kzlvat/rYZqZnW3u4wkLzWTaFwKs= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/jtblin/go-ldap-client v0.0.0-20170223121919-b73f66626b33 h1:XDpFOMOZq0u0Ar4F0p/wklqQXp/AMV1pTF5T5bDoUfQ= +github.com/jtblin/go-ldap-client v0.0.0-20170223121919-b73f66626b33/go.mod h1:+0BcLY5d54TVv6irFzHoiFvwAHR6T0g9B+by/UaS9T0= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= @@ -97,6 +101,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 h1:D9EvfGQvlkKaDr2CRKN++7HbSXbefUNDrPq60T+g24s= +github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484/go.mod h1:O1EljZ+oHprtxDDPHiMWVo/5dBT6PlvWX5PSwj80aBA= +github.com/nmcclain/ldap v0.0.0-20190703182433-09931d85c0ff h1:SNpbw8iNcHdnboQsLB5wkRAgCSqWXplItrd8Xxu+9Dc= +github.com/nmcclain/ldap v0.0.0-20190703182433-09931d85c0ff/go.mod h1:YtrVB1/v9Td9SyjXpjYVmbdKgj9B0nPTBsdGUxy0i8U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/opencontainers/distribution-spec v1.0.0-rc0 h1:xMzwhweo1gjvEo74mQjGTLau0TD3ACyTEC1310NbuSQ= github.com/opencontainers/distribution-spec v1.0.0-rc0/go.mod h1:copR2flp+jTEvQIFMb6MIx45OkrxzqyjszPDT3hx/5Q= @@ -221,9 +229,13 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +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-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ldap.v2 v2.5.1 h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU= +gopkg.in/ldap.v2 v2.5.1/go.mod h1:oI0cpe/D7HRtBQl8aTg+ZmzFUAvu4lsv3eLXMLGFxWk= gopkg.in/resty.v1 v1.12.0 h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= diff --git a/pkg/api/BUILD.bazel b/pkg/api/BUILD.bazel index a08b1de1..032b4ae0 100644 --- a/pkg/api/BUILD.bazel +++ b/pkg/api/BUILD.bazel @@ -7,6 +7,7 @@ go_library( "config.go", "controller.go", "errors.go", + "ldap.go", "log.go", "regexp.go", "routes.go", @@ -17,12 +18,15 @@ go_library( "//docs:go_default_library", "//errors:go_default_library", "//pkg/storage:go_default_library", + "@com_github_getlantern_deepcopy//:go_default_library", "@com_github_gorilla_mux//:go_default_library", "@com_github_json_iterator_go//:go_default_library", + "@com_github_jtblin_go_ldap_client//:go_default_library", "@com_github_opencontainers_distribution_spec//:go_default_library", "@com_github_opencontainers_image_spec//specs-go/v1:go_default_library", "@com_github_rs_zerolog//:go_default_library", "@com_github_swaggo_http_swagger//:go_default_library", + "@in_gopkg_ldap_v2//:go_default_library", "@org_golang_x_crypto//bcrypt:go_default_library", ], ) @@ -40,6 +44,7 @@ go_test( embed = [":go_default_library"], race = "on", deps = [ + "@com_github_nmcclain_ldap//:go_default_library", "@com_github_opencontainers_go_digest//:go_default_library", "@com_github_opencontainers_image_spec//specs-go/v1:go_default_library", "@com_github_smartystreets_goconvey//convey:go_default_library", diff --git a/pkg/api/auth.go b/pkg/api/auth.go index 35571dca..fa0af8ca 100644 --- a/pkg/api/auth.go +++ b/pkg/api/auth.go @@ -2,14 +2,19 @@ package api import ( "bufio" + "crypto/x509" "encoding/base64" + "fmt" + "io/ioutil" "net/http" "os" "strconv" "strings" "time" + "github.com/anuvu/zot/errors" "github.com/gorilla/mux" + "github.com/jtblin/go-ldap-client" "golang.org/x/crypto/bcrypt" ) @@ -20,25 +25,25 @@ func authFail(w http.ResponseWriter, realm string, delay int) { WriteJSON(w, http.StatusUnauthorized, NewError(UNAUTHORIZED)) } +// nolint (gocyclo) - we use closure making this a complex subroutine func BasicAuthHandler(c *Controller) mux.MiddlewareFunc { realm := c.Config.HTTP.Realm if realm == "" { realm = "Authorization Required" } realm = "Basic realm=" + strconv.Quote(realm) - delay := c.Config.HTTP.Auth.FailDelay - if c.Config.HTTP.Auth.HTPasswd.Path == "" { + // no password based authN, if neither LDAP nor HTTP BASIC is enabled + if c.Config.HTTP.Auth == nil || (c.Config.HTTP.Auth.HTPasswd.Path == "" && c.Config.HTTP.Auth.LDAP == nil) { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if c.Config.HTTP.AllowReadAccess && c.Config.HTTP.TLS.CACert != "" && r.TLS.VerifiedChains == nil && r.Method != "GET" && r.Method != "HEAD" { - authFail(w, realm, delay) + authFail(w, realm, 5) return } - // Process request next.ServeHTTP(w, r) }) @@ -46,20 +51,63 @@ func BasicAuthHandler(c *Controller) mux.MiddlewareFunc { } credMap := make(map[string]string) + delay := c.Config.HTTP.Auth.FailDelay + var ldapClient *LDAPClient - f, err := os.Open(c.Config.HTTP.Auth.HTPasswd.Path) - if err != nil { - panic(err) - } - - for { - r := bufio.NewReader(f) - line, err := r.ReadString('\n') - if err != nil { - break + if c.Config.HTTP.Auth != nil { + if c.Config.HTTP.Auth.LDAP != nil { + l := c.Config.HTTP.Auth.LDAP + ldapClient = &LDAPClient{ + LDAPClient: ldap.LDAPClient{ + Host: l.Address, + Port: l.Port, + UseSSL: !l.Insecure, + SkipTLS: !l.StartTLS, + Base: l.BaseDN, + BindDN: l.BindDN, + BindPassword: l.BindPassword, + UserFilter: fmt.Sprintf("(%s=%%s)", l.UserAttribute), + InsecureSkipVerify: l.SkipVerify, + ServerName: l.Address, + }, + log: c.Log, + subtreeSearch: l.SubtreeSearch, + } + if c.Config.HTTP.Auth.LDAP.CACert != "" { + caCert, err := ioutil.ReadFile(c.Config.HTTP.Auth.LDAP.CACert) + if err != nil { + panic(err) + } + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + panic(errors.ErrBadCACert) + } + ldapClient.clientCAs = caCertPool + } else { + // default to system cert pool + caCertPool, err := x509.SystemCertPool() + if err != nil { + panic(errors.ErrBadCACert) + } + ldapClient.clientCAs = caCertPool + } + } + if c.Config.HTTP.Auth.HTPasswd.Path != "" { + f, err := os.Open(c.Config.HTTP.Auth.HTPasswd.Path) + if err != nil { + panic(err) + } + + for { + r := bufio.NewReader(f) + line, err := r.ReadString('\n') + if err != nil { + break + } + tokens := strings.Split(line, ":") + credMap[tokens[0]] = tokens[1] + } } - tokens := strings.Split(line, ":") - credMap[tokens[0]] = tokens[1] } return func(next http.Handler) http.Handler { @@ -97,6 +145,17 @@ func BasicAuthHandler(c *Controller) mux.MiddlewareFunc { username := pair[0] passphrase := pair[1] + // prefer LDAP if configured + if c.Config.HTTP.Auth != nil && c.Config.HTTP.Auth.LDAP != nil { + ok, _, err := ldapClient.Authenticate(username, passphrase) + if ok && err == nil { + // Process request + next.ServeHTTP(w, r) + return + } + } + + // fallback to HTTPPassword passphraseHash, ok := credMap[username] if !ok { authFail(w, realm, delay) diff --git a/pkg/api/config.go b/pkg/api/config.go index 3889ea6c..65055e63 100644 --- a/pkg/api/config.go +++ b/pkg/api/config.go @@ -1,7 +1,10 @@ package api import ( + "github.com/anuvu/zot/errors" + "github.com/getlantern/deepcopy" dspec "github.com/opencontainers/distribution-spec" + "github.com/rs/zerolog" ) //nolint (gochecknoglobals) @@ -24,17 +27,32 @@ type AuthHTPasswd struct { type AuthConfig struct { FailDelay int HTPasswd AuthHTPasswd + LDAP *LDAPConfig } type HTTPConfig struct { Address string Port string - TLS TLSConfig `mapstructure:",omitempty"` - Auth AuthConfig `mapstructure:",omitempty"` + TLS *TLSConfig + Auth *AuthConfig Realm string AllowReadAccess bool `mapstructure:",omitempty"` } +type LDAPConfig struct { + Port int + Insecure bool + StartTLS bool // if !Insecure, then StartTLS or LDAPs + SkipVerify bool + SubtreeSearch bool + Address string + BindDN string + BindPassword string + BaseDN string + UserAttribute string + CACert string +} + type LogConfig struct { Level string Output string @@ -45,7 +63,7 @@ type Config struct { Commit string Storage StorageConfig HTTP HTTPConfig - Log LogConfig `mapstructure:",omitempty"` + Log *LogConfig } func NewConfig() *Config { @@ -53,6 +71,35 @@ func NewConfig() *Config { Version: dspec.Version, Commit: Commit, HTTP: HTTPConfig{Address: "127.0.0.1", Port: "8080"}, - Log: LogConfig{Level: "debug"}, + Log: &LogConfig{Level: "debug"}, } } + +// Sanitize makes a sanitized copy of the config removing any secrets +func (c *Config) Sanitize() *Config { + if c.HTTP.Auth != nil && c.HTTP.Auth.LDAP != nil && c.HTTP.Auth.LDAP.BindPassword != "" { + s := &Config{} + if err := deepcopy.Copy(s, c); err != nil { + panic(err) + } + s.HTTP.Auth.LDAP = &LDAPConfig{} + if err := deepcopy.Copy(s.HTTP.Auth.LDAP, c.HTTP.Auth.LDAP); err != nil { + panic(err) + } + s.HTTP.Auth.LDAP.BindPassword = "******" + return s + } + return c +} + +func (c *Config) Validate(log zerolog.Logger) error { + // LDAP configuration + if c.HTTP.Auth != nil && c.HTTP.Auth.LDAP != nil { + l := c.HTTP.Auth.LDAP + if l.UserAttribute == "" { + log.Error().Str("userAttribute", l.UserAttribute).Msg("invalid LDAP configuration") + return errors.ErrLDAPConfig + } + } + return nil +} diff --git a/pkg/api/controller.go b/pkg/api/controller.go index 1b95c250..c4cf8941 100644 --- a/pkg/api/controller.go +++ b/pkg/api/controller.go @@ -27,12 +27,20 @@ func NewController(config *Config) *Controller { } func (c *Controller) Run() error { + // validate configuration + if err := c.Config.Validate(c.Log); err != nil { + c.Log.Error().Err(err).Msg("configuration validation failed") + return err + } + + // print the current configuration, but strip secrets + c.Log.Info().Interface("params", c.Config.Sanitize()).Msg("configuration settings") + engine := mux.NewRouter() engine.Use(Logger(c.Log)) c.Router = engine _ = NewRouteHandler(c) - c.Log.Info().Interface("params", c.Config).Msg("configuration settings") c.ImageStore = storage.NewImageStore(c.Config.Storage.RootDirectory, c.Log) addr := fmt.Sprintf("%s:%s", c.Config.HTTP.Address, c.Config.HTTP.Port) @@ -45,10 +53,10 @@ func (c *Controller) Run() error { return err } - if c.Config.HTTP.TLS.Key != "" && c.Config.HTTP.TLS.Cert != "" { + if c.Config.HTTP.TLS != nil && c.Config.HTTP.TLS.Key != "" && c.Config.HTTP.TLS.Cert != "" { if c.Config.HTTP.TLS.CACert != "" { clientAuth := tls.VerifyClientCertIfGiven - if c.Config.HTTP.Auth.HTPasswd.Path == "" && !c.Config.HTTP.AllowReadAccess { + if (c.Config.HTTP.Auth == nil || c.Config.HTTP.Auth.HTPasswd.Path == "") && !c.Config.HTTP.AllowReadAccess { clientAuth = tls.RequireAndVerifyClientCert } diff --git a/pkg/api/controller_test.go b/pkg/api/controller_test.go index 5bdbfeaa..9dda7703 100644 --- a/pkg/api/controller_test.go +++ b/pkg/api/controller_test.go @@ -5,12 +5,16 @@ import ( "crypto/tls" "crypto/x509" "encoding/json" + "errors" + "fmt" "io/ioutil" + "net" "os" "testing" "time" "github.com/anuvu/zot/pkg/api" + vldap "github.com/nmcclain/ldap" . "github.com/smartystreets/goconvey/convey" "gopkg.in/resty.v1" ) @@ -41,7 +45,11 @@ func TestBasicAuth(t *testing.T) { Convey("Make a new controller", t, func() { config := api.NewConfig() config.HTTP.Port = SecurePort1 - config.HTTP.Auth.HTPasswd.Path = htpasswdPath + config.HTTP.Auth = &api.AuthConfig{ + HTPasswd: api.AuthHTPasswd{ + Path: htpasswdPath, + }, + } c := api.NewController(config) dir, err := ioutil.TempDir("", "oci-repo-test") if err != nil { @@ -101,9 +109,15 @@ func TestTLSWithBasicAuth(t *testing.T) { defer func() { resty.SetTLSClientConfig(nil) }() config := api.NewConfig() config.HTTP.Port = SecurePort2 - config.HTTP.Auth.HTPasswd.Path = htpasswdPath - config.HTTP.TLS.Cert = ServerCert - config.HTTP.TLS.Key = ServerKey + config.HTTP.TLS = &api.TLSConfig{ + Cert: ServerCert, + Key: ServerKey, + } + config.HTTP.Auth = &api.AuthConfig{ + HTPasswd: api.AuthHTPasswd{ + Path: htpasswdPath, + }, + } c := api.NewController(config) dir, err := ioutil.TempDir("", "oci-repo-test") @@ -170,9 +184,15 @@ func TestTLSWithBasicAuthAllowReadAccess(t *testing.T) { defer func() { resty.SetTLSClientConfig(nil) }() config := api.NewConfig() config.HTTP.Port = SecurePort2 - config.HTTP.Auth.HTPasswd.Path = htpasswdPath - config.HTTP.TLS.Cert = ServerCert - config.HTTP.TLS.Key = ServerKey + config.HTTP.Auth = &api.AuthConfig{ + HTPasswd: api.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + config.HTTP.TLS = &api.TLSConfig{ + Cert: ServerCert, + Key: ServerKey, + } config.HTTP.AllowReadAccess = true c := api.NewController(config) @@ -241,9 +261,11 @@ func TestTLSMutualAuth(t *testing.T) { defer func() { resty.SetTLSClientConfig(nil) }() config := api.NewConfig() config.HTTP.Port = SecurePort2 - config.HTTP.TLS.Cert = ServerCert - config.HTTP.TLS.Key = ServerKey - config.HTTP.TLS.CACert = CACert + config.HTTP.TLS = &api.TLSConfig{ + Cert: ServerCert, + Key: ServerKey, + CACert: CACert, + } c := api.NewController(config) dir, err := ioutil.TempDir("", "oci-repo-test") @@ -323,9 +345,11 @@ func TestTLSMutualAuthAllowReadAccess(t *testing.T) { defer func() { resty.SetTLSClientConfig(nil) }() config := api.NewConfig() config.HTTP.Port = SecurePort2 - config.HTTP.TLS.Cert = ServerCert - config.HTTP.TLS.Key = ServerKey - config.HTTP.TLS.CACert = CACert + config.HTTP.TLS = &api.TLSConfig{ + Cert: ServerCert, + Key: ServerKey, + CACert: CACert, + } config.HTTP.AllowReadAccess = true c := api.NewController(config) @@ -413,10 +437,16 @@ func TestTLSMutualAndBasicAuth(t *testing.T) { defer func() { resty.SetTLSClientConfig(nil) }() config := api.NewConfig() config.HTTP.Port = SecurePort2 - config.HTTP.TLS.Cert = ServerCert - config.HTTP.TLS.Key = ServerKey - config.HTTP.TLS.CACert = CACert - config.HTTP.Auth.HTPasswd.Path = htpasswdPath + config.HTTP.Auth = &api.AuthConfig{ + HTPasswd: api.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + config.HTTP.TLS = &api.TLSConfig{ + Cert: ServerCert, + Key: ServerKey, + CACert: CACert, + } c := api.NewController(config) dir, err := ioutil.TempDir("", "oci-repo-test") @@ -499,10 +529,16 @@ func TestTLSMutualAndBasicAuthAllowReadAccess(t *testing.T) { defer func() { resty.SetTLSClientConfig(nil) }() config := api.NewConfig() config.HTTP.Port = SecurePort2 - config.HTTP.TLS.Cert = ServerCert - config.HTTP.TLS.Key = ServerKey - config.HTTP.TLS.CACert = CACert - config.HTTP.Auth.HTPasswd.Path = htpasswdPath + config.HTTP.Auth = &api.AuthConfig{ + HTPasswd: api.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + config.HTTP.TLS = &api.TLSConfig{ + Cert: ServerCert, + Key: ServerKey, + CACert: CACert, + } config.HTTP.AllowReadAccess = true c := api.NewController(config) @@ -578,3 +614,139 @@ func TestTLSMutualAndBasicAuthAllowReadAccess(t *testing.T) { So(resp.StatusCode(), ShouldEqual, 200) }) } + +const ( + LDAPAddress = "127.0.0.1" + LDAPPort = 9636 + LDAPBaseDN = "ou=test" + LDAPBindDN = "cn=reader," + LDAPBaseDN + LDAPBindPassword = "bindPassword" +) + +type testLDAPServer struct { + server *vldap.Server + quitCh chan bool +} + +func newTestLDAPServer() *testLDAPServer { + l := &testLDAPServer{} + quitCh := make(chan bool) + server := vldap.NewServer() + server.QuitChannel(quitCh) + server.BindFunc("", l) + server.SearchFunc("", l) + l.server = server + l.quitCh = quitCh + return l +} + +func (l *testLDAPServer) Start() { + addr := fmt.Sprintf("%s:%d", LDAPAddress, LDAPPort) + go func() { + if err := l.server.ListenAndServe(addr); err != nil { + panic(err) + } + }() + for { + _, err := net.Dial("tcp", addr) + if err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } +} + +func (l *testLDAPServer) Stop() { + l.quitCh <- true +} + +func (l *testLDAPServer) Bind(bindDN, bindSimplePw string, conn net.Conn) (vldap.LDAPResultCode, error) { + if bindDN == "" || bindSimplePw == "" { + return vldap.LDAPResultInappropriateAuthentication, errors.New("ldap: bind creds required") + } + if (bindDN == LDAPBindDN && bindSimplePw == LDAPBindPassword) || + (bindDN == fmt.Sprintf("cn=%s,%s", username, LDAPBaseDN) && bindSimplePw == passphrase) { + return vldap.LDAPResultSuccess, nil + } + return vldap.LDAPResultInvalidCredentials, errors.New("ldap: invalid credentials") +} + +func (l *testLDAPServer) Search(boundDN string, req vldap.SearchRequest, + conn net.Conn) (vldap.ServerSearchResult, error) { + check := fmt.Sprintf("(uid=%s)", username) + if check == req.Filter { + return vldap.ServerSearchResult{ + Entries: []*vldap.Entry{ + {DN: fmt.Sprintf("cn=%s,%s", username, LDAPBaseDN)}, + }, + ResultCode: vldap.LDAPResultSuccess, + }, nil + } + return vldap.ServerSearchResult{}, nil +} + +func TestBasicAuthWithLDAP(t *testing.T) { + Convey("Make a new controller", t, func() { + l := newTestLDAPServer() + l.Start() + defer l.Stop() + config := api.NewConfig() + config.HTTP.Port = SecurePort1 + config.HTTP.Auth = &api.AuthConfig{ + LDAP: &api.LDAPConfig{ + Insecure: true, + Address: LDAPAddress, + Port: LDAPPort, + BindDN: LDAPBindDN, + BindPassword: LDAPBindPassword, + BaseDN: LDAPBaseDN, + UserAttribute: "uid", + }, + } + c := api.NewController(config) + dir, err := ioutil.TempDir("", "oci-repo-test") + if err != nil { + panic(err) + } + defer os.RemoveAll(dir) + c.Config.Storage.RootDirectory = dir + go func() { + // this blocks + if err := c.Run(); err != nil { + return + } + }() + + // wait till ready + for { + _, err := resty.R().Get(BaseURL1) + if err == nil { + break + } + time.Sleep(100 * time.Millisecond) + } + + defer func() { + ctx := context.Background() + _ = c.Server.Shutdown(ctx) + }() + + // without creds, should get access error + resp, err := resty.R().Get(BaseURL1 + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 401) + var e api.Error + err = json.Unmarshal(resp.Body(), &e) + So(err, ShouldBeNil) + + // with creds, should get expected status code + resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(BaseURL1) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 404) + + resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(BaseURL1 + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + }) +} diff --git a/pkg/api/ldap.go b/pkg/api/ldap.go new file mode 100644 index 00000000..a7dad16c --- /dev/null +++ b/pkg/api/ldap.go @@ -0,0 +1,146 @@ +package api + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + + "github.com/anuvu/zot/errors" + "github.com/jtblin/go-ldap-client" + "github.com/rs/zerolog" + goldap "gopkg.in/ldap.v2" +) + +type LDAPClient struct { + ldap.LDAPClient + subtreeSearch bool + clientCAs *x509.CertPool + log zerolog.Logger +} + +// Connect connects to the ldap backend. +func (lc *LDAPClient) Connect() error { + if lc.Conn == nil { + var l *goldap.Conn + var err error + address := fmt.Sprintf("%s:%d", lc.Host, lc.Port) + if !lc.UseSSL { + l, err = goldap.Dial("tcp", address) + if err != nil { + lc.log.Error().Err(err).Str("address", address).Msg("non-TLS connection failed") + return err + } + + // Reconnect with TLS + if !lc.SkipTLS { + config := &tls.Config{ + InsecureSkipVerify: lc.InsecureSkipVerify, // nolint (gosec): InsecureSkipVerify is not true by default + RootCAs: lc.clientCAs, + } + if lc.ClientCertificates != nil && len(lc.ClientCertificates) > 0 { + config.Certificates = lc.ClientCertificates + config.BuildNameToCertificate() + } + err = l.StartTLS(config) + if err != nil { + lc.log.Error().Err(err).Str("address", address).Msg("TLS connection failed") + return err + } + } + } else { + config := &tls.Config{ + InsecureSkipVerify: lc.InsecureSkipVerify, // nolint (gosec): InsecureSkipVerify is not true by default + ServerName: lc.ServerName, + RootCAs: lc.clientCAs, + } + if lc.ClientCertificates != nil && len(lc.ClientCertificates) > 0 { + config.Certificates = lc.ClientCertificates + config.BuildNameToCertificate() + } + l, err = goldap.DialTLS("tcp", address, config) + if err != nil { + lc.log.Error().Err(err).Str("address", address).Msg("TLS connection failed") + return err + } + } + + lc.Conn = l + } + return nil +} + +// Authenticate authenticates the user against the ldap backend. +func (lc *LDAPClient) Authenticate(username, password string) (bool, map[string]string, error) { + err := lc.Connect() + if err != nil { + return false, nil, err + } + + // First bind with a read only user + if lc.BindDN != "" && lc.BindPassword != "" { + err := lc.Conn.Bind(lc.BindDN, lc.BindPassword) + if err != nil { + lc.log.Error().Err(err).Str("bindDN", lc.BindDN).Msg("bind failed") + return false, nil, err + } + } + + attributes := append(lc.Attributes, "dn") + searchScope := goldap.ScopeSingleLevel + if lc.subtreeSearch { + searchScope = goldap.ScopeWholeSubtree + } + // Search for the given username + searchRequest := goldap.NewSearchRequest( + lc.Base, + searchScope, goldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf(lc.UserFilter, username), + attributes, + nil, + ) + + sr, err := lc.Conn.Search(searchRequest) + if err != nil { + fmt.Printf("%v\n", err) + lc.log.Error().Err(err).Str("bindDN", lc.BindDN).Str("username", username). + Str("baseDN", lc.Base).Msg("search failed") + return false, nil, err + } + + if len(sr.Entries) < 1 { + err := errors.ErrBadUser + lc.log.Error().Err(err).Str("bindDN", lc.BindDN).Str("username", username). + Str("baseDN", lc.Base).Msg("entries not found") + return false, nil, err + } + + if len(sr.Entries) > 1 { + err := errors.ErrEntriesExceeded + lc.log.Error().Err(err).Str("bindDN", lc.BindDN).Str("username", username). + Str("baseDN", lc.Base).Msg("too many entries") + return false, nil, err + } + + userDN := sr.Entries[0].DN + user := map[string]string{} + for _, attr := range lc.Attributes { + user[attr] = sr.Entries[0].GetAttributeValue(attr) + } + + // Bind as the user to verify their password + err = lc.Conn.Bind(userDN, password) + if err != nil { + lc.log.Error().Err(err).Str("bindDN", userDN).Msg("user bind failed") + return false, user, err + } + + // Rebind as the read only user for any further queries + if lc.BindDN != "" && lc.BindPassword != "" { + err = lc.Conn.Bind(lc.BindDN, lc.BindPassword) + if err != nil { + return true, user, err + } + } + + return true, user, nil +} diff --git a/pkg/storage/BUILD.bazel b/pkg/storage/BUILD.bazel index fb9a8fe8..7ba1f27b 100644 --- a/pkg/storage/BUILD.bazel +++ b/pkg/storage/BUILD.bazel @@ -21,6 +21,8 @@ go_test( embed = [":go_default_library"], race = "on", deps = [ + "@com_github_opencontainers_go_digest//:go_default_library", + "@com_github_opencontainers_image_spec//specs-go/v1:go_default_library", "@com_github_rs_zerolog//:go_default_library", "@com_github_smartystreets_goconvey//convey:go_default_library", ],