Add post token implementation
Signed-off-by: Derek McGowan <derek@mcgstyle.net> (github: dmcgowan)master
							parent
							
								
									98620458e3
								
							
						
					
					
						commit
						1126e32234
					
				|  | @ -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, | ||||
| 	}) | ||||
| ) | ||||
|  | @ -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") | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue