Skip to content

Commit 43acab8

Browse files
authored
Merge pull request #2690 from dmcgowan/resolver-updates
Update Docker resolver to pass in Authorizer interface
2 parents 90b7b88 + a6198b7 commit 43acab8

4 files changed

Lines changed: 395 additions & 277 deletions

File tree

cmd/ctr/commands/resolver.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,6 @@ func GetResolver(ctx gocontext.Context, clicontext *cli.Context) (remotes.Resolv
7979
secret = rt
8080
}
8181

82-
options.Credentials = func(host string) (string, string, error) {
83-
// Only one host
84-
return username, secret, nil
85-
}
86-
8782
tr := &http.Transport{
8883
Proxy: http.ProxyFromEnvironment,
8984
DialContext: (&net.Dialer{
@@ -104,5 +99,11 @@ func GetResolver(ctx gocontext.Context, clicontext *cli.Context) (remotes.Resolv
10499
Transport: tr,
105100
}
106101

102+
credentials := func(host string) (string, string, error) {
103+
// Only one host
104+
return username, secret, nil
105+
}
106+
options.Authorizer = docker.NewAuthorizer(options.Client, credentials)
107+
107108
return docker.NewResolver(options), nil
108109
}

remotes/docker/authorizer.go

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package docker
18+
19+
import (
20+
"context"
21+
"encoding/base64"
22+
"encoding/json"
23+
"fmt"
24+
"io"
25+
"io/ioutil"
26+
"net/http"
27+
"net/url"
28+
"strings"
29+
"sync"
30+
"time"
31+
32+
"github.com/containerd/containerd/errdefs"
33+
"github.com/containerd/containerd/log"
34+
"github.com/pkg/errors"
35+
"github.com/sirupsen/logrus"
36+
"golang.org/x/net/context/ctxhttp"
37+
)
38+
39+
type dockerAuthorizer struct {
40+
credentials func(string) (string, string, error)
41+
42+
client *http.Client
43+
mu sync.Mutex
44+
45+
auth map[string]string
46+
}
47+
48+
// NewAuthorizer creates a Docker authorizer using the provided function to
49+
// get credentials for the token server or basic auth.
50+
func NewAuthorizer(client *http.Client, f func(string) (string, string, error)) Authorizer {
51+
if client == nil {
52+
client = http.DefaultClient
53+
}
54+
return &dockerAuthorizer{
55+
credentials: f,
56+
client: client,
57+
auth: map[string]string{},
58+
}
59+
}
60+
61+
func (a *dockerAuthorizer) Authorize(ctx context.Context, req *http.Request) error {
62+
// TODO: Lookup matching challenge and scope rather than just host
63+
if auth := a.getAuth(req.URL.Host); auth != "" {
64+
req.Header.Set("Authorization", auth)
65+
}
66+
67+
return nil
68+
}
69+
70+
func (a *dockerAuthorizer) AddResponses(ctx context.Context, responses []*http.Response) error {
71+
last := responses[len(responses)-1]
72+
host := last.Request.URL.Host
73+
for _, c := range parseAuthHeader(last.Header) {
74+
if c.scheme == bearerAuth {
75+
if err := invalidAuthorization(c, responses); err != nil {
76+
// TODO: Clear token
77+
a.setAuth(host, "")
78+
return err
79+
}
80+
81+
// TODO(dmcg): Store challenge, not token
82+
// Move token fetching to authorize
83+
if err := a.setTokenAuth(ctx, host, c.parameters); err != nil {
84+
return err
85+
}
86+
87+
return nil
88+
} else if c.scheme == basicAuth {
89+
// TODO: Resolve credentials on authorize
90+
username, secret, err := a.credentials(host)
91+
if err != nil {
92+
return err
93+
}
94+
if username != "" && secret != "" {
95+
auth := username + ":" + secret
96+
a.setAuth(host, fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(auth))))
97+
return nil
98+
}
99+
}
100+
}
101+
102+
return errors.Wrap(errdefs.ErrNotImplemented, "failed to find supported auth scheme")
103+
}
104+
105+
func (a *dockerAuthorizer) getAuth(host string) string {
106+
a.mu.Lock()
107+
defer a.mu.Unlock()
108+
109+
return a.auth[host]
110+
}
111+
112+
func (a *dockerAuthorizer) setAuth(host string, auth string) bool {
113+
a.mu.Lock()
114+
defer a.mu.Unlock()
115+
116+
changed := a.auth[host] != auth
117+
a.auth[host] = auth
118+
119+
return changed
120+
}
121+
122+
func (a *dockerAuthorizer) setTokenAuth(ctx context.Context, host string, params map[string]string) error {
123+
realm, ok := params["realm"]
124+
if !ok {
125+
return errors.New("no realm specified for token auth challenge")
126+
}
127+
128+
realmURL, err := url.Parse(realm)
129+
if err != nil {
130+
return errors.Wrap(err, "invalid token auth challenge realm")
131+
}
132+
133+
to := tokenOptions{
134+
realm: realmURL.String(),
135+
service: params["service"],
136+
}
137+
138+
to.scopes = getTokenScopes(ctx, params)
139+
if len(to.scopes) == 0 {
140+
return errors.Errorf("no scope specified for token auth challenge")
141+
}
142+
143+
if a.credentials != nil {
144+
to.username, to.secret, err = a.credentials(host)
145+
if err != nil {
146+
return err
147+
}
148+
}
149+
150+
var token string
151+
if to.secret != "" {
152+
// Credential information is provided, use oauth POST endpoint
153+
token, err = a.fetchTokenWithOAuth(ctx, to)
154+
if err != nil {
155+
return errors.Wrap(err, "failed to fetch oauth token")
156+
}
157+
} else {
158+
// Do request anonymously
159+
token, err = a.fetchToken(ctx, to)
160+
if err != nil {
161+
return errors.Wrap(err, "failed to fetch anonymous token")
162+
}
163+
}
164+
a.setAuth(host, fmt.Sprintf("Bearer %s", token))
165+
166+
return nil
167+
}
168+
169+
type tokenOptions struct {
170+
realm string
171+
service string
172+
scopes []string
173+
username string
174+
secret string
175+
}
176+
177+
type postTokenResponse struct {
178+
AccessToken string `json:"access_token"`
179+
RefreshToken string `json:"refresh_token"`
180+
ExpiresIn int `json:"expires_in"`
181+
IssuedAt time.Time `json:"issued_at"`
182+
Scope string `json:"scope"`
183+
}
184+
185+
func (a *dockerAuthorizer) fetchTokenWithOAuth(ctx context.Context, to tokenOptions) (string, error) {
186+
form := url.Values{}
187+
form.Set("scope", strings.Join(to.scopes, " "))
188+
form.Set("service", to.service)
189+
// TODO: Allow setting client_id
190+
form.Set("client_id", "containerd-client")
191+
192+
if to.username == "" {
193+
form.Set("grant_type", "refresh_token")
194+
form.Set("refresh_token", to.secret)
195+
} else {
196+
form.Set("grant_type", "password")
197+
form.Set("username", to.username)
198+
form.Set("password", to.secret)
199+
}
200+
201+
resp, err := ctxhttp.PostForm(ctx, a.client, to.realm, form)
202+
if err != nil {
203+
return "", err
204+
}
205+
defer resp.Body.Close()
206+
207+
// Registries without support for POST may return 404 for POST /v2/token.
208+
// As of September 2017, GCR is known to return 404.
209+
// As of February 2018, JFrog Artifactory is known to return 401.
210+
if (resp.StatusCode == 405 && to.username != "") || resp.StatusCode == 404 || resp.StatusCode == 401 {
211+
return a.fetchToken(ctx, to)
212+
} else if resp.StatusCode < 200 || resp.StatusCode >= 400 {
213+
b, _ := ioutil.ReadAll(io.LimitReader(resp.Body, 64000)) // 64KB
214+
log.G(ctx).WithFields(logrus.Fields{
215+
"status": resp.Status,
216+
"body": string(b),
217+
}).Debugf("token request failed")
218+
// TODO: handle error body and write debug output
219+
return "", errors.Errorf("unexpected status: %s", resp.Status)
220+
}
221+
222+
decoder := json.NewDecoder(resp.Body)
223+
224+
var tr postTokenResponse
225+
if err = decoder.Decode(&tr); err != nil {
226+
return "", fmt.Errorf("unable to decode token response: %s", err)
227+
}
228+
229+
return tr.AccessToken, nil
230+
}
231+
232+
type getTokenResponse struct {
233+
Token string `json:"token"`
234+
AccessToken string `json:"access_token"`
235+
ExpiresIn int `json:"expires_in"`
236+
IssuedAt time.Time `json:"issued_at"`
237+
RefreshToken string `json:"refresh_token"`
238+
}
239+
240+
// getToken fetches a token using a GET request
241+
func (a *dockerAuthorizer) fetchToken(ctx context.Context, to tokenOptions) (string, error) {
242+
req, err := http.NewRequest("GET", to.realm, nil)
243+
if err != nil {
244+
return "", err
245+
}
246+
247+
reqParams := req.URL.Query()
248+
249+
if to.service != "" {
250+
reqParams.Add("service", to.service)
251+
}
252+
253+
for _, scope := range to.scopes {
254+
reqParams.Add("scope", scope)
255+
}
256+
257+
if to.secret != "" {
258+
req.SetBasicAuth(to.username, to.secret)
259+
}
260+
261+
req.URL.RawQuery = reqParams.Encode()
262+
263+
resp, err := ctxhttp.Do(ctx, a.client, req)
264+
if err != nil {
265+
return "", err
266+
}
267+
defer resp.Body.Close()
268+
269+
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
270+
// TODO: handle error body and write debug output
271+
return "", errors.Errorf("unexpected status: %s", resp.Status)
272+
}
273+
274+
decoder := json.NewDecoder(resp.Body)
275+
276+
var tr getTokenResponse
277+
if err = decoder.Decode(&tr); err != nil {
278+
return "", fmt.Errorf("unable to decode token response: %s", err)
279+
}
280+
281+
// `access_token` is equivalent to `token` and if both are specified
282+
// the choice is undefined. Canonicalize `access_token` by sticking
283+
// things in `token`.
284+
if tr.AccessToken != "" {
285+
tr.Token = tr.AccessToken
286+
}
287+
288+
if tr.Token == "" {
289+
return "", ErrNoToken
290+
}
291+
292+
return tr.Token, nil
293+
}
294+
295+
func invalidAuthorization(c challenge, responses []*http.Response) error {
296+
errStr := c.parameters["error"]
297+
if errStr == "" {
298+
return nil
299+
}
300+
301+
n := len(responses)
302+
if n == 1 || (n > 1 && !sameRequest(responses[n-2].Request, responses[n-1].Request)) {
303+
return nil
304+
}
305+
306+
return errors.Wrapf(ErrInvalidAuthorization, "server message: %s", errStr)
307+
}
308+
309+
func sameRequest(r1, r2 *http.Request) bool {
310+
if r1.Method != r2.Method {
311+
return false
312+
}
313+
if *r1.URL != *r2.URL {
314+
return false
315+
}
316+
return true
317+
}

0 commit comments

Comments
 (0)