diff --git a/config.example.yml b/config.example.yml index 7db099b7..aa7405c5 100644 --- a/config.example.yml +++ b/config.example.yml @@ -51,6 +51,11 @@ listener: # Required if TLS is enabled to be able to verify client certificate if the client provides one. #ca: /path/to/ca.crt + # Path to a Certificate Revocation List (CRL) file in PEM or DER format to enable client certificate revocation checks. + # When set, client certificates signed by the configured CA are checked against this CRL during the TLS handshake. + # Requires ca to be set. Leave unset (the default) to disable CRL checking. + #crl_file: /path/to/ca.crl + # Connection configuration for the database where Icinga Notifications stores configuration and historical data. # This is also the database used in Icinga Notifications Web to view and work with the data. database: diff --git a/doc/03-Configuration.md b/doc/03-Configuration.md index bb597b8f..4f308e78 100644 --- a/doc/03-Configuration.md +++ b/doc/03-Configuration.md @@ -50,6 +50,10 @@ TLS options apply only to the TCP listener. When a Unix socket is configured, Icinga Notifications identifies connecting processes by their OS user, and matches the OS username against the source's configured username. No password or HTTP Basic Auth is involved. +When TLS is enabled, sources can optionally be identified by a TLS client certificate's Subject +instead of a username/password pair. To use this, configure a CA via `ca` so that client certificates +can be verified, and set the `client_certificate_subject` field on the source in Icinga Notifications Web. + For YAML configuration, the options are part of the `listener` section. For environment variables, each option is prefixed with `ICINGA_NOTIFICATIONS_LISTENER_`. @@ -65,6 +69,7 @@ For environment variables, each option is prefixed with `ICINGA_NOTIFICATIONS_LI | cert | **Optional.** Path to TLS server certificate. Required if `tls` is enabled. | | key | **Optional.** Path to the TLS private key. Required if `tls` is enabled. | | ca | **Optional.** Path to TLS CA cert/bundle to verify client certs. Required if `tls` is enabled. | +| crl_file | **Optional.** Path to CRL file to verify client certificates. Requires `ca` to be set. Defaults to `false`. | !!! important diff --git a/doc/20-HTTP-API.md b/doc/20-HTTP-API.md index e78fbf84..73bdacfe 100644 --- a/doc/20-HTTP-API.md +++ b/doc/20-HTTP-API.md @@ -15,6 +15,8 @@ Authentication differs by transport: - **TCP:** HTTP Basic Authentication is used; both the source's username and password must match the configured credentials. +- **TCP with TLS:** If the request arrives with a TLS client certificate signed by the CA, the source is identified by the + certificate's Subject. If no matching source is found, the request falls back to HTTP Basic Authentication. - **Unix socket:** The caller is identified automatically by their OS user. No HTTP Basic Auth or password is involved. @@ -106,6 +108,13 @@ No credentials are needed; the daemon identifies the caller by their OS user aut curl must be executed as a user which is configured as listener_username of a source. +To submit over TLS using a client certificate instead of HTTP Basic Authentication, +pass `--cacert ca.crt --cert client.crt --key client.key` to curl and omit `-u`: + +!!! info + + The daemon identifies the source by the certificate's Subject. If no matching source is found, the request is rejected. + ## Incidents The `/incidents` Icinga Notifications HTTP API endpoint allows sources to query and modify a list of open incidents diff --git a/go.mod b/go.mod index 6f8b9c69..451b8708 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/emersion/go-smtp v0.24.0 github.com/google/uuid v1.6.0 - github.com/icinga/icinga-go-library v0.9.1-0.20260714123216-927526041e77 + github.com/icinga/icinga-go-library v0.9.1-0.20260714133932-f2fcca3b7321 github.com/jhillyerd/enmime v1.3.0 github.com/jmoiron/sqlx v1.4.0 github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd @@ -27,6 +27,7 @@ require ( github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/goccy/go-yaml v1.13.0 // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect diff --git a/go.sum b/go.sum index f2fdeeda..de6e785e 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6 github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -38,6 +40,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/icinga/icinga-go-library v0.9.1-0.20260714123216-927526041e77 h1:YQS63i/R9rHd72SYfNdSDZzgiU4kp1tv3vkdYyf4skM= github.com/icinga/icinga-go-library v0.9.1-0.20260714123216-927526041e77/go.mod h1:HDw4ODUuK0oineNCDzrJjFBdI0i1F3O2ODbQsiAiZ8k= +github.com/icinga/icinga-go-library v0.9.1-0.20260714133932-f2fcca3b7321 h1:fCRXtKP0gCRCt/YzZ3yhPu3apdJRr98vSnoV4XkCeWY= +github.com/icinga/icinga-go-library v0.9.1-0.20260714133932-f2fcca3b7321/go.mod h1:5xO4CWkWKr6scfle6vRgJTlvsteYfYVz/cRynv/I+ms= github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA= github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= diff --git a/internal/config/runtime.go b/internal/config/runtime.go index 98915cee..9a93b89c 100644 --- a/internal/config/runtime.go +++ b/internal/config/runtime.go @@ -210,6 +210,19 @@ func (r *RuntimeConfig) GetSourceByUsername(user string) *Source { return nil } +// GetSourceByClientCertSubject returns a *Source by the given certificate subject. +func (r *RuntimeConfig) GetSourceByClientCertSubject(subject string) *Source { + r.RLock() + defer r.RUnlock() + for _, src := range r.Sources { + if src.ClientCertificateSubject.Valid && src.ClientCertificateSubject.String == subject { + return src + } + } + + return nil +} + func (r *RuntimeConfig) fetchFromDatabase(ctx context.Context) error { tx, err := r.db.BeginTxx(ctx, &sql.TxOptions{ Isolation: sql.LevelRepeatableRead, diff --git a/internal/config/source.go b/internal/config/source.go index c2c817ec..36e0c52f 100644 --- a/internal/config/source.go +++ b/internal/config/source.go @@ -3,12 +3,13 @@ package config import ( "crypto/subtle" "fmt" + "slices" + "sync" + "github.com/icinga/icinga-go-library/types" "github.com/icinga/icinga-notifications/internal/config/baseconf" "go.uber.org/zap/zapcore" "golang.org/x/crypto/bcrypt" - "slices" - "sync" ) // Source entry within the ConfigSet to describe a source. @@ -18,10 +19,11 @@ type Source struct { Type string `db:"type"` Name string `db:"name"` - ListenerUsername types.String `db:"listener_username"` - ListenerPasswordHash types.String `db:"listener_password_hash"` - listenerPassword []byte `db:"-"` - listenerPasswordMutex sync.Mutex + ClientCertificateSubject types.String `db:"client_certificate_subject"` + ListenerUsername types.String `db:"listener_username"` + ListenerPasswordHash types.String `db:"listener_password_hash"` + listenerPassword []byte `db:"-"` + listenerPasswordMutex sync.Mutex // ruleIDs is a list of rule IDs belonging to this source. // diff --git a/internal/daemon/config.go b/internal/daemon/config.go index 84581b6b..33148ff9 100644 --- a/internal/daemon/config.go +++ b/internal/daemon/config.go @@ -93,10 +93,21 @@ func (l *Listener) Validate() error { return nil } -// GetTlsConfig returns a *[tls.Config] based on the TLS options specified in the Listener configuration. -func (l *Listener) GetTlsConfig() (*tls.Config, error) { +// GetTlsConfig returns a *[tls.Config] and a *[utils.CrlChecker] based on the TLS options +// specified in the Listener configuration. +func (l *Listener) GetTlsConfig() (*tls.Config, *config.CrlChecker, error) { tlsOpts := &config.ServerTLS{TLSCommon: l.TLSOptions, ClientAuth: config.TlsClientAuthType(tls.VerifyClientCertIfGiven)} - return tlsOpts.MakeConfig() + tlsConfig, err := tlsOpts.MakeConfig() + if err != nil { + return nil, nil, err + } + + crlChecker, err := tlsOpts.InitRevocationChecking(tlsConfig) + if err != nil { + return nil, nil, err + } + + return tlsConfig, crlChecker, nil } type ConfigFile struct { diff --git a/internal/listener/listener.go b/internal/listener/listener.go index d722546c..41643b59 100644 --- a/internal/listener/listener.go +++ b/internal/listener/listener.go @@ -125,11 +125,14 @@ func (l *Listener) Run(ctx context.Context) error { ErrorLog: stdlogger, } + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) + var listeningFunc func() error if l.useSocket { listeningFunc, err = l.initSocketServer(server) } else { - listeningFunc, err = l.initTcpServer(server) + listeningFunc, err = l.initTcpServer(ctx, cancel, server) } if err != nil { return err @@ -142,9 +145,12 @@ func (l *Listener) Run(ctx context.Context) error { select { case <-ctx.Done(): - shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - return server.Shutdown(shutdownCtx) + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer shutdownCancel() + if err := server.Shutdown(shutdownCtx); err != nil { + return err + } + return context.Cause(ctx) case err := <-serverErr: return err @@ -153,13 +159,23 @@ func (l *Listener) Run(ctx context.Context) error { // initTcpServer configures the given HTTP(S) server for the configured TCP address and returns a function that // starts serving. The caller is responsible for actually calling that function and for graceful shutdown. -func (l *Listener) initTcpServer(server *http.Server) (func() error, error) { +func (l *Listener) initTcpServer(ctx context.Context, cancel context.CancelCauseFunc, server *http.Server) (func() error, error) { conf := daemon.Config().Listener - tlsConfig, err := conf.GetTlsConfig() + tlsConfig, crlChecker, err := conf.GetTlsConfig() if err != nil { return nil, err } + go func() { + err := crlChecker.WatchAndReload(ctx, l.logger.SugaredLogger) + if ctx.Err() != nil || err == nil { + // WatchAndReload returning is expected (Context was cancelled externally or noop). + return + } + l.logger.Errorw("CRL watcher exited unexpectedly", zap.Error(err)) + cancel(fmt.Errorf("CRL watcher exited: %w", err)) + }() + var https string if conf.TLSOptions.Enable { https = "s" @@ -274,45 +290,98 @@ func (l *Listener) initSocketServer(server *http.Server) (fn func() error, retEr // up by the OS username of the connecting process via peer credentials. For TCP connections, HTTP Basic Auth is used. // If no matching source is found, nil is returned and 401 is written to the response. func (l *Listener) sourceFromAuthOrAbort(w http.ResponseWriter, r *http.Request) (src *config.Source) { - errFunc := func(errMsg string) *config.Source { - w.WriteHeader(http.StatusUnauthorized) + var errMsg string + errFunc := func(httpCode int, sendAuthHeader bool) *config.Source { + if sendAuthHeader { + w.Header().Set("WWW-Authenticate", `Basic realm="icinga-notifications source"`) + } + l.logger.Errorw("Error in sourceFromAuthOrAbort", zap.String("error", errMsg)) + w.WriteHeader(httpCode) _, _ = fmt.Fprintln(w, errMsg) return nil } if l.useSocket { - l.logger.Debugw("Source is authenticated via socket connection") + errMsg = "unix socket: " value := r.Context().Value(peerUserLookupKey{}) if value == nil { - return errFunc("no value found in context") + errMsg += "no client certificate found" + return errFunc(http.StatusUnauthorized, false) } lookup, ok := value.(peerUserLookupFunc) if !ok { - return errFunc("no lookup function present") + errMsg += "no lookup function present" + return errFunc(http.StatusUnauthorized, false) } username, err := lookup() if err != nil { - return errFunc(err.Error()) + errMsg += err.Error() + return errFunc(http.StatusUnauthorized, false) } src = l.runtimeConfig.GetSourceByUsername(username) if src == nil { - return errFunc(fmt.Sprintf("system user %q is not registered as a source username", username)) + errMsg += fmt.Sprintf("system user %q is not registered as a source username", username) + return errFunc(http.StatusUnauthorized, false) } + + l.logger.Debugw( + "Source is authenticated via socket connection", + zap.String("source_name", src.Name), + zap.String("username", username), + ) } else { - l.logger.Debugw("Source is authenticated via HTTP Basic Auth") authUser, authPass, authOk := r.BasicAuth() - if !authOk { - w.Header().Set("WWW-Authenticate", `Basic realm="icinga-notifications source"`) - return errFunc("missing or malformed basic auth credentials") - } + if r.TLS != nil && len(r.TLS.VerifiedChains) > 0 { + // if VerifiedChains isn't empty, the request doesn't fall back to basic auth + errMsg = "tls cert: " + if authUser != "" || authPass != "" { + errMsg += " both client certificate and basic auth provided" + return errFunc(http.StatusBadRequest, false) + } - src = l.runtimeConfig.GetSourceFromCredentials(authUser, authPass, l.logger) - if src == nil { - w.Header().Set("WWW-Authenticate", `Basic realm="icinga-notifications source"`) - return errFunc("expected valid icinga-notifications source basic auth credentials") + if len(r.TLS.VerifiedChains[0]) == 0 { + errMsg += "no verified chain found" + return errFunc(http.StatusUnauthorized, true) + } + + clientCert := r.TLS.VerifiedChains[0][0] + if clientCert == nil { + errMsg += "no client certificate found" + return errFunc(http.StatusUnauthorized, true) + } + + src = l.runtimeConfig.GetSourceByClientCertSubject(clientCert.Subject.String()) + if src == nil { + errMsg += "no matching source found" + return errFunc(http.StatusUnauthorized, true) + } + + l.logger.Debugw( + "Source is authenticated via a TLS client certificate", + zap.String("source_name", src.Name), + zap.String("common_name", clientCert.Subject.CommonName), + ) + } else { + errMsg = "basic auth: " + if !authOk { + errMsg += "missing or malformed basic auth credentials" + return errFunc(http.StatusUnauthorized, true) + } + + src = l.runtimeConfig.GetSourceFromCredentials(authUser, authPass, l.logger) + if src == nil { + errMsg += "expected valid icinga-notifications source basic auth credentials" + return errFunc(http.StatusUnauthorized, true) + } + + l.logger.Debugw( + "Source is authenticated via HTTP Basic Auth", + zap.String("source_name", src.Name), + zap.String("username", authUser), + ) } } diff --git a/internal/listener/listener_test.go b/internal/listener/listener_test.go index d531f70a..59f4cc32 100644 --- a/internal/listener/listener_test.go +++ b/internal/listener/listener_test.go @@ -2,6 +2,9 @@ package listener import ( "context" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "net/http" "net/http/httptest" "os/user" @@ -17,7 +20,7 @@ import ( "golang.org/x/crypto/bcrypt" ) -func makeTestListener(t *testing.T, useSocket bool) *Listener { +func makeTestListener(t *testing.T, useSocket bool, withCNSrc bool) *Listener { var src *config.Source if useSocket { u, err := user.Current() @@ -39,6 +42,10 @@ func makeTestListener(t *testing.T, useSocket bool) *Listener { rc := &config.RuntimeConfig{} rc.Sources = map[int64]*config.Source{1: src} + if withCNSrc { + rc.Sources[2] = &config.Source{ClientCertificateSubject: types.MakeString("CN=icinga-source")} + } + return &Listener{ runtimeConfig: rc, useSocket: useSocket, @@ -54,6 +61,17 @@ func withPeerUsername(req *http.Request, username string) *http.Request { return req.WithContext(context.WithValue(req.Context(), peerUserLookupKey{}, peerUserLookupFunc(setUser))) } +// makeRequestWithClientCert returns a POST request whose TLS state contains a verified client +// certificate with the given common name. +func makeRequestWithClientCert(cn string) *http.Request { + cert := &x509.Certificate{Subject: pkix.Name{CommonName: cn}} + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.TLS = &tls.ConnectionState{ + VerifiedChains: [][]*x509.Certificate{{cert}}, + } + return req +} + func TestSourceFromAuthOrAbort(t *testing.T) { t.Parallel() @@ -63,7 +81,7 @@ func TestSourceFromAuthOrAbort(t *testing.T) { t.Run("ValidCredsMatchingSource", func(t *testing.T) { t.Parallel() - l := makeTestListener(t, true) + l := makeTestListener(t, true, false) src := l.runtimeConfig.Sources[1] username := src.ListenerUsername.String req := withPeerUsername(httptest.NewRequest(http.MethodPost, "/", nil), username) @@ -77,7 +95,7 @@ func TestSourceFromAuthOrAbort(t *testing.T) { t.Run("ValidCredsNoMatchingSource", func(t *testing.T) { t.Parallel() - l := makeTestListener(t, true) + l := makeTestListener(t, true, false) username := l.runtimeConfig.Sources[1].ListenerUsername.String // Replace the source with one that has a different username. l.runtimeConfig.Sources[1] = &config.Source{ @@ -95,7 +113,7 @@ func TestSourceFromAuthOrAbort(t *testing.T) { t.Run("CredsWithUnknownUsername", func(t *testing.T) { t.Parallel() - l := makeTestListener(t, true) + l := makeTestListener(t, true, false) req := withPeerUsername(httptest.NewRequest(http.MethodPost, "/", nil), "unknown User") rw := httptest.NewRecorder() @@ -109,7 +127,7 @@ func TestSourceFromAuthOrAbort(t *testing.T) { t.Run("NoCredsInContext", func(t *testing.T) { t.Parallel() - l := makeTestListener(t, true) + l := makeTestListener(t, true, false) req := httptest.NewRequest(http.MethodPost, "/", nil) rw := httptest.NewRecorder() @@ -126,7 +144,7 @@ func TestSourceFromAuthOrAbort(t *testing.T) { t.Run("CorrectCredentials", func(t *testing.T) { t.Parallel() - l := makeTestListener(t, false) + l := makeTestListener(t, false, false) src := l.runtimeConfig.Sources[1] req := httptest.NewRequest(http.MethodPost, "/", nil) req.SetBasicAuth("icingadb", "secret") @@ -140,7 +158,7 @@ func TestSourceFromAuthOrAbort(t *testing.T) { t.Run("WrongPassword", func(t *testing.T) { t.Parallel() - l := makeTestListener(t, false) + l := makeTestListener(t, false, false) req := httptest.NewRequest(http.MethodPost, "/", nil) req.SetBasicAuth("icingadb", "wrongpassword") rw := httptest.NewRecorder() @@ -154,7 +172,7 @@ func TestSourceFromAuthOrAbort(t *testing.T) { t.Run("UnknownUsername", func(t *testing.T) { t.Parallel() - l := makeTestListener(t, false) + l := makeTestListener(t, false, false) req := httptest.NewRequest(http.MethodPost, "/", nil) req.SetBasicAuth("unknown", "secret") rw := httptest.NewRecorder() @@ -168,7 +186,7 @@ func TestSourceFromAuthOrAbort(t *testing.T) { t.Run("MissingAuthHeader", func(t *testing.T) { t.Parallel() - l := makeTestListener(t, false) + l := makeTestListener(t, false, false) req := httptest.NewRequest(http.MethodPost, "/", nil) rw := httptest.NewRecorder() @@ -178,4 +196,87 @@ func TestSourceFromAuthOrAbort(t *testing.T) { assert.NotEmpty(t, rw.Header().Get("WWW-Authenticate")) }) }) + + t.Run("Tls", func(t *testing.T) { + t.Parallel() + l := makeTestListener(t, false, true) + + cnSrc := l.runtimeConfig.Sources[2] + basicSrc := l.runtimeConfig.Sources[1] + + t.Run("CertMatchingSource", func(t *testing.T) { + t.Parallel() + + req := makeRequestWithClientCert("icinga-source") + rw := httptest.NewRecorder() + + assert.Same(t, cnSrc, l.sourceFromAuthOrAbort(rw, req)) + }) + + t.Run("CertCNNotMatchingAnySource", func(t *testing.T) { + t.Parallel() + + req := makeRequestWithClientCert("unknown-cn") + rw := httptest.NewRecorder() + + assert.Nil(t, l.sourceFromAuthOrAbort(rw, req)) + assert.Equal(t, http.StatusUnauthorized, rw.Code) + assert.NotEmpty(t, rw.Header().Get("WWW-Authenticate")) + assert.Contains(t, rw.Body.String(), "no matching source found") + }) + + t.Run("NoCertFallsBackToBasicAuth", func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.SetBasicAuth("icingadb", "secret") + rw := httptest.NewRecorder() + + req.TLS = &tls.ConnectionState{} // no verified chains + assert.Same(t, basicSrc, l.sourceFromAuthOrAbort(rw, req)) + + req.TLS = &tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{}} // empty verified chains + assert.Same(t, basicSrc, l.sourceFromAuthOrAbort(rw, req)) + }) + + t.Run("ClientCertAndBasicAuth", func(t *testing.T) { + t.Parallel() + + req := makeRequestWithClientCert("unknown-cn") + req.SetBasicAuth("icingadb", "secret") + rw := httptest.NewRecorder() + + assert.Nil(t, l.sourceFromAuthOrAbort(rw, req)) + assert.Equal(t, http.StatusBadRequest, rw.Code) + assert.Contains(t, rw.Body.String(), "client certificate and basic auth provided") + }) + + t.Run("EmptyVerifiedChain", func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/", nil) + rw := httptest.NewRecorder() + + req.TLS = &tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{}}} + + assert.Nil(t, l.sourceFromAuthOrAbort(rw, req)) + assert.Equal(t, http.StatusUnauthorized, rw.Code) + assert.NotEmpty(t, rw.Header().Get("WWW-Authenticate")) + assert.Contains(t, rw.Body.String(), "no verified chain found") + }) + + t.Run("EmptyClientCertificate", func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/", nil) + rw := httptest.NewRecorder() + + req.TLS = &tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{nil}}} + + assert.Nil(t, l.sourceFromAuthOrAbort(rw, req)) + assert.Equal(t, http.StatusUnauthorized, rw.Code) + assert.NotEmpty(t, rw.Header().Get("WWW-Authenticate")) + assert.Contains(t, rw.Body.String(), "no client certificate found") + }) + }) } diff --git a/schema/mysql/schema.sql b/schema/mysql/schema.sql index f26d85ff..1c483cf4 100644 --- a/schema/mysql/schema.sql +++ b/schema/mysql/schema.sql @@ -243,12 +243,16 @@ CREATE TABLE source ( listener_username varchar(255), listener_password_hash text, + client_certificate_subject varchar(768) DEFAULT NULL, + changed_at bigint NOT NULL, deleted enum('n', 'y') NOT NULL DEFAULT 'n', locked enum('n', 'y') NOT NULL DEFAULT 'n', -- set to 'y' when the source is maintained by an integration CONSTRAINT uk_source_listener_username UNIQUE (listener_username), - CONSTRAINT ck_source_listener_username_or_deleted CHECK (deleted = 'y' OR listener_username IS NOT NULL), + CONSTRAINT uk_source_client_certificate_subject UNIQUE (client_certificate_subject), + CONSTRAINT ck_source_listener_identity_or_deleted CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_subject IS NOT NULL), + CONSTRAINT ck_source_listener_cert_xor_credentials CHECK (listener_username IS NULL OR client_certificate_subject IS NULL), -- The hash is a PHP password_hash with PASSWORD_DEFAULT algorithm, defaulting to bcrypt. This check roughly ensures -- that listener_password_hash can only be populated with bcrypt hashes. diff --git a/schema/mysql/upgrades/add-client-certificates.sql b/schema/mysql/upgrades/add-client-certificates.sql new file mode 100644 index 00000000..bd2b6a04 --- /dev/null +++ b/schema/mysql/upgrades/add-client-certificates.sql @@ -0,0 +1,7 @@ +ALTER TABLE source ADD COLUMN client_certificate_subject varchar(768) DEFAULT NULL AFTER listener_password_hash; +ALTER TABLE source ADD CONSTRAINT uk_source_client_certificate_subject UNIQUE (client_certificate_subject); +ALTER TABLE source DROP CONSTRAINT ck_source_listener_username_or_deleted; +ALTER TABLE source ADD CONSTRAINT ck_source_listener_identity_or_deleted + CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_subject IS NOT NULL); +ALTER TABLE source ADD CONSTRAINT ck_source_listener_cert_xor_credentials + CHECK (listener_username IS NULL OR client_certificate_subject IS NULL); diff --git a/schema/pgsql/schema.sql b/schema/pgsql/schema.sql index 359556e7..25afcf30 100644 --- a/schema/pgsql/schema.sql +++ b/schema/pgsql/schema.sql @@ -269,12 +269,16 @@ CREATE TABLE source ( listener_username varchar(255), listener_password_hash text, + client_certificate_subject varchar(768) DEFAULT NULL, + changed_at bigint NOT NULL, deleted boolenum NOT NULL DEFAULT 'n', locked boolenum NOT NULL DEFAULT 'n', -- set to 'y' when the source is maintained by an integration CONSTRAINT uk_source_listener_username UNIQUE (listener_username), - CONSTRAINT ck_source_listener_username_or_deleted CHECK (deleted = 'y' OR listener_username IS NOT NULL), + CONSTRAINT uk_source_client_certificate_subject UNIQUE (client_certificate_subject), + CONSTRAINT ck_source_listener_identity_or_deleted CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_subject IS NOT NULL), + CONSTRAINT ck_source_listener_cert_xor_credentials CHECK (listener_username IS NULL OR client_certificate_subject IS NULL), -- The hash is a PHP password_hash with PASSWORD_DEFAULT algorithm, defaulting to bcrypt. This check roughly ensures -- that listener_password_hash can only be populated with bcrypt hashes. diff --git a/schema/pgsql/upgrades/add-client-certificates.sql b/schema/pgsql/upgrades/add-client-certificates.sql new file mode 100644 index 00000000..f49c5eed --- /dev/null +++ b/schema/pgsql/upgrades/add-client-certificates.sql @@ -0,0 +1,7 @@ +ALTER TABLE source ADD COLUMN client_certificate_subject varchar(768) DEFAULT NULL; +ALTER TABLE source ADD CONSTRAINT uk_source_client_certificate_subject UNIQUE (client_certificate_subject); +ALTER TABLE source DROP CONSTRAINT ck_source_listener_username_or_deleted; +ALTER TABLE source ADD CONSTRAINT ck_source_listener_identity_or_deleted + CHECK (deleted = 'y' OR listener_username IS NOT NULL OR client_certificate_subject IS NOT NULL); +ALTER TABLE source ADD CONSTRAINT ck_source_listener_cert_xor_credentials + CHECK (listener_username IS NULL OR client_certificate_subject IS NULL);