package server
import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
log "github.com/Sirupsen/logrus"
context "github.com/docker/distribution/context"
registryauth "github.com/docker/distribution/registry/auth"
kerrors "k8s.io/kubernetes/pkg/api/errors"
kclientset "k8s.io/kubernetes/pkg/client/clientset_generated/internalclientset"
"k8s.io/kubernetes/pkg/client/restclient"
authorizationapi "github.com/openshift/origin/pkg/authorization/api"
"github.com/openshift/origin/pkg/client"
"github.com/openshift/origin/pkg/cmd/util/clientcmd"
imageapi "github.com/openshift/origin/pkg/image/api"
"github.com/openshift/origin/pkg/util/httprequest"
)
type deferredErrors map[string]error
func (d deferredErrors) Add(namespace string, name string, err error) {
d[namespace+"/"+name] = err
}
func (d deferredErrors) Get(namespace string, name string) (error, bool) {
err, exists := d[namespace+"/"+name]
return err, exists
}
func (d deferredErrors) Empty() bool {
return len(d) == 0
}
const (
OpenShiftAuth = "openshift"
defaultTokenPath = "/openshift/token"
RealmKey = "realm"
TokenRealmKey = "tokenrealm"
)
// RegistryClient encapsulates getting access to the OpenShift API.
type RegistryClient interface {
// Clients return the authenticated clients to use with the server.
Clients() (client.Interface, kclientset.Interface, error)
// SafeClientConfig returns a client config without authentication info.
SafeClientConfig() restclient.Config
}
// DefaultRegistryClient is exposed for testing the registry with fake client.
var DefaultRegistryClient = NewRegistryClient(clientcmd.NewConfig().BindToFile())
// registryClient implements RegistryClient
type registryClient struct {
config *clientcmd.Config
}
var _ RegistryClient = ®istryClient{}
// NewRegistryClient creates a registry client.
func NewRegistryClient(config *clientcmd.Config) RegistryClient {
return ®istryClient{config: config}
}
// Client returns the authenticated client to use with the server.
func (r *registryClient) Clients() (client.Interface, kclientset.Interface, error) {
oc, _, kc, err := r.config.Clients()
return oc, kc, err
}
// SafeClientConfig returns a client config without authentication info.
func (r *registryClient) SafeClientConfig() restclient.Config {
return clientcmd.AnonymousClientConfig(r.config.OpenShiftConfig())
}
func init() {
registryauth.Register(OpenShiftAuth, registryauth.InitFunc(newAccessController))
}
type contextKey int
var userClientKey contextKey = 0
func WithUserClient(parent context.Context, userClient client.Interface) context.Context {
return context.WithValue(parent, userClientKey, userClient)
}
func UserClientFrom(ctx context.Context) (client.Interface, bool) {
userClient, ok := ctx.Value(userClientKey).(client.Interface)
return userClient, ok
}
const authPerformedKey = "openshift.auth.performed"
func WithAuthPerformed(parent context.Context) context.Context {
return context.WithValue(parent, authPerformedKey, true)
}
func AuthPerformed(ctx context.Context) bool {
authPerformed, ok := ctx.Value(authPerformedKey).(bool)
return ok && authPerformed
}
const deferredErrorsKey = "openshift.auth.deferredErrors"
func WithDeferredErrors(parent context.Context, errs deferredErrors) context.Context {
return context.WithValue(parent, deferredErrorsKey, errs)
}
func DeferredErrorsFrom(ctx context.Context) (deferredErrors, bool) {
errs, ok := ctx.Value(deferredErrorsKey).(deferredErrors)
return errs, ok
}
type AccessController struct {
realm string
tokenRealm *url.URL
config restclient.Config
}
var _ registryauth.AccessController = &AccessController{}
type authChallenge struct {
realm string
err error
}
var _ registryauth.Challenge = &authChallenge{}
type tokenAuthChallenge struct {
realm string
service string
err error
}
var _ registryauth.Challenge = &tokenAuthChallenge{}
// Errors used and exported by this package.
var (
// Challenging errors
ErrTokenRequired = errors.New("authorization header required")
ErrTokenInvalid = errors.New("failed to decode credentials")
ErrOpenShiftAccessDenied = errors.New("access denied")
// Non-challenging errors
ErrNamespaceRequired = errors.New("repository namespace required")
ErrUnsupportedAction = errors.New("unsupported action")
ErrUnsupportedResource = errors.New("unsupported resource")
)
// TokenRealm returns the template URL to use as the token realm redirect.
// An empty scheme/host in the returned URL means to match the scheme/host on incoming requests.
func TokenRealm(options map[string]interface{}) (*url.URL, error) {
if options[TokenRealmKey] == nil {
// If not specified, default to "/openshift/token", auto-detecting the scheme and host
return &url.URL{Path: defaultTokenPath}, nil
}
tokenRealmString, ok := options[TokenRealmKey].(string)
if !ok {
return nil, fmt.Errorf("%s config option must be a string, got %T", TokenRealmKey, options[TokenRealmKey])
}
tokenRealm, err := url.Parse(tokenRealmString)
if err != nil {
return nil, fmt.Errorf("error parsing URL in %s config option: %v", TokenRealmKey, err)
}
if len(tokenRealm.RawQuery) > 0 || len(tokenRealm.Fragment) > 0 {
return nil, fmt.Errorf("%s config option may not contain query parameters or a fragment", TokenRealmKey)
}
if len(tokenRealm.Path) > 0 {
return nil, fmt.Errorf("%s config option may not contain a path (%q was specified)", TokenRealmKey, tokenRealm.Path)
}
// pin to "/openshift/token"
tokenRealm.Path = defaultTokenPath
return tokenRealm, nil
}
func newAccessController(options map[string]interface{}) (registryauth.AccessController, error) {
log.Info("Using Origin Auth handler")
realm, ok := options[RealmKey].(string)
if !ok {
// Default to openshift if not present
realm = "origin"
}
tokenRealm, err := TokenRealm(options)
if err != nil {
return nil, err
}
return &AccessController{realm: realm, tokenRealm: tokenRealm, config: DefaultRegistryClient.SafeClientConfig()}, nil
}
// Error returns the internal error string for this authChallenge.
func (ac *authChallenge) Error() string {
return ac.err.Error()
}
// SetHeaders sets the basic challenge header on the response.
func (ac *authChallenge) SetHeaders(w http.ResponseWriter) {
// WWW-Authenticate response challenge header.
// See https://tools.ietf.org/html/rfc6750#section-3
str := fmt.Sprintf("Basic realm=%s", ac.realm)
if ac.err != nil {
str = fmt.Sprintf("%s,error=%q", str, ac.Error())
}
w.Header().Set("WWW-Authenticate", str)
}
// Error returns the internal error string for this authChallenge.
func (ac *tokenAuthChallenge) Error() string {
return ac.err.Error()
}
// SetHeaders sets the bearer challenge header on the response.
func (ac *tokenAuthChallenge) SetHeaders(w http.ResponseWriter) {
// WWW-Authenticate response challenge header.
// See https://docs.docker.com/registry/spec/auth/token/#/how-to-authenticate and https://tools.ietf.org/html/rfc6750#section-3
str := fmt.Sprintf("Bearer realm=%q", ac.realm)
if ac.service != "" {
str += fmt.Sprintf(",service=%q", ac.service)
}
w.Header().Set("WWW-Authenticate", str)
}
// wrapErr wraps errors related to authorization in an authChallenge error that will present a WWW-Authenticate challenge response
func (ac *AccessController) wrapErr(ctx context.Context, err error) error {
switch err {
case ErrTokenRequired:
// Challenge for errors that involve missing tokens
if ac.tokenRealm == nil {
// Send the basic challenge if we don't have a place to redirect
return &authChallenge{realm: ac.realm, err: err}
}
if len(ac.tokenRealm.Scheme) > 0 && len(ac.tokenRealm.Host) > 0 {
// Redirect to token auth if we've been given an absolute URL
return &tokenAuthChallenge{realm: ac.tokenRealm.String(), err: err}
}
// Auto-detect scheme/host from request
req, reqErr := context.GetRequest(ctx)
if reqErr != nil {
return reqErr
}
scheme, host := httprequest.SchemeHost(req)
tokenRealmCopy := *ac.tokenRealm
if len(tokenRealmCopy.Scheme) == 0 {
tokenRealmCopy.Scheme = scheme
}
if len(tokenRealmCopy.Host) == 0 {
tokenRealmCopy.Host = host
}
return &tokenAuthChallenge{realm: tokenRealmCopy.String(), err: err}
case ErrTokenInvalid, ErrOpenShiftAccessDenied:
// Challenge for errors that involve tokens or access denied
return &authChallenge{realm: ac.realm, err: err}
case ErrNamespaceRequired, ErrUnsupportedAction, ErrUnsupportedResource:
// Malformed or unsupported request, no challenge
return err
default:
// By default, just return the error, this gets surfaced as a bad request / internal error, but no challenge
return err
}
}
// Authorized handles checking whether the given request is authorized
// for actions on resources allowed by openshift.
// Sources of access records:
// origin/pkg/cmd/dockerregistry/dockerregistry.go#Execute
// docker/distribution/registry/handlers/app.go#appendAccessRecords
func (ac *AccessController) Authorized(ctx context.Context, accessRecords ...registryauth.Access) (context.Context, error) {
req, err := context.GetRequest(ctx)
if err != nil {
return nil, ac.wrapErr(ctx, err)
}
bearerToken, err := getOpenShiftAPIToken(ctx, req)
if err != nil {
return nil, ac.wrapErr(ctx, err)
}
copied := ac.config
copied.BearerToken = bearerToken
osClient, err := client.New(&copied)
if err != nil {
return nil, ac.wrapErr(ctx, err)
}
// In case of docker login, hits endpoint /v2
if len(accessRecords) == 0 {
if err := verifyOpenShiftUser(ctx, osClient); err != nil {
return nil, ac.wrapErr(ctx, err)
}
}
// pushChecks remembers which ns/name pairs had push access checks done
pushChecks := map[string]bool{}
// possibleCrossMountErrors holds errors which may be related to cross mount errors
possibleCrossMountErrors := deferredErrors{}
verifiedPrune := false
// Validate all requested accessRecords
// Only return failure errors from this loop. Success should continue to validate all records
for _, access := range accessRecords {
context.GetLogger(ctx).Debugf("Origin auth: checking for access to %s:%s:%s", access.Resource.Type, access.Resource.Name, access.Action)
switch access.Resource.Type {
case "repository":
imageStreamNS, imageStreamName, err := getNamespaceName(access.Resource.Name)
if err != nil {
return nil, ac.wrapErr(ctx, err)
}
verb := ""
switch access.Action {
case "push":
verb = "update"
pushChecks[imageStreamNS+"/"+imageStreamName] = true
case "pull":
verb = "get"
case "*":
verb = "prune"
default:
return nil, ac.wrapErr(ctx, ErrUnsupportedAction)
}
switch verb {
case "prune":
if verifiedPrune {
continue
}
if err := verifyPruneAccess(ctx, osClient); err != nil {
return nil, ac.wrapErr(ctx, err)
}
verifiedPrune = true
default:
if err := verifyImageStreamAccess(ctx, imageStreamNS, imageStreamName, verb, osClient); err != nil {
if access.Action != "pull" {
return nil, ac.wrapErr(ctx, err)
}
possibleCrossMountErrors.Add(imageStreamNS, imageStreamName, ac.wrapErr(ctx, err))
}
}
case "admin":
switch access.Action {
case "prune":
if verifiedPrune {
continue
}
if err := verifyPruneAccess(ctx, osClient); err != nil {
return nil, ac.wrapErr(ctx, err)
}
verifiedPrune = true
default:
return nil, ac.wrapErr(ctx, ErrUnsupportedAction)
}
default:
return nil, ac.wrapErr(ctx, ErrUnsupportedResource)
}
}
// deal with any possible cross-mount errors
for namespaceAndName, err := range possibleCrossMountErrors {
// If we have no push requests, this can't be a cross-mount request, so error
if len(pushChecks) == 0 {
return nil, err
}
// If we also requested a push to this ns/name, this isn't a cross-mount request, so error
if pushChecks[namespaceAndName] {
return nil, err
}
}
// Conditionally add auth errors we want to handle later to the context
if !possibleCrossMountErrors.Empty() {
context.GetLogger(ctx).Debugf("Origin auth: deferring errors: %#v", possibleCrossMountErrors)
ctx = WithDeferredErrors(ctx, possibleCrossMountErrors)
}
// Always add a marker to the context so we know auth was run
ctx = WithAuthPerformed(ctx)
return WithUserClient(ctx, osClient), nil
}
func getOpenShiftAPIToken(ctx context.Context, req *http.Request) (string, error) {
token := ""
authParts := strings.SplitN(req.Header.Get("Authorization"), " ", 2)
if len(authParts) != 2 {
return "", ErrTokenRequired
}
switch strings.ToLower(authParts[0]) {
case "bearer":
// This is either a direct API token, or a token issued by our docker token handler
token = authParts[1]
// Recognize the token issued to anonymous users by our docker token handler
if token == anonymousToken {
token = ""
}
case "basic":
_, password, ok := req.BasicAuth()
if !ok || len(password) == 0 {
return "", ErrTokenInvalid
}
token = password
default:
return "", ErrTokenRequired
}
return token, nil
}
func verifyOpenShiftUser(ctx context.Context, client client.UsersInterface) error {
if _, err := client.Users().Get("~"); err != nil {
context.GetLogger(ctx).Errorf("Get user failed with error: %s", err)
if kerrors.IsUnauthorized(err) || kerrors.IsForbidden(err) {
return ErrOpenShiftAccessDenied
}
return err
}
return nil
}
func verifyImageStreamAccess(ctx context.Context, namespace, imageRepo, verb string, client client.LocalSubjectAccessReviewsNamespacer) error {
sar := authorizationapi.LocalSubjectAccessReview{
Action: authorizationapi.Action{
Verb: verb,
Group: imageapi.GroupName,
Resource: "imagestreams/layers",
ResourceName: imageRepo,
},
}
response, err := client.LocalSubjectAccessReviews(namespace).Create(&sar)
if err != nil {
context.GetLogger(ctx).Errorf("OpenShift client error: %s", err)
if kerrors.IsUnauthorized(err) || kerrors.IsForbidden(err) {
return ErrOpenShiftAccessDenied
}
return err
}
if !response.Allowed {
context.GetLogger(ctx).Errorf("OpenShift access denied: %s", response.Reason)
return ErrOpenShiftAccessDenied
}
return nil
}
func verifyPruneAccess(ctx context.Context, client client.SubjectAccessReviews) error {
sar := authorizationapi.SubjectAccessReview{
Action: authorizationapi.Action{
Verb: "delete",
Group: imageapi.GroupName,
Resource: "images",
},
}
response, err := client.SubjectAccessReviews().Create(&sar)
if err != nil {
context.GetLogger(ctx).Errorf("OpenShift client error: %s", err)
if kerrors.IsUnauthorized(err) || kerrors.IsForbidden(err) {
return ErrOpenShiftAccessDenied
}
return err
}
if !response.Allowed {
context.GetLogger(ctx).Errorf("OpenShift access denied: %s", response.Reason)
return ErrOpenShiftAccessDenied
}
return nil
}