3b391d3290
Go 1.18 and up now provides a strings.Cut() which is better suited for splitting key/value pairs (and similar constructs), and performs better: ```go func BenchmarkSplit(b *testing.B) { b.ReportAllocs() data := []string{"12hello=world", "12hello=", "12=hello", "12hello"} for i := 0; i < b.N; i++ { for _, s := range data { _ = strings.SplitN(s, "=", 2)[0] } } } func BenchmarkCut(b *testing.B) { b.ReportAllocs() data := []string{"12hello=world", "12hello=", "12=hello", "12hello"} for i := 0; i < b.N; i++ { for _, s := range data { _, _, _ = strings.Cut(s, "=") } } } ``` BenchmarkSplit BenchmarkSplit-10 8244206 128.0 ns/op 128 B/op 4 allocs/op BenchmarkCut BenchmarkCut-10 54411998 21.80 ns/op 0 B/op 0 allocs/op While looking at occurrences of `strings.Split()`, I also updated some for alternatives, or added some constraints; - for cases where an specific number of items is expected, I used `strings.SplitN()` with a suitable limit. This prevents (theoretical) unlimited splits. - in some cases it we were using `strings.Split()`, but _actually_ were trying to match a prefix; for those I replaced the code to just match (and/or strip) the prefix. Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
290 lines
7.8 KiB
Go
290 lines
7.8 KiB
Go
package token
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
dcontext "github.com/distribution/distribution/v3/context"
|
|
"github.com/distribution/distribution/v3/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, 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
|
|
autoRedirect bool
|
|
service string
|
|
accessSet accessSet
|
|
}
|
|
|
|
var _ auth.Challenge = authChallenge{}
|
|
|
|
// 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(r *http.Request) string {
|
|
var realm string
|
|
if ac.autoRedirect {
|
|
realm = fmt.Sprintf("https://%s/auth/token", r.Host)
|
|
} else {
|
|
realm = ac.realm
|
|
}
|
|
str := fmt.Sprintf("Bearer realm=%q,service=%q", realm, ac.service)
|
|
|
|
if scope := ac.accessSet.scopeParam(); scope != "" {
|
|
str = fmt.Sprintf("%s,scope=%q", str, scope)
|
|
}
|
|
|
|
if ac.err == ErrInvalidToken || ac.err == ErrMalformedToken {
|
|
str = fmt.Sprintf("%s,error=%q", str, "invalid_token")
|
|
} else if ac.err == ErrInsufficientScope {
|
|
str = fmt.Sprintf("%s,error=%q", str, "insufficient_scope")
|
|
}
|
|
|
|
return str
|
|
}
|
|
|
|
// SetChallenge sets the WWW-Authenticate value for the response.
|
|
func (ac authChallenge) SetHeaders(r *http.Request, w http.ResponseWriter) {
|
|
w.Header().Add("WWW-Authenticate", ac.challengeParams(r))
|
|
}
|
|
|
|
// accessController implements the auth.AccessController interface.
|
|
type accessController struct {
|
|
realm string
|
|
autoRedirect bool
|
|
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
|
|
autoRedirect bool
|
|
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]
|
|
|
|
autoRedirectVal, ok := options["autoredirect"]
|
|
if ok {
|
|
autoRedirect, ok := autoRedirectVal.(bool)
|
|
if !ok {
|
|
return opts, fmt.Errorf("token auth requires a valid option bool: autoredirect")
|
|
}
|
|
opts.autoRedirect = autoRedirect
|
|
}
|
|
|
|
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 := io.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 {
|
|
if pemBlock.Type == "CERTIFICATE" {
|
|
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,
|
|
autoRedirect: config.autoRedirect,
|
|
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(ctx context.Context, accessItems ...auth.Access) (context.Context, error) {
|
|
challenge := &authChallenge{
|
|
realm: ac.realm,
|
|
autoRedirect: ac.autoRedirect,
|
|
service: ac.service,
|
|
accessSet: newAccessSet(accessItems...),
|
|
}
|
|
|
|
req, err := dcontext.GetRequest(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
prefix, rawToken, ok := strings.Cut(req.Header.Get("Authorization"), " ")
|
|
if !ok || rawToken == "" || !strings.EqualFold(prefix, "bearer") {
|
|
challenge.err = ErrTokenRequired
|
|
return nil, challenge
|
|
}
|
|
|
|
token, err := NewToken(rawToken)
|
|
if err != nil {
|
|
challenge.err = err
|
|
return nil, challenge
|
|
}
|
|
|
|
verifyOpts := VerifyOptions{
|
|
TrustedIssuers: []string{ac.issuer},
|
|
AcceptedAudiences: []string{ac.service},
|
|
Roots: ac.rootCerts,
|
|
TrustedKeys: ac.trustedKeys,
|
|
}
|
|
|
|
if err = token.Verify(verifyOpts); err != nil {
|
|
challenge.err = err
|
|
return nil, challenge
|
|
}
|
|
|
|
accessSet := token.accessSet()
|
|
for _, access := range accessItems {
|
|
if !accessSet.contains(access) {
|
|
challenge.err = ErrInsufficientScope
|
|
return nil, challenge
|
|
}
|
|
}
|
|
|
|
ctx = auth.WithResources(ctx, token.resources())
|
|
|
|
return auth.WithUser(ctx, auth.UserInfo{Name: token.Claims.Subject}), nil
|
|
}
|
|
|
|
// init handles registering the token auth backend.
|
|
func init() {
|
|
auth.Register("token", auth.InitFunc(newAccessController))
|
|
}
|