diff --git a/controller/handler/config.go b/controller/handler/config.go new file mode 100644 index 00000000..7190d633 --- /dev/null +++ b/controller/handler/config.go @@ -0,0 +1,26 @@ +package handler + +import ( + "fmt" + "net/http" + + "github.com/TwiN/gatus/v3/security" +) + +// ConfigHandler is a handler that returns information for the front end of the application. +type ConfigHandler struct { + securityConfig *security.Config +} + +func (handler ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + hasOIDC := false + isAuthenticated := false // Default to true if no security config is set + if handler.securityConfig != nil { + hasOIDC = handler.securityConfig.OIDC != nil + isAuthenticated = handler.securityConfig.IsAuthenticated(r) + } + // Return the config + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(fmt.Sprintf(`{"oidc":%v,"authenticated":%v}`, hasOIDC, isAuthenticated))) +} diff --git a/controller/handler/config_test.go b/controller/handler/config_test.go new file mode 100644 index 00000000..25d67f46 --- /dev/null +++ b/controller/handler/config_test.go @@ -0,0 +1,34 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/TwiN/gatus/v3/security" + "github.com/gorilla/mux" +) + +func TestConfigHandler_ServeHTTP(t *testing.T) { + securityConfig := &security.Config{ + OIDC: &security.OIDCConfig{ + IssuerURL: "https://sso.gatus.io/", + RedirectURL: "http://localhost:80/authorization-code/callback", + Scopes: []string{"openid"}, + AllowedSubjects: []string{"user1@example.com"}, + }, + } + handler := ConfigHandler{securityConfig: securityConfig} + // Create a fake router. We're doing this because I need the gate to be initialized. + securityConfig.ApplySecurityMiddleware(mux.NewRouter()) + // Test the config handler + request, _ := http.NewRequest("GET", "/api/v1/config", http.NoBody) + responseRecorder := httptest.NewRecorder() + handler.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != http.StatusOK { + t.Error("expected code to be 200, but was", responseRecorder.Code) + } + if responseRecorder.Body.String() != `{"oidc":true,"authenticated":false}` { + t.Error("expected body to be `{\"oidc\":true,\"authenticated\":false}`, but was", responseRecorder.Body.String()) + } +} diff --git a/controller/handler/cors.go b/controller/handler/cors.go index 9a1fbe57..f7e0b9b9 100644 --- a/controller/handler/cors.go +++ b/controller/handler/cors.go @@ -4,7 +4,11 @@ import "net/http" func DevelopmentCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8081") + if r.Method == "OPTIONS" { + return + } next.ServeHTTP(w, r) }) } diff --git a/controller/handler/handler.go b/controller/handler/handler.go index 9c943b50..faaeeda4 100644 --- a/controller/handler/handler.go +++ b/controller/handler/handler.go @@ -25,6 +25,7 @@ func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig securityConfig.ApplySecurityMiddleware(protected) } // Endpoints + unprotected.Handle("/v1/config", ConfigHandler{securityConfig: securityConfig}).Methods("GET") protected.HandleFunc("/v1/endpoints/statuses", EndpointStatuses).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already protected.HandleFunc("/v1/endpoints/{key}/statuses", GzipHandlerFunc(EndpointStatus)).Methods("GET") unprotected.HandleFunc("/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET") diff --git a/go.mod b/go.mod index 51a5aea0..5cfebc20 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/TwiN/gatus/v3 go 1.17 require ( - github.com/TwiN/g8 v1.2.0 + github.com/TwiN/g8 v1.3.0 github.com/TwiN/gocache v1.2.4 github.com/TwiN/gocache/v2 v2.0.0 github.com/TwiN/health v1.3.0 diff --git a/go.sum b/go.sum index bfddc9d6..c19c94ef 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,8 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/TwiN/g8 v1.2.0 h1:pNCSaNuFe0B8cAm9Ir2aCsnAeO2j4Y1FsHeYop+mOXQ= -github.com/TwiN/g8 v1.2.0/go.mod h1:SiIdItS0agSUloFqdQQt/RObB2jGSq+nnE9WfFv3RIo= +github.com/TwiN/g8 v1.3.0 h1:mNv3R35GhDn1gEV0BKMl1oupZ1tDtOWPTHUKu+W/k3U= +github.com/TwiN/g8 v1.3.0/go.mod h1:SiIdItS0agSUloFqdQQt/RObB2jGSq+nnE9WfFv3RIo= github.com/TwiN/gocache v1.2.4 h1:AfJ1YRcxtQ/zZEN61URDwk/dwFG7LSRenU5qIm9dQzo= github.com/TwiN/gocache v1.2.4/go.mod h1:BjabsQQy6z5uHDorHa4LJVPEzFeitLIDbCtdv3gc1gA= github.com/TwiN/gocache/v2 v2.0.0 h1:CPbDNKdSJpmBkh7aWcO7D3KK1yWaMlwX+3dsBPE8/so= diff --git a/security/config.go b/security/config.go index d632e56d..41eeb940 100644 --- a/security/config.go +++ b/security/config.go @@ -18,6 +18,8 @@ const ( type Config struct { Basic *BasicConfig `yaml:"basic,omitempty"` OIDC *OIDCConfig `yaml:"oidc,omitempty"` + + gate *g8.Gate } // IsValid returns whether the security configuration is valid or not @@ -37,6 +39,8 @@ func (c *Config) RegisterHandlers(router *mux.Router) error { return nil } +// ApplySecurityMiddleware applies an authentication middleware to the router passed. +// The router passed should be a subrouter in charge of handlers that require authentication. func (c *Config) ApplySecurityMiddleware(api *mux.Router) { if c.OIDC != nil { // We're going to use g8 for session handling @@ -55,8 +59,8 @@ func (c *Config) ApplySecurityMiddleware(api *mux.Router) { } // TODO: g8: Add a way to update cookie after? would need the writer authorizationService := g8.NewAuthorizationService().WithClientProvider(clientProvider) - gate := g8.New().WithAuthorizationService(authorizationService).WithCustomTokenExtractor(customTokenExtractorFunc) - api.Use(gate.Protect) + c.gate = g8.New().WithAuthorizationService(authorizationService).WithCustomTokenExtractor(customTokenExtractorFunc) + api.Use(c.gate.Protect) } else if c.Basic != nil { api.Use(func(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -72,3 +76,14 @@ func (c *Config) ApplySecurityMiddleware(api *mux.Router) { }) } } + +// IsAuthenticated checks whether the user is authenticated +// If the Config does not warrant authentication, it will always return true. +func (c *Config) IsAuthenticated(r *http.Request) bool { + if c.gate != nil { + token := c.gate.ExtractTokenFromRequest(r) + _, hasSession := sessions.Get(token) + return hasSession + } + return false +} diff --git a/security/config_test.go b/security/config_test.go index ae562e43..5678da6e 100644 --- a/security/config_test.go +++ b/security/config_test.go @@ -33,14 +33,14 @@ func TestConfig_ApplySecurityMiddleware(t *testing.T) { }) c.ApplySecurityMiddleware(api) // Try to access the route without basic auth - request, _ := http.NewRequest("GET", "/test", nil) + request, _ := http.NewRequest("GET", "/test", http.NoBody) responseRecorder := httptest.NewRecorder() api.ServeHTTP(responseRecorder, request) if responseRecorder.Code != http.StatusUnauthorized { t.Error("expected code to be 401, but was", responseRecorder.Code) } // Try again, but with basic auth - request, _ = http.NewRequest("GET", "/test", nil) + request, _ = http.NewRequest("GET", "/test", http.NoBody) responseRecorder = httptest.NewRecorder() request.SetBasicAuth("john.doe", "hunter2") api.ServeHTTP(responseRecorder, request) @@ -65,14 +65,14 @@ func TestConfig_ApplySecurityMiddleware(t *testing.T) { c.Basic = nil c.ApplySecurityMiddleware(api) // Try without any session cookie - request, _ = http.NewRequest("GET", "/test", nil) + request, _ = http.NewRequest("GET", "/test", http.NoBody) responseRecorder = httptest.NewRecorder() api.ServeHTTP(responseRecorder, request) if responseRecorder.Code != http.StatusUnauthorized { t.Error("expected code to be 401, but was", responseRecorder.Code) } // Try with a session cookie - request, _ = http.NewRequest("GET", "/test", nil) + request, _ = http.NewRequest("GET", "/test", http.NoBody) request.AddCookie(&http.Cookie{Name: "session", Value: "123"}) responseRecorder = httptest.NewRecorder() api.ServeHTTP(responseRecorder, request) @@ -86,7 +86,7 @@ func TestConfig_RegisterHandlers(t *testing.T) { router := mux.NewRouter() c.RegisterHandlers(router) // Try to access the OIDC handler. This should fail, because the security config doesn't have OIDC - request, _ := http.NewRequest("GET", "/oidc/login", nil) + request, _ := http.NewRequest("GET", "/oidc/login", http.NoBody) responseRecorder := httptest.NewRecorder() router.ServeHTTP(responseRecorder, request) if responseRecorder.Code != http.StatusNotFound { @@ -107,7 +107,7 @@ func TestConfig_RegisterHandlers(t *testing.T) { if err := c.RegisterHandlers(router); err != nil { t.Fatal("expected no error, but got", err) } - request, _ = http.NewRequest("GET", "/oidc/login", nil) + request, _ = http.NewRequest("GET", "/oidc/login", http.NoBody) responseRecorder = httptest.NewRecorder() router.ServeHTTP(responseRecorder, request) if responseRecorder.Code != http.StatusFound { diff --git a/vendor/github.com/TwiN/g8/gate.go b/vendor/github.com/TwiN/g8/gate.go index 2b92c56a..08082c8b 100644 --- a/vendor/github.com/TwiN/g8/gate.go +++ b/vendor/github.com/TwiN/g8/gate.go @@ -181,12 +181,7 @@ func (gate *Gate) ProtectFuncWithPermissions(handlerFunc http.HandlerFunc, permi } } if gate.authorizationService != nil { - var token string - if gate.customTokenExtractorFunc != nil { - token = gate.customTokenExtractorFunc(request) - } else { - token = extractTokenFromRequest(request) - } + token := gate.ExtractTokenFromRequest(request) if !gate.authorizationService.IsAuthorized(token, permissions) { writer.WriteHeader(http.StatusUnauthorized) _, _ = writer.Write(gate.unauthorizedResponseBody) @@ -206,7 +201,17 @@ func (gate *Gate) ProtectFuncWithPermission(handlerFunc http.HandlerFunc, permis return gate.ProtectFuncWithPermissions(handlerFunc, []string{permission}) } -// extractTokenFromRequest extracts the bearer token from the AuthorizationHeader -func extractTokenFromRequest(request *http.Request) string { +// ExtractTokenFromRequest extracts a token from a request. +// +// By default, it extracts the bearer token from the AuthorizationHeader, but if a customTokenExtractorFunc is defined, +// it will use that instead. +// +// Note that this method is internally used by Protect, ProtectWithPermission, ProtectFunc and +// ProtectFuncWithPermissions, but it is exposed in case you need to use it directly. +func (gate *Gate) ExtractTokenFromRequest(request *http.Request) string { + if gate.customTokenExtractorFunc != nil { + // A custom token extractor function is defined, so we'll use it instead of the default token extraction logic + return gate.customTokenExtractorFunc(request) + } return strings.TrimPrefix(request.Header.Get(AuthorizationHeader), "Bearer ") } diff --git a/vendor/modules.txt b/vendor/modules.txt index 80b1f5ff..4a04c52e 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,4 +1,4 @@ -# github.com/TwiN/g8 v1.2.0 +# github.com/TwiN/g8 v1.3.0 ## explicit; go 1.17 github.com/TwiN/g8 # github.com/TwiN/gocache v1.2.4 diff --git a/web/app/src/App.vue b/web/app/src/App.vue index 958ffffa..deece065 100644 --- a/web/app/src/App.vue +++ b/web/app/src/App.vue @@ -1,5 +1,5 @@