
Currently the user agent is only being used on the initial resolve request, then switching to the default user agent. This ensures the correct user agent is always used. There is a larger fix in progress which does this is a cleaner way, but the scope of this change is fixing the user agent issue. Signed-off-by: Derek McGowan <derek@mcgstyle.net>
330 lines
8.3 KiB
Go
330 lines
8.3 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/containerd/containerd/version"
|
|
"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
|
|
ua string
|
|
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,
|
|
ua: "containerd/" + version.Version,
|
|
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)
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", to.realm, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
|
|
if a.ua != "" {
|
|
req.Header.Set("User-Agent", a.ua)
|
|
}
|
|
|
|
resp, err := ctxhttp.Do(ctx, a.client, req)
|
|
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
|
|
}
|
|
|
|
if a.ua != "" {
|
|
req.Header.Set("User-Agent", a.ua)
|
|
}
|
|
|
|
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
|
|
}
|