diff --git a/cmd/test-webhook/main.go b/cmd/test-webhook/main.go new file mode 100644 index 000000000..6ed214b48 --- /dev/null +++ b/cmd/test-webhook/main.go @@ -0,0 +1,304 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Package main provides a authentication webhook program. +// +// This webhook is meant to be used in demo settings to play around with +// Pinniped. As well, it can come in handy in integration tests. +// +// This webhook is NOT meant for production settings. +package main + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/csv" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" + authenticationv1 "k8s.io/api/authentication/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + kubeinformers "k8s.io/client-go/informers" + corev1informers "k8s.io/client-go/informers/core/v1" + "k8s.io/client-go/kubernetes" + restclient "k8s.io/client-go/rest" + "k8s.io/klog/v2" + + "github.com/suzerain-io/pinniped/internal/provider" +) + +const ( + // namespace is the assumed namespace of this webhook. It is hardcoded now for + // simplicity, but should probably be made configurable in the future. + namespace = "test-webhook" + + defaultResyncInterval = 3 * time.Minute +) + +type webhook struct { + certProvider provider.DynamicTLSServingCertProvider + secretInformer corev1informers.SecretInformer +} + +func newWebhook( + certProvider provider.DynamicTLSServingCertProvider, + secretInformer corev1informers.SecretInformer, +) *webhook { + return &webhook{ + certProvider: certProvider, + secretInformer: secretInformer, + } +} + +// start runs the webhook in a separate goroutine and returns whether or not the +// webhook was started successfully. +func (w *webhook) start(ctx context.Context, l net.Listener) error { + server := http.Server{ + Handler: w, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS13, + GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + certPEM, keyPEM := w.certProvider.CurrentCertKeyContent() + cert, err := tls.X509KeyPair(certPEM, keyPEM) + return &cert, err + }, + }, + } + + errCh := make(chan error) + go func() { + // Per ListenAndServeTLS doc, the {cert,key}File parameters can be empty + // since we want to use the certs from http.Server.TLSConfig. + errCh <- server.ServeTLS(l, "", "") + }() + + go func() { + select { + case err := <-errCh: + klog.InfoS("server exited", "err", err) + case <-ctx.Done(): + klog.InfoS("server context cancelled", "err", ctx.Err()) + if err := server.Shutdown(context.Background()); err != nil { + klog.InfoS("server shutdown failed", "err", err) + } + } + }() + + return nil +} + +func (w *webhook) ServeHTTP(rsp http.ResponseWriter, req *http.Request) { + defer req.Body.Close() + + if req.URL.Path != "/authenticate" { + rsp.WriteHeader(http.StatusNotFound) + return + } + + if req.Method != http.MethodPost { + rsp.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if !contains(req.Header.Values("Content-Type"), "application/json") { + rsp.WriteHeader(http.StatusUnsupportedMediaType) + return + } + if !contains(req.Header.Values("Accept"), "application/json") { + rsp.WriteHeader(http.StatusUnsupportedMediaType) + return + } + + var body authenticationv1.TokenReview + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + klog.InfoS("failed to decode body", "err", err) + rsp.WriteHeader(http.StatusBadRequest) + return + } + + tokenSegments := strings.SplitN(body.Spec.Token, ":", 2) + if len(tokenSegments) != 2 { + rsp.WriteHeader(http.StatusBadRequest) + return + } + username := tokenSegments[0] + password := tokenSegments[1] + + secret, err := w.secretInformer.Lister().Secrets(namespace).Get(username) + notFound := k8serrors.IsNotFound(err) + if err != nil && !notFound { + klog.InfoS("could not get secret", "err", err) + rsp.WriteHeader(http.StatusInternalServerError) + return + } + + if notFound { + respondWithUnauthenticated(rsp) + return + } + + passwordMatches := bcrypt.CompareHashAndPassword( + secret.Data["passwordHash"], + []byte(password), + ) == nil + if !passwordMatches { + respondWithUnauthenticated(rsp) + } + + groupsBuf := bytes.NewBuffer(secret.Data["groups"]) + gr := csv.NewReader(groupsBuf) + groups, err := gr.Read() + if err != nil { + klog.InfoS("could not read groups", "err", err) + rsp.WriteHeader(http.StatusInternalServerError) + return + } + trimSpace(groups) + + respondWithAuthenticated(rsp, secret.ObjectMeta.Name, string(secret.UID), groups) +} + +func contains(ss []string, s string) bool { + for i := range ss { + if ss[i] == s { + return true + } + } + return false +} + +func trimSpace(ss []string) { + for i := range ss { + ss[i] = strings.TrimSpace(ss[i]) + } +} + +func respondWithUnauthenticated(rsp http.ResponseWriter) { + rsp.Header().Add("Content-Type", "application/json") + + body := authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: false, + }, + } + if err := json.NewEncoder(rsp).Encode(body); err != nil { + klog.InfoS("could not encode response", "err", err) + rsp.WriteHeader(http.StatusInternalServerError) + } +} + +func respondWithAuthenticated( + rsp http.ResponseWriter, + username, uid string, + groups []string, +) { + rsp.Header().Add("Content-Type", "application/json") + + body := authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: true, + User: authenticationv1.UserInfo{ + Username: username, + UID: uid, + Groups: groups, + }, + }, + } + if err := json.NewEncoder(rsp).Encode(body); err != nil { + klog.InfoS("could not encode response", "err", err) + rsp.WriteHeader(http.StatusInternalServerError) + } +} + +func newK8sClient() (kubernetes.Interface, error) { + kubeConfig, err := restclient.InClusterConfig() + if err != nil { + return nil, fmt.Errorf("could not load in-cluster configuration: %w", err) + } + + // Connect to the core Kubernetes API. + kubeClient, err := kubernetes.NewForConfig(kubeConfig) + if err != nil { + return nil, fmt.Errorf("could not load in-cluster configuration: %w", err) + } + + return kubeClient, nil +} + +func startControllers(ctx context.Context) error { + return nil +} + +func startWebhook( + ctx context.Context, + l net.Listener, + secretInformer corev1informers.SecretInformer, +) error { + return newWebhook( + provider.NewDynamicTLSServingCertProvider(), + secretInformer, + ).start(ctx, l) +} + +func waitForSignal() os.Signal { + signalCh := make(chan os.Signal, 1) + signal.Notify(signalCh, os.Interrupt) + return <-signalCh +} + +func run() error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + kubeClient, err := newK8sClient() + if err != nil { + return fmt.Errorf("cannot create k8s client: %w", err) + } + + kubeInformers := kubeinformers.NewSharedInformerFactoryWithOptions( + kubeClient, + defaultResyncInterval, + kubeinformers.WithNamespace(namespace), + ) + + if err := startControllers(ctx); err != nil { + return fmt.Errorf("cannot start controllers: %w", err) + } + klog.InfoS("controllers are ready") + + l, err := net.Listen("tcp", "127.0.0.1:443") + if err != nil { + return fmt.Errorf("cannot create listener: %w", err) + } + defer l.Close() + + if err := startWebhook( + ctx, + l, + kubeInformers.Core().V1().Secrets(), + ); err != nil { + return fmt.Errorf("cannot start webhook: %w", err) + } + klog.InfoS("webhook is ready", "address", l.Addr().String()) + + signal := waitForSignal() + klog.InfoS("webhook exiting", "signal", signal) + + return nil +} + +func main() { + if err := run(); err != nil { + klog.Fatal(err) + } +} diff --git a/cmd/test-webhook/main_test.go b/cmd/test-webhook/main_test.go new file mode 100644 index 000000000..82e434c6f --- /dev/null +++ b/cmd/test-webhook/main_test.go @@ -0,0 +1,457 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package main + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net" + "net/http" + "net/url" + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + kubeinformers "k8s.io/client-go/informers" + corev1informers "k8s.io/client-go/informers/core/v1" + "k8s.io/client-go/kubernetes" + kubernetesfake "k8s.io/client-go/kubernetes/fake" + + "github.com/suzerain-io/pinniped/internal/certauthority" + "github.com/suzerain-io/pinniped/internal/provider" +) + +func TestWebhook(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + uid, otherUID, colonUID := "some-uid", "some-other-uid", "some-colon-uid" + user, otherUser, colonUser := "some-user", "some-other-user", "some-colon-user" + password, otherPassword, colonPassword := "some-password", "some-other-password", "some-:-password" + group0, group1 := "some-group-0", "some-group-1" + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) + require.NoError(t, err) + + otherPasswordHash, err := bcrypt.GenerateFromPassword([]byte(otherPassword), bcrypt.MinCost) + require.NoError(t, err) + + colonPasswordHash, err := bcrypt.GenerateFromPassword([]byte(colonPassword), bcrypt.MinCost) + require.NoError(t, err) + + groups := group0 + " , " + group1 + + userSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + UID: types.UID(uid), + Name: user, + Namespace: "test-webhook", + }, + Data: map[string][]byte{ + "passwordHash": passwordHash, + "groups": []byte(groups), + }, + } + otherUserSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + UID: types.UID(otherUID), + Name: otherUser, + Namespace: "test-webhook", + }, + Data: map[string][]byte{ + "passwordHash": otherPasswordHash, + "groups": []byte(groups), + }, + } + colonUserSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + UID: types.UID(colonUID), + Name: colonUser, + Namespace: "test-webhook", + }, + Data: map[string][]byte{ + "passwordHash": colonPasswordHash, + "groups": []byte(groups), + }, + } + + kubeClient := kubernetesfake.NewSimpleClientset() + require.NoError(t, kubeClient.Tracker().Add(userSecret)) + require.NoError(t, kubeClient.Tracker().Add(otherUserSecret)) + require.NoError(t, kubeClient.Tracker().Add(colonUserSecret)) + + secretInformer := createSecretInformer(t, kubeClient) + + certProvider, caBundle, serverName := newCertProvider(t) + w := newWebhook(certProvider, secretInformer) + + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer l.Close() + require.NoError(t, w.start(ctx, l)) + + client := newClient(caBundle, serverName) + + tests := []struct { + name string + url string + method string + headers map[string][]string + body func() (io.ReadCloser, error) + + wantStatus int + wantHeaders map[string][]string + wantBody *authenticationv1.TokenReview + }{ + { + name: "success", + url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), + method: http.MethodPost, + headers: map[string][]string{ + "Content-Type": []string{"application/json"}, + "Accept": []string{"application/json"}, + }, + body: func() (io.ReadCloser, error) { + return newTokenReviewBody(user + ":" + password) + }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{ + "Content-Type": []string{"application/json"}, + }, + wantBody: &authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: true, + User: authenticationv1.UserInfo{ + Username: user, + UID: uid, + Groups: []string{group0, group1}, + }, + }, + }, + }, + { + name: "wrong username for password", + url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), + method: http.MethodPost, + headers: map[string][]string{ + "Content-Type": []string{"application/json"}, + "Accept": []string{"application/json"}, + }, + body: func() (io.ReadCloser, error) { + return newTokenReviewBody(otherUser + ":" + password) + }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{ + "Content-Type": []string{"application/json"}, + }, + wantBody: &authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: false, + }, + }, + }, + { + name: "wrong password for username", + url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), + method: http.MethodPost, + headers: map[string][]string{ + "Content-Type": []string{"application/json"}, + "Accept": []string{"application/json"}, + }, + body: func() (io.ReadCloser, error) { + return newTokenReviewBody(user + ":" + otherPassword) + }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{ + "Content-Type": []string{"application/json"}, + }, + wantBody: &authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: false, + }, + }, + }, + { + name: "non-existent password for username", + url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), + method: http.MethodPost, + headers: map[string][]string{ + "Content-Type": []string{"application/json"}, + "Accept": []string{"application/json"}, + }, + body: func() (io.ReadCloser, error) { + return newTokenReviewBody(user + ":" + "some-non-existent-password") + }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{ + "Content-Type": []string{"application/json"}, + }, + wantBody: &authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: false, + }, + }, + }, + { + name: "non-existent username", + url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), + method: http.MethodPost, + headers: map[string][]string{ + "Content-Type": []string{"application/json"}, + "Accept": []string{"application/json"}, + }, + body: func() (io.ReadCloser, error) { + return newTokenReviewBody("some-non-existent-user" + ":" + password) + }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{ + "Content-Type": []string{"application/json"}, + }, + wantBody: &authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: false, + }, + }, + }, + { + name: "invalid token (missing colon)", + url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), + method: http.MethodPost, + headers: map[string][]string{ + "Content-Type": []string{"application/json"}, + "Accept": []string{"application/json"}, + }, + body: func() (io.ReadCloser, error) { + return newTokenReviewBody(user) + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "password contains colon", + url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), + method: http.MethodPost, + headers: map[string][]string{ + "Content-Type": []string{"application/json"}, + "Accept": []string{"application/json"}, + }, + body: func() (io.ReadCloser, error) { + return newTokenReviewBody(colonUser + ":" + colonPassword) + }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{ + "Content-Type": []string{"application/json"}, + }, + wantBody: &authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: true, + User: authenticationv1.UserInfo{ + Username: colonUser, + UID: colonUID, + Groups: []string{group0, group1}, + }, + }, + }, + }, + { + name: "bad path", + url: fmt.Sprintf("https://%s/tuna", l.Addr().String()), + method: http.MethodPost, + headers: map[string][]string{ + "Content-Type": []string{"application/json"}, + "Accept": []string{"application/json"}, + }, + body: func() (io.ReadCloser, error) { + return newTokenReviewBody("some-token") + }, + wantStatus: http.StatusNotFound, + }, + { + name: "bad method", + url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), + method: http.MethodGet, + headers: map[string][]string{ + "Content-Type": []string{"application/json"}, + "Accept": []string{"application/json"}, + }, + body: func() (io.ReadCloser, error) { + return newTokenReviewBody("some-token") + }, + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "bad content type", + url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), + method: http.MethodPost, + headers: map[string][]string{ + "Content-Type": []string{"application/xml"}, + "Accept": []string{"application/json"}, + }, + body: func() (io.ReadCloser, error) { + return newTokenReviewBody("some-token") + }, + wantStatus: http.StatusUnsupportedMediaType, + }, + { + name: "bad accept", + url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), + method: http.MethodPost, + headers: map[string][]string{ + "Content-Type": []string{"application/json"}, + "Accept": []string{"application/xml"}, + }, + body: func() (io.ReadCloser, error) { + return newTokenReviewBody("some-token") + }, + wantStatus: http.StatusUnsupportedMediaType, + }, + { + name: "bad body", + url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), + method: http.MethodPost, + headers: map[string][]string{ + "Content-Type": []string{"application/json"}, + "Accept": []string{"application/json"}, + }, + body: func() (io.ReadCloser, error) { + return ioutil.NopCloser(bytes.NewBuffer([]byte("invalid body"))), nil + }, + wantStatus: http.StatusBadRequest, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + url, err := url.Parse(test.url) + require.NoError(t, err) + + body, err := test.body() + require.NoError(t, err) + + rsp, err := client.Do(&http.Request{ + Method: test.method, + URL: url, + Header: test.headers, + Body: body, + }) + require.NoError(t, err) + defer rsp.Body.Close() + + if test.wantStatus != 0 { + require.Equal(t, test.wantStatus, rsp.StatusCode) + } + if test.wantHeaders != nil { + for k, v := range test.wantHeaders { + require.Equal(t, v, rsp.Header.Values(k)) + } + } + if test.wantBody != nil { + rspBody, err := newTokenReview(rsp.Body) + require.NoError(t, err) + require.Equal(t, test.wantBody, rspBody) + } + }) + } +} + +func createSecretInformer(t *testing.T, kubeClient kubernetes.Interface) corev1informers.SecretInformer { + t.Helper() + + kubeInformers := kubeinformers.NewSharedInformerFactory(kubeClient, 0) + + secretInformer := kubeInformers.Core().V1().Secrets() + + // We need to call Informer() on the secretInformer to lazily instantiate the + // informer factory before syncing it. + secretInformer.Informer() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) + defer cancel() + + kubeInformers.Start(ctx.Done()) + + informerTypesSynced := kubeInformers.WaitForCacheSync(ctx.Done()) + require.True(t, informerTypesSynced[reflect.TypeOf(&corev1.Secret{})]) + + return secretInformer +} + +// newClientProvider returns a provider.DynamicTLSServingCertProvider configured +// with valid serving cert, the CA bundle that can be used to verify the serving +// cert, and the server name that can be used to verify the TLS peer. +func newCertProvider(t *testing.T) (provider.DynamicTLSServingCertProvider, []byte, string) { + t.Helper() + + ca, err := certauthority.New(pkix.Name{CommonName: "test-webhook CA"}, time.Hour*24) + require.NoError(t, err) + + serverName := "test-webhook" + cert, err := ca.Issue( + pkix.Name{CommonName: serverName}, + []string{}, + time.Hour*24, + ) + require.NoError(t, err) + + certPEM, keyPEM, err := certauthority.ToPEM(cert) + require.NoError(t, err) + + certProvider := provider.NewDynamicTLSServingCertProvider() + certProvider.Set(certPEM, keyPEM) + + return certProvider, ca.Bundle(), serverName +} + +// newClient creates an http.Client that can be used to make an HTTPS call to a +// service whose serving certs can be verified by the provided CA bundle. +func newClient(caBundle []byte, serverName string) *http.Client { + rootCAs := x509.NewCertPool() + rootCAs.AppendCertsFromPEM(caBundle) + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS13, + RootCAs: rootCAs, + ServerName: serverName, + }, + }, + } +} + +// newTokenReviewBody creates an io.ReadCloser that contains a JSON-encoded +// TokenReview request. +func newTokenReviewBody(token string) (io.ReadCloser, error) { + buf := bytes.NewBuffer([]byte{}) + tr := authenticationv1.TokenReview{ + Spec: authenticationv1.TokenReviewSpec{ + Token: token, + }, + } + err := json.NewEncoder(buf).Encode(&tr) + return ioutil.NopCloser(buf), err +} + +// newTokenReview reads a JSON-encoded authenticationv1.TokenReview from an +// io.Reader. +func newTokenReview(body io.Reader) (*authenticationv1.TokenReview, error) { + var tr authenticationv1.TokenReview + err := json.NewDecoder(body).Decode(&tr) + return &tr, err +} diff --git a/go.mod b/go.mod index 5c6f75b22..246020d64 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/stretchr/testify v1.6.1 github.com/suzerain-io/pinniped/generated/1.19/apis v0.0.0-00010101000000-000000000000 github.com/suzerain-io/pinniped/generated/1.19/client v0.0.0-00010101000000-000000000000 + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 k8s.io/api v0.19.0 k8s.io/apimachinery v0.19.0 k8s.io/apiserver v0.19.0