From 6e2e18bc6bec9a7c820aebb86c95714367a365f2 Mon Sep 17 00:00:00 2001 From: Bart Jeukendrup Date: Thu, 14 Sep 2023 16:20:32 +0200 Subject: [PATCH] feat: add support for multiple backends and authorization service --- cmd/filter-proxy/main.go | 198 ++++++++++++++++++-------------------- config.yaml | 44 ++++++--- internal/config/config.go | 38 +++++--- internal/utils/utils.go | 69 +++++++++++++ 4 files changed, 217 insertions(+), 132 deletions(-) create mode 100644 internal/utils/utils.go diff --git a/cmd/filter-proxy/main.go b/cmd/filter-proxy/main.go index b20d482..b634cc5 100644 --- a/cmd/filter-proxy/main.go +++ b/cmd/filter-proxy/main.go @@ -3,27 +3,24 @@ package main import ( "crypto/tls" "encoding/json" - "fmt" "io" "log" "net/http" "net/url" - "os" "time" - "github.com/MicahParks/keyfunc" "github.com/golang-jwt/jwt/v4" "github.com/gorilla/mux" "github.com/itchyny/gojq" - "github.com/ory/oathkeeper/helper" "github.com/delta10/filter-proxy/internal/config" "github.com/delta10/filter-proxy/internal/route" + "github.com/delta10/filter-proxy/internal/utils" ) type ClaimsWithGroups struct { - Groups []string - jwt.StandardClaims + jwt.RegisteredClaims + Groups []string `json:"groups"` } func main() { @@ -33,42 +30,50 @@ func main() { } router := mux.NewRouter() - for _, path := range config.Paths { - currentPath := path - router.HandleFunc(currentPath.Path, func(w http.ResponseWriter, r *http.Request) { - authorized := authorizeRequest(config.JwksUrl, currentPath.Authorization.Groups, w, r) - if !authorized { + for _, configuredPath := range config.Paths { + path := configuredPath + router.HandleFunc(path.Path, func(w http.ResponseWriter, r *http.Request) { + backend, ok := config.Backends[path.Backend.Slug] + if !ok { + writeError(w, http.StatusBadRequest, "could not find backend associated with this path: "+path.Backend.Slug) return } - routeRegexp, _ := route.NewRouteRegexp(currentPath.Backend.URL, route.RegexpTypePath, route.RouteRegexpOptions{}) + utils.DelHopHeaders(r.Header) - requestUrl, err := routeRegexp.URL(mux.Vars(r)) + if !authorizeRequestWithService(config, path, r) { + writeError(w, http.StatusUnauthorized, "unauthorized request") + return + } + + routeRegexp, _ := route.NewRouteRegexp(path.Backend.Path, route.RegexpTypePath, route.RouteRegexpOptions{}) + + parsedRequestPath, err := routeRegexp.URL(mux.Vars(r)) if err != nil { writeError(w, http.StatusBadRequest, "could not parse request URL") return } - backendURL, err := url.Parse(requestUrl) + backendBaseUrl, err := url.Parse(backend.BaseURL) if err != nil { writeError(w, http.StatusInternalServerError, "could not parse backend URL") return } + fullBackendURL := backendBaseUrl.JoinPath(parsedRequestPath) + // Copy query parameters to backend - backendURL.RawQuery = r.URL.Query().Encode() - - request := &http.Request{ - Method: "GET", - URL: backendURL, - Header: map[string][]string{ - "X-Api-Key": {os.Getenv("API_KEY")}, - }, + fullBackendURL.RawQuery = r.URL.Query().Encode() + + request, err := http.NewRequest("GET", fullBackendURL.String(), nil) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not construct backend request") + return } tlsConfig := &tls.Config{} - if path.Backend.TLSCertificate != "" && path.Backend.TLSKey != "" { - cert, err := tls.LoadX509KeyPair(path.Backend.TLSCertificate, path.Backend.TLSKey) + if backend.Auth.TLS.Certificate != "" && backend.Auth.TLS.Key != "" { + cert, err := tls.LoadX509KeyPair(backend.Auth.TLS.Certificate, backend.Auth.TLS.Key) if err != nil { writeError(w, http.StatusInternalServerError, "could not load TLS keypair for backend") return @@ -80,8 +85,18 @@ func main() { } transport := &http.Transport{TLSClientConfig: tlsConfig} + + if backend.Auth.Basic.Username != "" && backend.Auth.Basic.Password != "" { + request.SetBasicAuth(backend.Auth.Basic.Username, backend.Auth.Basic.Password) + } + + for headerKey, headerValue := range backend.Auth.Header { + parsedHeaderValue := utils.EnvSubst(headerValue) + request.Header.Set(headerKey, parsedHeaderValue) + } + client := &http.Client{ - Timeout: 5 * time.Second, + Timeout: 25 * time.Second, Transport: transport, } @@ -90,57 +105,46 @@ func main() { writeError(w, http.StatusInternalServerError, "could not fetch backend response") return } - defer proxyResp.Body.Close() - if proxyResp.StatusCode != http.StatusOK { - copyHeader(w.Header(), proxyResp.Header) - w.WriteHeader(proxyResp.StatusCode) - io.Copy(w, proxyResp.Body) - return - } + defer proxyResp.Body.Close() - body, _ := io.ReadAll(proxyResp.Body) + if path.Filter != "" { + body, _ := io.ReadAll(proxyResp.Body) - var result map[string]interface{} - json.Unmarshal(body, &result) + var result map[string]interface{} + json.Unmarshal(body, &result) - if currentPath.Filter == "" { - response, err := json.MarshalIndent(result, "", " ") + query, err := gojq.Parse(path.Filter) if err != nil { - writeError(w, http.StatusInternalServerError, "could not marshall json") + writeError(w, http.StatusInternalServerError, "could not parse filter") return } - w.Header().Set("Content-Type", "application/json") - w.Write(response) - return - } - - query, err := gojq.Parse(currentPath.Filter) - if err != nil { - writeError(w, http.StatusInternalServerError, "could not parse filter") - return - } - - iter := query.Run(result) - for { - v, ok := iter.Next() - if !ok { - break - } - - if _, ok := v.(error); ok { - continue - } - - response, err := json.MarshalIndent(v, "", " ") - if err != nil { - writeError(w, http.StatusInternalServerError, "could not marshal json") - return + iter := query.Run(result) + for { + v, ok := iter.Next() + if !ok { + break + } + + if _, ok := v.(error); ok { + continue + } + + response, err := json.MarshalIndent(v, "", " ") + if err != nil { + writeError(w, http.StatusInternalServerError, "could not marshal json") + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(response) } - - w.Header().Set("Content-Type", "application/json") - w.Write(response) + } else { + utils.DelHopHeaders(proxyResp.Header) + utils.CopyHeader(w.Header(), proxyResp.Header) + w.WriteHeader(proxyResp.StatusCode) + io.Copy(w, proxyResp.Body) } }) } @@ -156,48 +160,44 @@ func main() { log.Fatal(s.ListenAndServe()) } -func authorizeRequest(jwksUrl string, authorizedGroups []string, w http.ResponseWriter, r *http.Request) bool { - // Create the JWKS from the resource at the given URL. - jwks, err := keyfunc.Get(jwksUrl, keyfunc.Options{}) - if err != nil { - writeError(w, http.StatusUnauthorized, fmt.Sprintf("could not authorize request: %s", err.Error())) +func authorizeRequestWithService(config *config.Config, path config.Path, r *http.Request) bool { + if config.AuthorizationServiceURL == "" { + log.Print("returned unauthenticated as there is no authorization service URL configured.") return false } - tokenFromRequest := helper.DefaultBearerTokenFromRequest(r) - if tokenFromRequest == "" { - writeError(w, http.StatusUnauthorized, "could not fetch token from request") + authorizationServiceURL, err := url.Parse(config.AuthorizationServiceURL) + if err != nil { + log.Printf("could not parse authorization url: %s", err) return false } - parsedToken, err := jwt.ParseWithClaims(tokenFromRequest, &ClaimsWithGroups{}, jwks.Keyfunc) - if err != nil { - writeError(w, http.StatusUnauthorized, fmt.Sprintf("could not authorize request: %s", err.Error())) - return false + authorizationServiceURL.RawQuery = r.URL.RawQuery + + authorizationHeaders := r.Header + + authorizationHeaders.Set("X-Source-Slug", path.Backend.Slug) + authorizationHeaders.Set("X-Original-Uri", r.URL.RequestURI()) + + request := &http.Request{ + Method: "GET", + URL: authorizationServiceURL, + Header: authorizationHeaders, } - if _, ok := parsedToken.Method.(*jwt.SigningMethodRSA); !ok { - writeError(w, http.StatusUnauthorized, fmt.Sprintf("unexpected signing method: %v", parsedToken.Header["alg"])) - return false + client := &http.Client{ + Timeout: 5 * time.Second, } - if !parsedToken.Valid { - writeError(w, http.StatusUnauthorized, "parsed token is not valid") + resp, err := client.Do(request) + if err != nil { + log.Printf("could not fetch authorization response: %s", err) return false } - if userClaims, ok := parsedToken.Claims.(*ClaimsWithGroups); ok { - for _, authorizedGroup := range authorizedGroups { - for _, userGroup := range userClaims.Groups { - if authorizedGroup == userGroup { - return true - } - } - } - } + defer resp.Body.Close() - writeError(w, http.StatusForbidden, "user is not in required groups") - return false + return resp.StatusCode == http.StatusOK } func writeError(w http.ResponseWriter, statusCode int, message string) { @@ -212,11 +212,3 @@ func writeError(w http.ResponseWriter, statusCode int, message string) { w.Header().Set("Content-Type", "application/json") w.Write(jsonResp) } - -func copyHeader(dst, src http.Header) { - for k, vv := range src { - for _, v := range vv { - dst.Add(k, v) - } - } -} diff --git a/config.yaml b/config.yaml index dc5b1df..d87709e 100644 --- a/config.yaml +++ b/config.yaml @@ -1,14 +1,19 @@ -listenAddress: localhost:8080 - -jwksUrl: http://localhost:8081/realms/datalab/protocol/openid-connect/certs +listenAddress: localhost:8050 +authorizationServiceUrl: http://localhost:8000/atlas/api/v1/authorize paths: + - path: /api/ows + backend: + slug: geoserver + path: /ows + - path: /api/wmts + backend: + slug: geoserver + path: /gwc/service/wmts - path: /api/brk/v1/kadastraalonroerendezaken/{kadastraalOnroerendeZaakIdentificatie:[0-9]+} backend: - url: https://api.brk.kadaster.nl/esd-eto-apikey/bevragen/v1/kadastraalonroerendezaken/{kadastraalOnroerendeZaakIdentificatie:[0-9]+} - authorization: - groups: - - /BRK Bevragen + slug: brk + path: /kadastraalonroerendezaken/{kadastraalOnroerendeZaakIdentificatie:[0-9]+} filter: | { aardCultuurBebouwd: .aardCultuurBebouwd, @@ -26,7 +31,8 @@ paths: } - path: /api/brk/v1/kadastraalonroerendezaken/{kadastraalOnroerendeZaakIdentificatie:[0-9]+}/zakelijkgerechtigden backend: - url: https://api.brk.kadaster.nl/esd-eto-apikey/bevragen/v1/kadastraalonroerendezaken/{kadastraalOnroerendeZaakIdentificatie:[0-9]+}/zakelijkgerechtigden + slug: brk + path: /kadastraalonroerendezaken/{kadastraalOnroerendeZaakIdentificatie:[0-9]+}/zakelijkgerechtigden filter: | { "_embedded": { @@ -64,12 +70,10 @@ paths: ] } } - authorization: - groups: - - /BRK Bevragen - path: /api/brk/v1/publiekrechtelijkebeperkingen backend: - url: https://api.brk.kadaster.nl/esd-eto-apikey/bevragen/v1/publiekrechtelijkebeperkingen + slug: brk + path: /publiekrechtelijkebeperkingen filter: | { "_embedded": { @@ -91,6 +95,16 @@ paths: ] } } - authorization: - groups: - - /BRK Bevragen + +backends: + geoserver: + baseUrl: http://localhost:8051/geoserver + auth: + basic: + username: user + spassword: geoserver + brk: + baseUrl: https://api.brk.kadaster.nl/esd-eto-apikey/bevragen/v1 + auth: + header: + X-Api-Key: ${API_KEY} diff --git a/internal/config/config.go b/internal/config/config.go index ae43909..6911a72 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,26 +7,36 @@ import ( ) type Backend struct { - URL string `yaml:"url"` - TLSCertificate string `yaml:"tlsCertificate"` - TLSKey string `yaml:"tlsKey"` -} - -type Authorization struct { - Groups []string `yaml:"groups"` + BaseURL string `yaml:"baseUrl"` + + Auth struct { + Header map[string]string `yaml:"header"` + Basic struct { + Username string `yaml:"username"` + Password string `yaml:"password"` + } `yaml:"basic"` + TLS struct { + Certificate string `yaml:"certificate"` + Key string `yaml:"key"` + } `yaml:"tls"` + } } type Path struct { - Path string `yaml:"path"` - Backend Backend `yaml:"backend"` - Filter string `yaml:"filter"` - Authorization Authorization `yaml:"authorization"` + Path string `yaml:"path"` + Backend struct { + Slug string `yaml:"slug"` + Path string `yaml:"path"` + } `yaml:"backend"` + Filter string `yaml:"filter"` } type Config struct { - ListenAddress string `yaml:"listenAddress"` - JwksUrl string `yaml:"jwksUrl"` - Paths []Path `yaml:"paths"` + ListenAddress string `yaml:"listenAddress"` + AuthorizationServiceURL string `yaml:"authorizationServiceUrl"` + JwksURL string `yaml:"jwksUrl"` + Paths []Path `yaml:"paths"` + Backends map[string]Backend `yaml:"backends"` } // NewConfig returns a new decoded Config struct diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..5fdc04e --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,69 @@ +package utils + +import ( + "encoding/base64" + "net/http" + "net/url" + "os" + "regexp" + "strings" +) + +func QueryParamsToLower(queryParams url.Values) url.Values { + lowercaseParams := url.Values{} + + for key, values := range queryParams { + lowercaseKey := strings.ToLower(key) + lowercaseParams[lowercaseKey] = values + } + + return lowercaseParams +} + +func GenerateBasicAuthHeader(username, password string) string { + auth := username + ":" + password + return "Basic " + base64.StdEncoding.EncodeToString([]byte(auth)) +} + +func CopyHeader(dst, src http.Header) { + for k, vv := range src { + for _, v := range vv { + dst.Add(k, v) + } + } +} + +func DelHopHeaders(header http.Header) { + // Hop-by-hop headers. These are removed when sent to the backend. + // http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html + var hopHeaders = []string{ + "Connection", + "Keep-Alive", + "Proxy-Authenticate", + "Proxy-Authorization", + "Te", // canonicalized version of "TE" + "Trailers", + "Transfer-Encoding", + "Upgrade", + "Access-Control-Allow-Origin", + } + + for _, h := range hopHeaders { + header.Del(h) + } +} + +func EnvSubst(input string) string { + re := regexp.MustCompile(`\${([^}]+)}`) + + result := re.ReplaceAllStringFunc(input, func(match string) string { + varName := match[2 : len(match)-1] + if value, exists := os.LookupEnv(varName); exists { + return value + } + + return "" + }) + + return result +}