318 lines
		
	
	
		
			8.0 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			318 lines
		
	
	
		
			8.0 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| /*
 | |
|    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
 | |
| 			return a.setTokenAuth(ctx, host, c.parameters)
 | |
| 		} else if c.scheme == basicAuth && a.credentials != nil {
 | |
| 			// 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.Post(
 | |
| 		ctx, a.client, to.realm,
 | |
| 		"application/x-www-form-urlencoded; charset=utf-8",
 | |
| 		strings.NewReader(form.Encode()),
 | |
| 	)
 | |
| 	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
 | |
| }
 | 
