diff --git a/pkg/cmd/dockerregistry/dockerregistry.go b/pkg/cmd/dockerregistry/dockerregistry.go index fc24424c4eef..49fae3ba7cdf 100644 --- a/pkg/cmd/dockerregistry/dockerregistry.go +++ b/pkg/cmd/dockerregistry/dockerregistry.go @@ -7,6 +7,7 @@ import ( "io" "io/ioutil" "net/http" + "net/url" "os" "time" @@ -46,6 +47,28 @@ func Execute(configFile io.Reader) { log.Fatalf("Error parsing configuration file: %s", err) } + tokenPath := "/openshift/token" + + // If needed, generate and populate the token realm URL in the config. + // Must be done prior to instantiating the app, so our auth provider has the config available. + _, usingOpenShiftAuth := config.Auth[server.OpenShiftAuth] + _, hasTokenRealm := config.Auth[server.OpenShiftAuth][server.TokenRealmKey].(string) + if usingOpenShiftAuth && !hasTokenRealm { + registryHost := os.Getenv(server.DockerRegistryURLEnvVar) + if len(registryHost) == 0 { + log.Fatalf("%s is required", server.DockerRegistryURLEnvVar) + } + tokenURL := &url.URL{Scheme: "https", Host: registryHost, Path: tokenPath} + if len(config.HTTP.TLS.Certificate) == 0 { + tokenURL.Scheme = "http" + } + + if config.Auth[server.OpenShiftAuth] == nil { + config.Auth[server.OpenShiftAuth] = configuration.Parameters{} + } + config.Auth[server.OpenShiftAuth][server.TokenRealmKey] = tokenURL.String() + } + ctx := context.Background() ctx, err = configureLogging(ctx, config) if err != nil { @@ -58,6 +81,11 @@ func Execute(configFile io.Reader) { app := handlers.NewApp(ctx, config) + // Add a token handling endpoint + if usingOpenShiftAuth { + app.NewRoute().Methods("GET").PathPrefix(tokenPath).Handler(server.NewTokenHandler(ctx, server.DefaultRegistryClient)) + } + // TODO add https scheme adminRouter := app.NewRoute().PathPrefix("/admin/").Subrouter() diff --git a/pkg/dockerregistry/server/auth.go b/pkg/dockerregistry/server/auth.go index a391ded9604c..8f98a5d48c6a 100644 --- a/pkg/dockerregistry/server/auth.go +++ b/pkg/dockerregistry/server/auth.go @@ -1,7 +1,6 @@ package server import ( - "encoding/base64" "errors" "fmt" "net/http" @@ -34,6 +33,13 @@ func (d deferredErrors) Empty() bool { return len(d) == 0 } +const ( + OpenShiftAuth = "openshift" + + RealmKey = "realm" + TokenRealmKey = "token-realm" +) + // DefaultRegistryClient is exposed for testing the registry with fake client. var DefaultRegistryClient = NewRegistryClient(clientcmd.NewConfig().BindToFile()) @@ -58,7 +64,7 @@ func (r *RegistryClient) SafeClientConfig() restclient.Config { } func init() { - registryauth.Register("openshift", registryauth.InitFunc(newAccessController)) + registryauth.Register(OpenShiftAuth, registryauth.InitFunc(newAccessController)) } type contextKey int @@ -96,8 +102,9 @@ func DeferredErrorsFrom(ctx context.Context) (deferredErrors, bool) { } type AccessController struct { - realm string - config restclient.Config + realm string + tokenRealm string + config restclient.Config } var _ registryauth.AccessController = &AccessController{} @@ -109,13 +116,20 @@ type authChallenge struct { var _ registryauth.Challenge = &authChallenge{} +type tokenAuthChallenge struct { + realm string + service string + err error +} + +var _ registryauth.Challenge = &tokenAuthChallenge{} + // Errors used and exported by this package. var ( // Challenging errors - ErrTokenRequired = errors.New("authorization header with basic token required") - ErrTokenInvalid = errors.New("failed to decode basic token") - ErrOpenShiftTokenRequired = errors.New("expected bearer token as password for basic token to registry") - ErrOpenShiftAccessDenied = errors.New("access denied") + ErrTokenRequired = errors.New("authorization header required") + ErrTokenInvalid = errors.New("failed to decode credentials") + ErrOpenShiftAccessDenied = errors.New("access denied") // Non-challenging errors ErrNamespaceRequired = errors.New("repository namespace required") @@ -125,12 +139,15 @@ var ( func newAccessController(options map[string]interface{}) (registryauth.AccessController, error) { log.Info("Using Origin Auth handler") - realm, ok := options["realm"].(string) + realm, ok := options[RealmKey].(string) if !ok { // Default to openshift if not present realm = "origin" } - return &AccessController{realm: realm, config: DefaultRegistryClient.SafeClientConfig()}, nil + + tokenRealm, _ := options[TokenRealmKey].(string) + + return &AccessController{realm: realm, tokenRealm: tokenRealm, config: DefaultRegistryClient.SafeClientConfig()}, nil } // Error returns the internal error string for this authChallenge. @@ -149,10 +166,35 @@ func (ac *authChallenge) SetHeaders(w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", str) } +// Error returns the internal error string for this authChallenge. +func (ac *tokenAuthChallenge) Error() string { + return ac.err.Error() +} + +// SetHeaders sets the bearer challenge header on the response. +func (ac *tokenAuthChallenge) SetHeaders(w http.ResponseWriter) { + // WWW-Authenticate response challenge header. + // See https://docs.docker.com/registry/spec/auth/token/#/how-to-authenticate and https://tools.ietf.org/html/rfc6750#section-3 + str := fmt.Sprintf("Bearer realm=%q", ac.realm) + if ac.service != "" { + str += fmt.Sprintf(",service=%q", ac.service) + } + w.Header().Set("WWW-Authenticate", str) +} + // wrapErr wraps errors related to authorization in an authChallenge error that will present a WWW-Authenticate challenge response func (ac *AccessController) wrapErr(err error) error { switch err { - case ErrTokenRequired, ErrTokenInvalid, ErrOpenShiftTokenRequired, ErrOpenShiftAccessDenied: + case ErrTokenRequired: + // Challenge for errors that involve missing tokens + if len(ac.tokenRealm) > 0 { + // Direct to token auth if we've been given a place to direct to + return &tokenAuthChallenge{realm: ac.tokenRealm, err: err} + } else { + // Otherwise just send the basic challenge + return &authChallenge{realm: ac.realm, err: err} + } + case ErrTokenInvalid, ErrOpenShiftAccessDenied: // Challenge for errors that involve tokens or access denied return &authChallenge{realm: ac.realm, err: err} case ErrNamespaceRequired, ErrUnsupportedAction, ErrUnsupportedResource: @@ -175,7 +217,7 @@ func (ac *AccessController) Authorized(ctx context.Context, accessRecords ...reg return nil, ac.wrapErr(err) } - bearerToken, err := getToken(ctx, req) + bearerToken, err := getOpenShiftAPIToken(ctx, req) if err != nil { return nil, ac.wrapErr(err) } @@ -301,26 +343,35 @@ func getNamespaceName(resourceName string) (string, string, error) { return ns, name, nil } -func getToken(ctx context.Context, req *http.Request) (string, error) { +func getOpenShiftAPIToken(ctx context.Context, req *http.Request) (string, error) { + token := "" + authParts := strings.SplitN(req.Header.Get("Authorization"), " ", 2) - if len(authParts) != 2 || strings.ToLower(authParts[0]) != "basic" { + if len(authParts) != 2 { return "", ErrTokenRequired } - basicToken := authParts[1] - payload, err := base64.StdEncoding.DecodeString(basicToken) - if err != nil { - context.GetLogger(ctx).Errorf("Basic token decode failed: %s", err) - return "", ErrTokenInvalid - } + switch strings.ToLower(authParts[0]) { + case "bearer": + // This is either a direct API token, or a token issued by our docker token handler + token = authParts[1] + // Recognize the token issued to anonymous users by our docker token handler + if token == anonymousToken { + token = "" + } + + case "basic": + _, password, ok := req.BasicAuth() + if !ok || len(password) == 0 { + return "", ErrTokenInvalid + } + token = password - osAuthParts := strings.SplitN(string(payload), ":", 2) - if len(osAuthParts) != 2 { - return "", ErrOpenShiftTokenRequired + default: + return "", ErrTokenRequired } - bearerToken := osAuthParts[1] - return bearerToken, nil + return token, nil } func verifyOpenShiftUser(ctx context.Context, client client.UsersInterface) error { diff --git a/pkg/dockerregistry/server/auth_test.go b/pkg/dockerregistry/server/auth_test.go index df274091fdb0..4ebfece3288f 100644 --- a/pkg/dockerregistry/server/auth_test.go +++ b/pkg/dockerregistry/server/auth_test.go @@ -85,16 +85,20 @@ func TestVerifyImageStreamAccess(t *testing.T) { // TestAccessController tests complete integration of the v2 registry auth package. func TestAccessController(t *testing.T) { options := map[string]interface{}{ - "addr": "https://openshift-example.com/osapi", - "apiVersion": latest.Version, + "addr": "https://openshift-example.com/osapi", + "apiVersion": latest.Version, + RealmKey: "myrealm", + TokenRealmKey: "https://tokenrealm.com/token", } tests := map[string]struct { access []auth.Access basicToken string + bearerToken string openshiftResponses []response expectedError error expectedChallenge bool + expectedHeaders http.Header expectedRepoErr string expectedActions []string }{ @@ -103,6 +107,7 @@ func TestAccessController(t *testing.T) { basicToken: "", expectedError: ErrTokenRequired, expectedChallenge: true, + expectedHeaders: http.Header{"Www-Authenticate": []string{`Bearer realm="https://tokenrealm.com/token"`}}, }, "invalid registry token": { access: []auth.Access{{ @@ -111,14 +116,16 @@ func TestAccessController(t *testing.T) { basicToken: "ab-cd-ef-gh", expectedError: ErrTokenInvalid, expectedChallenge: true, + expectedHeaders: http.Header{"Www-Authenticate": []string{`Basic realm=myrealm,error="failed to decode credentials"`}}, }, - "invalid openshift bearer token": { + "invalid openshift basic password": { access: []auth.Access{{ Resource: auth.Resource{Type: "repository"}, }}, basicToken: "abcdefgh", - expectedError: ErrOpenShiftTokenRequired, + expectedError: ErrTokenInvalid, expectedChallenge: true, + expectedHeaders: http.Header{"Www-Authenticate": []string{`Basic realm=myrealm,error="failed to decode credentials"`}}, }, "valid openshift token but invalid namespace": { access: []auth.Access{{ @@ -155,7 +162,8 @@ func TestAccessController(t *testing.T) { openshiftResponses: []response{{403, ""}}, expectedError: ErrOpenShiftAccessDenied, expectedChallenge: true, - expectedActions: []string{"GET /oapi/v1/users/~"}, + expectedHeaders: http.Header{"Www-Authenticate": []string{`Basic realm=myrealm,error="access denied"`}}, + expectedActions: []string{"GET /oapi/v1/users/~ (Authorization=Bearer awesome)"}, }, "docker login with valid openshift creds": { basicToken: "dXNyMTphd2Vzb21l", @@ -164,7 +172,7 @@ func TestAccessController(t *testing.T) { }, expectedError: nil, expectedChallenge: false, - expectedActions: []string{"GET /oapi/v1/users/~"}, + expectedActions: []string{"GET /oapi/v1/users/~ (Authorization=Bearer awesome)"}, }, "error running subject access review": { access: []auth.Access{{ @@ -180,7 +188,7 @@ func TestAccessController(t *testing.T) { }, expectedError: errors.New("an error on the server has prevented the request from succeeding (post localSubjectAccessReviews)"), expectedChallenge: false, - expectedActions: []string{"POST /oapi/v1/namespaces/foo/localsubjectaccessreviews"}, + expectedActions: []string{"POST /oapi/v1/namespaces/foo/localsubjectaccessreviews (Authorization=Bearer awesome)"}, }, "valid openshift token but token not scoped for the given repo operation": { access: []auth.Access{{ @@ -196,7 +204,8 @@ func TestAccessController(t *testing.T) { }, expectedError: ErrOpenShiftAccessDenied, expectedChallenge: true, - expectedActions: []string{"POST /oapi/v1/namespaces/foo/localsubjectaccessreviews"}, + expectedHeaders: http.Header{"Www-Authenticate": []string{`Basic realm=myrealm,error="access denied"`}}, + expectedActions: []string{"POST /oapi/v1/namespaces/foo/localsubjectaccessreviews (Authorization=Bearer awesome)"}, }, "partially valid openshift token": { // Check all the different resource-type/verb combinations we allow to make sure they validate and continue to validate remaining Resource requests @@ -215,11 +224,12 @@ func TestAccessController(t *testing.T) { }, expectedError: ErrOpenShiftAccessDenied, expectedChallenge: true, + expectedHeaders: http.Header{"Www-Authenticate": []string{`Basic realm=myrealm,error="access denied"`}}, expectedActions: []string{ - "POST /oapi/v1/namespaces/foo/localsubjectaccessreviews", - "POST /oapi/v1/namespaces/bar/localsubjectaccessreviews", - "POST /oapi/v1/subjectaccessreviews", - "POST /oapi/v1/namespaces/baz/localsubjectaccessreviews", + "POST /oapi/v1/namespaces/foo/localsubjectaccessreviews (Authorization=Bearer awesome)", + "POST /oapi/v1/namespaces/bar/localsubjectaccessreviews (Authorization=Bearer awesome)", + "POST /oapi/v1/subjectaccessreviews (Authorization=Bearer awesome)", + "POST /oapi/v1/namespaces/baz/localsubjectaccessreviews (Authorization=Bearer awesome)", }, }, "deferred cross-mount error": { @@ -241,9 +251,9 @@ func TestAccessController(t *testing.T) { expectedChallenge: false, expectedRepoErr: "fromrepo/bbb", expectedActions: []string{ - "POST /oapi/v1/namespaces/pushrepo/localsubjectaccessreviews", - "POST /oapi/v1/namespaces/pushrepo/localsubjectaccessreviews", - "POST /oapi/v1/namespaces/fromrepo/localsubjectaccessreviews", + "POST /oapi/v1/namespaces/pushrepo/localsubjectaccessreviews (Authorization=Bearer awesome)", + "POST /oapi/v1/namespaces/pushrepo/localsubjectaccessreviews (Authorization=Bearer awesome)", + "POST /oapi/v1/namespaces/fromrepo/localsubjectaccessreviews (Authorization=Bearer awesome)", }, }, "valid openshift token": { @@ -260,7 +270,23 @@ func TestAccessController(t *testing.T) { }, expectedError: nil, expectedChallenge: false, - expectedActions: []string{"POST /oapi/v1/namespaces/foo/localsubjectaccessreviews"}, + expectedActions: []string{"POST /oapi/v1/namespaces/foo/localsubjectaccessreviews (Authorization=Bearer awesome)"}, + }, + "valid anonymous token": { + access: []auth.Access{{ + Resource: auth.Resource{ + Type: "repository", + Name: "foo/bar", + }, + Action: "pull", + }}, + bearerToken: "anonymous", + openshiftResponses: []response{ + {200, runtime.EncodeOrDie(kapi.Codecs.LegacyCodec(registered.GroupOrDie(kapi.GroupName).GroupVersions[0]), &api.SubjectAccessReviewResponse{Namespace: "foo", Allowed: true, Reason: "authorized!"})}, + }, + expectedError: nil, + expectedChallenge: false, + expectedActions: []string{"POST /oapi/v1/namespaces/foo/localsubjectaccessreviews (Authorization=)"}, }, "pruning": { access: []auth.Access{ @@ -285,7 +311,7 @@ func TestAccessController(t *testing.T) { expectedError: nil, expectedChallenge: false, expectedActions: []string{ - "POST /oapi/v1/subjectaccessreviews", + "POST /oapi/v1/subjectaccessreviews (Authorization=Bearer awesome)", }, }, } @@ -299,6 +325,9 @@ func TestAccessController(t *testing.T) { if len(test.basicToken) > 0 { req.Header.Set("Authorization", fmt.Sprintf("Basic %s", test.basicToken)) } + if len(test.bearerToken) > 0 { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", test.bearerToken)) + } ctx := context.WithValue(context.Background(), "http.request", req) server, actions := simulateOpenShiftMaster(test.openshiftResponses) @@ -351,11 +380,19 @@ func TestAccessController(t *testing.T) { } } } else { - _, isChallenge := err.(auth.Challenge) + challengeErr, isChallenge := err.(auth.Challenge) if test.expectedChallenge != isChallenge { t.Errorf("%s: expected challenge=%v, accessController returned challenge=%v", k, test.expectedChallenge, isChallenge) continue } + if isChallenge { + recorder := httptest.NewRecorder() + challengeErr.SetHeaders(recorder) + if !reflect.DeepEqual(recorder.HeaderMap, test.expectedHeaders) { + t.Errorf("%s: expected headers\n%#v\ngot\n%#v", k, test.expectedHeaders, recorder.HeaderMap) + continue + } + } if err.Error() != test.expectedError.Error() { t.Errorf("%s: accessController did not get expected error - got %s - expected %s", k, err, test.expectedError) @@ -386,7 +423,7 @@ func simulateOpenShiftMaster(responses []response) (*httptest.Server, *[]string) w.Header().Set("Content-Type", "application/json") w.WriteHeader(response.code) fmt.Fprintln(w, response.body) - actions = append(actions, r.Method+" "+r.URL.Path) + actions = append(actions, fmt.Sprintf(`%s %s (Authorization=%s)`, r.Method, r.URL.Path, r.Header.Get("Authorization"))) })) return server, &actions } diff --git a/pkg/dockerregistry/server/token.go b/pkg/dockerregistry/server/token.go new file mode 100644 index 000000000000..465f0da1cda0 --- /dev/null +++ b/pkg/dockerregistry/server/token.go @@ -0,0 +1,86 @@ +package server + +import ( + "encoding/json" + "net/http" + + context "github.com/docker/distribution/context" + + "k8s.io/kubernetes/pkg/client/restclient" + + "github.com/openshift/origin/pkg/client" +) + +type tokenHandler struct { + ctx context.Context + anonymousConfig restclient.Config +} + +// NewTokenHandler returns a handler that implements the docker token protocol +func NewTokenHandler(ctx context.Context, client *RegistryClient) http.Handler { + return &tokenHandler{ + ctx: ctx, + anonymousConfig: client.SafeClientConfig(), + } +} + +// bearer token issued to token requests that present no credentials +// recognized by the openshift auth provider as identifying the anonymous user +const anonymousToken = "anonymous" + +func (t *tokenHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + ctx := context.WithRequest(t.ctx, req) + + // If no authorization is provided, return a token the auth provider will treat as an anonymous user + if len(req.Header.Get("Authorization")) == 0 { + context.GetRequestLogger(ctx).Debugf("anonymous token request") + t.writeToken(anonymousToken, w, req) + return + } + + // use the password as the token + _, token, ok := req.BasicAuth() + if !ok { + context.GetRequestLogger(ctx).Debugf("no basic auth credentials provided") + t.writeUnauthorized(w, req) + return + } + + // TODO: if this doesn't validate as an API token, attempt to obtain an API token using the given username/password + copied := t.anonymousConfig + copied.BearerToken = token + osClient, err := client.New(&copied) + if err != nil { + context.GetRequestLogger(ctx).Errorf("error building client: %v", err) + t.writeError(w, req) + return + } + + if _, err := osClient.Users().Get("~"); err != nil { + context.GetRequestLogger(ctx).Debugf("invalid token: %v", err) + t.writeUnauthorized(w, req) + return + } + + t.writeToken(token, w, req) +} + +func (t *tokenHandler) writeError(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"error": "invalid_request"}) +} + +func (t *tokenHandler) writeToken(token string, w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "token": token, + "access_token": token, + }) +} + +func (t *tokenHandler) writeUnauthorized(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) +} diff --git a/test/end-to-end/core.sh b/test/end-to-end/core.sh index 951f31093b44..254d1d947073 100755 --- a/test/end-to-end/core.sh +++ b/test/end-to-end/core.sh @@ -191,6 +191,31 @@ echo "[INFO] Docker login as pusher to ${DOCKER_REGISTRY}" os::cmd::expect_success "docker login -u e2e-user -p ${pusher_token} -e pusher@openshift.com ${DOCKER_REGISTRY}" echo "[INFO] Docker login successful" +# Test anonymous registry access +# setup: log out of docker, log into openshift as e2e-user to run policy commands, tag image to use for push attempts +os::cmd::expect_success 'oc login -u e2e-user' +os::cmd::expect_success 'docker pull busybox' +os::cmd::expect_success "docker tag -f busybox ${DOCKER_REGISTRY}/missing/image:tag" +os::cmd::expect_success "docker logout ${DOCKER_REGISTRY}" +# unauthorized pulls return "not found" errors to anonymous users, regardless of backing data +os::cmd::expect_failure_and_text "docker pull ${DOCKER_REGISTRY}/missing/image:tag" "not found" +os::cmd::expect_failure_and_text "docker pull ${DOCKER_REGISTRY}/custom/cross:namespace-pull" "not found" +os::cmd::expect_failure_and_text "docker pull ${DOCKER_REGISTRY}/custom/cross:namespace-pull-id" "not found" +# test anonymous pulls +os::cmd::expect_success 'oc policy add-role-to-user system:image-puller system:anonymous -n custom' +os::cmd::try_until_text 'oc policy who-can get imagestreams/layers -n custom' 'system:anonymous' +os::cmd::expect_success "docker pull ${DOCKER_REGISTRY}/custom/cross:namespace-pull" +os::cmd::expect_success "docker pull ${DOCKER_REGISTRY}/custom/cross:namespace-pull-id" +# unauthorized pushes return authorization errors, regardless of backing data +os::cmd::expect_failure_and_text "docker push ${DOCKER_REGISTRY}/missing/image:tag" "authentication required" +os::cmd::expect_failure_and_text "docker push ${DOCKER_REGISTRY}/custom/cross:namespace-pull" "authentication required" +os::cmd::expect_failure_and_text "docker push ${DOCKER_REGISTRY}/custom/cross:namespace-pull-id" "authentication required" +# test anonymous pushes +os::cmd::expect_success 'oc policy add-role-to-user system:image-pusher system:anonymous -n custom' +os::cmd::try_until_text 'oc policy who-can update imagestreams/layers -n custom' 'system:anonymous' +os::cmd::expect_success "docker push ${DOCKER_REGISTRY}/custom/cross:namespace-pull" +os::cmd::expect_success "docker push ${DOCKER_REGISTRY}/custom/cross:namespace-pull-id" + # log back into docker as e2e-user again os::cmd::expect_success "docker login -u e2e-user -p ${e2e_user_token} -e e2e-user@openshift.com ${DOCKER_REGISTRY}"