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