feat(backend): support header based auth

This commit is contained in:
Łukasz Mierzwa
2020-02-21 22:05:11 +00:00
parent 541b1ef519
commit fbff53c51b
17 changed files with 399 additions and 38 deletions

View File

@@ -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))

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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: ".+"

View File

@@ -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"

View File

@@ -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: ".++***"

View File

@@ -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:"

View File

@@ -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:"

View File

@@ -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())

View File

@@ -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)
}
}
}
})
}
}

View File

@@ -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

View File

@@ -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{}

View File

@@ -21,7 +21,11 @@ func resetEnv() {
func testReadConfig(t *testing.T) {
expectedConfig := `authentication:
users: []
header:
name: ""
value_re: ""
basicAuth:
users: []
alertmanager:
interval: 1s
servers:

View File

@@ -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