From 6cfbf7678104df1290e6b64d7450a9ecbcfe461b Mon Sep 17 00:00:00 2001 From: Chenyu Zhang Date: Mon, 13 Jun 2022 10:05:05 +0800 Subject: [PATCH] fix(replication): azurecr replication with token (#16888) (#16947) Fix azurecr use ACR token failed to list tags, the root cause is the scope action of acr token is 'metadata_read' not 'pull' when list v2 tags API. Signed-off-by: chlins --- src/pkg/reg/adapter/azurecr/adapter.go | 16 +- src/pkg/reg/adapter/azurecr/adapter_test.go | 14 ++ src/pkg/reg/adapter/azurecr/auth.go | 164 ++++++++++++++++++++ src/pkg/reg/adapter/azurecr/auth_test.go | 64 ++++++++ 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 src/pkg/reg/adapter/azurecr/auth.go create mode 100644 src/pkg/reg/adapter/azurecr/auth_test.go diff --git a/src/pkg/reg/adapter/azurecr/adapter.go b/src/pkg/reg/adapter/azurecr/adapter.go index b248b4b3a..ec3b817bc 100644 --- a/src/pkg/reg/adapter/azurecr/adapter.go +++ b/src/pkg/reg/adapter/azurecr/adapter.go @@ -1,3 +1,17 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package azurecr import ( @@ -17,7 +31,7 @@ func init() { func newAdapter(registry *model.Registry) (adp.Adapter, error) { return &adapter{ - Adapter: native.NewAdapter(registry), + Adapter: native.NewAdapterWithAuthorizer(registry, newAuthorizer(registry)), }, nil } diff --git a/src/pkg/reg/adapter/azurecr/adapter_test.go b/src/pkg/reg/adapter/azurecr/adapter_test.go index a33a6ad30..dfb7d7860 100644 --- a/src/pkg/reg/adapter/azurecr/adapter_test.go +++ b/src/pkg/reg/adapter/azurecr/adapter_test.go @@ -1,3 +1,17 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package azurecr import ( diff --git a/src/pkg/reg/adapter/azurecr/auth.go b/src/pkg/reg/adapter/azurecr/auth.go new file mode 100644 index 000000000..8dc1026c1 --- /dev/null +++ b/src/pkg/reg/adapter/azurecr/auth.go @@ -0,0 +1,164 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package azurecr + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + + "github.com/docker/distribution/registry/client/auth/challenge" + commonhttp "github.com/goharbor/harbor/src/common/http" + "github.com/goharbor/harbor/src/lib" + "github.com/goharbor/harbor/src/lib/errors" + "github.com/goharbor/harbor/src/pkg/reg/model" + "github.com/goharbor/harbor/src/pkg/registry/auth" +) + +var ( + // scopeActionMetadataRead represents metadata_read action + scopeActionMetadataRead = "metadata_read" + // scopeTypeRepository represents repository resource + scopeTypeRepository = "repository" +) + +var _ lib.Authorizer = &authorizer{} + +type token struct { + AccessToken string `json:"access_token"` +} + +// authorizer is a customize authorizer for azurecr adapter which +// inherits lib authorizer. +type authorizer struct { + registry *model.Registry + innerAuthorizer lib.Authorizer + client *http.Client +} + +func newAuthorizer(registry *model.Registry) *authorizer { + var username, password string + if registry.Credential != nil { + username = registry.Credential.AccessKey + password = registry.Credential.AccessSecret + } + + return &authorizer{ + registry: registry, + innerAuthorizer: auth.NewAuthorizer(username, password, registry.Insecure), + client: &http.Client{Transport: commonhttp.GetHTTPTransport(commonhttp.WithInsecure(registry.Insecure))}, + } +} + +func (a *authorizer) Modify(req *http.Request) error { + if !isTagList(req.URL) { + // pass through non tag list api + return a.innerAuthorizer.Modify(req) + } + + // tag list api should fetch token + url, err := a.buildTokenAPI(req.URL) + if err != nil { + return err + } + + tokenReq, err := http.NewRequest(http.MethodGet, url.String(), nil) + if err != nil { + return nil + } + + if a.registry.Credential != nil { + tokenReq.SetBasicAuth(a.registry.Credential.AccessKey, a.registry.Credential.AccessSecret) + } + + resp, err := a.client.Do(tokenReq) + if err != nil { + return err + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + var tk token + if err = json.Unmarshal(body, &tk); err != nil { + return err + } + + if tk.AccessToken != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", tk.AccessToken)) + } + + return nil +} + +// buildTokenAPI builds token request API path. +func (a *authorizer) buildTokenAPI(u *url.URL) (*url.URL, error) { + v2URL, err := url.Parse(u.Scheme + "://" + u.Host + "/v2/") + if err != nil { + return nil, err + } + + resp, err := a.client.Get(v2URL.String()) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + challenges := challenge.ResponseChallenges(resp) + if len(challenges) == 0 { + return nil, errors.New("invalid response challenges") + } + cm := map[string]challenge.Challenge{} + for _, challenge := range challenges { + cm[challenge.Scheme] = challenge + } + + challenge, exist := cm["bearer"] + if !exist { + return nil, errors.New("no bearer challenge found") + } + + tokenURL, err := url.Parse(challenge.Parameters["realm"]) + if err != nil { + return nil, err + } + + query := tokenURL.Query() + query.Add("service", challenge.Parameters["service"]) + + var repository string + if subs := lib.V2TagListURLRe.FindStringSubmatch(u.Path); len(subs) >= 2 { + // tag + repository = subs[1] + } + + if repository == "" { + return nil, errors.Errorf("invalid repository name, url: %s", u.String()) + } + + query.Add("scope", fmt.Sprintf("%s:%s:%s", scopeTypeRepository, repository, scopeActionMetadataRead)) + tokenURL.RawQuery = query.Encode() + return tokenURL, nil +} + +// isTagList checks the request whether tag list API. +func isTagList(u *url.URL) bool { + return lib.V2TagListURLRe.Match([]byte(u.Path)) +} diff --git a/src/pkg/reg/adapter/azurecr/auth_test.go b/src/pkg/reg/adapter/azurecr/auth_test.go new file mode 100644 index 000000000..3eb75448a --- /dev/null +++ b/src/pkg/reg/adapter/azurecr/auth_test.go @@ -0,0 +1,64 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package azurecr + +import ( + "fmt" + "net/http" + "testing" + + "github.com/goharbor/harbor/src/pkg/reg/model" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +var ( + mockURL = "https://test.azurecr.io" + mockUsername = "user" + mockPassword = "password" + mockToken = "test-token" +) + +func TestAuth(t *testing.T) { + // mock server + defer gock.Off() + // mock v2 API + gock.New(mockURL). + Get("/v2/"). + Reply(401). + SetHeader("Www-Authenticate", `Bearer realm="https://test.azurecr.io/oauth2/token",service="test.azurecr.io"`) + // mock token API + gock.New(mockURL). + Get("/oauth2/token"). + MatchParam("service", "test.azurecr.io"). + MatchParam("scope", `repository:library/busybox:metadata_read`). + BasicAuth(mockUsername, mockPassword). + Reply(200). + JSON(fmt.Sprintf(`{"access_token": "%s"}`, mockToken)) + + a := newAuthorizer(&model.Registry{URL: mockURL, Credential: &model.Credential{AccessKey: mockUsername, AccessSecret: mockPassword}}) + ct := &http.Client{} + a.client = ct + gock.InterceptClient(ct) + + // test authorize + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s%s", mockURL, "/v2/library/busybox/tags/list"), nil) + assert.NoError(t, err) + err = a.Modify(req) + assert.NoError(t, err) + // check whether set bearer token + tokenHeader := req.Header.Get("Authorization") + assert.Equal(t, fmt.Sprintf("Bearer %s", mockToken), tokenHeader) +}