diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go index ac99473..5da93e2 100644 --- a/internal/controller/proxy_controller.go +++ b/internal/controller/proxy_controller.go @@ -17,18 +17,16 @@ import ( "github.com/google/go-querystring/query" ) -type RequestType int +type AuthModuleType int const ( - AuthRequest RequestType = iota + AuthRequest AuthModuleType = iota ExtAuthz ForwardAuth ) var BrowserUserAgentRegex = regexp.MustCompile("Chrome|Gecko|AppleWebKit|Opera|Edge") -var SupportedProxies = []string{"nginx", "traefik", "caddy", "envoy"} - type Proxy struct { Proxy string `uri:"proxy" binding:"required"` } @@ -38,7 +36,11 @@ type ProxyContext struct { Proto string Path string Method string +<<<<<<< HEAD Type RequestType +======= + Type AuthModuleType +>>>>>>> main IsBrowser bool } @@ -339,12 +341,10 @@ func (controller *ProxyController) getForwardAuthContext(c *gin.Context) (ProxyC return ProxyContext{}, errors.New("x-forwarded-proto not found") } + // Normally we should only allow GET for forward auth but since it's a fallback + // for envoy we should allow everything, not a big deal method := c.Request.Method - if method != http.MethodGet { - return ProxyContext{}, errors.New("method not allowed") - } - return ProxyContext{ Host: host, Proto: proto, @@ -368,14 +368,20 @@ func (controller *ProxyController) getAuthRequestContext(c *gin.Context) (ProxyC } host := url.Host + + if strings.TrimSpace(host) == "" { + return ProxyContext{}, errors.New("host not found") + } + proto := url.Scheme + + if strings.TrimSpace(proto) == "" { + return ProxyContext{}, errors.New("proto not found") + } + path := url.Path method := c.Request.Method - if method != http.MethodGet { - return ProxyContext{}, errors.New("method not allowed") - } - return ProxyContext{ Host: host, Proto: proto, @@ -386,19 +392,22 @@ func (controller *ProxyController) getAuthRequestContext(c *gin.Context) (ProxyC } func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyContext, error) { + // We hope for the someone to set the x-forwarded-proto header proto, ok := controller.getHeader(c, "x-forwarded-proto") if !ok { return ProxyContext{}, errors.New("x-forwarded-proto not found") } - host, ok := controller.getHeader(c, "host") + // It sets the host to the original host, not the forwarded host + host := c.Request.Host - if !ok { + if strings.TrimSpace(host) == "" { return ProxyContext{}, errors.New("host not found") } - // Seems like we can't get the path? + // We get the path from the query string + path := c.Query("path") // For envoy we need to support every method method := c.Request.Method @@ -406,11 +415,49 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont return ProxyContext{ Host: host, Proto: proto, + Path: path, Method: method, Type: ExtAuthz, }, nil } +func (controller *ProxyController) determineAuthModules(proxy string) []AuthModuleType { + switch proxy { + case "traefik", "caddy": + return []AuthModuleType{ForwardAuth} + case "envoy": + return []AuthModuleType{ExtAuthz, ForwardAuth} + case "nginx": + return []AuthModuleType{AuthRequest, ForwardAuth} + default: + return []AuthModuleType{} + } +} + +func (controller *ProxyController) getContextFromAuthModule(c *gin.Context, module AuthModuleType) (ProxyContext, error) { + switch module { + case ForwardAuth: + ctx, err := controller.getForwardAuthContext(c) + if err != nil { + return ProxyContext{}, err + } + return ctx, nil + case ExtAuthz: + ctx, err := controller.getExtAuthzContext(c) + if err != nil { + return ProxyContext{}, err + } + return ctx, nil + case AuthRequest: + ctx, err := controller.getAuthRequestContext(c) + if err != nil { + return ProxyContext{}, err + } + return ctx, nil + } + return ProxyContext{}, fmt.Errorf("unsupported auth module: %v", module) +} + func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext, error) { var req Proxy @@ -419,44 +466,28 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext return ProxyContext{}, err } + tlog.App.Debug().Msgf("Proxy: %v", req.Proxy) + + authModules := controller.determineAuthModules(req.Proxy) + + if len(authModules) == 0 { + return ProxyContext{}, fmt.Errorf("no auth modules supported for proxy: %v", req.Proxy) + } + var ctx ProxyContext - switch req.Proxy { - // For nginx we need to handle both forward_auth and auth_request extraction since it can be - // used either with something line nginx proxy manager with advanced config or with - // the kubernetes ingress controller - case "nginx": - tlog.App.Debug().Str("proxy", req.Proxy).Msg("Attempting forward_auth compatible extraction") - forwardAuthCtx, err := controller.getForwardAuthContext(c) + for _, module := range authModules { + tlog.App.Debug().Msgf("Trying auth module: %v", module) + ctx, err = controller.getContextFromAuthModule(c, module) if err == nil { - tlog.App.Debug().Str("proxy", req.Proxy).Msg("Extractions success using forward_auth") - ctx = forwardAuthCtx - } else { - tlog.App.Debug().Str("proxy", req.Proxy).Msg("Extractions failed using forward_auth trying with auth_request") - authRequestCtx, err := controller.getAuthRequestContext(c) - if err != nil { - tlog.App.Warn().Str("proxy", req.Proxy).Msg("Failed to determine required module for header extraction") - return ProxyContext{}, err - } - ctx = authRequestCtx + tlog.App.Debug().Msgf("Auth module %v succeeded", module) + break } - case "envoy": - tlog.App.Debug().Str("proxy", req.Proxy).Msg("Attempting ext_authz compatible extraction") - extAuthzCtx, err := controller.getExtAuthzContext(c) - if err != nil { - tlog.App.Warn().Str("proxy", req.Proxy).Msg("Failed to determine required module for header extraction") - return ProxyContext{}, err - } - ctx = extAuthzCtx - // By default we fallback to the forward_auth module which supports most proxies like traefik or caddy - default: - tlog.App.Debug().Str("proxy", req.Proxy).Msg("Attempting forward_auth compatible extraction") - forwardAuthCtx, err := controller.getForwardAuthContext(c) - if err != nil { - tlog.App.Warn().Str("proxy", req.Proxy).Msg("Failed to determine required module for header extraction") - return ProxyContext{}, err - } - ctx = forwardAuthCtx + tlog.App.Debug().Err(err).Msgf("Auth module %v failed", module) + } + + if err != nil { + return ProxyContext{}, err } // We don't care if the header is empty, we will just assume it's not a browser @@ -464,9 +495,9 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext isBrowser := BrowserUserAgentRegex.MatchString(userAgent) if isBrowser { - tlog.App.Debug().Msg("Request identified as (most likely) coming from a browser") + tlog.App.Debug().Msg("Request identified as coming from a browser") } else { - tlog.App.Debug().Msg("Request identified as (most likely) coming from a non-browser client") + tlog.App.Debug().Msg("Request identified as coming from a non-browser client") } ctx.IsBrowser = isBrowser diff --git a/internal/controller/proxy_controller_test.go b/internal/controller/proxy_controller_test.go index 1d91784..e22e7c4 100644 --- a/internal/controller/proxy_controller_test.go +++ b/internal/controller/proxy_controller_test.go @@ -1,6 +1,7 @@ package controller_test import ( + "net/http" "net/http/httptest" "testing" @@ -9,21 +10,26 @@ import ( "github.com/steveiliop56/tinyauth/internal/controller" "github.com/steveiliop56/tinyauth/internal/repository" "github.com/steveiliop56/tinyauth/internal/service" - "github.com/steveiliop56/tinyauth/internal/utils/tlog" "github.com/gin-gonic/gin" "gotest.tools/v3/assert" ) -func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder, *service.AuthService) { - tlog.NewSimpleLogger().Init() +var loggedInCtx = config.UserContext{ + Username: "test", + Name: "Test", + Email: "test@example.com", + IsLoggedIn: true, + Provider: "local", +} +func setupProxyController(t *testing.T, middlewares []gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) { // Setup gin.SetMode(gin.TestMode) router := gin.Default() - if middlewares != nil { - for _, m := range *middlewares { + if len(middlewares) > 0 { + for _, m := range middlewares { router.Use(m) } } @@ -48,7 +54,13 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En assert.NilError(t, dockerService.Init()) // Access controls - accessControlsService := service.NewAccessControlsService(dockerService, map[string]config.App{}) + accessControlsService := service.NewAccessControlsService(dockerService, map[string]config.App{ + "whoami": { + Path: config.AppPath{ + Allow: "/allow", + }, + }, + }) assert.NilError(t, accessControlsService.Init()) @@ -77,107 +89,214 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En // Controller ctrl := controller.NewProxyController(controller.ProxyControllerConfig{ - AppURL: "http://localhost:8080", + AppURL: "http://tinyauth.example.com", }, group, accessControlsService, authService) ctrl.SetupRoutes() - return router, recorder, authService + return router, recorder } // TODO: Needs tests for context middleware func TestProxyHandler(t *testing.T) { - // Setup - router, recorder, _ := setupProxyController(t, nil) + // Test logged out user traefik/caddy (forward_auth) + router, recorder := setupProxyController(t, nil) + + req, err := http.NewRequest("GET", "/api/auth/traefik", nil) + assert.NilError(t, err) + + req.Header.Set("x-forwarded-host", "whoami.example.com") + req.Header.Set("x-forwarded-proto", "http") + req.Header.Set("x-forwarded-uri", "/") - // Test invalid proxy - req := httptest.NewRequest("GET", "/api/auth/invalidproxy", nil) router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusUnauthorized) - assert.Equal(t, 400, recorder.Code) + // Test logged out user nginx (auth_request) + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/nginx", nil) + assert.NilError(t, err) + + req.Header.Set("x-original-url", "http://whoami.example.com/") - // Test invalid method for non-envoy proxy - recorder = httptest.NewRecorder() - req = httptest.NewRequest("POST", "/api/auth/traefik", nil) router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusUnauthorized) - assert.Equal(t, 405, recorder.Code) - assert.Equal(t, "GET", recorder.Header().Get("Allow")) + // Test logged out user envoy (ext_authz) + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/envoy?path=/", nil) + assert.NilError(t, err) + + req.Host = "whoami.example.com" + req.Header.Set("x-forwarded-proto", "http") - // Test logged out user (traefik/caddy) - recorder = httptest.NewRecorder() - req = httptest.NewRequest("GET", "/api/auth/traefik", nil) - req.Header.Set("X-Forwarded-Proto", "https") - req.Header.Set("X-Forwarded-Host", "example.com") - req.Header.Set("X-Forwarded-Uri", "/somepath") - req.Header.Set("Accept", "text/html") router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusUnauthorized) - assert.Equal(t, 307, recorder.Code) - assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location")) - - // Test logged out user (envoy - POST method) - recorder = httptest.NewRecorder() - req = httptest.NewRequest("POST", "/api/auth/envoy", nil) - req.Header.Set("X-Forwarded-Proto", "https") - req.Header.Set("X-Forwarded-Host", "example.com") - req.Header.Set("X-Forwarded-Uri", "/somepath") - req.Header.Set("Accept", "text/html") - router.ServeHTTP(recorder, req) - - assert.Equal(t, 307, recorder.Code) - assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location")) - - // Test logged out user (envoy - DELETE method) - recorder = httptest.NewRecorder() - req = httptest.NewRequest("DELETE", "/api/auth/envoy", nil) - req.Header.Set("X-Forwarded-Proto", "https") - req.Header.Set("X-Forwarded-Host", "example.com") - req.Header.Set("X-Forwarded-Uri", "/somepath") - req.Header.Set("Accept", "text/html") - router.ServeHTTP(recorder, req) - - assert.Equal(t, 307, recorder.Code) - assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location")) - - // Test logged out user (nginx) - recorder = httptest.NewRecorder() - req = httptest.NewRequest("GET", "/api/auth/nginx", nil) - req.Header.Set("X-Forwarded-Proto", "https") - req.Header.Set("X-Forwarded-Host", "example.com") - // we won't set X-Forwarded-Uri to test that the controller can work without it - router.ServeHTTP(recorder, req) - - assert.Equal(t, 401, recorder.Code) - - // Test logged in user - router, recorder, _ = setupProxyController(t, &[]gin.HandlerFunc{ + // Test logged in user traefik/caddy (forward_auth) + router, recorder = setupProxyController(t, []gin.HandlerFunc{ func(c *gin.Context) { - c.Set("context", &config.UserContext{ - Username: "testuser", - Name: "testuser", - Email: "testuser@example.com", - IsLoggedIn: true, - OAuth: false, - Provider: "local", - TotpPending: false, - OAuthGroups: "", - TotpEnabled: false, - }) + c.Set("context", &loggedInCtx) c.Next() }, }) - req = httptest.NewRequest("GET", "/api/auth/traefik", nil) - req.Header.Set("X-Forwarded-Proto", "https") - req.Header.Set("X-Forwarded-Host", "example.com") - req.Header.Set("X-Original-Uri", "/somepath") // Test with original URI for kubernetes ingress - req.Header.Set("Accept", "text/html") + req, err = http.NewRequest("GET", "/api/auth/traefik", nil) + assert.NilError(t, err) + + req.Header.Set("x-forwarded-host", "whoami.example.com") + req.Header.Set("x-forwarded-proto", "http") + req.Header.Set("x-forwarded-uri", "/") router.ServeHTTP(recorder, req) - assert.Equal(t, 200, recorder.Code) + assert.Equal(t, recorder.Code, http.StatusOK) - assert.Equal(t, "testuser", recorder.Header().Get("Remote-User")) - assert.Equal(t, "testuser", recorder.Header().Get("Remote-Name")) - assert.Equal(t, "testuser@example.com", recorder.Header().Get("Remote-Email")) + // Test logged in user nginx (auth_request) + router, recorder = setupProxyController(t, []gin.HandlerFunc{ + func(c *gin.Context) { + c.Set("context", &loggedInCtx) + c.Next() + }, + }) + + req, err = http.NewRequest("GET", "/api/auth/nginx", nil) + assert.NilError(t, err) + + req.Header.Set("x-original-url", "http://whoami.example.com/") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusOK) + + // Test logged in user envoy (ext_authz) + router, recorder = setupProxyController(t, []gin.HandlerFunc{ + func(c *gin.Context) { + c.Set("context", &loggedInCtx) + c.Next() + }, + }) + + req, err = http.NewRequest("GET", "/api/auth/envoy?path=/", nil) + assert.NilError(t, err) + + req.Host = "whoami.example.com" + req.Header.Set("x-forwarded-proto", "http") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusOK) + + // Test ACL allow caddy/traefik (forward_auth) + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/traefik", nil) + assert.NilError(t, err) + + req.Header.Set("x-forwarded-host", "whoami.example.com") + req.Header.Set("x-forwarded-proto", "http") + req.Header.Set("x-forwarded-uri", "/allow") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusOK) + + // Test ACL allow nginx + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/nginx", nil) + assert.NilError(t, err) + + req.Header.Set("x-original-url", "http://whoami.example.com/allow") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusOK) + + // Test ACL allow envoy + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/envoy?path=/allow", nil) + assert.NilError(t, err) + + req.Host = "whoami.example.com" + req.Header.Set("x-forwarded-proto", "http") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusOK) + + // Test traefik/caddy (forward_auth) without required headers + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/traefik", nil) + assert.NilError(t, err) + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusBadRequest) + + // Test nginx (forward_auth) without required headers + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/nginx", nil) + assert.NilError(t, err) + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusBadRequest) + + // Test envoy (forward_auth) without required headers + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/envoy", nil) + assert.NilError(t, err) + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusBadRequest) + + // Test nginx (auth_request) with forward_auth fallback with ACLs + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/nginx", nil) + assert.NilError(t, err) + + req.Header.Set("x-forwarded-host", "whoami.example.com") + req.Header.Set("x-forwarded-proto", "http") + req.Header.Set("x-forwarded-uri", "/allow") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusOK) + + // Test envoy (ext_authz) with forward_auth fallback with ACLs + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/envoy", nil) + assert.NilError(t, err) + + req.Header.Set("x-forwarded-host", "whoami.example.com") + req.Header.Set("x-forwarded-proto", "http") + req.Header.Set("x-forwarded-uri", "/allow") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusOK) + + // Test envoy (ext_authz) with empty path + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/envoy", nil) + assert.NilError(t, err) + + req.Host = "whoami.example.com" + req.Header.Set("x-forwarded-proto", "http") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusUnauthorized) + + // Ensure forward_auth fallback works with path (should ignore) + router, recorder = setupProxyController(t, nil) + + req, err = http.NewRequest("GET", "/api/auth/traefik?path=/allow", nil) + assert.NilError(t, err) + + req.Header.Set("x-forwarded-proto", "http") + req.Header.Set("x-forwarded-host", "whoami.example.com") + req.Header.Set("x-forwarded-uri", "/allow") + + router.ServeHTTP(recorder, req) + assert.Equal(t, recorder.Code, http.StatusOK) }