1package auth 2 3import ( 4 "encoding/json" 5 "errors" 6 "fmt" 7 "net/http" 8 "net/url" 9 "strings" 10 "sync" 11 "time" 12 13 "github.com/docker/distribution/registry/client" 14 "github.com/docker/distribution/registry/client/auth/challenge" 15 "github.com/docker/distribution/registry/client/transport" 16) 17 18var ( 19 // ErrNoBasicAuthCredentials is returned if a request can't be authorized with 20 // basic auth due to lack of credentials. 21 ErrNoBasicAuthCredentials = errors.New("no basic auth credentials") 22 23 // ErrNoToken is returned if a request is successful but the body does not 24 // contain an authorization token. 25 ErrNoToken = errors.New("authorization server did not include a token in the response") 26) 27 28const defaultClientID = "registry-client" 29 30// AuthenticationHandler is an interface for authorizing a request from 31// params from a "WWW-Authenicate" header for a single scheme. 32type AuthenticationHandler interface { 33 // Scheme returns the scheme as expected from the "WWW-Authenicate" header. 34 Scheme() string 35 36 // AuthorizeRequest adds the authorization header to a request (if needed) 37 // using the parameters from "WWW-Authenticate" method. The parameters 38 // values depend on the scheme. 39 AuthorizeRequest(req *http.Request, params map[string]string) error 40} 41 42// CredentialStore is an interface for getting credentials for 43// a given URL 44type CredentialStore interface { 45 // Basic returns basic auth for the given URL 46 Basic(*url.URL) (string, string) 47 48 // RefreshToken returns a refresh token for the 49 // given URL and service 50 RefreshToken(*url.URL, string) string 51 52 // SetRefreshToken sets the refresh token if none 53 // is provided for the given url and service 54 SetRefreshToken(realm *url.URL, service, token string) 55} 56 57// NewAuthorizer creates an authorizer which can handle multiple authentication 58// schemes. The handlers are tried in order, the higher priority authentication 59// methods should be first. The challengeMap holds a list of challenges for 60// a given root API endpoint (for example "https://registry-1.docker.io/v2/"). 61func NewAuthorizer(manager challenge.Manager, handlers ...AuthenticationHandler) transport.RequestModifier { 62 return &endpointAuthorizer{ 63 challenges: manager, 64 handlers: handlers, 65 } 66} 67 68type endpointAuthorizer struct { 69 challenges challenge.Manager 70 handlers []AuthenticationHandler 71} 72 73func (ea *endpointAuthorizer) ModifyRequest(req *http.Request) error { 74 pingPath := req.URL.Path 75 if v2Root := strings.Index(req.URL.Path, "/v2/"); v2Root != -1 { 76 pingPath = pingPath[:v2Root+4] 77 } else if v1Root := strings.Index(req.URL.Path, "/v1/"); v1Root != -1 { 78 pingPath = pingPath[:v1Root] + "/v2/" 79 } else { 80 return nil 81 } 82 83 ping := url.URL{ 84 Host: req.URL.Host, 85 Scheme: req.URL.Scheme, 86 Path: pingPath, 87 } 88 89 challenges, err := ea.challenges.GetChallenges(ping) 90 if err != nil { 91 return err 92 } 93 94 if len(challenges) > 0 { 95 for _, handler := range ea.handlers { 96 for _, c := range challenges { 97 if c.Scheme != handler.Scheme() { 98 continue 99 } 100 if err := handler.AuthorizeRequest(req, c.Parameters); err != nil { 101 return err 102 } 103 } 104 } 105 } 106 107 return nil 108} 109 110// This is the minimum duration a token can last (in seconds). 111// A token must not live less than 60 seconds because older versions 112// of the Docker client didn't read their expiration from the token 113// response and assumed 60 seconds. So to remain compatible with 114// those implementations, a token must live at least this long. 115const minimumTokenLifetimeSeconds = 60 116 117// Private interface for time used by this package to enable tests to provide their own implementation. 118type clock interface { 119 Now() time.Time 120} 121 122type tokenHandler struct { 123 creds CredentialStore 124 transport http.RoundTripper 125 clock clock 126 127 offlineAccess bool 128 forceOAuth bool 129 clientID string 130 scopes []Scope 131 132 tokenLock sync.Mutex 133 tokenCache string 134 tokenExpiration time.Time 135 136 logger Logger 137} 138 139// Scope is a type which is serializable to a string 140// using the allow scope grammar. 141type Scope interface { 142 String() string 143} 144 145// RepositoryScope represents a token scope for access 146// to a repository. 147type RepositoryScope struct { 148 Repository string 149 Class string 150 Actions []string 151} 152 153// String returns the string representation of the repository 154// using the scope grammar 155func (rs RepositoryScope) String() string { 156 repoType := "repository" 157 // Keep existing format for image class to maintain backwards compatibility 158 // with authorization servers which do not support the expanded grammar. 159 if rs.Class != "" && rs.Class != "image" { 160 repoType = fmt.Sprintf("%s(%s)", repoType, rs.Class) 161 } 162 return fmt.Sprintf("%s:%s:%s", repoType, rs.Repository, strings.Join(rs.Actions, ",")) 163} 164 165// RegistryScope represents a token scope for access 166// to resources in the registry. 167type RegistryScope struct { 168 Name string 169 Actions []string 170} 171 172// String returns the string representation of the user 173// using the scope grammar 174func (rs RegistryScope) String() string { 175 return fmt.Sprintf("registry:%s:%s", rs.Name, strings.Join(rs.Actions, ",")) 176} 177 178// Logger defines the injectable logging interface, used on TokenHandlers. 179type Logger interface { 180 Debugf(format string, args ...interface{}) 181} 182 183func logDebugf(logger Logger, format string, args ...interface{}) { 184 if logger == nil { 185 return 186 } 187 logger.Debugf(format, args...) 188} 189 190// TokenHandlerOptions is used to configure a new token handler 191type TokenHandlerOptions struct { 192 Transport http.RoundTripper 193 Credentials CredentialStore 194 195 OfflineAccess bool 196 ForceOAuth bool 197 ClientID string 198 Scopes []Scope 199 Logger Logger 200} 201 202// An implementation of clock for providing real time data. 203type realClock struct{} 204 205// Now implements clock 206func (realClock) Now() time.Time { return time.Now() } 207 208// NewTokenHandler creates a new AuthenicationHandler which supports 209// fetching tokens from a remote token server. 210func NewTokenHandler(transport http.RoundTripper, creds CredentialStore, scope string, actions ...string) AuthenticationHandler { 211 // Create options... 212 return NewTokenHandlerWithOptions(TokenHandlerOptions{ 213 Transport: transport, 214 Credentials: creds, 215 Scopes: []Scope{ 216 RepositoryScope{ 217 Repository: scope, 218 Actions: actions, 219 }, 220 }, 221 }) 222} 223 224// NewTokenHandlerWithOptions creates a new token handler using the provided 225// options structure. 226func NewTokenHandlerWithOptions(options TokenHandlerOptions) AuthenticationHandler { 227 handler := &tokenHandler{ 228 transport: options.Transport, 229 creds: options.Credentials, 230 offlineAccess: options.OfflineAccess, 231 forceOAuth: options.ForceOAuth, 232 clientID: options.ClientID, 233 scopes: options.Scopes, 234 clock: realClock{}, 235 logger: options.Logger, 236 } 237 238 return handler 239} 240 241func (th *tokenHandler) client() *http.Client { 242 return &http.Client{ 243 Transport: th.transport, 244 Timeout: 15 * time.Second, 245 } 246} 247 248func (th *tokenHandler) Scheme() string { 249 return "bearer" 250} 251 252func (th *tokenHandler) AuthorizeRequest(req *http.Request, params map[string]string) error { 253 var additionalScopes []string 254 if fromParam := req.URL.Query().Get("from"); fromParam != "" { 255 additionalScopes = append(additionalScopes, RepositoryScope{ 256 Repository: fromParam, 257 Actions: []string{"pull"}, 258 }.String()) 259 } 260 261 token, err := th.getToken(params, additionalScopes...) 262 if err != nil { 263 return err 264 } 265 266 req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) 267 268 return nil 269} 270 271func (th *tokenHandler) getToken(params map[string]string, additionalScopes ...string) (string, error) { 272 th.tokenLock.Lock() 273 defer th.tokenLock.Unlock() 274 scopes := make([]string, 0, len(th.scopes)+len(additionalScopes)) 275 for _, scope := range th.scopes { 276 scopes = append(scopes, scope.String()) 277 } 278 var addedScopes bool 279 for _, scope := range additionalScopes { 280 if hasScope(scopes, scope) { 281 continue 282 } 283 scopes = append(scopes, scope) 284 addedScopes = true 285 } 286 287 now := th.clock.Now() 288 if now.After(th.tokenExpiration) || addedScopes { 289 token, expiration, err := th.fetchToken(params, scopes) 290 if err != nil { 291 return "", err 292 } 293 294 // do not update cache for added scope tokens 295 if !addedScopes { 296 th.tokenCache = token 297 th.tokenExpiration = expiration 298 } 299 300 return token, nil 301 } 302 303 return th.tokenCache, nil 304} 305 306func hasScope(scopes []string, scope string) bool { 307 for _, s := range scopes { 308 if s == scope { 309 return true 310 } 311 } 312 return false 313} 314 315type postTokenResponse struct { 316 AccessToken string `json:"access_token"` 317 RefreshToken string `json:"refresh_token"` 318 ExpiresIn int `json:"expires_in"` 319 IssuedAt time.Time `json:"issued_at"` 320 Scope string `json:"scope"` 321} 322 323func (th *tokenHandler) fetchTokenWithOAuth(realm *url.URL, refreshToken, service string, scopes []string) (token string, expiration time.Time, err error) { 324 form := url.Values{} 325 form.Set("scope", strings.Join(scopes, " ")) 326 form.Set("service", service) 327 328 clientID := th.clientID 329 if clientID == "" { 330 // Use default client, this is a required field 331 clientID = defaultClientID 332 } 333 form.Set("client_id", clientID) 334 335 if refreshToken != "" { 336 form.Set("grant_type", "refresh_token") 337 form.Set("refresh_token", refreshToken) 338 } else if th.creds != nil { 339 form.Set("grant_type", "password") 340 username, password := th.creds.Basic(realm) 341 form.Set("username", username) 342 form.Set("password", password) 343 344 // attempt to get a refresh token 345 form.Set("access_type", "offline") 346 } else { 347 // refuse to do oauth without a grant type 348 return "", time.Time{}, fmt.Errorf("no supported grant type") 349 } 350 351 resp, err := th.client().PostForm(realm.String(), form) 352 if err != nil { 353 return "", time.Time{}, err 354 } 355 defer resp.Body.Close() 356 357 if !client.SuccessStatus(resp.StatusCode) { 358 err := client.HandleErrorResponse(resp) 359 return "", time.Time{}, err 360 } 361 362 decoder := json.NewDecoder(resp.Body) 363 364 var tr postTokenResponse 365 if err = decoder.Decode(&tr); err != nil { 366 return "", time.Time{}, fmt.Errorf("unable to decode token response: %s", err) 367 } 368 369 if tr.RefreshToken != "" && tr.RefreshToken != refreshToken { 370 th.creds.SetRefreshToken(realm, service, tr.RefreshToken) 371 } 372 373 if tr.ExpiresIn < minimumTokenLifetimeSeconds { 374 // The default/minimum lifetime. 375 tr.ExpiresIn = minimumTokenLifetimeSeconds 376 logDebugf(th.logger, "Increasing token expiration to: %d seconds", tr.ExpiresIn) 377 } 378 379 if tr.IssuedAt.IsZero() { 380 // issued_at is optional in the token response. 381 tr.IssuedAt = th.clock.Now().UTC() 382 } 383 384 return tr.AccessToken, tr.IssuedAt.Add(time.Duration(tr.ExpiresIn) * time.Second), nil 385} 386 387type getTokenResponse struct { 388 Token string `json:"token"` 389 AccessToken string `json:"access_token"` 390 ExpiresIn int `json:"expires_in"` 391 IssuedAt time.Time `json:"issued_at"` 392 RefreshToken string `json:"refresh_token"` 393} 394 395func (th *tokenHandler) fetchTokenWithBasicAuth(realm *url.URL, service string, scopes []string) (token string, expiration time.Time, err error) { 396 397 req, err := http.NewRequest("GET", realm.String(), nil) 398 if err != nil { 399 return "", time.Time{}, err 400 } 401 402 reqParams := req.URL.Query() 403 404 if service != "" { 405 reqParams.Add("service", service) 406 } 407 408 for _, scope := range scopes { 409 reqParams.Add("scope", scope) 410 } 411 412 if th.offlineAccess { 413 reqParams.Add("offline_token", "true") 414 clientID := th.clientID 415 if clientID == "" { 416 clientID = defaultClientID 417 } 418 reqParams.Add("client_id", clientID) 419 } 420 421 if th.creds != nil { 422 username, password := th.creds.Basic(realm) 423 if username != "" && password != "" { 424 reqParams.Add("account", username) 425 req.SetBasicAuth(username, password) 426 } 427 } 428 429 req.URL.RawQuery = reqParams.Encode() 430 431 resp, err := th.client().Do(req) 432 if err != nil { 433 return "", time.Time{}, err 434 } 435 defer resp.Body.Close() 436 437 if !client.SuccessStatus(resp.StatusCode) { 438 err := client.HandleErrorResponse(resp) 439 return "", time.Time{}, err 440 } 441 442 decoder := json.NewDecoder(resp.Body) 443 444 var tr getTokenResponse 445 if err = decoder.Decode(&tr); err != nil { 446 return "", time.Time{}, fmt.Errorf("unable to decode token response: %s", err) 447 } 448 449 if tr.RefreshToken != "" && th.creds != nil { 450 th.creds.SetRefreshToken(realm, service, tr.RefreshToken) 451 } 452 453 // `access_token` is equivalent to `token` and if both are specified 454 // the choice is undefined. Canonicalize `access_token` by sticking 455 // things in `token`. 456 if tr.AccessToken != "" { 457 tr.Token = tr.AccessToken 458 } 459 460 if tr.Token == "" { 461 return "", time.Time{}, ErrNoToken 462 } 463 464 if tr.ExpiresIn < minimumTokenLifetimeSeconds { 465 // The default/minimum lifetime. 466 tr.ExpiresIn = minimumTokenLifetimeSeconds 467 logDebugf(th.logger, "Increasing token expiration to: %d seconds", tr.ExpiresIn) 468 } 469 470 if tr.IssuedAt.IsZero() { 471 // issued_at is optional in the token response. 472 tr.IssuedAt = th.clock.Now().UTC() 473 } 474 475 return tr.Token, tr.IssuedAt.Add(time.Duration(tr.ExpiresIn) * time.Second), nil 476} 477 478func (th *tokenHandler) fetchToken(params map[string]string, scopes []string) (token string, expiration time.Time, err error) { 479 realm, ok := params["realm"] 480 if !ok { 481 return "", time.Time{}, errors.New("no realm specified for token auth challenge") 482 } 483 484 // TODO(dmcgowan): Handle empty scheme and relative realm 485 realmURL, err := url.Parse(realm) 486 if err != nil { 487 return "", time.Time{}, fmt.Errorf("invalid token auth challenge realm: %s", err) 488 } 489 490 service := params["service"] 491 492 var refreshToken string 493 494 if th.creds != nil { 495 refreshToken = th.creds.RefreshToken(realmURL, service) 496 } 497 498 if refreshToken != "" || th.forceOAuth { 499 return th.fetchTokenWithOAuth(realmURL, refreshToken, service, scopes) 500 } 501 502 return th.fetchTokenWithBasicAuth(realmURL, service, scopes) 503} 504 505type basicHandler struct { 506 creds CredentialStore 507} 508 509// NewBasicHandler creaters a new authentiation handler which adds 510// basic authentication credentials to a request. 511func NewBasicHandler(creds CredentialStore) AuthenticationHandler { 512 return &basicHandler{ 513 creds: creds, 514 } 515} 516 517func (*basicHandler) Scheme() string { 518 return "basic" 519} 520 521func (bh *basicHandler) AuthorizeRequest(req *http.Request, params map[string]string) error { 522 if bh.creds != nil { 523 username, password := bh.creds.Basic(req.URL) 524 if username != "" && password != "" { 525 req.SetBasicAuth(username, password) 526 return nil 527 } 528 } 529 return ErrNoBasicAuthCredentials 530} 531