diff --git a/doc/20-HTTP-API.md b/doc/20-HTTP-API.md index 4d27280d..e5e273b5 100644 --- a/doc/20-HTTP-API.md +++ b/doc/20-HTTP-API.md @@ -11,8 +11,12 @@ After creating a source in Icinga Notifications Web, the specified credentials can be used via HTTP Basic Authentication to submit a JSON-encoded [`Event`](https://github.com/Icinga/icinga-go-library/blob/main/notifications/event/event.go). -The authentication is performed via HTTP Basic Authentication, expecting `source-${id}` as the username, -`${id}` being the source's `id` within the database, and the configured password. +The authentication is performed via HTTP Basic Authentication using the source's username and password. + +!!! info + + Before Icinga Notifications version 0.2.0, the username was a fixed string based on the source ID, such as `source-${id}`. + When upgrading a setup from an earlier version, these usernames are still valid, but can be changed in Icinga Notifications Web. Events sent to Icinga Notifications are expected to match rules that describe further event escalations. These rules can be created in the web interface. diff --git a/internal/config/runtime.go b/internal/config/runtime.go index 65b028a7..4640d62f 100644 --- a/internal/config/runtime.go +++ b/internal/config/runtime.go @@ -2,6 +2,7 @@ package config import ( "context" + "crypto/subtle" "database/sql" "errors" "github.com/icinga/icinga-go-library/database" @@ -13,7 +14,6 @@ import ( "github.com/icinga/icinga-notifications/internal/timeperiod" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" - "strconv" "strings" "sync" "time" @@ -205,29 +205,27 @@ func (r *RuntimeConfig) GetSourceFromCredentials(user, pass string, logger *logg r.RLock() defer r.RUnlock() - sourceIdRaw, sourceIdOk := strings.CutPrefix(user, "source-") - if !sourceIdOk { - logger.Debugw("Cannot extract source ID from HTTP basic auth username", zap.String("user_input", user)) - return nil - } - sourceId, err := strconv.ParseInt(sourceIdRaw, 10, 64) - if err != nil { - logger.Debugw("Cannot convert extracted source Id to int", zap.String("user_input", user), zap.Error(err)) - return nil + var src *Source + for _, tmpSrc := range r.Sources { + if !tmpSrc.ListenerUsername.Valid { + continue + } + if subtle.ConstantTimeCompare([]byte(tmpSrc.ListenerUsername.String), []byte(user)) == 1 { + src = tmpSrc + break + } } - - src, ok := r.Sources[sourceId] - if !ok { - logger.Debugw("Cannot check credentials for unknown source ID", zap.Int64("id", sourceId)) + if src == nil { + logger.Debugw("Cannot find source for username", zap.String("user", user)) return nil } - err = src.PasswordCompare([]byte(pass)) + err := src.PasswordCompare([]byte(pass)) if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { - logger.Debugw("Invalid password for this source", zap.Int64("id", sourceId)) + logger.Debugw("Invalid password for source", zap.Int64("id", src.ID)) return nil } else if err != nil { - logger.Errorw("Failed to verify password for this source", zap.Int64("id", sourceId), zap.Error(err)) + logger.Errorw("Failed to verify password for source", zap.Int64("id", src.ID), zap.Error(err)) return nil } diff --git a/internal/config/source.go b/internal/config/source.go index b1f2b5a3..d783e477 100644 --- a/internal/config/source.go +++ b/internal/config/source.go @@ -17,6 +17,7 @@ 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 diff --git a/internal/object/objects_test.go b/internal/object/objects_test.go index 9a056447..335c53a0 100644 --- a/internal/object/objects_test.go +++ b/internal/object/objects_test.go @@ -24,13 +24,14 @@ func TestRestoreMutedObjects(t *testing.T) { "type": "notifications", "name": "Icinga Notifications", "changed_at": int64(1720702049000), + "user": "jane.doe", "pwd_hash": "$2y$", // Needed to pass the database constraint. } // We can't use config.Source here unfortunately due to cyclic import error! id, err := database.InsertObtainID( ctx, tx, - `INSERT INTO source (type, name, changed_at, listener_password_hash) VALUES (:type, :name, :changed_at, :pwd_hash)`, + `INSERT INTO source (type, name, changed_at, listener_username, listener_password_hash) VALUES (:type, :name, :changed_at, :user, :pwd_hash)`, args) require.NoError(t, err, "populating source table should not fail") diff --git a/schema/mysql/schema.sql b/schema/mysql/schema.sql index e0ce1b37..19b8b98f 100644 --- a/schema/mysql/schema.sql +++ b/schema/mysql/schema.sql @@ -212,13 +212,15 @@ CREATE TABLE source ( -- will likely need a distinguishing value for multiple sources of the same type in the future, like for example -- the Icinga DB environment ID for Icinga 2 sources - -- This column is required to limit API access for incoming connections to the Listener. - -- The username will be "source-${id}", allowing early verification. + -- listener_{username,password_hash} are required to limit API access for incoming connections to the Listener. + listener_username varchar(255), listener_password_hash text, changed_at bigint NOT NULL, deleted enum('n', 'y') NOT NULL DEFAULT 'n', + CONSTRAINT uk_source_listener_username UNIQUE(listener_username), + -- 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. -- https://icinga.com/docs/icinga-web/latest/doc/20-Advanced-Topics/#manual-user-creation-for-database-authentication-backend diff --git a/schema/mysql/upgrades/0.2.0-source-username.sql b/schema/mysql/upgrades/0.2.0-source-username.sql new file mode 100644 index 00000000..1a37241e --- /dev/null +++ b/schema/mysql/upgrades/0.2.0-source-username.sql @@ -0,0 +1,3 @@ +ALTER TABLE source ADD COLUMN listener_username varchar(255) AFTER name; +UPDATE source SET listener_username = CONCAT('source-', source.id); +ALTER TABLE source ADD CONSTRAINT uk_source_listener_username UNIQUE(listener_username); diff --git a/schema/pgsql/schema.sql b/schema/pgsql/schema.sql index 35381d3a..e3030769 100644 --- a/schema/pgsql/schema.sql +++ b/schema/pgsql/schema.sql @@ -244,13 +244,15 @@ CREATE TABLE source ( -- will likely need a distinguishing value for multiple sources of the same type in the future, like for example -- the Icinga DB environment ID for Icinga 2 sources - -- This column is required to limit API access for incoming connections to the Listener. - -- The username will be "source-${id}", allowing early verification. + -- listener_{username,password_hash} are required to limit API access for incoming connections to the Listener. + listener_username varchar(255), listener_password_hash text, changed_at bigint NOT NULL, deleted boolenum NOT NULL DEFAULT 'n', + CONSTRAINT uk_source_listener_username UNIQUE(listener_username), + -- 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. -- https://icinga.com/docs/icinga-web/latest/doc/20-Advanced-Topics/#manual-user-creation-for-database-authentication-backend diff --git a/schema/pgsql/upgrades/0.2.0-source-username.sql b/schema/pgsql/upgrades/0.2.0-source-username.sql new file mode 100644 index 00000000..3a4ed0af --- /dev/null +++ b/schema/pgsql/upgrades/0.2.0-source-username.sql @@ -0,0 +1,3 @@ +ALTER TABLE source ADD COLUMN listener_username varchar(255); +UPDATE source SET listener_username = CONCAT('source-', source.id); +ALTER TABLE source ADD CONSTRAINT uk_source_listener_username UNIQUE(listener_username);