From d67c526d78066a0937c31007e4ebf24dc5203d02 Mon Sep 17 00:00:00 2001 From: Kacper Rzetelski Date: Wed, 10 May 2023 17:17:22 +0200 Subject: [PATCH] Add path exclusion support to mTLS authentication Signed-off-by: Kacper Rzetelski --- .../x509/testdata/client2_selfsigned.pem | 12 + .../x509/testdata/client_selfsigned.pem | 12 + web/internal/authentication/x509/x509.go | 115 ++++++ web/internal/authentication/x509/x509_test.go | 355 ++++++++++++++++++ ...erifyclientcert.authexcludedpaths.good.yml | 8 + ...auth_client_san.authexcludedpaths.bad.yaml | 10 + web/tls_config.go | 152 ++++++-- web/tls_config_test.go | 46 +++ 8 files changed, 676 insertions(+), 34 deletions(-) create mode 100644 web/internal/authentication/x509/testdata/client2_selfsigned.pem create mode 100644 web/internal/authentication/x509/testdata/client_selfsigned.pem create mode 100644 web/internal/authentication/x509/x509.go create mode 100644 web/internal/authentication/x509/x509_test.go create mode 100644 web/testdata/tls_config_noAuth.requireandverifyclientcert.authexcludedpaths.good.yml create mode 100644 web/testdata/web_config_auth_client_san.authexcludedpaths.bad.yaml diff --git a/web/internal/authentication/x509/testdata/client2_selfsigned.pem b/web/internal/authentication/x509/testdata/client2_selfsigned.pem new file mode 100644 index 00000000..be1426c4 --- /dev/null +++ b/web/internal/authentication/x509/testdata/client2_selfsigned.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIB3DCCAWGgAwIBAgIUJVN8KehL1MmccvLb/mHthSMfnnswCgYIKoZIzj0EAwIw +EDEOMAwGA1UEAwwFdGVzdDMwIBcNMjMwMTEwMTgxMTAwWhgPMjEyMjEyMTcxODEx +MDBaMBAxDjAMBgNVBAMMBXRlc3QzMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEf8wC +qU9e4lPZZqJMA4nJ84rLPdfryoUI8tquBAHtae4yfXP3z6Hz92XdPaS4ZAFDjTLt +Jsl45KYixNb7y9dtbVoNxNxdDC4ywaoklqkpBGY0I9GEpNzaBll/4DIJvGcgo3ow +eDAdBgNVHQ4EFgQUvyvu/TnJyRS7OGdujTbWM/W07yMwHwYDVR0jBBgwFoAUvyvu +/TnJyRS7OGdujTbWM/W07yMwDwYDVR0TAQH/BAUwAwEB/zAQBgNVHREECTAHggV0 +ZXN0MzATBgNVHSUEDDAKBggrBgEFBQcDAjAKBggqhkjOPQQDAgNpADBmAjEAt7HK +knE2MzwZ2B2dgn1/q3ikWDiO20Hbd97jo3tmv87FcF2vMqqJpHjcldJqplfsAjEA +sfAz49y6Sf6LNlNS+Fc/lbOOwcrlzC+J5GJ8OmNoQPsvvDvhzGbwFiVw1M2uMqtG +-----END CERTIFICATE----- diff --git a/web/internal/authentication/x509/testdata/client_selfsigned.pem b/web/internal/authentication/x509/testdata/client_selfsigned.pem new file mode 100644 index 00000000..d25ddca8 --- /dev/null +++ b/web/internal/authentication/x509/testdata/client_selfsigned.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBxzCCAU2gAwIBAgIUGCNnsX0qd0HD7UaQsx67ze0UaNowCgYIKoZIzj0EAwIw +DzENMAsGA1UEAwwEdGVzdDAgFw0yMTA4MjAxNDQ5MTRaGA8yMTIxMDcyNzE0NDkx +NFowDzENMAsGA1UEAwwEdGVzdDB2MBAGByqGSM49AgEGBSuBBAAiA2IABLFRLjQB +XViHUAEIsKglwb0HxPC/+CDa1TTOp1b0WErYW7Xcx5mRNEksVWAXOWYKPej10hfy +JSJE/2NiRAbrAcPjiRv01DgDt+OzwM4A0ZYqBj/3qWJKH/Kc8oKhY41bzKNoMGYw +HQYDVR0OBBYEFPRbKtRBgw+AZ0b6T8oWw/+QoyjaMB8GA1UdIwQYMBaAFPRbKtRB +gw+AZ0b6T8oWw/+QoyjaMA8GA1UdEwEB/wQFMAMBAf8wEwYDVR0lBAwwCgYIKwYB +BQUHAwIwCgYIKoZIzj0EAwIDaAAwZQIwZqwXMJiTycZdmLN+Pwk/8Sb7wQazbocb +16Zw5mZXqFJ4K+74OQMZ33i82hYohtE/AjEAn0a8q8QupgiXpr0I/PvGTRKqLQRM +0mptBvpn/DcB2p3Hi80GJhtchz9Z0OqbMX4S +-----END CERTIFICATE----- diff --git a/web/internal/authentication/x509/x509.go b/web/internal/authentication/x509/x509.go new file mode 100644 index 00000000..19b528f4 --- /dev/null +++ b/web/internal/authentication/x509/x509.go @@ -0,0 +1,115 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package x509 + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "net/http" + + "github.com/prometheus/exporter-toolkit/web/internal/authentication" +) + +const ( + denyReasonCertificateRequired = "certificate required" + denyReasonBadCertificate = "bad certificate" + denyReasonUnknownCA = "unknown certificate authority" + denyReasonCertificateExpired = "expired certificate" +) + +// X509Authenticator allows for client certificate verification at HTTP level for X.509 certificates. +// The purpose behind it is to delegate or extend the TLS certificate verification beyond the standard TLS handshake. +type X509Authenticator struct { + // requireClientCerts specifies whether client certificates are required. + // This vaguely corresponds to crypto/tls ClientAuthType: https://pkg.go.dev/crypto/tls#ClientAuthType. + // If true, it is equivalent to RequireAnyClientCert or RequireAndVerifyClientCert. + requireClientCerts bool + + // verifyOptions returns VerifyOptions used to obtain parameters for Certificate.Verify. + // Optional: if not provided, the client cert is not verified and hence it does not have to be valid. + verifyOptions func() x509.VerifyOptions + + // verifyPeerCertificate corresponds to `VerifyPeerCertificate` from crypto/tls Config: https://pkg.go.dev/crypto/tls#Config. + // It bears the same semantics. + // Optional: if not provided, it is not invoked on any of the peer certificates. + verifyPeerCertificate func([][]byte, [][]*x509.Certificate) error +} + +// Authenticate performs client cert verification by mimicking the steps the server would normally take during the standard TLS handshake in crypto/tls. +// https://cs.opensource.google/go/go/+/refs/tags/go1.23.2:src/crypto/tls/handshake_server.go;l=874-950 +func (x *X509Authenticator) Authenticate(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) { + if r.TLS == nil { + return false, "", nil, errors.New("no tls connection state in request") + } + + if len(r.TLS.PeerCertificates) == 0 && x.requireClientCerts { + if r.TLS.Version == tls.VersionTLS13 { + return false, denyReasonCertificateRequired, nil, nil + } + + return false, denyReasonBadCertificate, nil, nil + } + + var verifiedChains [][]*x509.Certificate + if len(r.TLS.PeerCertificates) > 0 && x.verifyOptions != nil { + opts := x.verifyOptions() + if opts.Intermediates == nil && len(r.TLS.PeerCertificates) > 1 { + opts.Intermediates = x509.NewCertPool() + for _, cert := range r.TLS.PeerCertificates[1:] { + opts.Intermediates.AddCert(cert) + } + } + + chains, err := r.TLS.PeerCertificates[0].Verify(opts) + if err != nil { + if errors.As(err, &x509.UnknownAuthorityError{}) { + return false, denyReasonUnknownCA, nil, nil + } + + var errCertificateInvalid x509.CertificateInvalidError + if errors.As(err, &errCertificateInvalid) && errCertificateInvalid.Reason == x509.Expired { + return false, denyReasonCertificateExpired, nil, nil + } + + return false, denyReasonBadCertificate, nil, nil + } + + verifiedChains = chains + } + + if x.verifyPeerCertificate != nil { + rawCerts := make([][]byte, 0, len(r.TLS.PeerCertificates)) + for _, c := range r.TLS.PeerCertificates { + rawCerts = append(rawCerts, c.Raw) + } + + err := x.verifyPeerCertificate(rawCerts, verifiedChains) + if err != nil { + return false, denyReasonBadCertificate, nil, nil + } + } + + return true, "", nil, nil +} + +func NewX509Authenticator(requireClientCerts bool, verifyOptions func() x509.VerifyOptions, verifyPeerCertificate func([][]byte, [][]*x509.Certificate) error) authentication.Authenticator { + return &X509Authenticator{ + requireClientCerts: requireClientCerts, + verifyOptions: verifyOptions, + verifyPeerCertificate: verifyPeerCertificate, + } +} + +var _ authentication.Authenticator = &X509Authenticator{} diff --git a/web/internal/authentication/x509/x509_test.go b/web/internal/authentication/x509/x509_test.go new file mode 100644 index 00000000..2bc47456 --- /dev/null +++ b/web/internal/authentication/x509/x509_test.go @@ -0,0 +1,355 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package x509 + +import ( + "crypto/tls" + "crypto/x509" + _ "embed" + "encoding/pem" + "errors" + "net/http" + "reflect" + "testing" + + "github.com/prometheus/exporter-toolkit/web/internal/authentication/testhelpers" +) + +//go:embed testdata/client_selfsigned.pem +var clientSelfsignedPEM []byte + +//go:embed testdata/client2_selfsigned.pem +var client2SelfsignedPEM []byte + +func TestX509Authenticator_Authenticate(t *testing.T) { + t.Parallel() + + tt := []struct { + Name string + + RequireClientCerts bool + VerifyOptions func() x509.VerifyOptions + VerifyPeerCertificate func([][]byte, [][]*x509.Certificate) error + + RequestFn func(*testing.T) *http.Request + + ExpectAuthenticated bool + ExpectedDenyReason string + ExpectedError error + }{ + { + Name: "No TLS connection state in request", + RequireClientCerts: false, + VerifyOptions: nil, + VerifyPeerCertificate: nil, + RequestFn: testhelpers.MakeDefaultRequest, + ExpectAuthenticated: false, + ExpectedDenyReason: "", + ExpectedError: errors.New("no tls connection state in request"), + }, + { + Name: "Certs not required, certs not provided", + RequireClientCerts: false, + VerifyOptions: nil, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{} + + return req + }, + ExpectAuthenticated: true, + ExpectedDenyReason: "", + ExpectedError: nil, + }, + { + Name: "Certs required, certs not provided", + RequireClientCerts: true, + VerifyOptions: nil, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{} + + return req + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "bad certificate", + ExpectedError: nil, + }, + { + Name: "Certs required, certs not provided, VersionTLS13", + RequireClientCerts: true, + VerifyOptions: nil, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + Version: tls.VersionTLS13, + } + + return req + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "certificate required", + ExpectedError: nil, + }, + { + Name: "Certs not required, no verify, selfsigned cert provided", + RequireClientCerts: false, + VerifyOptions: nil, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + PeerCertificates: getCerts(t, clientSelfsignedPEM), + } + + return req + }, + ExpectAuthenticated: true, + ExpectedDenyReason: "", + ExpectedError: nil, + }, + { + Name: "Certs required, no verify, selfsigned cert provided", + RequireClientCerts: true, + VerifyOptions: nil, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + PeerCertificates: getCerts(t, clientSelfsignedPEM), + } + + return req + }, + ExpectAuthenticated: true, + ExpectedDenyReason: "", + ExpectedError: nil, + }, + { + Name: "Certs not required, verify, selfsigned cert provided", + RequireClientCerts: false, + VerifyOptions: func() x509.VerifyOptions { + opts := baseVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + PeerCertificates: getCerts(t, clientSelfsignedPEM), + } + + return req + }, + ExpectAuthenticated: true, + ExpectedDenyReason: "", + ExpectedError: nil, + }, + { + Name: "Certs not required, verify, no certs provided", + RequireClientCerts: false, + VerifyOptions: func() x509.VerifyOptions { + opts := baseVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{} + + return req + }, + ExpectAuthenticated: true, + ExpectedDenyReason: "", + ExpectedError: nil, + }, + { + Name: "Certs required, verify, selfsigned cert provided", + RequireClientCerts: true, + VerifyOptions: func() x509.VerifyOptions { + opts := baseVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + PeerCertificates: getCerts(t, clientSelfsignedPEM), + } + + return req + }, + ExpectAuthenticated: true, + ExpectedDenyReason: "", + ExpectedError: nil, + }, + { + Name: "Certs required, verify, cert signed by an unknown CA provided", + RequireClientCerts: true, + VerifyOptions: func() x509.VerifyOptions { + opts := baseVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + VerifyPeerCertificate: nil, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + PeerCertificates: getCerts(t, client2SelfsignedPEM), + } + + return req + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "unknown certificate authority", + ExpectedError: nil, + }, + { + Name: "Certs required, verify, selfsigned cert provided, verify peer certificate func returns an error", + RequireClientCerts: true, + VerifyOptions: func() x509.VerifyOptions { + opts := baseVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + VerifyPeerCertificate: func(_ [][]byte, _ [][]*x509.Certificate) error { + return errors.New("invalid peer certificate") + }, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + PeerCertificates: getCerts(t, clientSelfsignedPEM), + } + + return req + }, + ExpectAuthenticated: false, + ExpectedDenyReason: "bad certificate", + ExpectedError: nil, + }, + { + Name: "RequireAndVerifyClientCert, selfsigned certs, verify peer certificate func does not return an error", + RequireClientCerts: true, + VerifyOptions: func() x509.VerifyOptions { + opts := baseVerifyOptions() + opts.Roots = getCertPool(t, clientSelfsignedPEM) + return opts + }, + VerifyPeerCertificate: func(_ [][]byte, _ [][]*x509.Certificate) error { + return nil + }, + RequestFn: func(t *testing.T) *http.Request { + t.Helper() + + req := testhelpers.MakeDefaultRequest(t) + req.TLS = &tls.ConnectionState{ + PeerCertificates: getCerts(t, clientSelfsignedPEM), + } + + return req + }, + ExpectAuthenticated: true, + ExpectedDenyReason: "", + ExpectedError: nil, + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + req := tc.RequestFn(t) + + a := NewX509Authenticator(tc.RequireClientCerts, tc.VerifyOptions, tc.VerifyPeerCertificate) + authenticated, denyReason, httpChallenge, err := a.Authenticate(req) + + if !reflect.DeepEqual(err, tc.ExpectedError) { + t.Fatalf("Expected error %v, got %v", tc.ExpectedError, err) + } + + if httpChallenge != nil { + t.Errorf("Expected http challenge to be nil, got %v", httpChallenge) + } + + if tc.ExpectedDenyReason != denyReason { + t.Errorf("Expected deny reason %q, got %q", tc.ExpectedDenyReason, denyReason) + } + + if tc.ExpectAuthenticated != authenticated { + t.Errorf("Expected authenticated %t, got %t", tc.ExpectAuthenticated, authenticated) + } + }) + } +} + +func getCertPool(t *testing.T, pemData ...[]byte) *x509.CertPool { + t.Helper() + + pool := x509.NewCertPool() + certs := getCerts(t, pemData...) + for _, c := range certs { + pool.AddCert(c) + } + + return pool +} + +func getCerts(t *testing.T, pemData ...[]byte) []*x509.Certificate { + t.Helper() + + certs := make([]*x509.Certificate, 0) + for _, pd := range pemData { + pemBlock, _ := pem.Decode(pd) + cert, err := x509.ParseCertificate(pemBlock.Bytes) + if err != nil { + t.Fatalf("Error parsing cert: %v", err) + } + certs = append(certs, cert) + } + + return certs +} + +// baseVerifyOptions require certificates to be valid for client auth (x509.ExtKeyUsageClientAuth). +func baseVerifyOptions() x509.VerifyOptions { + return x509.VerifyOptions{ + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } +} diff --git a/web/testdata/tls_config_noAuth.requireandverifyclientcert.authexcludedpaths.good.yml b/web/testdata/tls_config_noAuth.requireandverifyclientcert.authexcludedpaths.good.yml new file mode 100644 index 00000000..2518daf3 --- /dev/null +++ b/web/testdata/tls_config_noAuth.requireandverifyclientcert.authexcludedpaths.good.yml @@ -0,0 +1,8 @@ +tls_server_config: + cert_file: "server.crt" + key_file: "server.key" + client_auth_type: "RequireAndVerifyClientCert" + client_ca_file: "client_selfsigned.pem" + +auth_excluded_paths: +- "/somepath" diff --git a/web/testdata/web_config_auth_client_san.authexcludedpaths.bad.yaml b/web/testdata/web_config_auth_client_san.authexcludedpaths.bad.yaml new file mode 100644 index 00000000..5157e3a8 --- /dev/null +++ b/web/testdata/web_config_auth_client_san.authexcludedpaths.bad.yaml @@ -0,0 +1,10 @@ +tls_server_config: + cert_file: "server.crt" + key_file: "server.key" + client_auth_type: "RequireAndVerifyClientCert" + client_ca_file: "client2_selfsigned.pem" + client_allowed_sans: + - "bad" + +auth_excluded_paths: +- "/somepath" diff --git a/web/tls_config.go b/web/tls_config.go index 73981cfb..f1a65baf 100644 --- a/web/tls_config.go +++ b/web/tls_config.go @@ -33,6 +33,7 @@ import ( "github.com/prometheus/exporter-toolkit/web/internal/authentication" basicauth_authentication "github.com/prometheus/exporter-toolkit/web/internal/authentication/basicauth" chain_authentication "github.com/prometheus/exporter-toolkit/web/internal/authentication/chain" + x509_authentication "github.com/prometheus/exporter-toolkit/web/internal/authentication/x509" "golang.org/x/sync/errgroup" "gopkg.in/yaml.v2" ) @@ -142,11 +143,15 @@ func getTLSConfig(configPath string) (*tls.Config, error) { return ConfigToTLSConfig(&c.TLSConfig) } +func isTLSEnabled(c *TLSConfig) bool { + return c.TLSCertPath != "" || c.TLSCert != "" || + c.TLSKeyPath != "" || c.TLSKey != "" || + c.ClientCAs != "" || c.ClientCAsText != "" || + c.ClientAuth != "" +} + func validateTLSPaths(c *TLSConfig) error { - if c.TLSCertPath == "" && c.TLSCert == "" && - c.TLSKeyPath == "" && c.TLSKey == "" && - c.ClientCAs == "" && c.ClientCAsText == "" && - c.ClientAuth == "" { + if !isTLSEnabled(c) { return errNoTLSConfig } @@ -161,6 +166,40 @@ func validateTLSPaths(c *TLSConfig) error { return nil } +func getClientCAs(clientCAsPath, clientCAsText string) (*x509.CertPool, error) { + clientCAPool := x509.NewCertPool() + + if clientCAsPath != "" { + clientCAFile, err := os.ReadFile(clientCAsPath) + if err != nil { + return nil, err + } + + clientCAPool.AppendCertsFromPEM(clientCAFile) + } else if clientCAsText != "" { + clientCAPool.AppendCertsFromPEM([]byte(clientCAsText)) + } + + return clientCAPool, nil +} + +func parseClientAuth(s string) (tls.ClientAuthType, error) { + switch s { + case "RequestClientCert": + return tls.RequestClientCert, nil + case "RequireAnyClientCert", "RequireClientCert": // Preserved for backwards compatibility. + return tls.RequireAnyClientCert, nil + case "VerifyClientCertIfGiven": + return tls.VerifyClientCertIfGiven, nil + case "RequireAndVerifyClientCert": + return tls.RequireAndVerifyClientCert, nil + case "", "NoClientCert": + return tls.NoClientCert, nil + default: + return tls.ClientAuthType(0), errors.New("Invalid ClientAuth: " + s) + } +} + // ConfigToTLSConfig generates the golang tls.Config from the TLSConfig struct. func ConfigToTLSConfig(c *TLSConfig) (*tls.Config, error) { if err := validateTLSPaths(c); err != nil { @@ -227,44 +266,31 @@ func ConfigToTLSConfig(c *TLSConfig) (*tls.Config, error) { cfg.CurvePreferences = cp } - if c.ClientCAs != "" { - clientCAPool := x509.NewCertPool() - clientCAFile, err := os.ReadFile(c.ClientCAs) - if err != nil { - return nil, err - } - clientCAPool.AppendCertsFromPEM(clientCAFile) - cfg.ClientCAs = clientCAPool - } else if c.ClientCAsText != "" { - clientCAPool := x509.NewCertPool() - clientCAPool.AppendCertsFromPEM([]byte(c.ClientCAsText)) - cfg.ClientCAs = clientCAPool - } - - if c.ClientAllowedSans != nil { - // verify that the client cert contains an allowed SAN - cfg.VerifyPeerCertificate = c.VerifyPeerCertificate + clientCAs, err := getClientCAs(c.ClientCAs, c.ClientCAsText) + if err != nil { + return nil, err } + cfg.ClientCAs = clientCAs - switch c.ClientAuth { - case "RequestClientCert": - cfg.ClientAuth = tls.RequestClientCert - case "RequireAnyClientCert", "RequireClientCert": // Preserved for backwards compatibility. - cfg.ClientAuth = tls.RequireAnyClientCert - case "VerifyClientCertIfGiven": - cfg.ClientAuth = tls.VerifyClientCertIfGiven - case "RequireAndVerifyClientCert": - cfg.ClientAuth = tls.RequireAndVerifyClientCert - case "", "NoClientCert": - cfg.ClientAuth = tls.NoClientCert - default: - return nil, errors.New("Invalid ClientAuth: " + c.ClientAuth) + clientAuth, err := parseClientAuth(c.ClientAuth) + if err != nil { + return nil, err } + cfg.ClientAuth = clientAuth if (c.ClientCAs != "" || c.ClientCAsText != "") && cfg.ClientAuth == tls.NoClientCert { return nil, errors.New("Client CA's have been configured without a Client Auth Policy") } + switch clientAuth { + case tls.RequireAnyClientCert, tls.VerifyClientCertIfGiven, tls.RequireAndVerifyClientCert: + // Cert verification is delegated to the authentication middleware. + cfg.ClientAuth = tls.RequestClientCert + + default: + // No changes to client auth type required. + } + return cfg, nil } @@ -345,6 +371,26 @@ func parseVsockPort(address string) (uint32, error) { return uint32(port), nil } +func isClientCertRequired(c tls.ClientAuthType) bool { + switch c { + case tls.RequireAnyClientCert, tls.RequireAndVerifyClientCert: + return true + + default: + return false + } +} + +func isClientCertVerificationRequired(c tls.ClientAuthType) bool { + switch c { + case tls.VerifyClientCertIfGiven, tls.RequireAndVerifyClientCert: + return true + + default: + return false + } +} + func withRequestAuthentication(handler http.Handler, webConfigPath string, logger *slog.Logger) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c, err := getConfig(webConfigPath) @@ -356,6 +402,44 @@ func withRequestAuthentication(handler http.Handler, webConfigPath string, logge authenticators := make([]authentication.Authenticator, 0) + if isTLSEnabled(&c.TLSConfig) { + clientAuth, err := parseClientAuth(c.TLSConfig.ClientAuth) + if err != nil { + logger.Error("Unable to parse ClientAuth", "err", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if clientAuth != tls.NoClientCert { + requireClientCerts := isClientCertRequired(clientAuth) + + var verifyOptions func() x509.VerifyOptions + if isClientCertVerificationRequired(clientAuth) { + clientCAs, err := getClientCAs(c.TLSConfig.ClientCAs, c.TLSConfig.ClientCAsText) + if err != nil { + logger.Error("Unable to get ClientCAs", "err", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + verifyOptions = func() x509.VerifyOptions { + return x509.VerifyOptions{ + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + Roots: clientCAs, + } + } + } + + var verifyPeerCertificate func([][]byte, [][]*x509.Certificate) error + if len(c.TLSConfig.ClientAllowedSans) > 0 { + verifyPeerCertificate = c.TLSConfig.VerifyPeerCertificate + } + + x509Authenticator := x509_authentication.NewX509Authenticator(requireClientCerts, verifyOptions, verifyPeerCertificate) + authenticators = append(authenticators, x509Authenticator) + } + } + if len(c.Users) > 0 { basicAuthAuthenticator := basicauth_authentication.NewBasicAuthAuthenticator(c.Users) authenticators = append(authenticators, basicAuthAuthenticator) diff --git a/web/tls_config_test.go b/web/tls_config_test.go index 3d98b88f..9b8e02a6 100644 --- a/web/tls_config_test.go +++ b/web/tls_config_test.go @@ -366,6 +366,52 @@ func TestServerBehaviour(t *testing.T) { ClientCertificate: "client2_selfsigned", ExpectedError: ErrorMap["Invalid client cert"], }, + { + Name: `valid tls config yml and tls client with RequireAndVerifyClientCert and auth_excluded_paths (path not matching, certificate not present)`, + YAMLConfigPath: "testdata/tls_config_noAuth.requireandverifyclientcert.authexcludedpaths.good.yml", + UseTLSClient: true, + URI: "/someotherpath", + ExpectedError: ErrorMap["Certificate required"], + }, + { + Name: `valid tls config yml and tls client with RequireAndVerifyClientCert and auth_excluded_paths (path not matching, certificate present)`, + YAMLConfigPath: "testdata/tls_config_noAuth.requireandverifyclientcert.authexcludedpaths.good.yml", + UseTLSClient: true, + ClientCertificate: "client_selfsigned", + URI: "/someotherpath", + ExpectedError: nil, + }, + { + Name: `valid tls config yml and tls client with RequireAndVerifyClientCert and auth_excluded_paths (path matching, certificate not present)`, + YAMLConfigPath: "testdata/tls_config_noAuth.requireandverifyclientcert.authexcludedpaths.good.yml", + UseTLSClient: true, + URI: "/somepath", + ExpectedError: nil, + }, + { + Name: `valid tls config yml and tls client with RequireAndVerifyClientCert and auth_excluded_paths (path matching, wrong certificate present)`, + YAMLConfigPath: "testdata/tls_config_noAuth.requireandverifyclientcert.authexcludedpaths.good.yml", + UseTLSClient: true, + ClientCertificate: "client2_selfsigned", + URI: "/somepath", + ExpectedError: nil, + }, + { + Name: `valid tls config yml and tls client with VerifyPeerCertificate and auth_excluded_paths (path matching, present invalid SAN DNS entries)`, + YAMLConfigPath: "testdata/web_config_auth_client_san.authexcludedpaths.bad.yaml", + UseTLSClient: true, + ClientCertificate: "client2_selfsigned", + URI: "/somepath", + ExpectedError: nil, + }, + { + Name: `valid tls config yml and tls client with VerifyPeerCertificate and auth_excluded_paths (path not matching, present invalid SAN DNS entries)`, + YAMLConfigPath: "testdata/web_config_auth_client_san.authexcludedpaths.bad.yaml", + UseTLSClient: true, + ClientCertificate: "client2_selfsigned", + URI: "/someotherpath", + ExpectedError: ErrorMap["Invalid client cert"], + }, } for _, testInputs := range testTables { t.Run(testInputs.Name, testInputs.Test)