diff --git a/cmd/karma/main.go b/cmd/karma/main.go index ff0dbdee1..1c9d2607a 100644 --- a/cmd/karma/main.go +++ b/cmd/karma/main.go @@ -67,6 +67,25 @@ func customJS(c *gin.Context) { serveFileOr404(config.Config.Custom.JS, "application/javascript", c) } +func headerAuth(name, valueRegex string) gin.HandlerFunc { + return func(c *gin.Context) { + user := c.Request.Header.Get(name) + if user == "" { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + r := regexp.MustCompile("^" + valueRegex + "$") + matches := r.FindAllStringSubmatch(user, 1) + if len(matches) > 0 && len(matches[0]) > 1 { + c.Set(gin.AuthUserKey, matches[0][1]) + } else { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + } +} + func setupRouter(router *gin.Engine) { router.Use(gzip.Gzip(gzip.DefaultCompression)) @@ -99,9 +118,14 @@ func setupRouter(router *gin.Engine) { })) var protected *gin.RouterGroup - if len(config.Config.Authentication.Users) > 0 { + if config.Config.Authentication.Header.Name != "" { + config.Config.Authentication.Enabled = true + protected = router.Group(getViewURL("/"), + headerAuth(config.Config.Authentication.Header.Name, config.Config.Authentication.Header.ValueRegex)) + } else if len(config.Config.Authentication.BasicAuth.Users) > 0 { + config.Config.Authentication.Enabled = true users := map[string]string{} - for _, u := range config.Config.Authentication.Users { + for _, u := range config.Config.Authentication.BasicAuth.Users { users[u.Username] = u.Password } protected = router.Group(getViewURL("/"), gin.BasicAuth(users)) diff --git a/cmd/karma/script_test.go b/cmd/karma/script_test.go index 573339cd2..57fb265ec 100644 --- a/cmd/karma/script_test.go +++ b/cmd/karma/script_test.go @@ -11,8 +11,13 @@ import ( ) func mainShoulFail() int { - defer func() { log.StandardLogger().ExitFunc = nil }() var wasFatal bool + defer func() { + if r := recover(); r != nil { + wasFatal = true + } + }() + defer func() { log.StandardLogger().ExitFunc = nil }() log.StandardLogger().ExitFunc = func(int) { wasFatal = true } _, err := mainSetup(pflag.ContinueOnError) diff --git a/cmd/karma/tests/testscript/auth_basicAuth_no_credentials.txt b/cmd/karma/tests/testscript/auth_basicAuth_no_credentials.txt new file mode 100644 index 000000000..29d2e5122 --- /dev/null +++ b/cmd/karma/tests/testscript/auth_basicAuth_no_credentials.txt @@ -0,0 +1,14 @@ +# Raises an error if basic auth credentials are missing +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="authentication.basicAuth.users require both username and password to be set"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +authentication: + basicAuth: + users: + - foo: bar diff --git a/cmd/karma/tests/testscript/auth_basicAuth_no_password.txt b/cmd/karma/tests/testscript/auth_basicAuth_no_password.txt new file mode 100644 index 000000000..65606d776 --- /dev/null +++ b/cmd/karma/tests/testscript/auth_basicAuth_no_password.txt @@ -0,0 +1,14 @@ +# Raises an error if basic auth password is missing +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="authentication.basicAuth.users require both username and password to be set"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +authentication: + basicAuth: + users: + - username: me diff --git a/cmd/karma/tests/testscript/auth_basicAuth_no_username.txt b/cmd/karma/tests/testscript/auth_basicAuth_no_username.txt new file mode 100644 index 000000000..0a812456f --- /dev/null +++ b/cmd/karma/tests/testscript/auth_basicAuth_no_username.txt @@ -0,0 +1,14 @@ +# Raises an error if basic auth username is missing +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="authentication.basicAuth.users require both username and password to be set"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +authentication: + basicAuth: + users: + - password: foo diff --git a/cmd/karma/tests/testscript/auth_header_and_basicAuth.txt b/cmd/karma/tests/testscript/auth_header_and_basicAuth.txt new file mode 100644 index 000000000..ee1d16c85 --- /dev/null +++ b/cmd/karma/tests/testscript/auth_header_and_basicAuth.txt @@ -0,0 +1,18 @@ +# Raises an error if both header & basic auth authentication is enabled +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="Both authentication.basicAuth.users and authentication.header.name is set, only one can be enabled"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +authentication: + header: + name: "foo" + value_re: ".+" + basicAuth: + users: + - username: me + password: foo diff --git a/cmd/karma/tests/testscript/auth_header_no_name.txt b/cmd/karma/tests/testscript/auth_header_no_name.txt new file mode 100644 index 000000000..b1bdd5e3b --- /dev/null +++ b/cmd/karma/tests/testscript/auth_header_no_name.txt @@ -0,0 +1,13 @@ +# Raises an error if header authentication config is missing name +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="authentication.header.name is required when authentication.header.value_re is set"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +authentication: + header: + value_re: ".+" diff --git a/cmd/karma/tests/testscript/auth_header_no_regex.txt b/cmd/karma/tests/testscript/auth_header_no_regex.txt new file mode 100644 index 000000000..dd7d2e737 --- /dev/null +++ b/cmd/karma/tests/testscript/auth_header_no_regex.txt @@ -0,0 +1,13 @@ +# Raises an error if header authentication config is missing regex rule +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="authentication.header.value_re is required when authentication.header.name is set"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +authentication: + header: + name: "foo" diff --git a/cmd/karma/tests/testscript/auth_header_regex_invalid.txt b/cmd/karma/tests/testscript/auth_header_regex_invalid.txt new file mode 100644 index 000000000..c7f15f3eb --- /dev/null +++ b/cmd/karma/tests/testscript/auth_header_regex_invalid.txt @@ -0,0 +1,14 @@ +# Raises an error if header authentication config contains invalid regex rule +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="Invalid regex for authentication.header.value_re: error parsing regexp: invalid nested repetition operator: `\+\+`"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +authentication: + header: + name: "foo" + value_re: ".++***" diff --git a/cmd/karma/tests/testscript/log_full_config_env.txt b/cmd/karma/tests/testscript/log_full_config_env.txt index 3d705b400..7c132316b 100644 --- a/cmd/karma/tests/testscript/log_full_config_env.txt +++ b/cmd/karma/tests/testscript/log_full_config_env.txt @@ -70,7 +70,11 @@ cmp stderr expected.stderr level=info msg="Version: dev" level=info msg="Parsed configuration:" level=info msg="authentication:" -level=info msg=" users: []" +level=info msg=" header:" +level=info msg=" name: \"\"" +level=info msg=" value_re: \"\"" +level=info msg=" basicAuth:" +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 6f669ab11..7535985ee 100644 --- a/cmd/karma/tests/testscript/log_full_config_file.txt +++ b/cmd/karma/tests/testscript/log_full_config_file.txt @@ -5,11 +5,12 @@ cmp stderr expected.stderr -- custom.yaml -- authentication: - users: - - username: number - password: 1234 - - username: string - password: '1234' + basicAuth: + users: + - username: number + password: 1234 + - username: string + password: '1234' alertmanager: interval: 10s servers: @@ -238,11 +239,15 @@ 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=" header:" +level=info msg=" name: \"\"" +level=info msg=" value_re: \"\"" +level=info msg=" basicAuth:" +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 7444ff12d..3700cc52e 100644 --- a/cmd/karma/views.go +++ b/cmd/karma/views.go @@ -144,7 +144,7 @@ func alerts(c *gin.Context) { ts, _ := start.UTC().MarshalText() var username string - if len(config.Config.Authentication.Users) > 0 { + if config.Config.Authentication.Enabled { username = c.MustGet(gin.AuthUserKey).(string) } @@ -181,7 +181,7 @@ func alerts(c *gin.Context) { }, } resp.Authentication = models.AuthenticationInfo{ - Enabled: len(config.Config.Authentication.Users) > 0, + Enabled: config.Config.Authentication.Enabled, Username: username, } @@ -209,6 +209,7 @@ func alerts(c *gin.Context) { } newResp.Settings = resp.Settings newResp.Timestamp = string(ts) + newResp.Authentication = resp.Authentication newData, err := json.Marshal(&newResp) if err != nil { log.Error(err.Error()) diff --git a/cmd/karma/views_test.go b/cmd/karma/views_test.go index 30a8f34e0..391483796 100644 --- a/cmd/karma/views_test.go +++ b/cmd/karma/views_test.go @@ -819,17 +819,149 @@ func TestEmptySettings(t *testing.T) { } } -func TestBasicAuth(t *testing.T) { - config.Config.Authentication.Users = []config.AuthenticationUser{ - {Username: "john", Password: "foobar"}, +func TestAuthentication(t *testing.T) { + type authTest struct { + name string + headerName string + headerRe string + basicAuthUsers []config.AuthenticationUser + requestHeaders map[string]string + requestBasicAuthUser string + requestBasicAuthPassword string + responseCode int + responseUsername string } - 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) - } + + authTests := []authTest{ + { + name: "basic auth, request without credentials, 401", + basicAuthUsers: []config.AuthenticationUser{ + {Username: "john", Password: "foobar"}, + }, + responseCode: 401, + }, + { + name: "basic auth, missing password, 401", + basicAuthUsers: []config.AuthenticationUser{ + {Username: "john", Password: "foobar"}, + }, + requestBasicAuthUser: "john", + responseCode: 401, + }, + { + name: "basic auth, missing username, 401", + basicAuthUsers: []config.AuthenticationUser{ + {Username: "john", Password: "foobar"}, + }, + requestBasicAuthPassword: "foobar", + responseCode: 401, + }, + { + name: "basic auth, wrong password, 401", + basicAuthUsers: []config.AuthenticationUser{ + {Username: "john", Password: "foobar"}, + }, + requestBasicAuthUser: "john", + requestBasicAuthPassword: "foobarx", + responseCode: 401, + }, + { + name: "basic auth, correct credentials, 200", + basicAuthUsers: []config.AuthenticationUser{ + {Username: "john", Password: "foobar"}, + }, + requestBasicAuthUser: "john", + requestBasicAuthPassword: "foobar", + responseCode: 200, + responseUsername: "john", + }, + { + name: "header auth, missing header, 401", + headerName: "X-Auth", + headerRe: "(.+)", + responseCode: 401, + }, + { + name: "header auth, header value doesn't match, 401", + headerName: "X-Auth", + headerRe: "Username (.+)", + requestHeaders: map[string]string{ + "X-Auth": "xxx", + }, + responseCode: 401, + }, + { + name: "header auth, header value doesn't match #2, 401", + headerName: "X-Auth", + headerRe: "Username (.+)", + requestHeaders: map[string]string{ + "X-Auth": "xxx Username xxx", + }, + responseCode: 401, + }, + { + name: "header auth, header correct, 200", + headerName: "X-Auth", + headerRe: "(.+)", + requestHeaders: map[string]string{ + "X-Auth": "john", + }, + responseCode: 200, + responseUsername: "john", + }, + { + name: "header auth, header correct #2, 200", + headerName: "X-Auth", + headerRe: "Username (.+)", + requestHeaders: map[string]string{ + "X-Auth": "Username john", + }, + responseCode: 200, + responseUsername: "john", + }, + } + + for _, testCase := range authTests { + t.Run(testCase.name, func(t *testing.T) { + config.Config.Authentication.Header.Name = testCase.headerName + config.Config.Authentication.Header.ValueRegex = testCase.headerRe + config.Config.Authentication.BasicAuth.Users = testCase.basicAuthUsers + r := ginTestEngine() + for _, path := range []string{ + "/", + "/alerts.json", + "/autocomplete.json?term=foo", + "/labelNames.json", + "/labelValues.json?name=foo", + "/silences.json", + "/custom.css", + "/custom.js", + } { + req := httptest.NewRequest("GET", path, nil) + for k, v := range testCase.requestHeaders { + req.Header.Set(k, v) + } + req.SetBasicAuth(testCase.requestBasicAuthUser, testCase.requestBasicAuthPassword) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + if resp.Code != testCase.responseCode { + t.Errorf("Expected %d from %s, got %d", testCase.responseCode, path, resp.Code) + } + + if resp.Code == 200 && path == "/alerts.json" { + ur := models.AlertsResponse{} + err := json.Unmarshal(resp.Body.Bytes(), &ur) + if err != nil { + t.Errorf("Failed to unmarshal response: %s", err) + } + if ur.Authentication.Enabled != true { + t.Errorf("Got Authentication.Enabled=%v", ur.Authentication.Enabled) + } + if ur.Authentication.Username != testCase.responseUsername { + t.Errorf("Got Authentication.Username=%s, expected %s", ur.Authentication.Username, testCase.responseUsername) + } + } + } + }) } } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ded297259..34744fd68 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -25,18 +25,77 @@ 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. +When set users will be required to authenticate when trying to access karma. +There are currently two supported authentication methods: + +- [Basic HTTP Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Basic_authentication_scheme). + Karma will be performing authentication using configured list of username & + password pairs. This method is only recommended for testing. +- External authentication via headers. Karma doesn't perform any authentication + itself, it is done by a frontend service (SSO or nginx reverse proxy) that + sets a header with username on every request. + +Only one method can be enabled in the config. Enabling authentication will also force silences to be created with usernames passed from credentials. ```YAML authentication: - users: - - username: string - password: string + header: + name: string + value_re: string + basicAuth: + users: + - username: string + password: string ``` +- `authentication:users:header:name` - name of the header that will contain the + username. If this header is missing from a request access will be forbidden. + When set header authentication is used. +- `authentication:users:header:value_re` - + [regex](https://golang.org/s/re2syntax) used to extract the username from the + request header value (when `authentication:users:header:name` is set). + It must include one numbered capturing group, whatever is matched by that + group will be used as the silence form author field. + This option must be set when `authentication:users:header:name` is set. - `authentication:users` - list of users (username & password) allowed to login. + Passwords are stored plain without any encryption. + When set HTTP basic authentication will be used. + +Defaults: + +```YAML +authentication: + header: + name: "" + value_re: "" + basicAuth: + users: [] +``` + +Example where HTTP Basic Authentication will be used with a list of username +and password pairs set in karma config file. + +```YAML +authentication: + basicAuth: + users: + - username: alice + password: secret + - username: bob + password: moreSecret +``` + +Example where the `X-Auth` header will be used for authentication, raw header +value will be used as username. + +```YAML +authentication: + header: + name: X-Auth + value_re: ^(.+)$ +``` ### Alertmanagers diff --git a/internal/config/config.go b/internal/config/config.go index 007280321..feead3c3e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -265,10 +265,30 @@ 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.Authentication.Header.Name != "" && len(config.Authentication.BasicAuth.Users) > 0 { + log.Fatalf("Both authentication.basicAuth.users and authentication.header.name is set, only one can be enabled") + } + + if config.Authentication.Header.ValueRegex != "" { + _, err = regexp.Compile(config.Authentication.Header.ValueRegex) + if err != nil { + log.Fatalf("Invalid regex for authentication.header.value_re: %s", err.Error()) } + if config.Authentication.Header.Name == "" { + log.Fatalf("authentication.header.name is required when authentication.header.value_re is set") + } + } else if config.Authentication.Header.Name != "" { + log.Fatalf("authentication.header.value_re is required when authentication.header.name is set") + } + + for _, u := range config.Authentication.BasicAuth.Users { + if u.Username == "" || u.Password == "" { + log.Fatalf("authentication.basicAuth.users require both username and password to be set") + } + } + + if config.Authentication.Header.Name != "" || len(config.Authentication.BasicAuth.Users) > 0 { + config.Authentication.Enabled = true } if config.SilenceForm.Author.PopulateFromHeader.ValueRegex != "" { @@ -352,14 +372,14 @@ func (config *configSchema) LogValues() { cfg := configSchema(*config) auth := []AuthenticationUser{} - for _, u := range cfg.Authentication.Users { + for _, u := range cfg.Authentication.BasicAuth.Users { uu := AuthenticationUser{ Username: u.Username, Password: "***", } auth = append(auth, uu) } - cfg.Authentication.Users = auth + cfg.Authentication.BasicAuth.Users = auth // replace passwords in Alertmanager URIs with 'xxx' servers := []AlertmanagerConfig{} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f3d1b5ae4..b4a70d185 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -21,7 +21,11 @@ func resetEnv() { func testReadConfig(t *testing.T) { expectedConfig := `authentication: - users: [] + header: + name: "" + value_re: "" + basicAuth: + users: [] alertmanager: interval: 1s servers: diff --git a/internal/config/models.go b/internal/config/models.go index 4599071ac..61b030ab1 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -47,7 +47,14 @@ type AuthenticationUser struct { type configSchema struct { Authentication struct { - Users []AuthenticationUser + Enabled bool `yaml:"-" koanf:"-"` + Header struct { + Name string + ValueRegex string `yaml:"value_re" koanf:"value_re"` + } + BasicAuth struct { + Users []AuthenticationUser + } `yaml:"basicAuth" koanf:"basicAuth"` } Alertmanager struct { Interval time.Duration