diff --git a/contrib/token-server/errors.go b/contrib/token-server/errors.go new file mode 100644 index 000000000..bcac8ee35 --- /dev/null +++ b/contrib/token-server/errors.go @@ -0,0 +1,38 @@ +package main + +import ( + "net/http" + + "github.com/docker/distribution/registry/api/errcode" +) + +var ( + errGroup = "tokenserver" + + // ErrorBadTokenOption is returned when a token parameter is invalid + ErrorBadTokenOption = errcode.Register(errGroup, errcode.ErrorDescriptor{ + Value: "BAD_TOKEN_OPTION", + Message: "bad token option", + Description: `This error may be returned when a request for a + token contains an option which is not valid`, + HTTPStatusCode: http.StatusBadRequest, + }) + + // ErrorMissingRequiredField is returned when a required form field is missing + ErrorMissingRequiredField = errcode.Register(errGroup, errcode.ErrorDescriptor{ + Value: "MISSING_REQUIRED_FIELD", + Message: "missing required field", + Description: `This error may be returned when a request for a + token does not contain a required form field`, + HTTPStatusCode: http.StatusBadRequest, + }) + + // ErrorUnsupportedValue is returned when a form field has an unsupported value + ErrorUnsupportedValue = errcode.Register(errGroup, errcode.ErrorDescriptor{ + Value: "UNSUPPORTED_VALUE", + Message: "unsupported value", + Description: `This error may be returned when a request for a + token contains a form field with an unsupported value`, + HTTPStatusCode: http.StatusBadRequest, + }) +) diff --git a/contrib/token-server/main.go b/contrib/token-server/main.go index e47e11c27..edd894f48 100644 --- a/contrib/token-server/main.go +++ b/contrib/token-server/main.go @@ -3,8 +3,11 @@ package main import ( "encoding/json" "flag" + "math/rand" "net/http" + "strconv" "strings" + "time" "github.com/Sirupsen/logrus" "github.com/docker/distribution/context" @@ -73,15 +76,20 @@ func main() { logrus.Fatalf("Error initializing access controller: %v", err) } + // TODO: Make configurable + issuer.Expiration = 15 * time.Minute + ctx := context.Background() ts := &tokenServer{ issuer: issuer, accessController: ac, + refreshCache: map[string]refreshToken{}, } router := mux.NewRouter() router.Path("/token/").Methods("GET").Handler(handlerWithContext(ctx, ts.getToken)) + router.Path("/token/").Methods("POST").Handler(handlerWithContext(ctx, ts.postToken)) if cert == "" { err = http.ListenAndServe(addr, router) @@ -120,9 +128,52 @@ func handleError(ctx context.Context, err error, w http.ResponseWriter) { context.GetResponseLogger(ctx).Info("application error") } +var refreshCharacters = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +const refreshTokenLength = 15 + +func newRefreshToken() string { + s := make([]rune, refreshTokenLength) + for i := range s { + s[i] = refreshCharacters[rand.Intn(len(refreshCharacters))] + } + return string(s) +} + +type refreshToken struct { + subject string + service string +} + type tokenServer struct { issuer *TokenIssuer accessController auth.AccessController + refreshCache map[string]refreshToken +} + +type tokenResponse struct { + Token string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` +} + +func filterAccessList(ctx context.Context, scope string, requestedAccessList []auth.Access) []auth.Access { + if !strings.HasSuffix(scope, "/") { + scope = scope + "/" + } + grantedAccessList := make([]auth.Access, 0, len(requestedAccessList)) + for _, access := range requestedAccessList { + if access.Type != "repository" { + context.GetLogger(ctx).Debugf("Skipping unsupported resource type: %s", access.Type) + continue + } + if !strings.HasPrefix(access.Name, scope) { + context.GetLogger(ctx).Debugf("Resource scope not allowed: %s", access.Name) + continue + } + grantedAccessList = append(grantedAccessList, access) + } + return grantedAccessList } // getToken handles authenticating the request and authorizing access to the @@ -133,6 +184,15 @@ func (ts *tokenServer) getToken(ctx context.Context, w http.ResponseWriter, r *h params := r.URL.Query() service := params.Get("service") scopeSpecifiers := params["scope"] + var offline bool + if offlineStr := params.Get("offline_token"); offlineStr != "" { + var err error + offline, err = strconv.ParseBool(offlineStr) + if err != nil { + handleError(ctx, ErrorBadTokenOption.WithDetail(err), w) + return + } + } requestedAccessList := ResolveScopeSpecifiers(ctx, scopeSpecifiers) @@ -166,20 +226,7 @@ func (ts *tokenServer) getToken(ctx context.Context, w http.ResponseWriter, r *h ctx = context.WithValue(ctx, "requestedAccess", requestedAccessList) ctx = context.WithLogger(ctx, context.GetLogger(ctx, "requestedAccess")) - scopePrefix := username + "/" - grantedAccessList := make([]auth.Access, 0, len(requestedAccessList)) - for _, access := range requestedAccessList { - if access.Type != "repository" { - context.GetLogger(ctx).Debugf("Skipping unsupported resource type: %s", access.Type) - continue - } - if !strings.HasPrefix(access.Name, scopePrefix) { - context.GetLogger(ctx).Debugf("Resource scope not allowed: %s", access.Name) - continue - } - grantedAccessList = append(grantedAccessList, access) - } - + grantedAccessList := filterAccessList(ctx, username, requestedAccessList) ctx = context.WithValue(ctx, "grantedAccess", grantedAccessList) ctx = context.WithLogger(ctx, context.GetLogger(ctx, "grantedAccess")) @@ -191,11 +238,151 @@ func (ts *tokenServer) getToken(ctx context.Context, w http.ResponseWriter, r *h context.GetLogger(ctx).Info("authorized client") - // Get response context. + response := tokenResponse{ + Token: token, + ExpiresIn: int(ts.issuer.Expiration.Seconds()), + } + + if offline { + response.RefreshToken = newRefreshToken() + ts.refreshCache[response.RefreshToken] = refreshToken{ + subject: username, + service: service, + } + } + ctx, w = context.WithResponseWriter(ctx, w) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"token": token}) + json.NewEncoder(w).Encode(response) context.GetResponseLogger(ctx).Info("get token complete") } + +type postTokenResponse struct { + Token string `json:"access_token"` + Scope string `json:"scope,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + IssuedAt string `json:"issued_at,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` +} + +// postToken handles authenticating the request and authorizing access to the +// requested scopes. +func (ts *tokenServer) postToken(ctx context.Context, w http.ResponseWriter, r *http.Request) { + grantType := r.PostFormValue("grant_type") + if grantType == "" { + handleError(ctx, ErrorMissingRequiredField.WithDetail("missing grant_type value"), w) + return + } + + service := r.PostFormValue("service") + if service == "" { + handleError(ctx, ErrorMissingRequiredField.WithDetail("missing service value"), w) + return + } + + clientID := r.PostFormValue("client_id") + if clientID == "" { + handleError(ctx, ErrorMissingRequiredField.WithDetail("missing client_id value"), w) + return + } + + var offline bool + switch r.PostFormValue("access_type") { + case "", "online": + case "offline": + offline = true + default: + handleError(ctx, ErrorUnsupportedValue.WithDetail("unknown access_type value"), w) + return + } + + requestedAccessList := ResolveScopeList(ctx, r.PostFormValue("scope")) + + var subject string + var rToken string + switch grantType { + case "refresh_token": + rToken = r.PostFormValue("refresh_token") + if rToken == "" { + handleError(ctx, ErrorUnsupportedValue.WithDetail("missing refresh_token value"), w) + return + } + rt, ok := ts.refreshCache[rToken] + if !ok || rt.service != service { + handleError(ctx, errcode.ErrorCodeUnauthorized.WithDetail("invalid refresh token"), w) + return + } + subject = rt.subject + case "password": + ca, ok := ts.accessController.(auth.CredentialAuthenticator) + if !ok { + handleError(ctx, ErrorUnsupportedValue.WithDetail("password grant type not supported"), w) + return + } + subject = r.PostFormValue("username") + if subject == "" { + handleError(ctx, ErrorUnsupportedValue.WithDetail("missing username value"), w) + return + } + password := r.PostFormValue("password") + if password == "" { + handleError(ctx, ErrorUnsupportedValue.WithDetail("missing password value"), w) + return + } + if err := ca.AuthenticateUser(subject, password); err != nil { + handleError(ctx, errcode.ErrorCodeUnauthorized.WithDetail("invalid credentials"), w) + return + } + default: + handleError(ctx, ErrorUnsupportedValue.WithDetail("unknown grant_type value"), w) + return + } + + ctx = context.WithValue(ctx, "acctSubject", subject) + ctx = context.WithLogger(ctx, context.GetLogger(ctx, "acctSubject")) + + context.GetLogger(ctx).Info("authenticated client") + + ctx = context.WithValue(ctx, "requestedAccess", requestedAccessList) + ctx = context.WithLogger(ctx, context.GetLogger(ctx, "requestedAccess")) + + grantedAccessList := filterAccessList(ctx, subject, requestedAccessList) + ctx = context.WithValue(ctx, "grantedAccess", grantedAccessList) + ctx = context.WithLogger(ctx, context.GetLogger(ctx, "grantedAccess")) + + token, err := ts.issuer.CreateJWT(subject, service, grantedAccessList) + if err != nil { + handleError(ctx, err, w) + return + } + + context.GetLogger(ctx).Info("authorized client") + + response := postTokenResponse{ + Token: token, + ExpiresIn: int(ts.issuer.Expiration.Seconds()), + IssuedAt: time.Now().UTC().Format(time.RFC3339), + Scope: ToScopeList(grantedAccessList), + } + + if offline { + rToken = newRefreshToken() + ts.refreshCache[rToken] = refreshToken{ + subject: subject, + service: service, + } + } + + if rToken != "" { + response.RefreshToken = rToken + } + + ctx, w = context.WithResponseWriter(ctx, w) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + + context.GetResponseLogger(ctx).Info("post token complete") +} diff --git a/contrib/token-server/token.go b/contrib/token-server/token.go index 15ace6220..e69fb9c19 100644 --- a/contrib/token-server/token.go +++ b/contrib/token-server/token.go @@ -55,6 +55,23 @@ func ResolveScopeSpecifiers(ctx context.Context, scopeSpecs []string) []auth.Acc return requestedAccessList } +// ResolveScopeList converts a scope list from a token request's +// `scope` parameter into a list of standard access objects. +func ResolveScopeList(ctx context.Context, scopeList string) []auth.Access { + scopes := strings.Split(scopeList, " ") + return ResolveScopeSpecifiers(ctx, scopes) +} + +// ToScopeList converts a list of access to a +// scope list string +func ToScopeList(access []auth.Access) string { + var s []string + for _, a := range access { + s = append(s, fmt.Sprintf("%s:%s:%s", a.Type, a.Name, a.Action)) + } + return strings.Join(s, ",") +} + // TokenIssuer represents an issuer capable of generating JWT tokens type TokenIssuer struct { Issuer string diff --git a/registry/auth/auth.go b/registry/auth/auth.go index 0164246c7..0cb37235b 100644 --- a/registry/auth/auth.go +++ b/registry/auth/auth.go @@ -54,7 +54,7 @@ var ( // ErrInvalidCredential is returned when the auth token does not authenticate correctly. ErrInvalidCredential = errors.New("invalid authorization credential") - // ErrAuthenticationFailure returned when authentication failure to be presented to agent. + // ErrAuthenticationFailure returned when authentication fails. ErrAuthenticationFailure = errors.New("authentication failure") ) @@ -106,7 +106,7 @@ type AccessController interface { Authorized(ctx context.Context, access ...Access) (context.Context, error) } -// CredentialAuthenticator is an object which is able to validate credentials +// CredentialAuthenticator is an object which is able to authenticate credentials type CredentialAuthenticator interface { AuthenticateUser(username, password string) error }