diff --git a/cmd/debug-make-impersonation-token/main.go b/cmd/debug-make-impersonation-token/main.go new file mode 100644 index 00000000..b33f1581 --- /dev/null +++ b/cmd/debug-make-impersonation-token/main.go @@ -0,0 +1,41 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + authenticationv1alpha1 "go.pinniped.dev/generated/1.20/apis/concierge/authentication/v1alpha1" + loginv1alpha1 "go.pinniped.dev/generated/1.20/apis/concierge/login/v1alpha1" +) + +func main() { + reqJSON, err := json.Marshal(&loginv1alpha1.TokenCredentialRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: os.Getenv("PINNIPED_TEST_CONCIERGE_NAMESPACE"), + }, + TypeMeta: metav1.TypeMeta{ + Kind: "TokenCredentialRequest", + APIVersion: loginv1alpha1.GroupName + "/v1alpha1", + }, + Spec: loginv1alpha1.TokenCredentialRequestSpec{ + Token: os.Getenv("PINNIPED_TEST_USER_TOKEN"), + Authenticator: corev1.TypedLocalObjectReference{ + APIGroup: &authenticationv1alpha1.SchemeGroupVersion.Group, + Kind: os.Getenv("PINNIPED_AUTHENTICATOR_KIND"), + Name: os.Getenv("PINNIPED_AUTHENTICATOR_NAME"), + }, + }, + }) + if err != nil { + panic(err) + } + fmt.Println(base64.RawURLEncoding.EncodeToString(reqJSON)) +} diff --git a/deploy/concierge/rbac.yaml b/deploy/concierge/rbac.yaml index 8df8734b..b81fc14e 100644 --- a/deploy/concierge/rbac.yaml +++ b/deploy/concierge/rbac.yaml @@ -28,6 +28,9 @@ rules: resources: [ securitycontextconstraints ] verbs: [ use ] resourceNames: [ nonroot ] + - apiGroups: [""] + resources: ["users", "groups"] + verbs: ["impersonate"] --- kind: ClusterRoleBinding apiVersion: rbac.authorization.k8s.io/v1 diff --git a/internal/concierge/impersonator/impersonator.go b/internal/concierge/impersonator/impersonator.go new file mode 100644 index 00000000..12f9e1b1 --- /dev/null +++ b/internal/concierge/impersonator/impersonator.go @@ -0,0 +1,156 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package impersonator + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/go-logr/logr" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/client-go/rest" + "k8s.io/client-go/transport" + + "go.pinniped.dev/generated/1.20/apis/concierge/login" + loginv1alpha1 "go.pinniped.dev/generated/1.20/apis/concierge/login/v1alpha1" + "go.pinniped.dev/internal/controller/authenticator/authncache" +) + +// allowedHeaders are the set of HTTP headers that are allowed to be forwarded through the impersonation proxy. +//nolint: gochecknoglobals +var allowedHeaders = []string{ + "Accept", + "Accept-Encoding", + "User-Agent", + "Connection", + "Upgrade", +} + +type Proxy struct { + cache *authncache.Cache + proxy *httputil.ReverseProxy + log logr.Logger +} + +func New(cache *authncache.Cache, log logr.Logger) (*Proxy, error) { + return newInternal(cache, log, rest.InClusterConfig) +} + +func newInternal(cache *authncache.Cache, log logr.Logger, getConfig func() (*rest.Config, error)) (*Proxy, error) { + kubeconfig, err := getConfig() + if err != nil { + return nil, fmt.Errorf("could not get in-cluster config: %w", err) + } + + serverURL, err := url.Parse(kubeconfig.Host) + if err != nil { + return nil, fmt.Errorf("could not parse host URL from in-cluster config: %w", err) + } + + kubeTransportConfig, err := kubeconfig.TransportConfig() + if err != nil { + return nil, fmt.Errorf("could not get in-cluster transport config: %w", err) + } + kubeTransportConfig.TLS.NextProtos = []string{"http/1.1"} + + kubeRoundTripper, err := transport.New(kubeTransportConfig) + if err != nil { + return nil, fmt.Errorf("could not get in-cluster transport: %w", err) + } + + proxy := httputil.NewSingleHostReverseProxy(serverURL) + proxy.Transport = kubeRoundTripper + + return &Proxy{ + cache: cache, + proxy: proxy, + log: log, + }, nil +} + +func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + log := p.log.WithValues( + "url", r.URL.String(), + "method", r.Method, + ) + + tokenCredentialReq, err := extractToken(r) + if err != nil { + log.Error(err, "invalid token encoding") + http.Error(w, "invalid token encoding", http.StatusBadRequest) + return + } + log = log.WithValues( + "authenticator", tokenCredentialReq.Spec.Authenticator, + "authenticatorNamespace", tokenCredentialReq.Namespace, + ) + + userInfo, err := p.cache.AuthenticateTokenCredentialRequest(r.Context(), tokenCredentialReq) + if err != nil { + log.Error(err, "received invalid token") + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + + if userInfo == nil { + log.Info("received token that did not authenticate") + http.Error(w, "not authenticated", http.StatusUnauthorized) + return + } + log = log.WithValues( + "user", userInfo.GetName(), + "groups", userInfo.GetGroups(), + ) + + newHeaders := getProxyHeaders(userInfo, r.Header) + r.Header = newHeaders + + log.Info("proxying authenticated request") + p.proxy.ServeHTTP(w, r) +} + +func getProxyHeaders(userInfo user.Info, requestHeaders http.Header) http.Header { + newHeaders := http.Header{} + newHeaders.Set("Impersonate-User", userInfo.GetName()) + for _, group := range userInfo.GetGroups() { + newHeaders.Add("Impersonate-Group", group) + } + for _, header := range allowedHeaders { + values := requestHeaders.Values(header) + for i := range values { + newHeaders.Add(header, values[i]) + } + } + return newHeaders +} + +func extractToken(req *http.Request) (*login.TokenCredentialRequest, error) { + authHeader := req.Header.Get("Authorization") + if authHeader == "" { + return nil, fmt.Errorf("missing authorization header") + } + if !strings.HasPrefix(authHeader, "Bearer ") { + return nil, fmt.Errorf("authorization header must be of type Bearer") + } + encoded := strings.TrimPrefix(authHeader, "Bearer ") + tokenCredentialRequestJSON, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("invalid base64 in encoded bearer token: %w", err) + } + + var v1alpha1Req loginv1alpha1.TokenCredentialRequest + if err := json.Unmarshal(tokenCredentialRequestJSON, &v1alpha1Req); err != nil { + return nil, fmt.Errorf("invalid TokenCredentialRequest encoded in bearer token: %w", err) + } + var internalReq login.TokenCredentialRequest + if err := loginv1alpha1.Convert_v1alpha1_TokenCredentialRequest_To_login_TokenCredentialRequest(&v1alpha1Req, &internalReq, nil); err != nil { + return nil, fmt.Errorf("failed to convert v1alpha1 TokenCredentialRequest to internal version: %w", err) + } + return &internalReq, nil +} diff --git a/internal/concierge/impersonator/impersonator_test.go b/internal/concierge/impersonator/impersonator_test.go new file mode 100644 index 00000000..9a8d1508 --- /dev/null +++ b/internal/concierge/impersonator/impersonator_test.go @@ -0,0 +1,259 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package impersonator + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd/api" + + loginv1alpha1 "go.pinniped.dev/generated/1.20/apis/concierge/login/v1alpha1" + "go.pinniped.dev/internal/controller/authenticator/authncache" + "go.pinniped.dev/internal/mocks/mocktokenauthenticator" + "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/internal/testutil/testlogger" +) + +func TestImpersonator(t *testing.T) { + validURL, _ := url.Parse("http://pinniped.dev/blah") + testServerCA, testServerURL := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) { + // Expect that the request is authenticated based on the kubeconfig credential. + if r.Header.Get("Authorization") != "Bearer some-service-account-token" { + http.Error(w, "expected to see service account token", http.StatusForbidden) + return + } + // Fail if we see the malicious header passed through the proxy (it's not on the allowlist). + if r.Header.Get("Malicious-Header") != "" { + http.Error(w, "didn't expect to see malicious header", http.StatusForbidden) + return + } + // Expect to see the user agent header passed through. + if r.Header.Get("User-Agent") != "test-user-agent" { + http.Error(w, "got unexpected user agent header", http.StatusBadRequest) + return + } + _, _ = w.Write([]byte("successful proxied response")) + }) + testServerKubeconfig := rest.Config{ + Host: testServerURL, + BearerToken: "some-service-account-token", + TLSClientConfig: rest.TLSClientConfig{CAData: []byte(testServerCA)}, + } + + tests := []struct { + name string + getKubeconfig func() (*rest.Config, error) + wantCreationErr string + request *http.Request + wantHTTPBody string + wantHTTPStatus int + wantLogs []string + expectMockToken func(*testing.T, *mocktokenauthenticator.MockTokenMockRecorder) + }{ + { + name: "fail to get in-cluster config", + getKubeconfig: func() (*rest.Config, error) { + return nil, fmt.Errorf("some kubernetes error") + }, + wantCreationErr: "could not get in-cluster config: some kubernetes error", + }, + { + name: "invalid kubeconfig host", + getKubeconfig: func() (*rest.Config, error) { + return &rest.Config{Host: ":"}, nil + }, + wantCreationErr: "could not parse host URL from in-cluster config: parse \":\": missing protocol scheme", + }, + { + name: "invalid transport config", + getKubeconfig: func() (*rest.Config, error) { + return &rest.Config{ + Host: "pinniped.dev/blah", + ExecProvider: &api.ExecConfig{}, + AuthProvider: &api.AuthProviderConfig{}, + }, nil + }, + wantCreationErr: "could not get in-cluster transport config: execProvider and authProvider cannot be used in combination", + }, + { + name: "fail to get transport from config", + getKubeconfig: func() (*rest.Config, error) { + return &rest.Config{ + Host: "pinniped.dev/blah", + BearerToken: "test-bearer-token", + Transport: http.DefaultTransport, + TLSClientConfig: rest.TLSClientConfig{Insecure: true}, + }, nil + }, + wantCreationErr: "could not get in-cluster transport: using a custom transport with TLS certificate options or the insecure flag is not allowed", + }, + { + name: "missing authorization header", + getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil }, + request: &http.Request{ + Method: "GET", + Header: map[string][]string{}, + URL: validURL, + }, + wantHTTPBody: "invalid token encoding\n", + wantHTTPStatus: http.StatusBadRequest, + wantLogs: []string{"\"error\"=\"missing authorization header\" \"msg\"=\"invalid token encoding\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""}, + }, + { + name: "authorization header missing bearer prefix", + getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil }, + request: &http.Request{ + Method: "GET", + Header: map[string][]string{"Authorization": {makeTestTokenRequest("foo", "authenticator-one", "test-token")}}, + URL: validURL, + }, + wantHTTPBody: "invalid token encoding\n", + wantHTTPStatus: http.StatusBadRequest, + wantLogs: []string{"\"error\"=\"authorization header must be of type Bearer\" \"msg\"=\"invalid token encoding\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""}, + }, + { + name: "token is not base64 encoded", + getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil }, + request: &http.Request{ + Method: "GET", + Header: map[string][]string{"Authorization": {"Bearer !!!"}}, + URL: validURL, + }, + wantHTTPBody: "invalid token encoding\n", + wantHTTPStatus: http.StatusBadRequest, + wantLogs: []string{"\"error\"=\"invalid base64 in encoded bearer token: illegal base64 data at input byte 0\" \"msg\"=\"invalid token encoding\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""}, + }, + { + name: "base64 encoded token is not valid json", + getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil }, + request: &http.Request{ + Method: "GET", + Header: map[string][]string{"Authorization": {"Bearer abc"}}, + URL: validURL, + }, + wantHTTPBody: "invalid token encoding\n", + wantHTTPStatus: http.StatusBadRequest, + wantLogs: []string{"\"error\"=\"invalid TokenCredentialRequest encoded in bearer token: invalid character 'i' looking for beginning of value\" \"msg\"=\"invalid token encoding\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""}, + }, + { + name: "token could not be authenticated", + getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil }, + request: &http.Request{ + Method: "GET", + Header: map[string][]string{"Authorization": {"Bearer " + makeTestTokenRequest("", "", "")}}, + URL: validURL, + }, + wantHTTPBody: "invalid token\n", + wantHTTPStatus: http.StatusUnauthorized, + wantLogs: []string{"\"error\"=\"no such authenticator\" \"msg\"=\"received invalid token\" \"authenticator\"={\"apiGroup\":null,\"kind\":\"\",\"name\":\"\"} \"authenticatorNamespace\"=\"\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""}, + }, + { + name: "token authenticates as nil", + getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil }, + request: &http.Request{ + Method: "GET", + Header: map[string][]string{"Authorization": {"Bearer " + makeTestTokenRequest("foo", "authenticator-one", "test-token")}}, + URL: validURL, + }, + expectMockToken: func(t *testing.T, recorder *mocktokenauthenticator.MockTokenMockRecorder) { + recorder.AuthenticateToken(gomock.Any(), "test-token").Return(nil, false, nil) + }, + wantHTTPBody: "not authenticated\n", + wantHTTPStatus: http.StatusUnauthorized, + wantLogs: []string{"\"level\"=0 \"msg\"=\"received token that did not authenticate\" \"authenticator\"={\"apiGroup\":null,\"kind\":\"\",\"name\":\"authenticator-one\"} \"authenticatorNamespace\"=\"foo\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""}, + }, + // happy path + { + name: "token validates", + getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil }, + request: &http.Request{ + Method: "GET", + Header: map[string][]string{ + "Authorization": {"Bearer " + makeTestTokenRequest("foo", "authenticator-one", "test-token")}, + "Malicious-Header": {"test-header-value-1"}, + "User-Agent": {"test-user-agent"}, + }, + URL: validURL, + }, + expectMockToken: func(t *testing.T, recorder *mocktokenauthenticator.MockTokenMockRecorder) { + userInfo := user.DefaultInfo{Name: "test-user", Groups: []string{"test-group-1", "test-group-2"}} + response := &authenticator.Response{User: &userInfo} + recorder.AuthenticateToken(gomock.Any(), "test-token").Return(response, true, nil) + }, + wantHTTPBody: "successful proxied response", + wantHTTPStatus: http.StatusOK, + wantLogs: []string{"\"level\"=0 \"msg\"=\"proxying authenticated request\" \"authenticator\"={\"apiGroup\":null,\"kind\":\"\",\"name\":\"authenticator-one\"} \"authenticatorNamespace\"=\"foo\" \"groups\"=[\"test-group-1\",\"test-group-2\"] \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\" \"user\"=\"test-user\""}, + }, + } + + for _, tt := range tests { + tt := tt + testLog := testlogger.New(t) + t.Run(tt.name, func(t *testing.T) { + // stole this from cache_test, hopefully it is sufficient + cacheWithMockAuthenticator := authncache.New() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + key := authncache.Key{Namespace: "foo", Name: "authenticator-one"} + mockToken := mocktokenauthenticator.NewMockToken(ctrl) + cacheWithMockAuthenticator.Store(key, mockToken) + + if tt.expectMockToken != nil { + tt.expectMockToken(t, mockToken.EXPECT()) + } + + proxy, err := newInternal(cacheWithMockAuthenticator, testLog, tt.getKubeconfig) + if tt.wantCreationErr != "" { + require.EqualError(t, err, tt.wantCreationErr) + return + } + require.NoError(t, err) + require.NotNil(t, proxy) + w := httptest.NewRecorder() + proxy.ServeHTTP(w, tt.request) + if tt.wantHTTPStatus != 0 { + require.Equal(t, tt.wantHTTPStatus, w.Code) + } + if tt.wantHTTPBody != "" { + require.Equal(t, tt.wantHTTPBody, w.Body.String()) + } + if tt.wantLogs != nil { + require.Equal(t, tt.wantLogs, testLog.Lines()) + } + }) + } +} + +func makeTestTokenRequest(namespace string, name string, token string) string { + reqJSON, err := json.Marshal(&loginv1alpha1.TokenCredentialRequest{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + }, + TypeMeta: metav1.TypeMeta{ + Kind: "TokenCredentialRequest", + APIVersion: loginv1alpha1.GroupName + "/v1alpha1", + }, + Spec: loginv1alpha1.TokenCredentialRequestSpec{ + Token: token, + Authenticator: corev1.TypedLocalObjectReference{Name: name}, + }, + }) + if err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(reqJSON) +} diff --git a/internal/concierge/server/server.go b/internal/concierge/server/server.go index 9c1a0a40..9b22ba28 100644 --- a/internal/concierge/server/server.go +++ b/internal/concierge/server/server.go @@ -6,8 +6,11 @@ package server import ( "context" + "crypto/tls" + "crypto/x509/pkix" "fmt" "io" + "net/http" "time" "github.com/spf13/cobra" @@ -18,11 +21,15 @@ import ( utilruntime "k8s.io/apimachinery/pkg/util/runtime" genericapiserver "k8s.io/apiserver/pkg/server" genericoptions "k8s.io/apiserver/pkg/server/options" + "k8s.io/klog/v2" + "k8s.io/klog/v2/klogr" loginapi "go.pinniped.dev/generated/1.20/apis/concierge/login" loginv1alpha1 "go.pinniped.dev/generated/1.20/apis/concierge/login/v1alpha1" + "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/certauthority/dynamiccertauthority" "go.pinniped.dev/internal/concierge/apiserver" + "go.pinniped.dev/internal/concierge/impersonator" "go.pinniped.dev/internal/config/concierge" "go.pinniped.dev/internal/controller/authenticator/authncache" "go.pinniped.dev/internal/controllermanager" @@ -162,6 +169,34 @@ func (a *App) runServer(ctx context.Context) error { return fmt.Errorf("could not create aggregated API server: %w", err) } + // run proxy handler + impersonationCA, err := certauthority.New(pkix.Name{CommonName: "test CA"}, 24*time.Hour) + if err != nil { + return fmt.Errorf("could not create impersonation CA: %w", err) + } + impersonationCert, err := impersonationCA.Issue(pkix.Name{}, []string{"impersonation-proxy"}, nil, 24*time.Hour) + if err != nil { + return fmt.Errorf("could not create impersonation cert: %w", err) + } + impersonationProxy, err := impersonator.New(authenticators, klogr.New().WithName("impersonation-proxy")) + if err != nil { + return fmt.Errorf("could not create impersonation proxy: %w", err) + } + + impersonationProxyServer := http.Server{ + Addr: "0.0.0.0:8444", + Handler: impersonationProxy, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{*impersonationCert}, + }, + } + go func() { + if err := impersonationProxyServer.ListenAndServeTLS("", ""); err != nil { + klog.ErrorS(err, "could not serve impersonation proxy") + } + }() + // Run the server. Its post-start hook will start the controllers. return server.GenericAPIServer.PrepareRun().Run(ctx.Done()) } diff --git a/proxy-kubeconfig.yaml b/proxy-kubeconfig.yaml new file mode 100644 index 00000000..37c60516 --- /dev/null +++ b/proxy-kubeconfig.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +clusters: + - cluster: + server: https://127.0.0.1:8444 + insecure-skip-tls-verify: true + name: kind-pinniped +contexts: + - context: + cluster: kind-pinniped + user: kind-pinniped + name: kind-pinniped +current-context: kind-pinniped +kind: Config +preferences: {} +users: + - name: kind-pinniped