ContainerImage.Pinniped/internal/localuserauthenticator/localuserauthenticator_test.go

570 lines
19 KiB
Go

// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package localuserauthenticator
import (
"bytes"
"context"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"reflect"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
authenticationv1beta1 "k8s.io/api/authentication/v1beta1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
utilnet "k8s.io/apimachinery/pkg/util/net"
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"
"go.pinniped.dev/internal/certauthority"
"go.pinniped.dev/internal/dynamiccert"
"go.pinniped.dev/internal/net/phttp"
)
func TestWebhook(t *testing.T) {
t.Parallel()
const namespace = "local-user-authenticator"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
user, otherUser, colonUser, noGroupUser, oneGroupUser, passwordUndefinedUser, emptyPasswordUser, invalidPasswordHashUser, undefinedGroupsUser :=
"some-user", "other-user", "colon-user", "no-group-user", "one-group-user", "password-undefined-user", "empty-password-user", "invalid-password-hash-user", "undefined-groups-user"
password, otherPassword, colonPassword, noGroupPassword, oneGroupPassword, undefinedGroupsPassword :=
"some-password", "other-password", "some-:-password", "no-group-password", "one-group-password", "undefined-groups-password"
group0, group1 := "some-group-0", "some-group-1"
groups := group0 + " , " + group1
kubeClient := kubernetesfake.NewSimpleClientset()
addSecretToFakeClientTracker(t, kubeClient, user, password, groups)
addSecretToFakeClientTracker(t, kubeClient, otherUser, otherPassword, groups)
addSecretToFakeClientTracker(t, kubeClient, colonUser, colonPassword, groups)
addSecretToFakeClientTracker(t, kubeClient, noGroupUser, noGroupPassword, "")
addSecretToFakeClientTracker(t, kubeClient, oneGroupUser, oneGroupPassword, group0)
addSecretToFakeClientTracker(t, kubeClient, emptyPasswordUser, "", groups)
require.NoError(t, kubeClient.Tracker().Add(&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: passwordUndefinedUser,
Namespace: namespace,
},
Data: map[string][]byte{
"groups": []byte(groups),
},
}))
undefinedGroupsUserPasswordHash, err := bcrypt.GenerateFromPassword([]byte(undefinedGroupsPassword), bcrypt.MinCost)
require.NoError(t, err)
require.NoError(t, kubeClient.Tracker().Add(&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: undefinedGroupsUser,
Namespace: namespace,
},
Data: map[string][]byte{
"passwordHash": undefinedGroupsUserPasswordHash,
},
}))
require.NoError(t, kubeClient.Tracker().Add(&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: invalidPasswordHashUser,
Namespace: namespace,
},
Data: map[string][]byte{
"groups": []byte(groups),
"passwordHash": []byte("not a valid password hash"),
},
}))
secretInformer := createSecretInformer(ctx, 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 func() { _ = l.Close() }()
require.NoError(t, w.start(ctx, l))
client := newClient(t, caBundle, serverName)
goodURL := fmt.Sprintf("https://%s/authenticate", l.Addr().String())
goodRequestHeaders := map[string][]string{
"Content-Type": {"application/json; charset=UTF-8"},
"Accept": {"application/json, */*"},
}
tests := []struct {
name string
url string
method string
headers map[string][]string
body func() (io.ReadCloser, error)
wantStatus int
wantHeaders map[string][]string
wantBody *authenticationv1beta1.TokenReview
}{
{
name: "success for a user who belongs to multiple groups",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + password) },
wantStatus: http.StatusOK,
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
wantBody: authenticatedResponseJSON(user, []string{group0, group1}),
},
{
name: "success for a user who belongs to one groups",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) { return newTokenReviewBody(oneGroupUser + ":" + oneGroupPassword) },
wantStatus: http.StatusOK,
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
wantBody: authenticatedResponseJSON(oneGroupUser, []string{group0}),
},
{
name: "success for a user who belongs to no groups",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) { return newTokenReviewBody(noGroupUser + ":" + noGroupPassword) },
wantStatus: http.StatusOK,
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
wantBody: authenticatedResponseJSON(noGroupUser, nil),
},
{
name: "wrong username for password",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) { return newTokenReviewBody(otherUser + ":" + password) },
wantStatus: http.StatusOK,
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
wantBody: unauthenticatedResponseJSON(),
},
{
name: "when a user has no password hash in the secret",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) { return newTokenReviewBody(passwordUndefinedUser + ":foo") },
wantStatus: http.StatusOK,
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
wantBody: unauthenticatedResponseJSON(),
},
{
name: "when a user has an invalid password hash in the secret",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) { return newTokenReviewBody(invalidPasswordHashUser + ":foo") },
wantStatus: http.StatusOK,
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
wantBody: unauthenticatedResponseJSON(),
},
{
name: "success for a user has no groups defined in the secret",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) {
return newTokenReviewBody(undefinedGroupsUser + ":" + undefinedGroupsPassword)
},
wantStatus: http.StatusOK,
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
wantBody: authenticatedResponseJSON(undefinedGroupsUser, nil),
},
{
name: "when a user has empty string as their password",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) { return newTokenReviewBody(passwordUndefinedUser + ":foo") },
wantStatus: http.StatusOK,
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
wantBody: unauthenticatedResponseJSON(),
},
{
name: "wrong password for username",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + otherPassword) },
wantStatus: http.StatusOK,
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
wantBody: unauthenticatedResponseJSON(),
},
{
name: "non-existent password for username",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + "some-non-existent-password") },
wantStatus: http.StatusOK,
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
wantBody: unauthenticatedResponseJSON(),
},
{
name: "non-existent username",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-non-existent-user" + ":" + password) },
wantStatus: http.StatusOK,
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
wantBody: unauthenticatedResponseJSON(),
},
{
name: "bad token format (missing colon)",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user) },
wantStatus: http.StatusBadRequest,
},
{
name: "password contains colon",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) { return newTokenReviewBody(colonUser + ":" + colonPassword) },
wantStatus: http.StatusOK,
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
wantBody: authenticatedResponseJSON(colonUser, []string{group0, group1}),
},
{
name: "bad TokenReview group",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) {
return newTokenReviewBodyWithGVK(
user+":"+password,
&schema.GroupVersionKind{
Group: "bad group",
Version: authenticationv1beta1.SchemeGroupVersion.Version,
Kind: "TokenReview",
},
)
},
wantStatus: http.StatusBadRequest,
},
{
name: "bad TokenReview version",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) {
return newTokenReviewBodyWithGVK(
user+":"+password,
&schema.GroupVersionKind{
Group: authenticationv1beta1.SchemeGroupVersion.Group,
Version: "bad version",
Kind: "TokenReview",
},
)
},
wantStatus: http.StatusBadRequest,
},
{
name: "bad TokenReview kind",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) {
return newTokenReviewBodyWithGVK(
user+":"+password,
&schema.GroupVersionKind{
Group: authenticationv1beta1.SchemeGroupVersion.Group,
Version: authenticationv1beta1.SchemeGroupVersion.Version,
Kind: "wrong-kind",
},
)
},
wantStatus: http.StatusBadRequest,
},
{
name: "bad path",
url: fmt.Sprintf("https://%s/tuna", l.Addr().String()),
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") },
wantStatus: http.StatusNotFound,
},
{
name: "bad method",
url: goodURL,
method: http.MethodGet,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") },
wantStatus: http.StatusMethodNotAllowed,
},
{
name: "bad content type",
url: goodURL,
method: http.MethodPost,
headers: map[string][]string{
"Content-Type": {"application/xml"},
"Accept": {"application/json"},
},
body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") },
wantStatus: http.StatusUnsupportedMediaType,
},
{
name: "bad accept",
url: goodURL,
method: http.MethodPost,
headers: map[string][]string{
"Content-Type": {"application/json"},
"Accept": {"application/xml"},
},
body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") },
wantStatus: http.StatusUnsupportedMediaType,
},
{
name: "success when there are multiple accepts and one of them is json",
url: goodURL,
method: http.MethodPost,
headers: map[string][]string{
"Content-Type": {"application/json"},
"Accept": {"something/else, application/xml, application/json"},
},
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + password) },
wantStatus: http.StatusOK,
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
wantBody: authenticatedResponseJSON(user, []string{group0, group1}),
},
{
name: "success when there are multiple accepts and one of them is */*",
url: goodURL,
method: http.MethodPost,
headers: map[string][]string{
"Content-Type": {"application/json"},
"Accept": {"something/else, */*, application/foo"},
},
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + password) },
wantStatus: http.StatusOK,
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
wantBody: authenticatedResponseJSON(user, []string{group0, group1}),
},
{
name: "success when there are multiple accepts and one of them is application/*",
url: goodURL,
method: http.MethodPost,
headers: map[string][]string{
"Content-Type": {"application/json"},
"Accept": {"something/else, application/*, application/foo"},
},
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + password) },
wantStatus: http.StatusOK,
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
wantBody: authenticatedResponseJSON(user, []string{group0, group1}),
},
{
name: "bad body",
url: goodURL,
method: http.MethodPost,
headers: goodRequestHeaders,
body: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewBuffer([]byte("invalid body"))), nil },
wantStatus: http.StatusBadRequest,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
parsedURL, 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: parsedURL,
Header: test.headers,
Body: body,
})
require.NoError(t, err)
defer func() { _ = rsp.Body.Close() }()
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))
}
}
responseBody, err := io.ReadAll(rsp.Body)
require.NoError(t, err)
if test.wantBody != nil {
require.NoError(t, err)
var tr authenticationv1beta1.TokenReview
require.NoError(t, json.Unmarshal(responseBody, &tr))
require.Equal(t, test.wantBody, &tr)
} else {
require.Empty(t, responseBody)
}
})
}
}
func createSecretInformer(ctx context.Context, 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()
kubeInformers.Start(ctx.Done())
informerTypesSynced := kubeInformers.WaitForCacheSync(ctx.Done())
require.True(t, informerTypesSynced[reflect.TypeOf(&corev1.Secret{})])
return secretInformer
}
// newClientProvider returns a dynamiccert.Provider 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) (dynamiccert.Private, []byte, string) {
t.Helper()
serverName := "local-user-authenticator"
ca, err := certauthority.New(serverName+" CA", time.Hour*24)
require.NoError(t, err)
cert, err := ca.IssueServerCert([]string{serverName}, nil, time.Hour*24)
require.NoError(t, err)
certPEM, keyPEM, err := certauthority.ToPEM(cert)
require.NoError(t, err)
certProvider := dynamiccert.NewServingCert(t.Name())
err = certProvider.SetCertKeyContent(certPEM, keyPEM)
require.NoError(t, err)
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(t *testing.T, caBundle []byte, serverName string) *http.Client {
t.Helper()
rootCAs := x509.NewCertPool()
ok := rootCAs.AppendCertsFromPEM(caBundle)
require.True(t, ok)
c := phttp.Secure(rootCAs)
tlsConfig, err := utilnet.TLSClientConfig(c.Transport)
require.NoError(t, err)
tlsConfig.ServerName = serverName
return c
}
// newTokenReviewBody creates an io.ReadCloser that contains a JSON-encodeed
// TokenReview request with expected APIVersion and Kind fields.
func newTokenReviewBody(token string) (io.ReadCloser, error) {
return newTokenReviewBodyWithGVK(
token,
&schema.GroupVersionKind{
Group: authenticationv1beta1.SchemeGroupVersion.Group,
Version: authenticationv1beta1.SchemeGroupVersion.Version,
Kind: "TokenReview",
},
)
}
// newTokenReviewBodyWithGVK creates an io.ReadCloser that contains a
// JSON-encoded TokenReview request. The TypeMeta fields of the TokenReview are
// filled in with the provided gvk.
func newTokenReviewBodyWithGVK(token string, gvk *schema.GroupVersionKind) (io.ReadCloser, error) {
buf := bytes.NewBuffer([]byte{})
tr := authenticationv1beta1.TokenReview{
TypeMeta: metav1.TypeMeta{
APIVersion: gvk.GroupVersion().String(),
Kind: gvk.Kind,
},
Spec: authenticationv1beta1.TokenReviewSpec{
Token: token,
},
}
err := json.NewEncoder(buf).Encode(&tr)
return io.NopCloser(buf), err
}
func unauthenticatedResponseJSON() *authenticationv1beta1.TokenReview {
return &authenticationv1beta1.TokenReview{
TypeMeta: metav1.TypeMeta{
Kind: "TokenReview",
APIVersion: "authentication.k8s.io/v1beta1",
},
Status: authenticationv1beta1.TokenReviewStatus{
Authenticated: false,
},
}
}
func authenticatedResponseJSON(user string, groups []string) *authenticationv1beta1.TokenReview {
return &authenticationv1beta1.TokenReview{
TypeMeta: metav1.TypeMeta{
Kind: "TokenReview",
APIVersion: "authentication.k8s.io/v1beta1",
},
Status: authenticationv1beta1.TokenReviewStatus{
Authenticated: true,
User: authenticationv1beta1.UserInfo{
Username: user,
Groups: groups,
},
},
}
}
func addSecretToFakeClientTracker(t *testing.T, kubeClient *kubernetesfake.Clientset, username, password, groups string) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
require.NoError(t, err)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: username,
Namespace: namespace,
},
Data: map[string][]byte{
"passwordHash": passwordHash,
"groups": []byte(groups),
},
}
require.NoError(t, kubeClient.Tracker().Add(secret))
}