Merge pull request #859 from jlhawn/ng_auth_package
Adds auth package with token auth backend
This commit is contained in:
commit
3c0dbe2595
6 changed files with 1157 additions and 0 deletions
107
auth/auth.go
Normal file
107
auth/auth.go
Normal file
|
@ -0,0 +1,107 @@
|
|||
// 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 (
|
||||
"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
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
270
auth/token/accesscontroller.go
Normal file
270
auth/token/accesscontroller.go
Normal file
|
@ -0,0 +1,270 @@
|
|||
package token
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/libtrust"
|
||||
|
||||
"github.com/docker/docker-registry/auth"
|
||||
"github.com/docker/docker-registry/common"
|
||||
)
|
||||
|
||||
// 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, exists := accessSet[resource]
|
||||
if !exists {
|
||||
set = newActionSet()
|
||||
accessSet[resource] = set
|
||||
}
|
||||
|
||||
set.Add(access.Action)
|
||||
}
|
||||
|
||||
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("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: common.NewStringSet(ac.issuer),
|
||||
AcceptedAudiences: common.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))
|
||||
}
|
332
auth/token/token.go
Normal file
332
auth/token/token.go
Normal file
|
@ -0,0 +1,332 @@
|
|||
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"
|
||||
"github.com/docker/docker-registry/common"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// VerifyOptions is used to specify
|
||||
// options when verifying a JSON Web Token.
|
||||
type VerifyOptions struct {
|
||||
TrustedIssuers common.StringSet
|
||||
AcceptedAudiences common.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 {
|
||||
// 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.AcceptedAudiences.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!
|
||||
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!
|
||||
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, exists := accessSet[resource]
|
||||
if !exists {
|
||||
set = newActionSet()
|
||||
accessSet[resource] = set
|
||||
}
|
||||
|
||||
for _, action := range resourceActions.Actions {
|
||||
set.Add(action)
|
||||
}
|
||||
}
|
||||
|
||||
return accessSet
|
||||
}
|
||||
|
||||
func (t *Token) compactRaw() string {
|
||||
return fmt.Sprintf("%s.%s", t.Raw, joseBase64UrlEncode(t.Signature))
|
||||
}
|
364
auth/token/token_test.go
Normal file
364
auth/token/token_test.go
Normal file
|
@ -0,0 +1,364 @@
|
|||
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/libtrust"
|
||||
|
||||
"github.com/docker/docker-registry/auth"
|
||||
"github.com/docker/docker-registry/common"
|
||||
)
|
||||
|
||||
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: common.NewStringSet(issuer),
|
||||
AcceptedAudiences: common.NewStringSet(audience),
|
||||
Roots: rootPool,
|
||||
TrustedKeys: trustedKeys,
|
||||
}
|
||||
|
||||
for _, token := range tokens {
|
||||
if err := token.Verify(verifyOps); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
49
auth/token/util.go
Normal file
49
auth/token/util.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
package token
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker-registry/common"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// actionSet is a special type of stringSet.
|
||||
type actionSet struct {
|
||||
common.StringSet
|
||||
}
|
||||
|
||||
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 {
|
||||
return s.StringSet.Contains("*") || s.StringSet.Contains(action)
|
||||
}
|
35
common/stringset.go
Normal file
35
common/stringset.go
Normal file
|
@ -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
|
||||
}
|
Loading…
Reference in a new issue