|
@@ -19,6 +19,8 @@ import (
|
|
// basic auth due to lack of credentials.
|
|
// basic auth due to lack of credentials.
|
|
var ErrNoBasicAuthCredentials = errors.New("no basic auth credentials")
|
|
var ErrNoBasicAuthCredentials = errors.New("no basic auth credentials")
|
|
|
|
|
|
|
|
+const defaultClientID = "registry-client"
|
|
|
|
+
|
|
// AuthenticationHandler is an interface for authorizing a request from
|
|
// AuthenticationHandler is an interface for authorizing a request from
|
|
// params from a "WWW-Authenicate" header for a single scheme.
|
|
// params from a "WWW-Authenicate" header for a single scheme.
|
|
type AuthenticationHandler interface {
|
|
type AuthenticationHandler interface {
|
|
@@ -36,6 +38,14 @@ type AuthenticationHandler interface {
|
|
type CredentialStore interface {
|
|
type CredentialStore interface {
|
|
// Basic returns basic auth for the given URL
|
|
// Basic returns basic auth for the given URL
|
|
Basic(*url.URL) (string, string)
|
|
Basic(*url.URL) (string, string)
|
|
|
|
+
|
|
|
|
+ // RefreshToken returns a refresh token for the
|
|
|
|
+ // given URL and service
|
|
|
|
+ RefreshToken(*url.URL, string) string
|
|
|
|
+
|
|
|
|
+ // SetRefreshToken sets the refresh token if none
|
|
|
|
+ // is provided for the given url and service
|
|
|
|
+ SetRefreshToken(realm *url.URL, service, token string)
|
|
}
|
|
}
|
|
|
|
|
|
// NewAuthorizer creates an authorizer which can handle multiple authentication
|
|
// NewAuthorizer creates an authorizer which can handle multiple authentication
|
|
@@ -105,27 +115,47 @@ type clock interface {
|
|
type tokenHandler struct {
|
|
type tokenHandler struct {
|
|
header http.Header
|
|
header http.Header
|
|
creds CredentialStore
|
|
creds CredentialStore
|
|
- scope tokenScope
|
|
|
|
transport http.RoundTripper
|
|
transport http.RoundTripper
|
|
clock clock
|
|
clock clock
|
|
|
|
|
|
|
|
+ offlineAccess bool
|
|
|
|
+ forceOAuth bool
|
|
|
|
+ clientID string
|
|
|
|
+ scopes []Scope
|
|
|
|
+
|
|
tokenLock sync.Mutex
|
|
tokenLock sync.Mutex
|
|
tokenCache string
|
|
tokenCache string
|
|
tokenExpiration time.Time
|
|
tokenExpiration time.Time
|
|
|
|
+}
|
|
|
|
|
|
- additionalScopes map[string]struct{}
|
|
|
|
|
|
+// Scope is a type which is serializable to a string
|
|
|
|
+// using the allow scope grammar.
|
|
|
|
+type Scope interface {
|
|
|
|
+ String() string
|
|
}
|
|
}
|
|
|
|
|
|
-// tokenScope represents the scope at which a token will be requested.
|
|
|
|
-// This represents a specific action on a registry resource.
|
|
|
|
-type tokenScope struct {
|
|
|
|
- Resource string
|
|
|
|
- Scope string
|
|
|
|
- Actions []string
|
|
|
|
|
|
+// RepositoryScope represents a token scope for access
|
|
|
|
+// to a repository.
|
|
|
|
+type RepositoryScope struct {
|
|
|
|
+ Repository string
|
|
|
|
+ Actions []string
|
|
}
|
|
}
|
|
|
|
|
|
-func (ts tokenScope) String() string {
|
|
|
|
- return fmt.Sprintf("%s:%s:%s", ts.Resource, ts.Scope, strings.Join(ts.Actions, ","))
|
|
|
|
|
|
+// String returns the string representation of the repository
|
|
|
|
+// using the scope grammar
|
|
|
|
+func (rs RepositoryScope) String() string {
|
|
|
|
+ return fmt.Sprintf("repository:%s:%s", rs.Repository, strings.Join(rs.Actions, ","))
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// TokenHandlerOptions is used to configure a new token handler
|
|
|
|
+type TokenHandlerOptions struct {
|
|
|
|
+ Transport http.RoundTripper
|
|
|
|
+ Credentials CredentialStore
|
|
|
|
+
|
|
|
|
+ OfflineAccess bool
|
|
|
|
+ ForceOAuth bool
|
|
|
|
+ ClientID string
|
|
|
|
+ Scopes []Scope
|
|
}
|
|
}
|
|
|
|
|
|
// An implementation of clock for providing real time data.
|
|
// An implementation of clock for providing real time data.
|
|
@@ -137,22 +167,33 @@ func (realClock) Now() time.Time { return time.Now() }
|
|
// NewTokenHandler creates a new AuthenicationHandler which supports
|
|
// NewTokenHandler creates a new AuthenicationHandler which supports
|
|
// fetching tokens from a remote token server.
|
|
// fetching tokens from a remote token server.
|
|
func NewTokenHandler(transport http.RoundTripper, creds CredentialStore, scope string, actions ...string) AuthenticationHandler {
|
|
func NewTokenHandler(transport http.RoundTripper, creds CredentialStore, scope string, actions ...string) AuthenticationHandler {
|
|
- return newTokenHandler(transport, creds, realClock{}, scope, actions...)
|
|
|
|
|
|
+ // Create options...
|
|
|
|
+ return NewTokenHandlerWithOptions(TokenHandlerOptions{
|
|
|
|
+ Transport: transport,
|
|
|
|
+ Credentials: creds,
|
|
|
|
+ Scopes: []Scope{
|
|
|
|
+ RepositoryScope{
|
|
|
|
+ Repository: scope,
|
|
|
|
+ Actions: actions,
|
|
|
|
+ },
|
|
|
|
+ },
|
|
|
|
+ })
|
|
}
|
|
}
|
|
|
|
|
|
-// newTokenHandler exposes the option to provide a clock to manipulate time in unit testing.
|
|
|
|
-func newTokenHandler(transport http.RoundTripper, creds CredentialStore, c clock, scope string, actions ...string) AuthenticationHandler {
|
|
|
|
- return &tokenHandler{
|
|
|
|
- transport: transport,
|
|
|
|
- creds: creds,
|
|
|
|
- clock: c,
|
|
|
|
- scope: tokenScope{
|
|
|
|
- Resource: "repository",
|
|
|
|
- Scope: scope,
|
|
|
|
- Actions: actions,
|
|
|
|
- },
|
|
|
|
- additionalScopes: map[string]struct{}{},
|
|
|
|
|
|
+// NewTokenHandlerWithOptions creates a new token handler using the provided
|
|
|
|
+// options structure.
|
|
|
|
+func NewTokenHandlerWithOptions(options TokenHandlerOptions) AuthenticationHandler {
|
|
|
|
+ handler := &tokenHandler{
|
|
|
|
+ transport: options.Transport,
|
|
|
|
+ creds: options.Credentials,
|
|
|
|
+ offlineAccess: options.OfflineAccess,
|
|
|
|
+ forceOAuth: options.ForceOAuth,
|
|
|
|
+ clientID: options.ClientID,
|
|
|
|
+ scopes: options.Scopes,
|
|
|
|
+ clock: realClock{},
|
|
}
|
|
}
|
|
|
|
+
|
|
|
|
+ return handler
|
|
}
|
|
}
|
|
|
|
|
|
func (th *tokenHandler) client() *http.Client {
|
|
func (th *tokenHandler) client() *http.Client {
|
|
@@ -169,88 +210,162 @@ func (th *tokenHandler) Scheme() string {
|
|
func (th *tokenHandler) AuthorizeRequest(req *http.Request, params map[string]string) error {
|
|
func (th *tokenHandler) AuthorizeRequest(req *http.Request, params map[string]string) error {
|
|
var additionalScopes []string
|
|
var additionalScopes []string
|
|
if fromParam := req.URL.Query().Get("from"); fromParam != "" {
|
|
if fromParam := req.URL.Query().Get("from"); fromParam != "" {
|
|
- additionalScopes = append(additionalScopes, tokenScope{
|
|
|
|
- Resource: "repository",
|
|
|
|
- Scope: fromParam,
|
|
|
|
- Actions: []string{"pull"},
|
|
|
|
|
|
+ additionalScopes = append(additionalScopes, RepositoryScope{
|
|
|
|
+ Repository: fromParam,
|
|
|
|
+ Actions: []string{"pull"},
|
|
}.String())
|
|
}.String())
|
|
}
|
|
}
|
|
- if err := th.refreshToken(params, additionalScopes...); err != nil {
|
|
|
|
|
|
+
|
|
|
|
+ token, err := th.getToken(params, additionalScopes...)
|
|
|
|
+ if err != nil {
|
|
return err
|
|
return err
|
|
}
|
|
}
|
|
|
|
|
|
- req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", th.tokenCache))
|
|
|
|
|
|
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
|
|
|
|
|
return nil
|
|
return nil
|
|
}
|
|
}
|
|
|
|
|
|
-func (th *tokenHandler) refreshToken(params map[string]string, additionalScopes ...string) error {
|
|
|
|
|
|
+func (th *tokenHandler) getToken(params map[string]string, additionalScopes ...string) (string, error) {
|
|
th.tokenLock.Lock()
|
|
th.tokenLock.Lock()
|
|
defer th.tokenLock.Unlock()
|
|
defer th.tokenLock.Unlock()
|
|
|
|
+ scopes := make([]string, 0, len(th.scopes)+len(additionalScopes))
|
|
|
|
+ for _, scope := range th.scopes {
|
|
|
|
+ scopes = append(scopes, scope.String())
|
|
|
|
+ }
|
|
var addedScopes bool
|
|
var addedScopes bool
|
|
for _, scope := range additionalScopes {
|
|
for _, scope := range additionalScopes {
|
|
- if _, ok := th.additionalScopes[scope]; !ok {
|
|
|
|
- th.additionalScopes[scope] = struct{}{}
|
|
|
|
- addedScopes = true
|
|
|
|
- }
|
|
|
|
|
|
+ scopes = append(scopes, scope)
|
|
|
|
+ addedScopes = true
|
|
}
|
|
}
|
|
|
|
+
|
|
now := th.clock.Now()
|
|
now := th.clock.Now()
|
|
if now.After(th.tokenExpiration) || addedScopes {
|
|
if now.After(th.tokenExpiration) || addedScopes {
|
|
- tr, err := th.fetchToken(params)
|
|
|
|
|
|
+ token, expiration, err := th.fetchToken(params, scopes)
|
|
if err != nil {
|
|
if err != nil {
|
|
- return err
|
|
|
|
|
|
+ return "", err
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // do not update cache for added scope tokens
|
|
|
|
+ if !addedScopes {
|
|
|
|
+ th.tokenCache = token
|
|
|
|
+ th.tokenExpiration = expiration
|
|
}
|
|
}
|
|
- th.tokenCache = tr.Token
|
|
|
|
- th.tokenExpiration = tr.IssuedAt.Add(time.Duration(tr.ExpiresIn) * time.Second)
|
|
|
|
|
|
+
|
|
|
|
+ return token, nil
|
|
}
|
|
}
|
|
|
|
|
|
- return nil
|
|
|
|
|
|
+ return th.tokenCache, nil
|
|
}
|
|
}
|
|
|
|
|
|
-type tokenResponse struct {
|
|
|
|
- Token string `json:"token"`
|
|
|
|
- AccessToken string `json:"access_token"`
|
|
|
|
- ExpiresIn int `json:"expires_in"`
|
|
|
|
- IssuedAt time.Time `json:"issued_at"`
|
|
|
|
|
|
+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 (th *tokenHandler) fetchToken(params map[string]string) (token *tokenResponse, err error) {
|
|
|
|
- //log.Debugf("Getting bearer token with %s for %s", challenge.Parameters, ta.auth.Username)
|
|
|
|
- realm, ok := params["realm"]
|
|
|
|
- if !ok {
|
|
|
|
- return nil, errors.New("no realm specified for token auth challenge")
|
|
|
|
- }
|
|
|
|
|
|
+func (th *tokenHandler) fetchTokenWithOAuth(realm *url.URL, refreshToken, service string, scopes []string) (token string, expiration time.Time, err error) {
|
|
|
|
+ form := url.Values{}
|
|
|
|
+ form.Set("scope", strings.Join(scopes, " "))
|
|
|
|
+ form.Set("service", service)
|
|
|
|
|
|
- // TODO(dmcgowan): Handle empty scheme
|
|
|
|
|
|
+ clientID := th.clientID
|
|
|
|
+ if clientID == "" {
|
|
|
|
+ // Use default client, this is a required field
|
|
|
|
+ clientID = defaultClientID
|
|
|
|
+ }
|
|
|
|
+ form.Set("client_id", clientID)
|
|
|
|
+
|
|
|
|
+ if refreshToken != "" {
|
|
|
|
+ form.Set("grant_type", "refresh_token")
|
|
|
|
+ form.Set("refresh_token", refreshToken)
|
|
|
|
+ } else if th.creds != nil {
|
|
|
|
+ form.Set("grant_type", "password")
|
|
|
|
+ username, password := th.creds.Basic(realm)
|
|
|
|
+ form.Set("username", username)
|
|
|
|
+ form.Set("password", password)
|
|
|
|
+
|
|
|
|
+ // attempt to get a refresh token
|
|
|
|
+ form.Set("access_type", "offline")
|
|
|
|
+ } else {
|
|
|
|
+ // refuse to do oauth without a grant type
|
|
|
|
+ return "", time.Time{}, fmt.Errorf("no supported grant type")
|
|
|
|
+ }
|
|
|
|
|
|
- realmURL, err := url.Parse(realm)
|
|
|
|
|
|
+ resp, err := th.client().PostForm(realm.String(), form)
|
|
if err != nil {
|
|
if err != nil {
|
|
- return nil, fmt.Errorf("invalid token auth challenge realm: %s", err)
|
|
|
|
|
|
+ return "", time.Time{}, err
|
|
|
|
+ }
|
|
|
|
+ defer resp.Body.Close()
|
|
|
|
+
|
|
|
|
+ if !client.SuccessStatus(resp.StatusCode) {
|
|
|
|
+ err := client.HandleErrorResponse(resp)
|
|
|
|
+ return "", time.Time{}, err
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ decoder := json.NewDecoder(resp.Body)
|
|
|
|
+
|
|
|
|
+ var tr postTokenResponse
|
|
|
|
+ if err = decoder.Decode(&tr); err != nil {
|
|
|
|
+ return "", time.Time{}, fmt.Errorf("unable to decode token response: %s", err)
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if tr.RefreshToken != "" && tr.RefreshToken != refreshToken {
|
|
|
|
+ th.creds.SetRefreshToken(realm, service, tr.RefreshToken)
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if tr.ExpiresIn < minimumTokenLifetimeSeconds {
|
|
|
|
+ // The default/minimum lifetime.
|
|
|
|
+ tr.ExpiresIn = minimumTokenLifetimeSeconds
|
|
|
|
+ logrus.Debugf("Increasing token expiration to: %d seconds", tr.ExpiresIn)
|
|
}
|
|
}
|
|
|
|
|
|
- req, err := http.NewRequest("GET", realmURL.String(), nil)
|
|
|
|
|
|
+ if tr.IssuedAt.IsZero() {
|
|
|
|
+ // issued_at is optional in the token response.
|
|
|
|
+ tr.IssuedAt = th.clock.Now().UTC()
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return tr.AccessToken, tr.IssuedAt.Add(time.Duration(tr.ExpiresIn) * time.Second), 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"`
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+func (th *tokenHandler) fetchTokenWithBasicAuth(realm *url.URL, service string, scopes []string) (token string, expiration time.Time, err error) {
|
|
|
|
+
|
|
|
|
+ req, err := http.NewRequest("GET", realm.String(), nil)
|
|
if err != nil {
|
|
if err != nil {
|
|
- return nil, err
|
|
|
|
|
|
+ return "", time.Time{}, err
|
|
}
|
|
}
|
|
|
|
|
|
reqParams := req.URL.Query()
|
|
reqParams := req.URL.Query()
|
|
- service := params["service"]
|
|
|
|
- scope := th.scope.String()
|
|
|
|
|
|
|
|
if service != "" {
|
|
if service != "" {
|
|
reqParams.Add("service", service)
|
|
reqParams.Add("service", service)
|
|
}
|
|
}
|
|
|
|
|
|
- for _, scopeField := range strings.Fields(scope) {
|
|
|
|
- reqParams.Add("scope", scopeField)
|
|
|
|
|
|
+ for _, scope := range scopes {
|
|
|
|
+ reqParams.Add("scope", scope)
|
|
}
|
|
}
|
|
|
|
|
|
- for scope := range th.additionalScopes {
|
|
|
|
- reqParams.Add("scope", scope)
|
|
|
|
|
|
+ if th.offlineAccess {
|
|
|
|
+ reqParams.Add("offline_token", "true")
|
|
|
|
+ clientID := th.clientID
|
|
|
|
+ if clientID == "" {
|
|
|
|
+ clientID = defaultClientID
|
|
|
|
+ }
|
|
|
|
+ reqParams.Add("client_id", clientID)
|
|
}
|
|
}
|
|
|
|
|
|
if th.creds != nil {
|
|
if th.creds != nil {
|
|
- username, password := th.creds.Basic(realmURL)
|
|
|
|
|
|
+ username, password := th.creds.Basic(realm)
|
|
if username != "" && password != "" {
|
|
if username != "" && password != "" {
|
|
reqParams.Add("account", username)
|
|
reqParams.Add("account", username)
|
|
req.SetBasicAuth(username, password)
|
|
req.SetBasicAuth(username, password)
|
|
@@ -261,20 +376,24 @@ func (th *tokenHandler) fetchToken(params map[string]string) (token *tokenRespon
|
|
|
|
|
|
resp, err := th.client().Do(req)
|
|
resp, err := th.client().Do(req)
|
|
if err != nil {
|
|
if err != nil {
|
|
- return nil, err
|
|
|
|
|
|
+ return "", time.Time{}, err
|
|
}
|
|
}
|
|
defer resp.Body.Close()
|
|
defer resp.Body.Close()
|
|
|
|
|
|
if !client.SuccessStatus(resp.StatusCode) {
|
|
if !client.SuccessStatus(resp.StatusCode) {
|
|
err := client.HandleErrorResponse(resp)
|
|
err := client.HandleErrorResponse(resp)
|
|
- return nil, err
|
|
|
|
|
|
+ return "", time.Time{}, err
|
|
}
|
|
}
|
|
|
|
|
|
decoder := json.NewDecoder(resp.Body)
|
|
decoder := json.NewDecoder(resp.Body)
|
|
|
|
|
|
- tr := new(tokenResponse)
|
|
|
|
- if err = decoder.Decode(tr); err != nil {
|
|
|
|
- return nil, fmt.Errorf("unable to decode token response: %s", err)
|
|
|
|
|
|
+ var tr getTokenResponse
|
|
|
|
+ if err = decoder.Decode(&tr); err != nil {
|
|
|
|
+ return "", time.Time{}, fmt.Errorf("unable to decode token response: %s", err)
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if tr.RefreshToken != "" && th.creds != nil {
|
|
|
|
+ th.creds.SetRefreshToken(realm, service, tr.RefreshToken)
|
|
}
|
|
}
|
|
|
|
|
|
// `access_token` is equivalent to `token` and if both are specified
|
|
// `access_token` is equivalent to `token` and if both are specified
|
|
@@ -285,7 +404,7 @@ func (th *tokenHandler) fetchToken(params map[string]string) (token *tokenRespon
|
|
}
|
|
}
|
|
|
|
|
|
if tr.Token == "" {
|
|
if tr.Token == "" {
|
|
- return nil, errors.New("authorization server did not include a token in the response")
|
|
|
|
|
|
+ return "", time.Time{}, errors.New("authorization server did not include a token in the response")
|
|
}
|
|
}
|
|
|
|
|
|
if tr.ExpiresIn < minimumTokenLifetimeSeconds {
|
|
if tr.ExpiresIn < minimumTokenLifetimeSeconds {
|
|
@@ -296,10 +415,37 @@ func (th *tokenHandler) fetchToken(params map[string]string) (token *tokenRespon
|
|
|
|
|
|
if tr.IssuedAt.IsZero() {
|
|
if tr.IssuedAt.IsZero() {
|
|
// issued_at is optional in the token response.
|
|
// issued_at is optional in the token response.
|
|
- tr.IssuedAt = th.clock.Now()
|
|
|
|
|
|
+ tr.IssuedAt = th.clock.Now().UTC()
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return tr.Token, tr.IssuedAt.Add(time.Duration(tr.ExpiresIn) * time.Second), nil
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+func (th *tokenHandler) fetchToken(params map[string]string, scopes []string) (token string, expiration time.Time, err error) {
|
|
|
|
+ realm, ok := params["realm"]
|
|
|
|
+ if !ok {
|
|
|
|
+ return "", time.Time{}, errors.New("no realm specified for token auth challenge")
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // TODO(dmcgowan): Handle empty scheme and relative realm
|
|
|
|
+ realmURL, err := url.Parse(realm)
|
|
|
|
+ if err != nil {
|
|
|
|
+ return "", time.Time{}, fmt.Errorf("invalid token auth challenge realm: %s", err)
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ service := params["service"]
|
|
|
|
+
|
|
|
|
+ var refreshToken string
|
|
|
|
+
|
|
|
|
+ if th.creds != nil {
|
|
|
|
+ refreshToken = th.creds.RefreshToken(realmURL, service)
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if refreshToken != "" || th.forceOAuth {
|
|
|
|
+ return th.fetchTokenWithOAuth(realmURL, refreshToken, service, scopes)
|
|
}
|
|
}
|
|
|
|
|
|
- return tr, nil
|
|
|
|
|
|
+ return th.fetchTokenWithBasicAuth(realmURL, service, scopes)
|
|
}
|
|
}
|
|
|
|
|
|
type basicHandler struct {
|
|
type basicHandler struct {
|