Merge pull request #20 from TwinProduction/basic-auth

Implement basic authentication for dashboard
This commit is contained in:
Chris C 2020-10-14 19:51:50 -04:00 committed by GitHub
commit 68c8076c73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 245 additions and 9 deletions

View File

@ -102,19 +102,23 @@ Note that you can also add environment variables in the configuration file (i.e.
| `services[].alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved | `false` |
| `services[].alerts[].description` | Description of the alert. Will be included in the alert sent | `""` |
| `alerting` | Configuration for alerting | `{}` |
| `alerting.slack` | Configuration for alerts of type `slack` | `""` |
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `""` |
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `{}` |
| `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key. | Required `""` |
| `alerting.twilio` | Settings for alerts of type `twilio` | `""` |
| `alerting.twilio` | Settings for alerts of type `twilio` | `{}` |
| `alerting.twilio.sid` | Twilio account SID | Required `""` |
| `alerting.twilio.token` | Twilio auth token | Required `""` |
| `alerting.twilio.from` | Number to send Twilio alerts from | Required `""` |
| `alerting.twilio.to` | Number to send twilio alerts to | Required `""` |
| `alerting.custom` | Configuration for custom actions on failure or alerts | `""` |
| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` |
| `alerting.custom.url` | Custom alerting request url | Required `""` |
| `alerting.custom.body` | Custom alerting request body. | `""` |
| `alerting.custom.headers` | Custom alerting request headers | `{}` |
| `security` | Security configuration | `{}` |
| `security.basic` | Basic authentication security configuration | `{}` |
| `security.basic.username` | Username for Basic authentication | Required `""` |
| `security.basic.password-sha512` | Password's SHA512 hash for Basic authentication | Required `""` |
### Conditions
@ -410,3 +414,17 @@ Placeholders `[STATUS]` and `[BODY]` as well as the fields `services[].body`, `s
**NOTE**: `[CONNECTED] == true` does not guarantee that the service itself is healthy - it only guarantees that there's
something at the given address listening to the given port, and that a connection to that address was successfully
established.
### Basic authentication
You can require Basic authentication by leveraging the `security.basic` configuration:
```yaml
security:
basic:
username: "john.doe"
password-sha512: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22"
```
The example above will require that you authenticate with the username `john.doe` as well as the password `hunter2`.

View File

@ -5,6 +5,7 @@ import (
"github.com/TwinProduction/gatus/alerting"
"github.com/TwinProduction/gatus/alerting/provider"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/security"
"gopkg.in/yaml.v2"
"io/ioutil"
"log"
@ -18,16 +19,18 @@ const (
)
var (
ErrNoServiceInConfig = errors.New("configuration file should contain at least 1 service")
ErrConfigFileNotFound = errors.New("configuration file not found")
ErrConfigNotLoaded = errors.New("configuration is nil")
config *Config
ErrNoServiceInConfig = errors.New("configuration file should contain at least 1 service")
ErrConfigFileNotFound = errors.New("configuration file not found")
ErrConfigNotLoaded = errors.New("configuration is nil")
ErrInvalidSecurityConfig = errors.New("invalid security configuration")
config *Config
)
// Config is the main configuration structure
type Config struct {
Metrics bool `yaml:"metrics"`
Debug bool `yaml:"debug"`
Security *security.Config `yaml:"security"`
Alerting *alerting.Config `yaml:"alerting"`
Services []*core.Service `yaml:"services"`
}
@ -83,6 +86,7 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
err = ErrNoServiceInConfig
} else {
validateAlertingConfig(config)
validateSecurityConfig(config)
validateServicesConfig(config)
}
return
@ -98,6 +102,20 @@ func validateServicesConfig(config *Config) {
log.Printf("[config][validateServicesConfig] Validated %d services", len(config.Services))
}
func validateSecurityConfig(config *Config) {
if config.Security != nil {
if config.Security.IsValid() {
if config.Debug {
log.Printf("[config][validateSecurityConfig] Basic security configuration has been validated")
}
} else {
// If there was an attempt to configure security, then it must mean that some confidential or private
// data are exposed. As a result, we'll force a panic because it's better to be safe than sorry.
panic(ErrInvalidSecurityConfig)
}
}
}
func validateAlertingConfig(config *Config) {
if config.Alerting == nil {
log.Printf("[config][validateAlertingConfig] Alerting is not configured")

View File

@ -1,6 +1,7 @@
package config
import (
"fmt"
"github.com/TwinProduction/gatus/core"
"testing"
"time"
@ -217,3 +218,56 @@ services:
t.Fatal("PagerDuty alerting config should've been invalid")
}
}
func TestParseAndValidateConfigBytesWithInvalidSecurityConfig(t *testing.T) {
defer func() { recover() }()
_, _ = parseAndValidateConfigBytes([]byte(`
security:
basic:
username: "admin"
password-sha512: "invalid-sha512-hash"
services:
- name: twinnation
url: https://twinnation.org/actuator/health
conditions:
- "[STATUS] == 200"
`))
t.Error("Function should've panicked")
}
func TestParseAndValidateConfigBytesWithValidSecurityConfig(t *testing.T) {
const expectedUsername = "admin"
const expectedPasswordHash = "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22"
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
security:
basic:
username: "%s"
password-sha512: "%s"
services:
- name: twinnation
url: https://twinnation.org/actuator/health
conditions:
- "[STATUS] == 200"
`, expectedUsername, expectedPasswordHash)))
if err != nil {
t.Error("No error should've been returned")
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Security == nil {
t.Fatal("config.Security shouldn't have been nil")
}
if !config.Security.IsValid() {
t.Error("Security config should've been valid")
}
if config.Security.Basic == nil {
t.Fatal("config.Security.Basic shouldn't have been nil")
}
if config.Security.Basic.Username != expectedUsername {
t.Errorf("config.Security.Basic.Username should've been %s, but was %s", expectedUsername, config.Security.Basic.Username)
}
if config.Security.Basic.PasswordSha512Hash != expectedPasswordHash {
t.Errorf("config.Security.Basic.PasswordSha512Hash should've been %s, but was %s", expectedPasswordHash, config.Security.Basic.PasswordSha512Hash)
}
}

View File

@ -4,6 +4,7 @@ import (
"bytes"
"compress/gzip"
"github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/security"
"github.com/TwinProduction/gatus/watchdog"
"github.com/prometheus/client_golang/prometheus/promhttp"
"log"
@ -23,7 +24,11 @@ var (
func main() {
cfg := loadConfiguration()
http.HandleFunc("/api/v1/results", serviceResultsHandler)
resultsHandler := serviceResultsHandler
if cfg.Security != nil && cfg.Security.IsValid() {
resultsHandler = security.Handler(serviceResultsHandler, cfg.Security)
}
http.HandleFunc("/api/v1/results", resultsHandler)
http.HandleFunc("/health", healthHandler)
http.Handle("/", GzipHandler(http.FileServer(http.Dir("./static"))))
if cfg.Metrics {

18
security/handler.go Normal file
View File

@ -0,0 +1,18 @@
package security
import (
"net/http"
)
func Handler(handler http.HandlerFunc, security *Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
usernameEntered, passwordEntered, ok := r.BasicAuth()
if !ok || usernameEntered != security.Basic.Username || Sha512(passwordEntered) != security.Basic.PasswordSha512Hash {
w.Header().Set("WWW-Authenticate", "Basic")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("Unauthorized"))
return
}
handler(w, r)
}
}

58
security/handler_test.go Normal file
View File

@ -0,0 +1,58 @@
package security
import (
"net/http"
"net/http/httptest"
"testing"
)
func mockHandler(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(200)
}
func TestHandlerWhenNotAuthenticated(t *testing.T) {
handler := Handler(mockHandler, &Config{&BasicConfig{
Username: "john.doe",
PasswordSha512Hash: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22",
}})
request, _ := http.NewRequest("GET", "/api/v1/results", nil)
responseRecorder := httptest.NewRecorder()
handler.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusUnauthorized {
t.Error("Expected code to be 401, but was", responseRecorder.Code)
}
}
func TestHandlerWhenAuthenticated(t *testing.T) {
handler := Handler(mockHandler, &Config{&BasicConfig{
Username: "john.doe",
PasswordSha512Hash: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22",
}})
request, _ := http.NewRequest("GET", "/api/v1/results", nil)
request.SetBasicAuth("john.doe", "hunter2")
responseRecorder := httptest.NewRecorder()
handler.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusOK {
t.Error("Expected code to be 200, but was", responseRecorder.Code)
}
}
func TestHandlerWhenAuthenticatedWithBadCredentials(t *testing.T) {
handler := Handler(mockHandler, &Config{&BasicConfig{
Username: "john.doe",
PasswordSha512Hash: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22",
}})
request, _ := http.NewRequest("GET", "/api/v1/results", nil)
request.SetBasicAuth("john.doe", "bad-password")
responseRecorder := httptest.NewRecorder()
handler.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusUnauthorized {
t.Error("Expected code to be 401, but was", responseRecorder.Code)
}
}

18
security/security.go Normal file
View File

@ -0,0 +1,18 @@
package security
type Config struct {
Basic *BasicConfig `yaml:"basic"`
}
func (c *Config) IsValid() bool {
return c.Basic != nil && c.Basic.IsValid()
}
type BasicConfig struct {
Username string `yaml:"username"`
PasswordSha512Hash string `yaml:"password-sha512"`
}
func (c *BasicConfig) IsValid() bool {
return len(c.Username) > 0 && len(c.PasswordSha512Hash) == 128
}

23
security/security_test.go Normal file
View File

@ -0,0 +1,23 @@
package security
import "testing"
func TestBasicConfig_IsValid(t *testing.T) {
basicConfig := &BasicConfig{
Username: "admin",
PasswordSha512Hash: Sha512("test"),
}
if !basicConfig.IsValid() {
t.Error("basicConfig should've been valid")
}
}
func TestBasicConfig_IsValidWhenPasswordIsInvalid(t *testing.T) {
basicConfig := &BasicConfig{
Username: "admin",
PasswordSha512Hash: "",
}
if basicConfig.IsValid() {
t.Error("basicConfig shouldn't have been valid")
}
}

12
security/sha512.go Normal file
View File

@ -0,0 +1,12 @@
package security
import (
"crypto/sha512"
"fmt"
)
func Sha512(s string) string {
hash := sha512.New()
hash.Write([]byte(s))
return fmt.Sprintf("%x", hash.Sum(nil))
}

12
security/sha512_test.go Normal file
View File

@ -0,0 +1,12 @@
package security
import "testing"
func TestSha512(t *testing.T) {
input := "password"
expectedHash := "b109f3bbbc244eb82441917ed06d618b9008dd09b3befd1b5e07394c706a8bb980b1d7785e5976ec049b46df5f1326af5a2ea6d103fd07c95385ffab0cacbc86"
hash := Sha512(input)
if hash != expectedHash {
t.Errorf("Expected hash to be '%s', but was '%s'", expectedHash, hash)
}
}