From fe81ac73243dd234288bed00c2351798cd62c916 Mon Sep 17 00:00:00 2001 From: Jonas Kaninda Date: Sat, 2 Nov 2024 11:55:37 +0100 Subject: [PATCH] feat: Add wildcard auth middleware paths (#24) * chore: add concurrent route health check requests * feat: Add wildcard auth middleware paths * fix: bind privileged port permission denied on Kubernetes for nonroot user --- Dockerfile | 6 +- README.md | 2 + examples/compose.yaml | 14 +++ examples/configMap.yaml | 152 ++++++++++++++++++++++++++++ examples/kubernetes.yaml | 44 ++++++++ goma.yml | 1 + pkg/middleware/access-middleware.go | 8 +- pkg/middleware/error-interceptor.go | 6 +- pkg/middleware/middleware.go | 6 +- pkg/middleware/rate-limiter.go | 2 +- pkg/proxy.go | 2 + pkg/route.go | 2 +- pkg/var.go | 1 + util/helpers.go | 14 +++ 14 files changed, 243 insertions(+), 17 deletions(-) create mode 100644 examples/compose.yaml create mode 100644 examples/configMap.yaml create mode 100644 examples/kubernetes.yaml diff --git a/Dockerfile b/Dockerfile index 64df2f0..27f472d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,15 +20,13 @@ LABEL author="Jonas Kaninda" LABEL version=${appVersion} LABEL github="github.com/jkaninda/goma-gateway" - -RUN apk --update add --no-cache tzdata ca-certificates curl RUN mkdir -p ${WORKDIR} ${CERTSDIR} && \ chmod a+rw ${WORKDIR} ${CERTSDIR} COPY --from=build /app/goma /usr/local/bin/goma -RUN chmod +x /usr/local/bin/goma && \ +RUN chmod a+x /usr/local/bin/goma && \ ln -s /usr/local/bin/goma /usr/bin/goma RUN addgroup -S ${user} && adduser -S ${user} -G ${user} - +RUN apk --update add --no-cache tzdata ca-certificates curl libcap && setcap 'cap_net_bind_service=+ep' /usr/local/bin/goma USER ${user} WORKDIR $WORKDIR ENTRYPOINT ["/usr/local/bin/goma"] \ No newline at end of file diff --git a/README.md b/README.md index 43de580..581e3f0 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,7 @@ gateway: middlewares: [] #Defines proxy middlewares +# middleware name must be unique middlewares: # Enable Basic auth authorization based - name: basic-auth @@ -191,6 +192,7 @@ middlewares: paths: - /protected-access - /example-of-jwt + #- /* or wildcard path rule: # This is an example URL url: https://www.googleapis.com/auth/userinfo.email diff --git a/examples/compose.yaml b/examples/compose.yaml new file mode 100644 index 0000000..f8ed22b --- /dev/null +++ b/examples/compose.yaml @@ -0,0 +1,14 @@ +services: + goma-gateway: + image: jkaninda/goma-gateway + command: server + healthcheck: + test: curl -f http://localhost/healthz || exit 1 + interval: 30s + retries: 5 + start_period: 20s + timeout: 10s + ports: + - "80:80" + volumes: + - ./config:/config/ \ No newline at end of file diff --git a/examples/configMap.yaml b/examples/configMap.yaml new file mode 100644 index 0000000..a0d0016 --- /dev/null +++ b/examples/configMap.yaml @@ -0,0 +1,152 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: goma-config +data: + goma.yml: | + gateway: + ########## Global settings + listenAddr: 0.0.0.0:80 + # Proxy write timeout + writeTimeout: 15 + # Proxy read timeout + readTimeout: 15 + # Proxy idle timeout + idleTimeout: 60 + # Proxy rate limit, it's In-Memory IP based + # Distributed Rate Limiting for Token based across multiple instances is not yet integrated + rateLimiter: 0 + accessLog: "/dev/Stdout" + errorLog: "/dev/stderr" + ## Returns backend route healthcheck errors + disableRouteHealthCheckError: false + # Disable display routes on start + disableDisplayRouteOnStart: false + # disableKeepAlive allows enabling and disabling KeepALive server + disableKeepAlive: false + # interceptErrors intercepts backend errors based on defined the status codes + interceptErrors: + - 405 + - 500 + # - 400 + # Proxy Global HTTP Cors + cors: + # Global routes cors for all routes + origins: + - http://localhost:8080 + - https://example.com + # Global routes cors headers for all routes + headers: + Access-Control-Allow-Headers: 'Origin, Authorization, Accept, Content-Type, Access-Control-Allow-Headers, X-Client-Id, X-Session-Id' + Access-Control-Allow-Credentials: 'true' + Access-Control-Max-Age: 1728000 + ##### Define routes + routes: + # Example of a route | 1 + - name: Public + # host Domain/host based request routing + host: "" # Host is optional + path: /public + ## Rewrite a request path + # e.g rewrite: /store to / + rewrite: /healthz + destination: https://example.com + #DisableHeaderXForward Disable X-forwarded header. + # [X-Forwarded-Host, X-Forwarded-For, Host, Scheme ] + # It will not match the backend route, by default, it's disabled + disableHeaderXForward: false + # Internal health check + healthCheck: '' #/internal/health/ready + # Route Cors, global cors will be overridden by route + cors: + # Route Origins Cors, global cors will be overridden by route + origins: + - https://dev.example.com + - http://localhost:3000 + - https://example.com + # Route Cors headers, route will override global cors + headers: + Access-Control-Allow-Methods: 'GET' + Access-Control-Allow-Headers: 'Origin, Authorization, Accept, Content-Type, Access-Control-Allow-Headers, X-Client-Id, X-Session-Id' + Access-Control-Allow-Credentials: 'true' + Access-Control-Max-Age: 1728000 + ##### Define route middlewares from middlewares names + ## The name must be unique + ## List of middleware name + middlewares: + - api-forbidden-paths + - basic-auth + # Example of a route | 2 + - name: Authentication service + path: /auth + rewrite: / + destination: 'http://security-service:8080' + healthCheck: /internal/health/ready + cors: {} + middlewares: + - api-forbidden-paths + # Example of a route | 3 + - name: Basic auth + path: /protected + rewrite: / + destination: 'http://notification-service:8080' + healthCheck: + cors: {} + middlewares: [] + + #Defines proxy middlewares + # middleware name must be unique + middlewares: + # Enable Basic auth authorization based + - name: basic-auth + # Authentication types | jwt, basic, OAuth + type: basic + paths: + - /user + - /admin + - /account + rule: + username: admin + password: admin + #Enables JWT authorization based on the result of a request and continues the request. + - name: google-auth + # Authentication types | jwt, basic, OAuth + # jwt authorization based on the result of backend's response and continue the request when the client is authorized + type: jwt + # Paths to protect + paths: + - /protected-access + - /example-of-jwt + #- /* or wildcard path + rule: + # This is an example URL + url: https://www.googleapis.com/auth/userinfo.email + # Required headers, if not present in the request, the proxy will return 403 + requiredHeaders: + - Authorization + #Sets the request variable to the given value after the authorization request completes. + # + # Add header to the next request from AuthRequest header, depending on your requirements + # Key is AuthRequest's response header Key, and value is Request's header Key + # In case you want to get headers from the Authentication service and inject them into the next request's headers + #Sets the request variable to the given value after the authorization request completes. + # + # Add header to the next request from AuthRequest header, depending on your requirements + # Key is AuthRequest's response header Key, and value is Request's header Key + # In case you want to get headers from the Authentication service and inject them into the next request's headers + headers: + userId: X-Auth-UserId + userCountryId: X-Auth-UserCountryId + # In case you want to get headers from the Authentication service and inject them to the next request's params + params: + userCountryId: countryId + # The server will return 404 + - name: api-forbidden-paths + type: access + ## Forbidden paths + paths: + - /swagger-ui/* + - /v2/swagger-ui/* + - /api-docs/* + - /internal/* + - /actuator/* \ No newline at end of file diff --git a/examples/kubernetes.yaml b/examples/kubernetes.yaml new file mode 100644 index 0000000..72a44ca --- /dev/null +++ b/examples/kubernetes.yaml @@ -0,0 +1,44 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: goma-gateway +spec: + selector: + matchLabels: + app: goma-gateway + template: + metadata: + labels: + app: goma-gateway + spec: + containers: + - name: goma-gateway + image: jkaninda/goma-gateway + command: ["goma","server"] + resources: + limits: + memory: "128Mi" + cpu: "200m" + ports: + - containerPort: 80 + livenessProbe: + httpGet: + path: /healthz + port: 80 + initialDelaySeconds: 15 + periodSeconds: 30 + timeoutSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: 80 + initialDelaySeconds: 15 + periodSeconds: 40 + timeoutSeconds: 10 + volumeMounts: + - name: config + mountPath: /config2/ + volumes: + - name: config + configMap: + name: goma-config \ No newline at end of file diff --git a/goma.yml b/goma.yml index e158515..8496ac3 100644 --- a/goma.yml +++ b/goma.yml @@ -113,6 +113,7 @@ middlewares: paths: - /protected-access - /example-of-jwt + #- /* or wildcard path rule: # This is an example URL url: https://www.googleapis.com/auth/userinfo.email diff --git a/pkg/middleware/access-middleware.go b/pkg/middleware/access-middleware.go index 1686a4b..af57fa7 100644 --- a/pkg/middleware/access-middleware.go +++ b/pkg/middleware/access-middleware.go @@ -30,13 +30,13 @@ func (blockList AccessListMiddleware) AccessMiddleware(next http.Handler) http.H return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { for _, block := range blockList.List { if isPathBlocked(r.URL.Path, util.ParseURLPath(blockList.Path+block)) { - logger.Debug("%s: %s access forbidden", getRealIP(r), r.URL.Path) + logger.Error("%s: %s access forbidden", getRealIP(r), r.URL.Path) w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotFound) + w.WriteHeader(http.StatusForbidden) err := json.NewEncoder(w).Encode(ProxyResponseError{ Success: false, - Code: http.StatusNotFound, - Message: fmt.Sprintf("Not found: %s", r.URL.Path), + Code: http.StatusForbidden, + Message: fmt.Sprintf("You do not have permission to access this resource"), }) if err != nil { return diff --git a/pkg/middleware/error-interceptor.go b/pkg/middleware/error-interceptor.go index 607df03..7630ef0 100644 --- a/pkg/middleware/error-interceptor.go +++ b/pkg/middleware/error-interceptor.go @@ -57,11 +57,9 @@ func (intercept InterceptErrors) ErrorInterceptor(next http.Handler) http.Handle return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rec := newResponseRecorder(w) next.ServeHTTP(rec, r) - //Set Server name - w.Header().Set("Server", "Goma") if canIntercept(rec.statusCode, intercept.Errors) { - logger.Debug("Backend error intercepted") - logger.Debug("An error occurred in the backend, %d", rec.statusCode) + logger.Error("Backend error") + logger.Error("An error occurred in the backend, %d", rec.statusCode) w.Header().Set("Content-Type", "application/json") w.WriteHeader(rec.statusCode) err := json.NewEncoder(w).Encode(ProxyResponseError{ diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index e30d6cc..75343c0 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -104,10 +104,10 @@ func (jwtAuth JwtAuth) AuthMiddleware(next http.Handler) http.Handler { if r.Header.Get(header) == "" { logger.Error("Proxy error, missing %s header", header) w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) + w.WriteHeader(http.StatusUnauthorized) err := json.NewEncoder(w).Encode(ProxyResponseError{ - Message: "Missing Authorization header", - Code: http.StatusForbidden, + Message: http.StatusText(http.StatusUnauthorized), + Code: http.StatusUnauthorized, Success: false, }) if err != nil { diff --git a/pkg/middleware/rate-limiter.go b/pkg/middleware/rate-limiter.go index d9df3bf..d814a9f 100644 --- a/pkg/middleware/rate-limiter.go +++ b/pkg/middleware/rate-limiter.go @@ -66,7 +66,7 @@ func (rl *RateLimiter) RateLimitMiddleware() mux.MiddlewareFunc { rl.mu.Unlock() if client.RequestCount > rl.Requests { - logger.Debug("Too many request from IP: %s %s %s", clientID, r.URL, r.UserAgent()) + logger.Error("Too many request from IP: %s %s %s", clientID, r.URL, r.UserAgent()) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusTooManyRequests) err := json.NewEncoder(w).Encode(ProxyResponseError{ diff --git a/pkg/proxy.go b/pkg/proxy.go index 88d9e90..3268824 100644 --- a/pkg/proxy.go +++ b/pkg/proxy.go @@ -88,6 +88,8 @@ func (proxyRoute ProxyRoute) ProxyHandler() http.HandlerFunc { r.URL.Path = strings.Replace(r.URL.Path, fmt.Sprintf("%s/", proxyRoute.path), proxyRoute.rewrite, 1) } } + w.Header().Set("Proxied-By", gatewayName) //Set Server name + w.Header().Set("Server", serverName) // Custom error handler for proxy errors proxy.ErrorHandler = ProxyErrorHandler proxy.ServeHTTP(w, r) diff --git a/pkg/route.go b/pkg/route.go index 8623102..009d7ac 100644 --- a/pkg/route.go +++ b/pkg/route.go @@ -76,7 +76,7 @@ func (gatewayServer GatewayServer) Initialize() *mux.Router { disableXForward: route.DisableHeaderXForward, cors: route.Cors, } - secureRouter := r.PathPrefix(util.ParseURLPath(route.Path + midPath)).Subrouter() + secureRouter := r.PathPrefix(util.ParseRoutePath(route.Path, midPath)).Subrouter() //Check Authentication middleware switch rMiddleware.Type { case BasicAuth: diff --git a/pkg/var.go b/pkg/var.go index c22d2da..8eb1c1c 100644 --- a/pkg/var.go +++ b/pkg/var.go @@ -3,6 +3,7 @@ package pkg const ConfigFile = "/config/goma.yml" // Default configuration file const accessControlAllowOrigin = "Access-Control-Allow-Origin" // Cors const serverName = "Goma" +const gatewayName = "Goma Gateway" const AccessMiddleware = "access" // access middleware const BasicAuth = "basic" // basic authentication middleware const JWTAuth = "jwt" // JWT authentication middleware diff --git a/util/helpers.go b/util/helpers.go index 264bfad..5fb0fa5 100644 --- a/util/helpers.go +++ b/util/helpers.go @@ -82,3 +82,17 @@ func ParseURLPath(urlPath string) string { } return urlPath } + +func ParseRoutePath(path, blockedPath string) string { + basePath := ParseURLPath(path) + switch { + case blockedPath == "": + return basePath + case strings.HasSuffix(blockedPath, "/*"): + return basePath + blockedPath[:len(blockedPath)-2] + case strings.HasSuffix(blockedPath, "*"): + return basePath + blockedPath[:len(blockedPath)-1] + default: + return basePath + blockedPath + } +}