oidc: Add /api/v1/config route for determining whether to display a login button on the UI

This commit is contained in:
TwiN
2022-01-02 21:14:01 -05:00
parent 8838f6f2ad
commit 425c1d3674
15 changed files with 135 additions and 27 deletions
+26
View File
@@ -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)))
}
+34
View File
@@ -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())
}
}
+4
View File
@@ -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)
})
}
+1
View File
@@ -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")
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+17 -2
View File
@@ -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
}
+6 -6
View File
@@ -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 {
+13 -8
View File
@@ -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 ")
}
+1 -1
View File
@@ -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
+26 -3
View File
@@ -1,5 +1,5 @@
<template>
<div class="container container-xs relative mx-auto xl:rounded xl:border xl:shadow-xl xl:my-5 p-5 pb-12 xl:pb-5 text-left dark:bg-gray-800 dark:text-gray-200 dark:border-gray-500" id="global">
<div v-if="retrievedConfig" class="container container-xs relative mx-auto xl:rounded xl:border xl:shadow-xl xl:my-5 p-5 pb-12 xl:pb-5 text-left dark:bg-gray-800 dark:text-gray-200 dark:border-gray-500" id="global">
<div class="mb-2">
<div class="flex flex-wrap">
<div class="w-3/4 text-left my-auto">
@@ -11,6 +11,11 @@
</div>
</div>
</div>
<div v-if="config && config.oidc && !config.authenticated">
<a :href="`${SERVER_URL}/oidc/login`" class="max-w-lg mx-auto w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-lg text-white bg-green-700 hover:bg-green-800">
Login with OIDC
</a>
</div>
<router-view @showTooltip="showTooltip"/>
</div>
<Tooltip :result="tooltip.result" :event="tooltip.event"/>
@@ -21,6 +26,7 @@
<script>
import Social from './components/Social.vue'
import Tooltip from './components/Tooltip.vue';
import {SERVER_URL} from "@/main";
export default {
name: 'App',
@@ -29,6 +35,17 @@ export default {
Tooltip
},
methods: {
fetchConfig() {
fetch(`${SERVER_URL}/api/v1/config`, {credentials: 'include'})
.then(response => {
this.retrievedConfig = true;
if (response.status === 200) {
response.json().then(data => {
this.config = data;
})
}
});
},
showTooltip(result, event) {
this.tooltip = {result: result, event: event};
}
@@ -40,8 +57,14 @@ export default {
},
data() {
return {
tooltip: {}
retrievedConfig: false,
config: { oidc: false, authenticated: true },
tooltip: {},
SERVER_URL
}
},
created() {
this.fetchConfig();
}
}
</script>
</script>
+1 -1
View File
@@ -3,6 +3,6 @@ import App from './App.vue'
import './index.css'
import router from './router'
export const SERVER_URL = process.env.NODE_ENV === 'production' ? '.' : 'http://localhost:8080'
export const SERVER_URL = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:8080'
createApp(App).use(router).mount('#app')
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long