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 <chenyuzh@vmware.com>
This commit is contained in:
Chenyu Zhang 2022-06-13 10:05:05 +08:00 committed by GitHub
parent e08ad05659
commit 6cfbf76781
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 257 additions and 1 deletions

View File

@ -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
}

View File

@ -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 (

View File

@ -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))
}

View File

@ -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)
}