diff --git a/cmd/ctr/commands/resolver.go b/cmd/ctr/commands/resolver.go index 882bbaee3..1d5dcb77a 100644 --- a/cmd/ctr/commands/resolver.go +++ b/cmd/ctr/commands/resolver.go @@ -79,11 +79,6 @@ func GetResolver(ctx gocontext.Context, clicontext *cli.Context) (remotes.Resolv secret = rt } - options.Credentials = func(host string) (string, string, error) { - // Only one host - return username, secret, nil - } - tr := &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{ @@ -104,5 +99,11 @@ func GetResolver(ctx gocontext.Context, clicontext *cli.Context) (remotes.Resolv Transport: tr, } + credentials := func(host string) (string, string, error) { + // Only one host + return username, secret, nil + } + options.Authorizer = docker.NewAuthorizer(options.Client, credentials) + return docker.NewResolver(options), nil } diff --git a/remotes/docker/authorizer.go b/remotes/docker/authorizer.go new file mode 100644 index 000000000..bb691f183 --- /dev/null +++ b/remotes/docker/authorizer.go @@ -0,0 +1,317 @@ +/* + Copyright The containerd 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 docker + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/containerd/containerd/errdefs" + "github.com/containerd/containerd/log" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "golang.org/x/net/context/ctxhttp" +) + +type dockerAuthorizer struct { + credentials func(string) (string, string, error) + + client *http.Client + mu sync.Mutex + + auth map[string]string +} + +// NewAuthorizer creates a Docker authorizer using the provided function to +// get credentials for the token server or basic auth. +func NewAuthorizer(client *http.Client, f func(string) (string, string, error)) Authorizer { + if client == nil { + client = http.DefaultClient + } + return &dockerAuthorizer{ + credentials: f, + client: client, + auth: map[string]string{}, + } +} + +func (a *dockerAuthorizer) Authorize(ctx context.Context, req *http.Request) error { + // TODO: Lookup matching challenge and scope rather than just host + if auth := a.getAuth(req.URL.Host); auth != "" { + req.Header.Set("Authorization", auth) + } + + return nil +} + +func (a *dockerAuthorizer) AddResponses(ctx context.Context, responses []*http.Response) error { + last := responses[len(responses)-1] + host := last.Request.URL.Host + for _, c := range parseAuthHeader(last.Header) { + if c.scheme == bearerAuth { + if err := invalidAuthorization(c, responses); err != nil { + // TODO: Clear token + a.setAuth(host, "") + return err + } + + // TODO(dmcg): Store challenge, not token + // Move token fetching to authorize + if err := a.setTokenAuth(ctx, host, c.parameters); err != nil { + return err + } + + return nil + } else if c.scheme == basicAuth { + // TODO: Resolve credentials on authorize + username, secret, err := a.credentials(host) + if err != nil { + return err + } + if username != "" && secret != "" { + auth := username + ":" + secret + a.setAuth(host, fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(auth)))) + return nil + } + } + } + + return errors.Wrap(errdefs.ErrNotImplemented, "failed to find supported auth scheme") +} + +func (a *dockerAuthorizer) getAuth(host string) string { + a.mu.Lock() + defer a.mu.Unlock() + + return a.auth[host] +} + +func (a *dockerAuthorizer) setAuth(host string, auth string) bool { + a.mu.Lock() + defer a.mu.Unlock() + + changed := a.auth[host] != auth + a.auth[host] = auth + + return changed +} + +func (a *dockerAuthorizer) setTokenAuth(ctx context.Context, host string, params map[string]string) error { + realm, ok := params["realm"] + if !ok { + return errors.New("no realm specified for token auth challenge") + } + + realmURL, err := url.Parse(realm) + if err != nil { + return errors.Wrap(err, "invalid token auth challenge realm") + } + + to := tokenOptions{ + realm: realmURL.String(), + service: params["service"], + } + + to.scopes = getTokenScopes(ctx, params) + if len(to.scopes) == 0 { + return errors.Errorf("no scope specified for token auth challenge") + } + + if a.credentials != nil { + to.username, to.secret, err = a.credentials(host) + if err != nil { + return err + } + } + + var token string + if to.secret != "" { + // Credential information is provided, use oauth POST endpoint + token, err = a.fetchTokenWithOAuth(ctx, to) + if err != nil { + return errors.Wrap(err, "failed to fetch oauth token") + } + } else { + // Do request anonymously + token, err = a.fetchToken(ctx, to) + if err != nil { + return errors.Wrap(err, "failed to fetch anonymous token") + } + } + a.setAuth(host, fmt.Sprintf("Bearer %s", token)) + + return nil +} + +type tokenOptions struct { + realm string + service string + scopes []string + username string + secret string +} + +type postTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + IssuedAt time.Time `json:"issued_at"` + Scope string `json:"scope"` +} + +func (a *dockerAuthorizer) fetchTokenWithOAuth(ctx context.Context, to tokenOptions) (string, error) { + form := url.Values{} + form.Set("scope", strings.Join(to.scopes, " ")) + form.Set("service", to.service) + // TODO: Allow setting client_id + form.Set("client_id", "containerd-client") + + if to.username == "" { + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", to.secret) + } else { + form.Set("grant_type", "password") + form.Set("username", to.username) + form.Set("password", to.secret) + } + + resp, err := ctxhttp.PostForm(ctx, a.client, to.realm, form) + if err != nil { + return "", err + } + defer resp.Body.Close() + + // Registries without support for POST may return 404 for POST /v2/token. + // As of September 2017, GCR is known to return 404. + // As of February 2018, JFrog Artifactory is known to return 401. + if (resp.StatusCode == 405 && to.username != "") || resp.StatusCode == 404 || resp.StatusCode == 401 { + return a.fetchToken(ctx, to) + } else if resp.StatusCode < 200 || resp.StatusCode >= 400 { + b, _ := ioutil.ReadAll(io.LimitReader(resp.Body, 64000)) // 64KB + log.G(ctx).WithFields(logrus.Fields{ + "status": resp.Status, + "body": string(b), + }).Debugf("token request failed") + // TODO: handle error body and write debug output + return "", errors.Errorf("unexpected status: %s", resp.Status) + } + + decoder := json.NewDecoder(resp.Body) + + var tr postTokenResponse + if err = decoder.Decode(&tr); err != nil { + return "", fmt.Errorf("unable to decode token response: %s", err) + } + + return tr.AccessToken, nil +} + +type getTokenResponse struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + IssuedAt time.Time `json:"issued_at"` + RefreshToken string `json:"refresh_token"` +} + +// getToken fetches a token using a GET request +func (a *dockerAuthorizer) fetchToken(ctx context.Context, to tokenOptions) (string, error) { + req, err := http.NewRequest("GET", to.realm, nil) + if err != nil { + return "", err + } + + reqParams := req.URL.Query() + + if to.service != "" { + reqParams.Add("service", to.service) + } + + for _, scope := range to.scopes { + reqParams.Add("scope", scope) + } + + if to.secret != "" { + req.SetBasicAuth(to.username, to.secret) + } + + req.URL.RawQuery = reqParams.Encode() + + resp, err := ctxhttp.Do(ctx, a.client, req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + // TODO: handle error body and write debug output + return "", errors.Errorf("unexpected status: %s", resp.Status) + } + + decoder := json.NewDecoder(resp.Body) + + var tr getTokenResponse + if err = decoder.Decode(&tr); err != nil { + return "", fmt.Errorf("unable to decode token response: %s", err) + } + + // `access_token` is equivalent to `token` and if both are specified + // the choice is undefined. Canonicalize `access_token` by sticking + // things in `token`. + if tr.AccessToken != "" { + tr.Token = tr.AccessToken + } + + if tr.Token == "" { + return "", ErrNoToken + } + + return tr.Token, nil +} + +func invalidAuthorization(c challenge, responses []*http.Response) error { + errStr := c.parameters["error"] + if errStr == "" { + return nil + } + + n := len(responses) + if n == 1 || (n > 1 && !sameRequest(responses[n-2].Request, responses[n-1].Request)) { + return nil + } + + return errors.Wrapf(ErrInvalidAuthorization, "server message: %s", errStr) +} + +func sameRequest(r1, r2 *http.Request) bool { + if r1.Method != r2.Method { + return false + } + if *r1.URL != *r2.URL { + return false + } + return true +} diff --git a/remotes/docker/resolver.go b/remotes/docker/resolver.go index f0a677c83..5cccdecba 100644 --- a/remotes/docker/resolver.go +++ b/remotes/docker/resolver.go @@ -18,18 +18,13 @@ package docker import ( "context" - "encoding/json" - "fmt" - "io" - "io/ioutil" "net/http" "net/url" "path" "strconv" "strings" - "sync" - "time" + "github.com/containerd/containerd/errdefs" "github.com/containerd/containerd/images" "github.com/containerd/containerd/log" "github.com/containerd/containerd/reference" @@ -51,19 +46,37 @@ var ( ErrInvalidAuthorization = errors.New("authorization failed") ) -type dockerResolver struct { - credentials func(string) (string, string, error) - host func(string) (string, error) - plainHTTP bool - client *http.Client - tracker StatusTracker +// Authorizer is used to authorize HTTP requests based on 401 HTTP responses. +// An Authorizer is responsible for caching tokens or credentials used by +// requests. +type Authorizer interface { + // Authorize sets the appropriate `Authorization` header on the given + // request. + // + // If no authorization is found for the request, the request remains + // unmodified. It may also add an `Authorization` header as + // "bearer " + // "basic " + Authorize(context.Context, *http.Request) error + + // AddResponses adds a 401 response for the authorizer to consider when + // authorizing requests. The last response should be unauthorized and + // the previous requests are used to consider redirects and retries + // that may have led to the 401. + // + // If response is not handled, returns `ErrNotImplemented` + AddResponses(context.Context, []*http.Response) error } // ResolverOptions are used to configured a new Docker register resolver type ResolverOptions struct { + // Authorizer is used to authorize registry requests + Authorizer Authorizer + // Credentials provides username and secret given a host. // If username is empty but a secret is given, that secret // is interpretted as a long lived token. + // Deprecated: use Authorizer Credentials func(string) (string, string, error) // Host provides the hostname given a namespace. @@ -89,22 +102,31 @@ func DefaultHost(ns string) (string, error) { return ns, nil } +type dockerResolver struct { + auth Authorizer + host func(string) (string, error) + plainHTTP bool + client *http.Client + tracker StatusTracker +} + // NewResolver returns a new resolver to a Docker registry func NewResolver(options ResolverOptions) remotes.Resolver { - tracker := options.Tracker - if tracker == nil { - tracker = NewInMemoryTracker() + if options.Tracker == nil { + options.Tracker = NewInMemoryTracker() } - host := options.Host - if host == nil { - host = DefaultHost + if options.Host == nil { + options.Host = DefaultHost + } + if options.Authorizer == nil { + options.Authorizer = NewAuthorizer(options.Client, options.Credentials) } return &dockerResolver{ - credentials: options.Credentials, - host: host, - plainHTTP: options.PlainHTTP, - client: options.Client, - tracker: tracker, + auth: options.Authorizer, + host: options.Host, + plainHTTP: options.PlainHTTP, + client: options.Client, + tracker: options.Tracker, } } @@ -272,18 +294,14 @@ type dockerBase struct { refspec reference.Spec base url.URL - client *http.Client - useBasic bool - username, secret string - token string - mu sync.Mutex + client *http.Client + auth Authorizer } func (r *dockerResolver) base(refspec reference.Spec) (*dockerBase, error) { var ( - err error - base url.URL - username, secret string + err error + base url.URL ) host := refspec.Hostname() @@ -300,61 +318,40 @@ func (r *dockerResolver) base(refspec reference.Spec) (*dockerBase, error) { base.Scheme = "http" } - if r.credentials != nil { - username, secret, err = r.credentials(base.Host) - if err != nil { - return nil, err - } - } - prefix := strings.TrimPrefix(refspec.Locator, host+"/") base.Path = path.Join("/v2", prefix) return &dockerBase{ - refspec: refspec, - base: base, - client: r.client, - username: username, - secret: secret, + refspec: refspec, + base: base, + client: r.client, + auth: r.auth, }, nil } -func (r *dockerBase) getToken() string { - r.mu.Lock() - defer r.mu.Unlock() - - return r.token -} - -func (r *dockerBase) setToken(token string) bool { - r.mu.Lock() - defer r.mu.Unlock() - - changed := r.token != token - r.token = token - - return changed -} - func (r *dockerBase) url(ps ...string) string { url := r.base url.Path = path.Join(url.Path, path.Join(ps...)) return url.String() } -func (r *dockerBase) authorize(req *http.Request) { - token := r.getToken() - if r.useBasic { - req.SetBasicAuth(r.username, r.secret) - } else if token != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) +func (r *dockerBase) authorize(ctx context.Context, req *http.Request) error { + // Check if has header for host + if r.auth != nil { + if err := r.auth.Authorize(ctx, req); err != nil { + return err + } } + + return nil } func (r *dockerBase) doRequest(ctx context.Context, req *http.Request) (*http.Response, error) { ctx = log.WithLogger(ctx, log.G(ctx).WithField("url", req.URL.String())) log.G(ctx).WithField("request.headers", req.Header).WithField("request.method", req.Method).Debug("do request") - r.authorize(req) + if err := r.authorize(ctx, req); err != nil { + return nil, errors.Wrap(err, "failed to authorize") + } resp, err := ctxhttp.Do(ctx, r.client, req) if err != nil { return nil, errors.Wrap(err, "failed to do request") @@ -392,23 +389,14 @@ func (r *dockerBase) retryRequest(ctx context.Context, req *http.Request, respon last := responses[len(responses)-1] if last.StatusCode == http.StatusUnauthorized { log.G(ctx).WithField("header", last.Header.Get("WWW-Authenticate")).Debug("Unauthorized") - for _, c := range parseAuthHeader(last.Header) { - if c.scheme == bearerAuth { - if err := invalidAuthorization(c, responses); err != nil { - r.setToken("") - return nil, err - } - if err := r.setTokenAuth(ctx, c.parameters); err != nil { - return nil, err - } - return copyRequest(req) - } else if c.scheme == basicAuth { - if r.username != "" && r.secret != "" { - r.useBasic = true - } + if r.auth != nil { + if err := r.auth.AddResponses(ctx, responses); err == nil { return copyRequest(req) + } else if !errdefs.IsNotImplemented(err) { + return nil, err } } + return nil, nil } else if last.StatusCode == http.StatusMethodNotAllowed && req.Method == http.MethodHead { // Support registries which have not properly implemented the HEAD method for @@ -424,30 +412,6 @@ func (r *dockerBase) retryRequest(ctx context.Context, req *http.Request, respon return nil, nil } -func invalidAuthorization(c challenge, responses []*http.Response) error { - errStr := c.parameters["error"] - if errStr == "" { - return nil - } - - n := len(responses) - if n == 1 || (n > 1 && !sameRequest(responses[n-2].Request, responses[n-1].Request)) { - return nil - } - - return errors.Wrapf(ErrInvalidAuthorization, "server message: %s", errStr) -} - -func sameRequest(r1, r2 *http.Request) bool { - if r1.Method != r2.Method { - return false - } - if *r1.URL != *r2.URL { - return false - } - return true -} - func copyRequest(req *http.Request) (*http.Request, error) { ireq := *req if ireq.GetBody != nil { @@ -459,167 +423,3 @@ func copyRequest(req *http.Request) (*http.Request, error) { } return &ireq, nil } - -func (r *dockerBase) setTokenAuth(ctx context.Context, params map[string]string) error { - realm, ok := params["realm"] - if !ok { - return errors.New("no realm specified for token auth challenge") - } - - realmURL, err := url.Parse(realm) - if err != nil { - return fmt.Errorf("invalid token auth challenge realm: %s", err) - } - - to := tokenOptions{ - realm: realmURL.String(), - service: params["service"], - } - - to.scopes = getTokenScopes(ctx, params) - if len(to.scopes) == 0 { - return errors.Errorf("no scope specified for token auth challenge") - } - - var token string - if r.secret != "" { - // Credential information is provided, use oauth POST endpoint - token, err = r.fetchTokenWithOAuth(ctx, to) - if err != nil { - return errors.Wrap(err, "failed to fetch oauth token") - } - } else { - // Do request anonymously - token, err = r.fetchToken(ctx, to) - if err != nil { - return errors.Wrap(err, "failed to fetch anonymous token") - } - } - r.setToken(token) - - return nil -} - -type tokenOptions struct { - realm string - service string - scopes []string -} - -type postTokenResponse struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` - IssuedAt time.Time `json:"issued_at"` - Scope string `json:"scope"` -} - -func (r *dockerBase) fetchTokenWithOAuth(ctx context.Context, to tokenOptions) (string, error) { - form := url.Values{} - form.Set("scope", strings.Join(to.scopes, " ")) - form.Set("service", to.service) - // TODO: Allow setting client_id - form.Set("client_id", "containerd-dist-tool") - - if r.username == "" { - form.Set("grant_type", "refresh_token") - form.Set("refresh_token", r.secret) - } else { - form.Set("grant_type", "password") - form.Set("username", r.username) - form.Set("password", r.secret) - } - - resp, err := ctxhttp.PostForm(ctx, r.client, to.realm, form) - if err != nil { - return "", err - } - defer resp.Body.Close() - - // Registries without support for POST may return 404 for POST /v2/token. - // As of September 2017, GCR is known to return 404. - // As of February 2018, JFrog Artifactory is known to return 401. - if (resp.StatusCode == 405 && r.username != "") || resp.StatusCode == 404 || resp.StatusCode == 401 { - return r.fetchToken(ctx, to) - } else if resp.StatusCode < 200 || resp.StatusCode >= 400 { - b, _ := ioutil.ReadAll(io.LimitReader(resp.Body, 64000)) // 64KB - log.G(ctx).WithFields(logrus.Fields{ - "status": resp.Status, - "body": string(b), - }).Debugf("token request failed") - // TODO: handle error body and write debug output - return "", errors.Errorf("unexpected status: %s", resp.Status) - } - - decoder := json.NewDecoder(resp.Body) - - var tr postTokenResponse - if err = decoder.Decode(&tr); err != nil { - return "", fmt.Errorf("unable to decode token response: %s", err) - } - - return tr.AccessToken, nil -} - -type getTokenResponse struct { - Token string `json:"token"` - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - IssuedAt time.Time `json:"issued_at"` - RefreshToken string `json:"refresh_token"` -} - -// getToken fetches a token using a GET request -func (r *dockerBase) fetchToken(ctx context.Context, to tokenOptions) (string, error) { - req, err := http.NewRequest("GET", to.realm, nil) - if err != nil { - return "", err - } - - reqParams := req.URL.Query() - - if to.service != "" { - reqParams.Add("service", to.service) - } - - for _, scope := range to.scopes { - reqParams.Add("scope", scope) - } - - if r.secret != "" { - req.SetBasicAuth(r.username, r.secret) - } - - req.URL.RawQuery = reqParams.Encode() - - resp, err := ctxhttp.Do(ctx, r.client, req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 400 { - // TODO: handle error body and write debug output - return "", errors.Errorf("unexpected status: %s", resp.Status) - } - - decoder := json.NewDecoder(resp.Body) - - var tr getTokenResponse - if err = decoder.Decode(&tr); err != nil { - return "", fmt.Errorf("unable to decode token response: %s", err) - } - - // `access_token` is equivalent to `token` and if both are specified - // the choice is undefined. Canonicalize `access_token` by sticking - // things in `token`. - if tr.AccessToken != "" { - tr.Token = tr.AccessToken - } - - if tr.Token == "" { - return "", ErrNoToken - } - - return tr.Token, nil -} diff --git a/remotes/docker/resolver_test.go b/remotes/docker/resolver_test.go index 433a58195..9f9b99ae5 100644 --- a/remotes/docker/resolver_test.go +++ b/remotes/docker/resolver_test.go @@ -69,9 +69,9 @@ func TestBasicResolver(t *testing.T) { }) base, options, close := tlsServer(wrapped) - options.Credentials = func(string) (string, string, error) { + options.Authorizer = NewAuthorizer(options.Client, func(string) (string, string, error) { return "user1", "password1", nil - } + }) return base, options, close } runBasicTest(t, "testname", basicAuth) @@ -215,7 +215,7 @@ func withTokenServer(th http.Handler, creds func(string) (string, string, error) }) base, options, close := tlsServer(wrapped) - options.Credentials = creds + options.Authorizer = NewAuthorizer(options.Client, creds) options.Client.Transport.(*http.Transport).TLSClientConfig.RootCAs.AddCert(cert) return base, options, func() { s.Close()