From e08c442e39b03d7eef636d853032e691f7348b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Fri, 21 Feb 2020 15:32:28 +0000 Subject: [PATCH] feat(backend): add basic auth support --- cmd/karma/main.go | 30 +++++++++++++------ .../tests/testscript/log_full_config_env.txt | 2 ++ .../tests/testscript/log_full_config_file.txt | 12 ++++++++ cmd/karma/views.go | 9 ++++++ cmd/karma/views_test.go | 15 ++++++++++ docs/CONFIGURATION.md | 16 ++++++++++ internal/config/config.go | 16 ++++++++++ internal/config/config_test.go | 4 ++- internal/config/models.go | 8 +++++ internal/models/api.go | 28 ++++++++++------- 10 files changed, 119 insertions(+), 21 deletions(-) diff --git a/cmd/karma/main.go b/cmd/karma/main.go index 613c4549d..ff0dbdee1 100644 --- a/cmd/karma/main.go +++ b/cmd/karma/main.go @@ -98,16 +98,28 @@ func setupRouter(router *gin.Engine) { ExposeHeaders: []string{"Content-Length"}, })) - router.GET(getViewURL("/"), index) - router.GET(getViewURL("/health"), pong) - router.GET(getViewURL("/alerts.json"), alerts) - router.GET(getViewURL("/autocomplete.json"), autocomplete) - router.GET(getViewURL("/labelNames.json"), knownLabelNames) - router.GET(getViewURL("/labelValues.json"), knownLabelValues) - router.GET(getViewURL("/silences.json"), silences) + var protected *gin.RouterGroup + if len(config.Config.Authentication.Users) > 0 { + users := map[string]string{} + for _, u := range config.Config.Authentication.Users { + users[u.Username] = u.Password + } + protected = router.Group(getViewURL("/"), gin.BasicAuth(users)) + } else { + protected = router.Group(getViewURL("/")) + } - router.GET(getViewURL("/custom.css"), customCSS) - router.GET(getViewURL("/custom.js"), customJS) + router.GET(getViewURL("/health"), pong) + + protected.GET("/", index) + protected.GET("/alerts.json", alerts) + protected.GET("/autocomplete.json", autocomplete) + protected.GET("/labelNames.json", knownLabelNames) + protected.GET("/labelValues.json", knownLabelValues) + protected.GET("/silences.json", silences) + + protected.GET("/custom.css", customCSS) + protected.GET("/custom.js", customJS) router.NoRoute(notFound) } diff --git a/cmd/karma/tests/testscript/log_full_config_env.txt b/cmd/karma/tests/testscript/log_full_config_env.txt index 91012dd53..3d705b400 100644 --- a/cmd/karma/tests/testscript/log_full_config_env.txt +++ b/cmd/karma/tests/testscript/log_full_config_env.txt @@ -69,6 +69,8 @@ cmp stderr expected.stderr -- expected.stderr -- level=info msg="Version: dev" level=info msg="Parsed configuration:" +level=info msg="authentication:" +level=info msg=" users: []" level=info msg="alertmanager:" level=info msg=" interval: 10s" level=info msg=" servers:" diff --git a/cmd/karma/tests/testscript/log_full_config_file.txt b/cmd/karma/tests/testscript/log_full_config_file.txt index 894d33f51..6f669ab11 100644 --- a/cmd/karma/tests/testscript/log_full_config_file.txt +++ b/cmd/karma/tests/testscript/log_full_config_file.txt @@ -4,6 +4,12 @@ karma.bin-should-work --config.file=custom.yaml --check-config cmp stderr expected.stderr -- custom.yaml -- +authentication: + users: + - username: number + password: 1234 + - username: string + password: '1234' alertmanager: interval: 10s servers: @@ -231,6 +237,12 @@ FLR1flnW2lx5o5csDzTpi+jgC6nu1zE0DWo1c5ZdpVO289POIpqh level=info msg="Reading configuration file custom.yaml" level=info msg="Version: dev" level=info msg="Parsed configuration:" +level=info msg="authentication:" +level=info msg=" users:" +level=info msg=" - username: number" +level=info msg=" password: '***'" +level=info msg=" - username: string" +level=info msg=" password: '***'" level=info msg="alertmanager:" level=info msg=" interval: 10s" level=info msg=" servers:" diff --git a/cmd/karma/views.go b/cmd/karma/views.go index 2075d9468..7444ff12d 100644 --- a/cmd/karma/views.go +++ b/cmd/karma/views.go @@ -143,6 +143,11 @@ func alerts(c *gin.Context) { start := time.Now() ts, _ := start.UTC().MarshalText() + var username string + if len(config.Config.Authentication.Users) > 0 { + username = c.MustGet(gin.AuthUserKey).(string) + } + // initialize response object, set fields that don't require any locking resp := models.AlertsResponse{} resp.Status = "success" @@ -175,6 +180,10 @@ func alerts(c *gin.Context) { CommentPrefix: config.Config.AlertAcknowledgement.CommentPrefix, }, } + resp.Authentication = models.AuthenticationInfo{ + Enabled: len(config.Config.Authentication.Users) > 0, + Username: username, + } if config.Config.Grid.Sorting.CustomValues.Labels != nil { resp.Settings.Sorting.ValueMapping = config.Config.Grid.Sorting.CustomValues.Labels diff --git a/cmd/karma/views_test.go b/cmd/karma/views_test.go index a38a134ec..30a8f34e0 100644 --- a/cmd/karma/views_test.go +++ b/cmd/karma/views_test.go @@ -818,3 +818,18 @@ func TestEmptySettings(t *testing.T) { t.Errorf("Wrong settings returned (-want +got):\n%s", diff) } } + +func TestBasicAuth(t *testing.T) { + config.Config.Authentication.Users = []config.AuthenticationUser{ + {Username: "john", Password: "foobar"}, + } + r := ginTestEngine() + for _, path := range []string{"/", "/alerts.json", "/autocomplete.json"} { + req := httptest.NewRequest("GET", path, nil) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + if resp.Code != 401 { + t.Errorf("Expected 401 from %s, got %d", path, resp.Code) + } + } +} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 33caa7fb6..ded297259 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -22,6 +22,22 @@ Example with environment variables: CONFIG_FILE="docs/example.yaml" ``` +### Authentication + +`authentication` sections allows enabling authentication support in karma. +When set users will be require to authenticate to access karma. +Enabling authentication will also force silences to be created with usernames +passed from credentials. + +```YAML +authentication: + users: + - username: string + password: string +``` + +- `authentication:users` - list of users (username & password) allowed to login. + ### Alertmanagers `alertmanager` section allows setting Alertmanager servers that should be diff --git a/internal/config/config.go b/internal/config/config.go index 883094aa9..007280321 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -265,6 +265,12 @@ func (config *configSchema) Read(flags *pflag.FlagSet) string { config.SilenceForm.Strip.Labels = []string{} } + for _, u := range config.Authentication.Users { + if u.Username == "" || u.Password == "" { + log.Fatalf("authentication.users require both username and password to be set") + } + } + if config.SilenceForm.Author.PopulateFromHeader.ValueRegex != "" { _, err = regexp.Compile(config.SilenceForm.Author.PopulateFromHeader.ValueRegex) if err != nil { @@ -345,6 +351,16 @@ func (config *configSchema) LogValues() { // make a copy of our config so we can edit it cfg := configSchema(*config) + auth := []AuthenticationUser{} + for _, u := range cfg.Authentication.Users { + uu := AuthenticationUser{ + Username: u.Username, + Password: "***", + } + auth = append(auth, uu) + } + cfg.Authentication.Users = auth + // replace passwords in Alertmanager URIs with 'xxx' servers := []AlertmanagerConfig{} for _, s := range cfg.Alertmanager.Servers { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 610dd94d6..f3d1b5ae4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -20,7 +20,9 @@ func resetEnv() { } func testReadConfig(t *testing.T) { - expectedConfig := `alertmanager: + expectedConfig := `authentication: + users: [] +alertmanager: interval: 1s servers: - name: default diff --git a/internal/config/models.go b/internal/config/models.go index 9a3b68f52..4599071ac 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -40,7 +40,15 @@ type CustomLabelColor struct { type CustomLabelColors map[string][]CustomLabelColor +type AuthenticationUser struct { + Username string + Password string +} + type configSchema struct { + Authentication struct { + Users []AuthenticationUser + } Alertmanager struct { Interval time.Duration Servers []AlertmanagerConfig diff --git a/internal/models/api.go b/internal/models/api.go index 485223158..f6269ea96 100644 --- a/internal/models/api.go +++ b/internal/models/api.go @@ -296,19 +296,25 @@ type Settings struct { AlertAcknowledgement AlertAcknowledgementSettings `json:"alertAcknowledgement"` } +type AuthenticationInfo struct { + Enabled bool `json:"enabled"` + Username string `json:"username"` +} + // AlertsResponse is the structure of JSON response UI will use to get alert data type AlertsResponse struct { - Status string `json:"status"` - Timestamp string `json:"timestamp"` - Version string `json:"version"` - Upstreams AlertmanagerAPISummary `json:"upstreams"` - Silences map[string]map[string]Silence `json:"silences"` - AlertGroups []APIAlertGroup `json:"groups"` - TotalAlerts int `json:"totalAlerts"` - Colors LabelsColorMap `json:"colors"` - Filters []Filter `json:"filters"` - Counters LabelNameStatsList `json:"counters"` - Settings Settings `json:"settings"` + Status string `json:"status"` + Timestamp string `json:"timestamp"` + Version string `json:"version"` + Upstreams AlertmanagerAPISummary `json:"upstreams"` + Silences map[string]map[string]Silence `json:"silences"` + AlertGroups []APIAlertGroup `json:"groups"` + TotalAlerts int `json:"totalAlerts"` + Colors LabelsColorMap `json:"colors"` + Filters []Filter `json:"filters"` + Counters LabelNameStatsList `json:"counters"` + Settings Settings `json:"settings"` + Authentication AuthenticationInfo `json:"authentication"` } // Autocomplete is the structure of autocomplete object for filter hints