From 56f685c0dd5ccf6d6ea258f5ee6ce0713652f32b Mon Sep 17 00:00:00 2001 From: Josh Hawn Date: Tue, 16 Dec 2014 22:58:39 -0800 Subject: [PATCH 1/4] Adds auth package with token auth backend Docker-DCO-1.1-Signed-off-by: Josh Hawn (github: jlhawn) --- auth/auth.go | 76 +++++++ auth/token/accesscontroller.go | 268 ++++++++++++++++++++++++ auth/token/token.go | 338 ++++++++++++++++++++++++++++++ auth/token/token_test.go | 365 +++++++++++++++++++++++++++++++++ auth/token/util.go | 77 +++++++ 5 files changed, 1124 insertions(+) create mode 100644 auth/auth.go create mode 100644 auth/token/accesscontroller.go create mode 100644 auth/token/token.go create mode 100644 auth/token/token_test.go create mode 100644 auth/token/util.go diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 000000000..bd31c56ac --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,76 @@ +package auth + +import ( + "fmt" + "net/http" +) + +// Resource describes a resource by type and name. +type Resource struct { + Type string + Name string +} + +// Access describes a specific action that is +// requested or allowed for a given recource. +type Access struct { + Resource + Action string +} + +// Challenge is a special error type which is used for HTTP 401 Unauthorized +// responses and is able to write the response with WWW-Authenticate challenge +// header values based on the error. +type Challenge interface { + error + Status() int + SetHeader(header http.Header) + ServeHTTP(w http.ResponseWriter, r *http.Request) +} + +// AccessController controls access to registry resources based on a request +// and required access levels for a request. Implementations can support both +// complete denial and http authorization challenges. +type AccessController interface { + // Authorized returns non-nil if the request is granted the request + // access. If the error is non-nil, access should always be denied. The + // error may be of type Challenge, in which case the caller may have the + // Challenge handle the request or choose what action to take based on the + // Challenge header or response status. + // + // In the future, other error types, besides Challenge, may be added to + // support more complex authorization flows. + Authorized(req *http.Request, access ...Access) error +} + +// InitFunc is the type of an AccessController factory function and is used +// to register the contsructor for different AccesController backends. +type InitFunc func(options map[string]interface{}) (AccessController, error) + +var accessControllers map[string]InitFunc + +func init() { + accessControllers = make(map[string]InitFunc) +} + +// Register is used to register an InitFunc for +// an AccessController backend with the given name. +func Register(name string, initFunc InitFunc) error { + if _, exists := accessControllers[name]; exists { + return fmt.Errorf("name already registered: %s", name) + } + + accessControllers[name] = initFunc + + return nil +} + +// GetAccessController constructs an AccessController +// with the given options using the named backend. +func GetAccessController(name string, options map[string]interface{}) (AccessController, error) { + if initFunc, exists := accessControllers[name]; exists { + return initFunc(options) + } + + return nil, fmt.Errorf("no access controller registered with name: %s", name) +} diff --git a/auth/token/accesscontroller.go b/auth/token/accesscontroller.go new file mode 100644 index 000000000..52e069120 --- /dev/null +++ b/auth/token/accesscontroller.go @@ -0,0 +1,268 @@ +package token + +import ( + "crypto" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "io/ioutil" + "net/http" + "os" + "strconv" + "strings" + + "github.com/docker/docker-registry/auth" + "github.com/docker/libtrust" +) + +// accessSet maps a typed, named resource to +// a set of actions requested or authorized. +type accessSet map[auth.Resource]actionSet + +// newAccessSet constructs an accessSet from +// a variable number of auth.Access items. +func newAccessSet(accessItems ...auth.Access) accessSet { + accessSet := make(accessSet, len(accessItems)) + + for _, access := range accessItems { + resource := auth.Resource{ + Type: access.Type, + Name: access.Name, + } + + set := accessSet[resource] + if set == nil { + set = make(actionSet) + accessSet[resource] = set + } + + set[access.Action] = struct{}{} + } + + return accessSet +} + +// contains returns whether or not the given access is in this accessSet. +func (s accessSet) contains(access auth.Access) bool { + actionSet, ok := s[access.Resource] + if ok { + return actionSet.contains(access.Action) + } + + return false +} + +// scopeParam returns a collection of scopes which can +// be used for a WWW-Authenticate challenge parameter. +// See https://tools.ietf.org/html/rfc6750#section-3 +func (s accessSet) scopeParam() string { + scopes := make([]string, 0, len(s)) + + for resource, actionSet := range s { + actions := strings.Join(actionSet.keys(), ",") + scopes = append(scopes, fmt.Sprintf("%s:%s:%s", resource.Type, resource.Name, actions)) + } + + return strings.Join(scopes, " ") +} + +// Errors used and exported by this package. +var ( + ErrInsufficientScope = errors.New("insufficient scope") + ErrTokenRequired = errors.New("authorization token required") +) + +// authChallenge implements the auth.Challenge interface. +type authChallenge struct { + err error + realm string + service string + accessSet accessSet +} + +// Error returns the internal error string for this authChallenge. +func (ac *authChallenge) Error() string { + return ac.err.Error() +} + +// Status returns the HTTP Response Status Code for this authChallenge. +func (ac *authChallenge) Status() int { + return http.StatusUnauthorized +} + +// challengeParams constructs the value to be used in +// the WWW-Authenticate response challenge header. +// See https://tools.ietf.org/html/rfc6750#section-3 +func (ac *authChallenge) challengeParams() string { + str := fmt.Sprintf("Bearer realm=%s,service=%s", strconv.Quote(ac.realm), strconv.Quote(ac.service)) + + if scope := ac.accessSet.scopeParam(); scope != "" { + str = fmt.Sprintf("%s,scope=%s", str, strconv.Quote(scope)) + } + + if ac.err == ErrInvalidToken || ac.err == ErrMalformedToken { + str = fmt.Sprintf("%s,error=%s", str, strconv.Quote("invalid_token")) + } else if ac.err == ErrInsufficientScope { + str = fmt.Sprintf("%s,error=%s", str, strconv.Quote("insufficient_scope")) + } + + return str +} + +// SetHeader sets the WWW-Authenticate value for the given header. +func (ac *authChallenge) SetHeader(header http.Header) { + header.Add(http.CanonicalHeaderKey("WWW-Authenticate"), ac.challengeParams()) +} + +// ServeHttp handles writing the challenge response +// by setting the challenge header and status code. +func (ac *authChallenge) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ac.SetHeader(w.Header()) + w.WriteHeader(ac.Status()) +} + +// accessController implements the auth.AccessController interface. +type accessController struct { + realm string + issuer string + service string + rootCerts *x509.CertPool + trustedKeys map[string]libtrust.PublicKey +} + +// tokenAccessOptions is a convenience type for handling +// options to the contstructor of an accessController. +type tokenAccessOptions struct { + realm string + issuer string + service string + rootCertBundle string +} + +// checkOptions gathers the necessary options +// for an accessController from the given map. +func checkOptions(options map[string]interface{}) (tokenAccessOptions, error) { + var opts tokenAccessOptions + + keys := []string{"realm", "issuer", "service", "rootCertBundle"} + vals := make([]string, 0, len(keys)) + for _, key := range keys { + val, ok := options[key].(string) + if !ok { + return opts, fmt.Errorf("token auth requires a valid option string: %q", key) + } + vals = append(vals, val) + } + + opts.realm, opts.issuer, opts.service, opts.rootCertBundle = vals[0], vals[1], vals[2], vals[3] + + return opts, nil +} + +// newAccessController creates an accessController using the given options. +func newAccessController(options map[string]interface{}) (auth.AccessController, error) { + config, err := checkOptions(options) + if err != nil { + return nil, err + } + + fp, err := os.Open(config.rootCertBundle) + if err != nil { + return nil, fmt.Errorf("unable to open token auth root certificate bundle file %q: %s", config.rootCertBundle, err) + } + defer fp.Close() + + rawCertBundle, err := ioutil.ReadAll(fp) + if err != nil { + return nil, fmt.Errorf("unable to read token auth root certificate bundle file %q: %s", config.rootCertBundle, err) + } + + var rootCerts []*x509.Certificate + pemBlock, rawCertBundle := pem.Decode(rawCertBundle) + for pemBlock != nil { + cert, err := x509.ParseCertificate(pemBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("unable to parse token auth root certificate: %s", err) + } + + rootCerts = append(rootCerts, cert) + + pemBlock, rawCertBundle = pem.Decode(rawCertBundle) + } + + if len(rootCerts) == 0 { + return nil, errors.New("token auth requires at least one token signing root certificate") + } + + rootPool := x509.NewCertPool() + trustedKeys := make(map[string]libtrust.PublicKey, len(rootCerts)) + for _, rootCert := range rootCerts { + rootPool.AddCert(rootCert) + pubKey, err := libtrust.FromCryptoPublicKey(crypto.PublicKey(rootCert.PublicKey)) + if err != nil { + return nil, fmt.Errorf("unable to get public key from token auth root certificate: %s", err) + } + trustedKeys[pubKey.KeyID()] = pubKey + } + + return &accessController{ + realm: config.realm, + issuer: config.issuer, + service: config.service, + rootCerts: rootPool, + trustedKeys: trustedKeys, + }, nil +} + +// Authorized handles checking whether the given request is authorized +// for actions on resources described by the given access items. +func (ac *accessController) Authorized(req *http.Request, accessItems ...auth.Access) error { + challenge := &authChallenge{ + realm: ac.realm, + service: ac.service, + accessSet: newAccessSet(accessItems...), + } + + parts := strings.Split(req.Header.Get("Authorization"), " ") + + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + challenge.err = ErrTokenRequired + return challenge + } + + rawToken := parts[1] + + token, err := NewToken(rawToken) + if err != nil { + challenge.err = err + return challenge + } + + verifyOpts := VerifyOptions{ + TrustedIssuers: newStringSet(ac.issuer), + AccpetedAudiences: newStringSet(ac.service), + Roots: ac.rootCerts, + TrustedKeys: ac.trustedKeys, + } + + if err = token.Verify(verifyOpts); err != nil { + challenge.err = err + return challenge + } + + accessSet := token.accessSet() + for _, access := range accessItems { + if !accessSet.contains(access) { + challenge.err = ErrInsufficientScope + return challenge + } + } + + return nil +} + +// init handles registering the token auth backend. +func init() { + auth.Register("token", auth.InitFunc(newAccessController)) +} diff --git a/auth/token/token.go b/auth/token/token.go new file mode 100644 index 000000000..d1baafe64 --- /dev/null +++ b/auth/token/token.go @@ -0,0 +1,338 @@ +package token + +import ( + "crypto" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + log "github.com/Sirupsen/logrus" + "github.com/docker/libtrust" + + "github.com/docker/docker-registry/auth" +) + +const ( + // TokenSeparator is the value which separates the header, claims, and + // signature in the compact serialization of a JSON Web Token. + TokenSeparator = "." +) + +// Errors used by token parsing and verification. +var ( + ErrMalformedToken = errors.New("malformed token") + ErrInvalidToken = errors.New("invalid token") +) + +// ResourceActions stores allowed actions on a named and typed resource. +type ResourceActions struct { + Type string `json:"type"` + Name string `json:"name"` + Actions []string `json:"actions"` +} + +// ClaimSet describes the main section of a JSON Web Token. +type ClaimSet struct { + // Public claims + Issuer string `json:"iss"` + Subject string `json:"sub"` + Audience string `json:"aud"` + Expiration int64 `json:"exp"` + NotBefore int64 `json:"nbf"` + IssuedAt int64 `json:"iat"` + JWTID string `json:"jti"` + + // Private claims + Access []*ResourceActions +} + +// Header describes the header section of a JSON Web Token. +type Header struct { + Type string `json:"typ"` + SigningAlg string `json:"alg"` + KeyID string `json:"kid,omitempty"` + RawJWK json.RawMessage `json:"jwk"` + SigningKey libtrust.PublicKey `json:"-"` +} + +// CheckSigningKey parses the `jwk` field of a JOSE header and sets the +// SigningKey field if it is valid. +func (h *Header) CheckSigningKey() (err error) { + if len(h.RawJWK) == 0 { + // No signing key was specified. + return + } + + h.SigningKey, err = libtrust.UnmarshalPublicKeyJWK([]byte(h.RawJWK)) + h.RawJWK = nil // Don't need this anymore! + + return +} + +// Token describes a JSON Web Token. +type Token struct { + Raw string + Header *Header + Claims *ClaimSet + Signature []byte + Valid bool +} + +// VerifyOptions is used to specify +// options when verifying a JSON Web Token. +type VerifyOptions struct { + TrustedIssuers stringSet + AccpetedAudiences stringSet + Roots *x509.CertPool + TrustedKeys map[string]libtrust.PublicKey +} + +// NewToken parses the given raw token string +// and constructs an unverified JSON Web Token. +func NewToken(rawToken string) (*Token, error) { + parts := strings.Split(rawToken, TokenSeparator) + if len(parts) != 3 { + return nil, ErrMalformedToken + } + + var ( + rawHeader, rawClaims = parts[0], parts[1] + headerJSON, claimsJSON []byte + err error + ) + + defer func() { + if err != nil { + log.Errorf("error while unmarshalling raw token: %s", err) + } + }() + + if headerJSON, err = joseBase64UrlDecode(rawHeader); err != nil { + err = fmt.Errorf("unable to decode header: %s", err) + return nil, ErrMalformedToken + } + + if claimsJSON, err = joseBase64UrlDecode(rawClaims); err != nil { + err = fmt.Errorf("unable to decode claims: %s", err) + return nil, ErrMalformedToken + } + + token := new(Token) + token.Header = new(Header) + token.Claims = new(ClaimSet) + + token.Raw = strings.Join(parts[:2], TokenSeparator) + if token.Signature, err = joseBase64UrlDecode(parts[2]); err != nil { + err = fmt.Errorf("unable to decode signature: %s", err) + return nil, ErrMalformedToken + } + + if err = json.Unmarshal(headerJSON, token.Header); err != nil { + return nil, ErrMalformedToken + } + + if err = token.Header.CheckSigningKey(); err != nil { + return nil, ErrMalformedToken + } + + if err = json.Unmarshal(claimsJSON, token.Claims); err != nil { + return nil, ErrMalformedToken + } + + return token, nil +} + +// Verify attempts to verify this token using the given options. +// Returns a nil error if the token is valid. +func (t *Token) Verify(verifyOpts VerifyOptions) error { + if t.Valid { + // Token was already verified. + return nil + } + + // Verify that the Issuer claim is a trusted authority. + if !verifyOpts.TrustedIssuers.contains(t.Claims.Issuer) { + log.Errorf("token from untrusted issuer: %q", t.Claims.Issuer) + return ErrInvalidToken + } + + // Verify that the Audience claim is allowed. + if !verifyOpts.AccpetedAudiences.contains(t.Claims.Audience) { + log.Errorf("token intended for another audience: %q", t.Claims.Audience) + return ErrInvalidToken + } + + // Verify that the token is currently usable and not expired. + currentUnixTime := time.Now().Unix() + if !(t.Claims.NotBefore <= currentUnixTime && currentUnixTime <= t.Claims.Expiration) { + log.Errorf("token not to be used before %d or after %d - currently %d", t.Claims.NotBefore, t.Claims.Expiration, currentUnixTime) + return ErrInvalidToken + } + + // Verify the token signature. + if len(t.Signature) == 0 { + log.Error("token has no signature") + return ErrInvalidToken + } + + // If the token header has a SigningKey field, verify the signature + // using that key and its included x509 certificate chain if necessary. + // If the Header's SigningKey field is nil, try using the KeyID field. + signingKey := t.Header.SigningKey + + if signingKey == nil { + // Find the key in the given collection of trusted keys. + trustedKey, ok := verifyOpts.TrustedKeys[t.Header.KeyID] + if !ok { + log.Errorf("token signed by untrusted key with ID: %q", t.Header.KeyID) + return ErrInvalidToken + } + signingKey = trustedKey + } + + // First verify the signature of the token using the key which signed it. + if err := signingKey.Verify(strings.NewReader(t.Raw), t.Header.SigningAlg, t.Signature); err != nil { + log.Errorf("unable to verify token signature: %s", err) + return ErrInvalidToken + } + + // Next, check if the signing key is one of the trusted keys. + if _, isTrustedKey := verifyOpts.TrustedKeys[signingKey.KeyID()]; isTrustedKey { + // We're done! The token was signed by a trusted key and has been verified! + t.Valid = true + return nil + } + + // Otherwise, we need to check the sigining keys included certificate chain. + return t.verifyCertificateChain(signingKey, verifyOpts.Roots) +} + +// verifyCertificateChain attempts to verify the token using the "x5c" field +// of the given leafKey which was used to sign it. Returns a nil error if +// the key's certificate chain is valid and rooted an one of the given roots. +func (t *Token) verifyCertificateChain(leafKey libtrust.PublicKey, roots *x509.CertPool) error { + // In this case, the token signature is valid, but the key that signed it + // is not in our set of trusted keys. So, we'll need to check if the + // token's signing key included an x509 certificate chain that can be + // verified up to one of our trusted roots. + x5cVal, ok := leafKey.GetExtendedField("x5c").([]interface{}) + if !ok || x5cVal == nil { + log.Error("unable to verify token signature: signed by untrusted key with no valid certificate chain") + return ErrInvalidToken + } + + // Ensure each item is of the correct type. + x5c := make([]string, len(x5cVal)) + for i, val := range x5cVal { + certString, ok := val.(string) + if !ok || len(certString) == 0 { + log.Error("unable to verify token signature: signed by untrusted key with malformed certificate chain") + return ErrInvalidToken + } + x5c[i] = certString + } + + // Ensure the first element is encoded correctly. + leafCertDer, err := base64.StdEncoding.DecodeString(x5c[0]) + if err != nil { + log.Errorf("unable to decode signing key leaf cert: %s", err) + return ErrInvalidToken + } + + // And that it is a valid x509 certificate. + leafCert, err := x509.ParseCertificate(leafCertDer) + if err != nil { + log.Errorf("unable to parse signing key leaf cert: %s", err) + return ErrInvalidToken + } + + // Verify that the public key in the leaf cert *is* the signing key. + leafCryptoKey, ok := leafCert.PublicKey.(crypto.PublicKey) + if !ok { + log.Error("unable to get signing key leaf cert public key value") + return ErrInvalidToken + } + + leafPubKey, err := libtrust.FromCryptoPublicKey(leafCryptoKey) + if err != nil { + log.Errorf("unable to make libtrust public key from signing key leaf cert: %s", err) + return ErrInvalidToken + } + + if leafPubKey.KeyID() != leafKey.KeyID() { + log.Error("token signing key ID and leaf certificate public key ID do not match") + return ErrInvalidToken + } + + // The rest of the x5c array are intermediate certificates. + intermediates := x509.NewCertPool() + for i := 1; i < len(x5c); i++ { + intermediateCertDer, err := base64.StdEncoding.DecodeString(x5c[i]) + if err != nil { + log.Errorf("unable to decode signing key intermediate cert: %s", err) + return ErrInvalidToken + } + + intermediateCert, err := x509.ParseCertificate(intermediateCertDer) + if err != nil { + log.Errorf("unable to parse signing key intermediate cert: %s", err) + return ErrInvalidToken + } + + intermediates.AddCert(intermediateCert) + } + + verifyOpts := x509.VerifyOptions{ + Intermediates: intermediates, + Roots: roots, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + } + + // TODO: this call returns certificate chains which we ignore for now, but + // we should check them for revocations if we have the ability later. + if _, err = leafCert.Verify(verifyOpts); err != nil { + log.Errorf("unable to verify signing key certificate: %s", err) + return ErrInvalidToken + } + + // The signing key's x509 chain is valid! + t.Valid = true + return nil +} + +// accessSet returns a set of actions available for the resource +// actions listed in the `access` section of this token. +func (t *Token) accessSet() accessSet { + if t.Claims == nil { + return nil + } + + accessSet := make(accessSet, len(t.Claims.Access)) + + for _, resourceActions := range t.Claims.Access { + resource := auth.Resource{ + Type: resourceActions.Type, + Name: resourceActions.Name, + } + + set := accessSet[resource] + if set == nil { + set = make(actionSet) + accessSet[resource] = set + } + + for _, action := range resourceActions.Actions { + set[action] = struct{}{} + } + } + + return accessSet +} + +func (t *Token) compactRaw() string { + return fmt.Sprintf("%s.%s", t.Raw, joseBase64UrlEncode(t.Signature)) +} diff --git a/auth/token/token_test.go b/auth/token/token_test.go new file mode 100644 index 000000000..be7470c9a --- /dev/null +++ b/auth/token/token_test.go @@ -0,0 +1,365 @@ +package token + +import ( + "crypto" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io/ioutil" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/docker/docker-registry/auth" + "github.com/docker/libtrust" +) + +func makeRootKeys(numKeys int) ([]libtrust.PrivateKey, error) { + keys := make([]libtrust.PrivateKey, 0, numKeys) + + for i := 0; i < numKeys; i++ { + key, err := libtrust.GenerateECP256PrivateKey() + if err != nil { + return nil, err + } + keys = append(keys, key) + } + + return keys, nil +} + +func makeSigningKeyWithChain(rootKey libtrust.PrivateKey, depth int) (libtrust.PrivateKey, error) { + if depth == 0 { + // Don't need to build a chain. + return rootKey, nil + } + + var ( + x5c = make([]string, depth) + parentKey = rootKey + key libtrust.PrivateKey + cert *x509.Certificate + err error + ) + + for depth > 0 { + if key, err = libtrust.GenerateECP256PrivateKey(); err != nil { + return nil, err + } + + if cert, err = libtrust.GenerateCACert(parentKey, key); err != nil { + return nil, err + } + + depth-- + x5c[depth] = base64.StdEncoding.EncodeToString(cert.Raw) + parentKey = key + } + + key.AddExtendedField("x5c", x5c) + + return key, nil +} + +func makeRootCerts(rootKeys []libtrust.PrivateKey) ([]*x509.Certificate, error) { + certs := make([]*x509.Certificate, 0, len(rootKeys)) + + for _, key := range rootKeys { + cert, err := libtrust.GenerateCACert(key, key) + if err != nil { + return nil, err + } + certs = append(certs, cert) + } + + return certs, nil +} + +func makeTrustedKeyMap(rootKeys []libtrust.PrivateKey) map[string]libtrust.PublicKey { + trustedKeys := make(map[string]libtrust.PublicKey, len(rootKeys)) + + for _, key := range rootKeys { + trustedKeys[key.KeyID()] = key.PublicKey() + } + + return trustedKeys +} + +func makeTestToken(issuer, audience string, access []*ResourceActions, rootKey libtrust.PrivateKey, depth int) (*Token, error) { + signingKey, err := makeSigningKeyWithChain(rootKey, depth) + if err != nil { + return nil, fmt.Errorf("unable to amke signing key with chain: %s", err) + } + + rawJWK, err := signingKey.PublicKey().MarshalJSON() + if err != nil { + return nil, fmt.Errorf("unable to marshal signing key to JSON: %s", err) + } + + joseHeader := &Header{ + Type: "JWT", + SigningAlg: "ES256", + RawJWK: json.RawMessage(rawJWK), + } + + now := time.Now() + + randomBytes := make([]byte, 15) + if _, err = rand.Read(randomBytes); err != nil { + return nil, fmt.Errorf("unable to read random bytes for jwt id: %s", err) + } + + claimSet := &ClaimSet{ + Issuer: issuer, + Subject: "foo", + Audience: audience, + Expiration: now.Add(5 * time.Minute).Unix(), + NotBefore: now.Unix(), + IssuedAt: now.Unix(), + JWTID: base64.URLEncoding.EncodeToString(randomBytes), + Access: access, + } + + var joseHeaderBytes, claimSetBytes []byte + + if joseHeaderBytes, err = json.Marshal(joseHeader); err != nil { + return nil, fmt.Errorf("unable to marshal jose header: %s", err) + } + if claimSetBytes, err = json.Marshal(claimSet); err != nil { + return nil, fmt.Errorf("unable to marshal claim set: %s", err) + } + + encodedJoseHeader := joseBase64UrlEncode(joseHeaderBytes) + encodedClaimSet := joseBase64UrlEncode(claimSetBytes) + encodingToSign := fmt.Sprintf("%s.%s", encodedJoseHeader, encodedClaimSet) + + var signatureBytes []byte + if signatureBytes, _, err = signingKey.Sign(strings.NewReader(encodingToSign), crypto.SHA256); err != nil { + return nil, fmt.Errorf("unable to sign jwt payload: %s", err) + } + + signature := joseBase64UrlEncode(signatureBytes) + tokenString := fmt.Sprintf("%s.%s", encodingToSign, signature) + + return NewToken(tokenString) +} + +// This test makes 4 tokens with a varying number of intermediate +// certificates ranging from no intermediate chain to a length of 3 +// intermediates. +func TestTokenVerify(t *testing.T) { + var ( + numTokens = 4 + issuer = "test-issuer" + audience = "test-audience" + access = []*ResourceActions{ + { + Type: "repository", + Name: "foo/bar", + Actions: []string{"pull", "push"}, + }, + } + ) + + rootKeys, err := makeRootKeys(numTokens) + if err != nil { + t.Fatal(err) + } + + rootCerts, err := makeRootCerts(rootKeys) + if err != nil { + t.Fatal(err) + } + + rootPool := x509.NewCertPool() + for _, rootCert := range rootCerts { + rootPool.AddCert(rootCert) + } + + trustedKeys := makeTrustedKeyMap(rootKeys) + + tokens := make([]*Token, 0, numTokens) + + for i := 0; i < numTokens; i++ { + token, err := makeTestToken(issuer, audience, access, rootKeys[i], i) + if err != nil { + t.Fatal(err) + } + tokens = append(tokens, token) + } + + verifyOps := VerifyOptions{ + TrustedIssuers: newStringSet(issuer), + AccpetedAudiences: newStringSet(audience), + Roots: rootPool, + TrustedKeys: trustedKeys, + } + + for _, token := range tokens { + if err := token.Verify(verifyOps); err != nil { + t.Fatal(err) + } + if !token.Valid { + t.Fatal("token not marked as Valid") + } + } +} + +func writeTempRootCerts(rootKeys []libtrust.PrivateKey) (filename string, err error) { + rootCerts, err := makeRootCerts(rootKeys) + if err != nil { + return "", err + } + + tempFile, err := ioutil.TempFile("", "rootCertBundle") + if err != nil { + return "", err + } + defer tempFile.Close() + + for _, cert := range rootCerts { + if err = pem.Encode(tempFile, &pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }); err != nil { + os.Remove(tempFile.Name()) + return "", err + } + } + + return tempFile.Name(), nil +} + +// TestAccessController tests complete integration of the token auth package. +// It starts by mocking the options for a token auth accessController which +// it creates. It then tries a few mock requests: +// - don't supply a token; should error with challenge +// - supply an invalid token; should error with challenge +// - supply a token with insufficient access; should error with challenge +// - supply a valid token; should not error +func TestAccessController(t *testing.T) { + // Make 2 keys; only the first is to be a trusted root key. + rootKeys, err := makeRootKeys(2) + if err != nil { + t.Fatal(err) + } + + rootCertBundleFilename, err := writeTempRootCerts(rootKeys[:1]) + if err != nil { + t.Fatal(err) + } + defer os.Remove(rootCertBundleFilename) + + realm := "https://auth.example.com/token/" + issuer := "test-issuer.example.com" + service := "test-service.example.com" + + options := map[string]interface{}{ + "realm": realm, + "issuer": issuer, + "service": service, + "rootCertBundle": rootCertBundleFilename, + } + + accessController, err := newAccessController(options) + if err != nil { + t.Fatal(err) + } + + // 1. Make a mock http.Request with no token. + req, err := http.NewRequest("GET", "http://example.com/foo", nil) + if err != nil { + t.Fatal(err) + } + + testAccess := auth.Access{ + Resource: auth.Resource{ + Type: "foo", + Name: "bar", + }, + Action: "baz", + } + + err = accessController.Authorized(req, testAccess) + challenge, ok := err.(auth.Challenge) + if !ok { + t.Fatal("accessController did not return a challenge") + } + + if challenge.Error() != ErrTokenRequired.Error() { + t.Fatalf("accessControler did not get expected error - got %s - expected %s", challenge, ErrTokenRequired) + } + + // 2. Supply an invalid token. + token, err := makeTestToken( + issuer, service, + []*ResourceActions{{ + Type: testAccess.Type, + Name: testAccess.Name, + Actions: []string{testAccess.Action}, + }}, + rootKeys[1], 1, // Everything is valid except the key which signed it. + ) + if err != nil { + t.Fatal(err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.compactRaw())) + + err = accessController.Authorized(req, testAccess) + challenge, ok = err.(auth.Challenge) + if !ok { + t.Fatal("accessController did not return a challenge") + } + + if challenge.Error() != ErrInvalidToken.Error() { + t.Fatalf("accessControler did not get expected error - got %s - expected %s", challenge, ErrTokenRequired) + } + + // 3. Supply a token with insufficient access. + token, err = makeTestToken( + issuer, service, + []*ResourceActions{}, // No access specified. + rootKeys[0], 1, + ) + if err != nil { + t.Fatal(err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.compactRaw())) + + err = accessController.Authorized(req, testAccess) + challenge, ok = err.(auth.Challenge) + if !ok { + t.Fatal("accessController did not return a challenge") + } + + if challenge.Error() != ErrInsufficientScope.Error() { + t.Fatalf("accessControler did not get expected error - got %s - expected %s", challenge, ErrInsufficientScope) + } + + // 4. Supply the token we need, or deserve, or whatever. + token, err = makeTestToken( + issuer, service, + []*ResourceActions{{ + Type: testAccess.Type, + Name: testAccess.Name, + Actions: []string{testAccess.Action}, + }}, + rootKeys[0], 1, // Everything is valid except the key which signed it. + ) + if err != nil { + t.Fatal(err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.compactRaw())) + + if err = accessController.Authorized(req, testAccess); err != nil { + t.Fatalf("accessController returned unexpected error: %s", err) + } +} diff --git a/auth/token/util.go b/auth/token/util.go new file mode 100644 index 000000000..94078fb8e --- /dev/null +++ b/auth/token/util.go @@ -0,0 +1,77 @@ +package token + +import ( + "encoding/base64" + "errors" + "strings" +) + +// joseBase64UrlEncode encodes the given data using the standard base64 url +// encoding format but with all trailing '=' characters ommitted in accordance +// with the jose specification. +// http://tools.ietf.org/html/draft-ietf-jose-json-web-signature-31#section-2 +func joseBase64UrlEncode(b []byte) string { + return strings.TrimRight(base64.URLEncoding.EncodeToString(b), "=") +} + +// joseBase64UrlDecode decodes the given string using the standard base64 url +// decoder but first adds the appropriate number of trailing '=' characters in +// accordance with the jose specification. +// http://tools.ietf.org/html/draft-ietf-jose-json-web-signature-31#section-2 +func joseBase64UrlDecode(s string) ([]byte, error) { + switch len(s) % 4 { + case 0: + case 2: + s += "==" + case 3: + s += "=" + default: + return nil, errors.New("illegal base64url string") + } + return base64.URLEncoding.DecodeString(s) +} + +// stringSet is a useful type for looking up strings. +type stringSet map[string]struct{} + +func newStringSet(strs ...string) stringSet { + set := make(stringSet, len(strs)) + for _, str := range strs { + set[str] = struct{}{} + } + + return set +} + +// contains returns whether the given key is in this StringSet. +func (ss stringSet) contains(key string) bool { + _, ok := ss[key] + return ok +} + +// keys returns a slice of all keys in this stringSet. +func (ss stringSet) keys() []string { + keys := make([]string, 0, len(ss)) + + for key := range ss { + keys = append(keys, key) + } + + return keys +} + +// actionSet is a special type of stringSet. +type actionSet stringSet + +// contains calls stringSet.contains() for +// either "*" or the given action string. +func (s actionSet) contains(action string) bool { + ss := stringSet(s) + + return ss.contains("*") || ss.contains(action) +} + +// keys wraps stringSet.keys() +func (s actionSet) keys() []string { + return stringSet(s).keys() +} From 88de2e11fb5f79135ec64761a3c26af4e607488f Mon Sep 17 00:00:00 2001 From: Josh Hawn Date: Wed, 17 Dec 2014 10:57:05 -0800 Subject: [PATCH 2/4] Refactor auth stringSet into common.StringSet Docker-DCO-1.1-Signed-off-by: Josh Hawn (github: jlhawn) --- auth/token/accesscontroller.go | 20 +++++++------ auth/token/token.go | 17 +++++------ auth/token/token_test.go | 8 ++++-- auth/token/util.go | 52 ++++++++-------------------------- common/stringset.go | 35 +++++++++++++++++++++++ 5 files changed, 72 insertions(+), 60 deletions(-) create mode 100644 common/stringset.go diff --git a/auth/token/accesscontroller.go b/auth/token/accesscontroller.go index 52e069120..66bf1c315 100644 --- a/auth/token/accesscontroller.go +++ b/auth/token/accesscontroller.go @@ -12,8 +12,10 @@ import ( "strconv" "strings" - "github.com/docker/docker-registry/auth" "github.com/docker/libtrust" + + "github.com/docker/docker-registry/auth" + "github.com/docker/docker-registry/common" ) // accessSet maps a typed, named resource to @@ -31,13 +33,13 @@ func newAccessSet(accessItems ...auth.Access) accessSet { Name: access.Name, } - set := accessSet[resource] - if set == nil { - set = make(actionSet) + set, exists := accessSet[resource] + if !exists { + set = newActionSet() accessSet[resource] = set } - set[access.Action] = struct{}{} + set.Add(access.Action) } return accessSet @@ -47,7 +49,7 @@ func newAccessSet(accessItems ...auth.Access) accessSet { func (s accessSet) contains(access auth.Access) bool { actionSet, ok := s[access.Resource] if ok { - return actionSet.contains(access.Action) + return actionSet.Contains(access.Action) } return false @@ -60,7 +62,7 @@ func (s accessSet) scopeParam() string { scopes := make([]string, 0, len(s)) for resource, actionSet := range s { - actions := strings.Join(actionSet.keys(), ",") + actions := strings.Join(actionSet.Keys(), ",") scopes = append(scopes, fmt.Sprintf("%s:%s:%s", resource.Type, resource.Name, actions)) } @@ -240,8 +242,8 @@ func (ac *accessController) Authorized(req *http.Request, accessItems ...auth.Ac } verifyOpts := VerifyOptions{ - TrustedIssuers: newStringSet(ac.issuer), - AccpetedAudiences: newStringSet(ac.service), + TrustedIssuers: common.NewStringSet(ac.issuer), + AccpetedAudiences: common.NewStringSet(ac.service), Roots: ac.rootCerts, TrustedKeys: ac.trustedKeys, } diff --git a/auth/token/token.go b/auth/token/token.go index d1baafe64..d1b0d67a7 100644 --- a/auth/token/token.go +++ b/auth/token/token.go @@ -14,6 +14,7 @@ import ( "github.com/docker/libtrust" "github.com/docker/docker-registry/auth" + "github.com/docker/docker-registry/common" ) const ( @@ -85,8 +86,8 @@ type Token struct { // VerifyOptions is used to specify // options when verifying a JSON Web Token. type VerifyOptions struct { - TrustedIssuers stringSet - AccpetedAudiences stringSet + TrustedIssuers common.StringSet + AccpetedAudiences common.StringSet Roots *x509.CertPool TrustedKeys map[string]libtrust.PublicKey } @@ -155,13 +156,13 @@ func (t *Token) Verify(verifyOpts VerifyOptions) error { } // Verify that the Issuer claim is a trusted authority. - if !verifyOpts.TrustedIssuers.contains(t.Claims.Issuer) { + if !verifyOpts.TrustedIssuers.Contains(t.Claims.Issuer) { log.Errorf("token from untrusted issuer: %q", t.Claims.Issuer) return ErrInvalidToken } // Verify that the Audience claim is allowed. - if !verifyOpts.AccpetedAudiences.contains(t.Claims.Audience) { + if !verifyOpts.AccpetedAudiences.Contains(t.Claims.Audience) { log.Errorf("token intended for another audience: %q", t.Claims.Audience) return ErrInvalidToken } @@ -319,14 +320,14 @@ func (t *Token) accessSet() accessSet { Name: resourceActions.Name, } - set := accessSet[resource] - if set == nil { - set = make(actionSet) + set, exists := accessSet[resource] + if !exists { + set = newActionSet() accessSet[resource] = set } for _, action := range resourceActions.Actions { - set[action] = struct{}{} + set.Add(action) } } diff --git a/auth/token/token_test.go b/auth/token/token_test.go index be7470c9a..da466ddeb 100644 --- a/auth/token/token_test.go +++ b/auth/token/token_test.go @@ -15,8 +15,10 @@ import ( "testing" "time" - "github.com/docker/docker-registry/auth" "github.com/docker/libtrust" + + "github.com/docker/docker-registry/auth" + "github.com/docker/docker-registry/common" ) func makeRootKeys(numKeys int) ([]libtrust.PrivateKey, error) { @@ -194,8 +196,8 @@ func TestTokenVerify(t *testing.T) { } verifyOps := VerifyOptions{ - TrustedIssuers: newStringSet(issuer), - AccpetedAudiences: newStringSet(audience), + TrustedIssuers: common.NewStringSet(issuer), + AccpetedAudiences: common.NewStringSet(audience), Roots: rootPool, TrustedKeys: trustedKeys, } diff --git a/auth/token/util.go b/auth/token/util.go index 94078fb8e..7ec52cef8 100644 --- a/auth/token/util.go +++ b/auth/token/util.go @@ -4,6 +4,8 @@ import ( "encoding/base64" "errors" "strings" + + "github.com/docker/docker-registry/common" ) // joseBase64UrlEncode encodes the given data using the standard base64 url @@ -31,47 +33,17 @@ func joseBase64UrlDecode(s string) ([]byte, error) { return base64.URLEncoding.DecodeString(s) } -// stringSet is a useful type for looking up strings. -type stringSet map[string]struct{} - -func newStringSet(strs ...string) stringSet { - set := make(stringSet, len(strs)) - for _, str := range strs { - set[str] = struct{}{} - } - - return set -} - -// contains returns whether the given key is in this StringSet. -func (ss stringSet) contains(key string) bool { - _, ok := ss[key] - return ok -} - -// keys returns a slice of all keys in this stringSet. -func (ss stringSet) keys() []string { - keys := make([]string, 0, len(ss)) - - for key := range ss { - keys = append(keys, key) - } - - return keys -} - // actionSet is a special type of stringSet. -type actionSet stringSet +type actionSet struct { + common.StringSet +} -// contains calls stringSet.contains() for +func newActionSet(actions ...string) actionSet { + return actionSet{common.NewStringSet(actions...)} +} + +// Contains calls StringSet.Contains() for // either "*" or the given action string. -func (s actionSet) contains(action string) bool { - ss := stringSet(s) - - return ss.contains("*") || ss.contains(action) -} - -// keys wraps stringSet.keys() -func (s actionSet) keys() []string { - return stringSet(s).keys() +func (s actionSet) Contains(action string) bool { + return s.StringSet.Contains("*") || s.StringSet.Contains(action) } diff --git a/common/stringset.go b/common/stringset.go new file mode 100644 index 000000000..36f4ba5a6 --- /dev/null +++ b/common/stringset.go @@ -0,0 +1,35 @@ +package common + +// StringSet is a useful type for looking up strings. +type StringSet map[string]struct{} + +// NewStringSet creates a new StringSet with the given strings. +func NewStringSet(keys ...string) StringSet { + ss := make(StringSet, len(keys)) + ss.Add(keys...) + return ss +} + +// Add inserts the given keys into this StringSet. +func (ss StringSet) Add(keys ...string) { + for _, key := range keys { + ss[key] = struct{}{} + } +} + +// Contains returns whether the given key is in this StringSet. +func (ss StringSet) Contains(key string) bool { + _, ok := ss[key] + return ok +} + +// Keys returns a slice of all keys in this StringSet. +func (ss StringSet) Keys() []string { + keys := make([]string, 0, len(ss)) + + for key := range ss { + keys = append(keys, key) + } + + return keys +} From d30a8321d8d2561dda41769a5e612462338d60c1 Mon Sep 17 00:00:00 2001 From: Josh Hawn Date: Wed, 17 Dec 2014 11:35:35 -0800 Subject: [PATCH 3/4] Address auth package comments from stevvooe Docker-DCO-1.1-Signed-off-by: Josh Hawn (github: jlhawn) --- auth/auth.go | 51 +++++++++++++++++++++++++++------- auth/token/accesscontroller.go | 2 +- auth/token/token.go | 11 ++------ auth/token/token_test.go | 3 -- 4 files changed, 44 insertions(+), 23 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index bd31c56ac..eb7332e70 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -1,3 +1,33 @@ +// Package auth defines a standard interface for request access controllers. +// +// An access controller has a simple interface with a single `Authorized` +// method which checks that a given request is authorized to perform one or +// more actions on one or more resources. This method should return a non-nil +// error if the requset is not authorized. +// +// An implementation registers its access controller by name with a constructor +// which accepts an options map for configuring the access controller. +// +// options := map[string]interface{}{"sillySecret": "whysosilly?"} +// accessController, _ := auth.GetAccessController("silly", options) +// +// This `accessController` can then be used in a request handler like so: +// +// func updateOrder(w http.ResponseWriter, r *http.Request) { +// orderNumber := r.FormValue("orderNumber") +// resource := auth.Resource{Type: "customerOrder", Name: orderNumber} +// access := auth.Access{Resource: resource, Action: "update"} +// +// if err := accessController.Authorized(r, access); err != nil { +// if challenge, ok := err.(auth.Challenge) { +// // Let the challenge write the response. +// challenge.ServeHTTP(w, r) +// } else { +// // Some other error. +// } +// } +// } +// package auth import ( @@ -23,8 +53,11 @@ type Access struct { // header values based on the error. type Challenge interface { error - Status() int - SetHeader(header http.Header) + // ServeHTTP prepares the request to conduct the appropriate challenge + // response. For most implementations, simply calling ServeHTTP should be + // sufficient. Because no body is written, users may write a custom body after + // calling ServeHTTP, but any headers must be written before the call and may + // be overwritten. ServeHTTP(w http.ResponseWriter, r *http.Request) } @@ -32,14 +65,12 @@ type Challenge interface { // and required access levels for a request. Implementations can support both // complete denial and http authorization challenges. type AccessController interface { - // Authorized returns non-nil if the request is granted the request - // access. If the error is non-nil, access should always be denied. The - // error may be of type Challenge, in which case the caller may have the - // Challenge handle the request or choose what action to take based on the - // Challenge header or response status. - // - // In the future, other error types, besides Challenge, may be added to - // support more complex authorization flows. + // Authorized returns non-nil if the request is granted access. If one or + // more Access structs are provided, the requested access will be compared + // with what is available to the request. If the error is non-nil, access + // should always be denied. The error may be of type Challenge, in which + // case the caller may have the Challenge handle the request or choose + // what action to take based on the Challenge header or response status. Authorized(req *http.Request, access ...Access) error } diff --git a/auth/token/accesscontroller.go b/auth/token/accesscontroller.go index 66bf1c315..09d78a71c 100644 --- a/auth/token/accesscontroller.go +++ b/auth/token/accesscontroller.go @@ -114,7 +114,7 @@ func (ac *authChallenge) challengeParams() string { // SetHeader sets the WWW-Authenticate value for the given header. func (ac *authChallenge) SetHeader(header http.Header) { - header.Add(http.CanonicalHeaderKey("WWW-Authenticate"), ac.challengeParams()) + header.Add("WWW-Authenticate", ac.challengeParams()) } // ServeHttp handles writing the challenge response diff --git a/auth/token/token.go b/auth/token/token.go index d1b0d67a7..2c1114a6b 100644 --- a/auth/token/token.go +++ b/auth/token/token.go @@ -80,7 +80,6 @@ type Token struct { Header *Header Claims *ClaimSet Signature []byte - Valid bool } // VerifyOptions is used to specify @@ -150,11 +149,6 @@ func NewToken(rawToken string) (*Token, error) { // Verify attempts to verify this token using the given options. // Returns a nil error if the token is valid. func (t *Token) Verify(verifyOpts VerifyOptions) error { - if t.Valid { - // Token was already verified. - return nil - } - // Verify that the Issuer claim is a trusted authority. if !verifyOpts.TrustedIssuers.Contains(t.Claims.Issuer) { log.Errorf("token from untrusted issuer: %q", t.Claims.Issuer) @@ -203,8 +197,8 @@ func (t *Token) Verify(verifyOpts VerifyOptions) error { // Next, check if the signing key is one of the trusted keys. if _, isTrustedKey := verifyOpts.TrustedKeys[signingKey.KeyID()]; isTrustedKey { - // We're done! The token was signed by a trusted key and has been verified! - t.Valid = true + // We're done! The token was signed by + // a trusted key and has been verified! return nil } @@ -301,7 +295,6 @@ func (t *Token) verifyCertificateChain(leafKey libtrust.PublicKey, roots *x509.C } // The signing key's x509 chain is valid! - t.Valid = true return nil } diff --git a/auth/token/token_test.go b/auth/token/token_test.go index da466ddeb..c1e0d2ad6 100644 --- a/auth/token/token_test.go +++ b/auth/token/token_test.go @@ -206,9 +206,6 @@ func TestTokenVerify(t *testing.T) { if err := token.Verify(verifyOps); err != nil { t.Fatal(err) } - if !token.Valid { - t.Fatal("token not marked as Valid") - } } } From b54bf450dcd4a8a1602ecc2225fcd75577ad16df Mon Sep 17 00:00:00 2001 From: Josh Hawn Date: Wed, 17 Dec 2014 12:16:32 -0800 Subject: [PATCH 4/4] Fixes typo on auth/token VerifyOptions field Docker-DCO-1.1-Signed-off-by: Josh Hawn (github: jlhawn) --- auth/token/accesscontroller.go | 2 +- auth/token/token.go | 4 ++-- auth/token/token_test.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/auth/token/accesscontroller.go b/auth/token/accesscontroller.go index 09d78a71c..f7ca4d52f 100644 --- a/auth/token/accesscontroller.go +++ b/auth/token/accesscontroller.go @@ -243,7 +243,7 @@ func (ac *accessController) Authorized(req *http.Request, accessItems ...auth.Ac verifyOpts := VerifyOptions{ TrustedIssuers: common.NewStringSet(ac.issuer), - AccpetedAudiences: common.NewStringSet(ac.service), + AcceptedAudiences: common.NewStringSet(ac.service), Roots: ac.rootCerts, TrustedKeys: ac.trustedKeys, } diff --git a/auth/token/token.go b/auth/token/token.go index 2c1114a6b..568b257a3 100644 --- a/auth/token/token.go +++ b/auth/token/token.go @@ -86,7 +86,7 @@ type Token struct { // options when verifying a JSON Web Token. type VerifyOptions struct { TrustedIssuers common.StringSet - AccpetedAudiences common.StringSet + AcceptedAudiences common.StringSet Roots *x509.CertPool TrustedKeys map[string]libtrust.PublicKey } @@ -156,7 +156,7 @@ func (t *Token) Verify(verifyOpts VerifyOptions) error { } // Verify that the Audience claim is allowed. - if !verifyOpts.AccpetedAudiences.Contains(t.Claims.Audience) { + if !verifyOpts.AcceptedAudiences.Contains(t.Claims.Audience) { log.Errorf("token intended for another audience: %q", t.Claims.Audience) return ErrInvalidToken } diff --git a/auth/token/token_test.go b/auth/token/token_test.go index c1e0d2ad6..13d7cedec 100644 --- a/auth/token/token_test.go +++ b/auth/token/token_test.go @@ -197,7 +197,7 @@ func TestTokenVerify(t *testing.T) { verifyOps := VerifyOptions{ TrustedIssuers: common.NewStringSet(issuer), - AccpetedAudiences: common.NewStringSet(audience), + AcceptedAudiences: common.NewStringSet(audience), Roots: rootPool, TrustedKeys: trustedKeys, }