diff --git a/controller/handler/handler.go b/controller/handler/handler.go index f7e753d4..2d548287 100644 --- a/controller/handler/handler.go +++ b/controller/handler/handler.go @@ -15,6 +15,11 @@ func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig if enabledMetrics { router.Handle("/metrics", promhttp.Handler()).Methods("GET") } + if securityConfig != nil { + if err := securityConfig.RegisterHandlers(router); err != nil { + panic(err) + } + } router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET") router.HandleFunc("/favicon.ico", FavIcon(staticFolder)).Methods("GET") // Endpoints diff --git a/go.mod b/go.mod index e9067dc5..e4a4ac03 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,11 @@ require ( github.com/TwiN/health v1.3.0 github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/coreos/go-oidc/v3 v3.1.0 github.com/go-ping/ping v0.0.0-20210911151512-381826476871 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/protobuf v1.5.2 // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/google/uuid v1.3.0 github.com/gorilla/mux v1.8.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/lib/pq v1.10.3 @@ -27,6 +28,7 @@ require ( golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect golang.org/x/mod v0.5.1 // indirect golang.org/x/net v0.0.0-20211209124913-491a49abca63 // indirect + golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect golang.org/x/sys v0.0.0-20211003122950-b1ebd4e1001c // indirect golang.org/x/tools v0.1.7 // indirect @@ -46,3 +48,9 @@ require ( modernc.org/strutil v1.1.1 // indirect modernc.org/token v1.0.0 // indirect ) + +require ( + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect + google.golang.org/appengine v1.6.6 // indirect + gopkg.in/square/go-jose.v2 v2.5.1 // indirect +) diff --git a/go.sum b/go.sum index db43fa4b..98e1d2cd 100644 --- a/go.sum +++ b/go.sum @@ -55,7 +55,10 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/coreos/go-oidc/v3 v3.1.0 h1:6avEvcdvTa1qYsOZ6I5PRkSYHzpTNWgKYmaJfaYbrRw= +github.com/coreos/go-oidc/v3 v3.1.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= @@ -190,6 +193,7 @@ github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= @@ -223,6 +227,8 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/tidwall/redcon v1.3.2/go.mod h1:bdYBm4rlcWpst2XMwKVzWDF9CoUxEbUmM7CQrKeOZas= github.com/wcharczuk/go-chart/v2 v2.1.0 h1:tY2slqVQ6bN+yHSnDYwZebLQFkphK4WNrVwnt7CJZ2I= github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA= @@ -244,6 +250,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -304,6 +311,7 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= @@ -323,6 +331,7 @@ golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4Iltr golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c h1:pkQiBZBvdos9qq4wBAHqlzuZHEXo07pqV06ef90u1WI= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -466,6 +475,7 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -533,6 +543,8 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= +gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= +gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/security/config.go b/security/config.go index a93b5cde..b2754cf7 100644 --- a/security/config.go +++ b/security/config.go @@ -1,13 +1,30 @@ package security +import ( + "github.com/gorilla/mux" +) + // Config is the security configuration for Gatus type Config struct { - Basic *BasicConfig `yaml:"basic"` + Basic *BasicConfig `yaml:"basic,omitempty"` + OIDC *OIDCConfig `yaml:"oidc,omitempty"` } // IsValid returns whether the security configuration is valid or not func (c *Config) IsValid() bool { - return c.Basic != nil && c.Basic.IsValid() + return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.isValid()) +} + +// RegisterHandlers registers all handlers required based on the security configuration +func (c *Config) RegisterHandlers(router *mux.Router) error { + if c.OIDC != nil { + if err := c.OIDC.initialize(); err != nil { + return err + } + router.HandleFunc("/login", c.OIDC.loginHandler) + router.HandleFunc("/authorization-code/callback", c.OIDC.callbackHandler) + } + return nil } // BasicConfig is the configuration for Basic authentication @@ -19,7 +36,7 @@ type BasicConfig struct { PasswordSha512Hash string `yaml:"password-sha512"` } -// IsValid returns whether the basic security configuration is valid or not -func (c *BasicConfig) IsValid() bool { +// isValid returns whether the basic security configuration is valid or not +func (c *BasicConfig) isValid() bool { return len(c.Username) > 0 && len(c.PasswordSha512Hash) == 128 } diff --git a/security/config_test.go b/security/config_test.go index 67d4e796..3579c1ab 100644 --- a/security/config_test.go +++ b/security/config_test.go @@ -7,7 +7,7 @@ func TestBasicConfig_IsValid(t *testing.T) { Username: "admin", PasswordSha512Hash: Sha512("test"), } - if !basicConfig.IsValid() { + if !basicConfig.isValid() { t.Error("basicConfig should've been valid") } } @@ -17,7 +17,7 @@ func TestBasicConfig_IsValidWhenPasswordIsInvalid(t *testing.T) { Username: "admin", PasswordSha512Hash: "", } - if basicConfig.IsValid() { + if basicConfig.isValid() { t.Error("basicConfig shouldn't have been valid") } } diff --git a/security/handler.go b/security/handler.go index af99b8b9..69f5817d 100644 --- a/security/handler.go +++ b/security/handler.go @@ -7,14 +7,24 @@ import ( // Handler takes care of security for a given handler with the given security configuration 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) != strings.ToLower(security.Basic.PasswordSha512Hash) { - w.Header().Set("WWW-Authenticate", "Basic") - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte("Unauthorized")) - return + if security == nil { + return handler + } else if security.Basic != nil { + return func(w http.ResponseWriter, r *http.Request) { + usernameEntered, passwordEntered, ok := r.BasicAuth() + if !ok || usernameEntered != security.Basic.Username || Sha512(passwordEntered) != strings.ToLower(security.Basic.PasswordSha512Hash) { + w.Header().Set("WWW-Authenticate", "Basic") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("Unauthorized")) + return + } + handler(w, r) + } + } else if security.OIDC != nil { + return func(w http.ResponseWriter, r *http.Request) { + // TODO: Check if the user is authenticated, and redirect to /login if they're not? + handler(w, r) } - handler(w, r) } + return handler } diff --git a/security/oidc.go b/security/oidc.go new file mode 100644 index 00000000..3a598449 --- /dev/null +++ b/security/oidc.go @@ -0,0 +1,104 @@ +package security + +import ( + "context" + "net/http" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/google/uuid" + "golang.org/x/oauth2" +) + +// OIDCConfig is the configuration for OIDC authentication +type OIDCConfig struct { + IssuerURL string `yaml:"issuer-url"` // e.g. https://dev-12345678.okta.com + RedirectURL string `yaml:"redirect-url"` // e.g. http://localhost:8080/authorization-code/callback + ClientID string `yaml:"client-id"` + ClientSecret string `yaml:"client-secret"` + Scopes []string `yaml:"scopes"` // e.g. [openid] + + oauth2Config oauth2.Config + verifier *oidc.IDTokenVerifier +} + +// isValid returns whether the basic security configuration is valid or not +func (c *OIDCConfig) isValid() bool { + return len(c.IssuerURL) > 0 && len(c.RedirectURL) > 0 && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0 +} + +func (c *OIDCConfig) initialize() error { + provider, err := oidc.NewProvider(context.Background(), c.IssuerURL) + if err != nil { + return err + } + c.verifier = provider.Verifier(&oidc.Config{ClientID: c.ClientID}) + // Configure an OpenID Connect aware OAuth2 client. + c.oauth2Config = oauth2.Config{ + ClientID: c.ClientID, + ClientSecret: c.ClientSecret, + Scopes: c.Scopes, + RedirectURL: c.RedirectURL, + Endpoint: provider.Endpoint(), + } + return nil +} + +func (c *OIDCConfig) loginHandler(w http.ResponseWriter, r *http.Request) { + state, nonce := uuid.NewString(), uuid.NewString() + http.SetCookie(w, &http.Cookie{ + Name: "state", + Value: state, + MaxAge: int(time.Hour.Seconds()), + Secure: r.TLS != nil, + HttpOnly: true, + }) + http.SetCookie(w, &http.Cookie{ + Name: "nonce", + Value: nonce, + MaxAge: int(time.Hour.Seconds()), + Secure: r.TLS != nil, + HttpOnly: true, + }) + http.Redirect(w, r, c.oauth2Config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound) +} + +func (c *OIDCConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { + // Ensure that the state has the expected value + state, err := r.Cookie("state") + if err != nil { + http.Error(w, "state not found", http.StatusBadRequest) + return + } + if r.URL.Query().Get("state") != state.Value { + http.Error(w, "state did not match", http.StatusBadRequest) + return + } + // Validate token + oauth2Token, err := c.oauth2Config.Exchange(r.Context(), r.URL.Query().Get("code")) + if err != nil { + http.Error(w, "Error exchanging token: "+err.Error(), http.StatusInternalServerError) + return + } + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + http.Error(w, "Missing 'id_token' in oauth2 token", http.StatusInternalServerError) + return + } + idToken, err := c.verifier.Verify(r.Context(), rawIDToken) + if err != nil { + http.Error(w, "Failed to verify id_token: "+err.Error(), http.StatusInternalServerError) + return + } + // Validate nonce + nonce, err := r.Cookie("nonce") + if err != nil { + http.Error(w, "nonce not found", http.StatusBadRequest) + return + } + if idToken.Nonce != nonce.Value { + http.Error(w, "nonce did not match", http.StatusBadRequest) + return + } + http.Redirect(w, r, "/", http.StatusFound) +}