refactor: Restructure project files for better organization, readability, and maintainability

This commit is contained in:
2024-11-04 08:48:38 +01:00
parent 096290bcb8
commit 4e49102433
22 changed files with 17 additions and 17 deletions

View File

@@ -0,0 +1,99 @@
package middleware
/*
Copyright 2024 Jonas Kaninda
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import (
"encoding/json"
"fmt"
"github.com/jkaninda/goma-gateway/pkg/logger"
"github.com/jkaninda/goma-gateway/util"
"net/http"
"strings"
"time"
)
// AccessMiddleware checks if the request path is forbidden and returns 403 Forbidden
func (blockList AccessListMiddleware) AccessMiddleware(next http.Handler) http.Handler {
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.Error("%s: %s access forbidden", getRealIP(r), r.URL.Path)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
err := json.NewEncoder(w).Encode(ProxyResponseError{
Success: false,
Code: http.StatusForbidden,
Message: fmt.Sprintf("You do not have permission to access this resource"),
})
if err != nil {
return
}
return
}
}
next.ServeHTTP(w, r)
})
}
// Helper function to determine if the request path is blocked
func isPathBlocked(requestPath, blockedPath string) bool {
// Handle exact match
if requestPath == blockedPath {
return true
}
// Handle wildcard match (e.g., /admin/* should block /admin and any subpath)
if strings.HasSuffix(blockedPath, "/*") {
basePath := strings.TrimSuffix(blockedPath, "/*")
if strings.HasPrefix(requestPath, basePath) {
return true
}
}
return false
}
// NewRateLimiter creates a new rate limiter with the specified refill rate and token capacity
func NewRateLimiter(maxTokens int, refillRate time.Duration) *TokenRateLimiter {
return &TokenRateLimiter{
tokens: maxTokens,
maxTokens: maxTokens,
refillRate: refillRate,
lastRefill: time.Now(),
}
}
// Allow checks if a request is allowed based on the current token bucket
func (rl *TokenRateLimiter) Allow() bool {
rl.mu.Lock()
defer rl.mu.Unlock()
// Refill tokens based on the time elapsed
now := time.Now()
elapsed := now.Sub(rl.lastRefill)
tokensToAdd := int(elapsed / rl.refillRate)
if tokensToAdd > 0 {
rl.tokens = min(rl.maxTokens, rl.tokens+tokensToAdd)
rl.lastRefill = now
}
// Check if there are enough tokens to allow the request
if rl.tokens > 0 {
rl.tokens--
return true
}
// Reject request if no tokens are available
return false
}

View File

@@ -0,0 +1,82 @@
package middleware
/*
* Copyright 2024 Jonas Kaninda
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
import (
"bytes"
"encoding/json"
"github.com/jkaninda/goma-gateway/pkg/logger"
"io"
"net/http"
)
func newResponseRecorder(w http.ResponseWriter) *responseRecorder {
return &responseRecorder{
ResponseWriter: w,
statusCode: http.StatusOK,
body: &bytes.Buffer{},
}
}
func (rec *responseRecorder) WriteHeader(code int) {
rec.statusCode = code
}
func (rec *responseRecorder) Write(data []byte) (int, error) {
return rec.body.Write(data)
}
// ErrorInterceptor Middleware intercepts backend errors
func (intercept InterceptErrors) ErrorInterceptor(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := newResponseRecorder(w)
next.ServeHTTP(rec, r)
if canIntercept(rec.statusCode, intercept.Errors) {
logger.Error("Backend error")
logger.Error("An error occurred from the backend with the status code: %d", rec.statusCode)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(rec.statusCode)
err := json.NewEncoder(w).Encode(ProxyResponseError{
Success: false,
Code: rec.statusCode,
Message: http.StatusText(rec.statusCode),
})
if err != nil {
return
}
} else {
// No error: write buffered response to client
w.WriteHeader(rec.statusCode)
_, err := io.Copy(w, rec.body)
if err != nil {
return
}
}
})
}
func canIntercept(code int, errors []int) bool {
for _, er := range errors {
if er == code {
return true
}
continue
}
return false
}

View File

@@ -0,0 +1,209 @@
package middleware
/*
Copyright 2024 Jonas Kaninda
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import (
"encoding/base64"
"encoding/json"
"github.com/jkaninda/goma-gateway/pkg/logger"
"io"
"net/http"
"net/url"
"strings"
)
// AuthMiddleware authenticate the client using JWT
//
// authorization based on the result of backend's response and continue the request when the client is authorized
func (jwtAuth JwtAuth) AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, header := range jwtAuth.RequiredHeaders {
if r.Header.Get(header) == "" {
logger.Error("Proxy error, missing %s header", header)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
err := json.NewEncoder(w).Encode(ProxyResponseError{
Message: http.StatusText(http.StatusUnauthorized),
Code: http.StatusUnauthorized,
Success: false,
})
if err != nil {
return
}
return
}
}
//token := r.Header.Get("Authorization")
authURL, err := url.Parse(jwtAuth.AuthURL)
if err != nil {
logger.Error("Error parsing auth URL: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
err = json.NewEncoder(w).Encode(ProxyResponseError{
Message: "Internal Server Error",
Code: http.StatusInternalServerError,
Success: false,
})
if err != nil {
return
}
return
}
// Create a new request for /authentication
authReq, err := http.NewRequest("GET", authURL.String(), nil)
if err != nil {
logger.Error("Proxy error creating authentication request: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
err = json.NewEncoder(w).Encode(ProxyResponseError{
Message: "Internal Server Error",
Code: http.StatusInternalServerError,
Success: false,
})
if err != nil {
return
}
return
}
// Copy headers from the original request to the new request
for name, values := range r.Header {
for _, value := range values {
authReq.Header.Set(name, value)
}
}
// Copy cookies from the original request to the new request
for _, cookie := range r.Cookies() {
authReq.AddCookie(cookie)
}
// Perform the request to the auth service
client := &http.Client{}
authResp, err := client.Do(authReq)
if err != nil || authResp.StatusCode != http.StatusOK {
logger.Info("%s %s %s %s", r.Method, getRealIP(r), r.URL, r.UserAgent())
logger.Warn("Proxy authentication error")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
err = json.NewEncoder(w).Encode(ProxyResponseError{
Message: "Unauthorized",
Code: http.StatusUnauthorized,
Success: false,
})
if err != nil {
return
}
return
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
}
}(authResp.Body)
// Inject specific header tp the current request's header
// Add header to the next request from AuthRequest header, depending on your requirements
if jwtAuth.Headers != nil {
for k, v := range jwtAuth.Headers {
r.Header.Set(v, authResp.Header.Get(k))
}
}
query := r.URL.Query()
// Add query parameters to the next request from AuthRequest header, depending on your requirements
if jwtAuth.Params != nil {
for k, v := range jwtAuth.Params {
query.Set(v, authResp.Header.Get(k))
}
}
r.URL.RawQuery = query.Encode()
next.ServeHTTP(w, r)
})
}
// AuthMiddleware checks for the Authorization header and verifies the credentials
func (basicAuth AuthBasic) AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get the Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
logger.Error("Proxy error, missing Authorization header")
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
err := json.NewEncoder(w).Encode(ProxyResponseError{
Success: false,
Code: http.StatusUnauthorized,
Message: http.StatusText(http.StatusUnauthorized),
})
if err != nil {
return
}
return
}
// Check if the Authorization header contains "Basic" scheme
if !strings.HasPrefix(authHeader, "Basic ") {
logger.Error("Proxy error, missing Basic Authorization header")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
err := json.NewEncoder(w).Encode(ProxyResponseError{
Success: false,
Code: http.StatusUnauthorized,
Message: http.StatusText(http.StatusUnauthorized),
})
if err != nil {
return
}
return
}
// Decode the base64 encoded username:password string
payload, err := base64.StdEncoding.DecodeString(authHeader[len("Basic "):])
if err != nil {
logger.Error("Proxy error, missing Basic Authorization header")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
err := json.NewEncoder(w).Encode(ProxyResponseError{
Success: false,
Code: http.StatusUnauthorized,
Message: http.StatusText(http.StatusUnauthorized),
})
if err != nil {
return
}
return
}
// Split the payload into username and password
pair := strings.SplitN(string(payload), ":", 2)
if len(pair) != 2 || pair[0] != basicAuth.Username || pair[1] != basicAuth.Password {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
err := json.NewEncoder(w).Encode(ProxyResponseError{
Success: false,
Code: http.StatusUnauthorized,
Message: http.StatusText(http.StatusUnauthorized),
})
if err != nil {
return
}
return
}
// Continue to the next handler if the authentication is successful
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,95 @@
package middleware
/*
Copyright 2024 Jonas Kaninda
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import (
"encoding/json"
"github.com/gorilla/mux"
"github.com/jkaninda/goma-gateway/pkg/logger"
"net/http"
"time"
)
// RateLimitMiddleware limits request based on the number of tokens peer minutes.
func (rl *TokenRateLimiter) RateLimitMiddleware() mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !rl.Allow() {
// Rate limit exceeded, return a 429 Too Many Requests response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
err := json.NewEncoder(w).Encode(ProxyResponseError{
Success: false,
Code: http.StatusTooManyRequests,
Message: "Too many requests, API rate limit exceeded. Please try again later.",
})
if err != nil {
return
}
return
}
// Proceed to the next handler if rate limit is not exceeded
next.ServeHTTP(w, r)
})
}
}
// RateLimitMiddleware limits request based on the number of requests peer minutes.
func (rl *RateLimiter) RateLimitMiddleware() mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientID := getRealIP(r)
rl.mu.Lock()
client, exists := rl.ClientMap[clientID]
if !exists || time.Now().After(client.ExpiresAt) {
client = &Client{
RequestCount: 0,
ExpiresAt: time.Now().Add(rl.Window),
}
rl.ClientMap[clientID] = client
}
client.RequestCount++
rl.mu.Unlock()
if client.RequestCount > rl.Requests {
logger.Error("Too many requests 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{
Success: false,
Code: http.StatusTooManyRequests,
Message: "Too many requests, API rate limit exceeded. Please try again later.",
})
if err != nil {
return
}
return
}
// Proceed to the next handler if rate limit is not exceeded
next.ServeHTTP(w, r)
})
}
}
func getRealIP(r *http.Request) string {
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
return ip
}
return r.RemoteAddr
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2024 Jonas Kaninda
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package middleware
import (
"bytes"
"net/http"
"sync"
"time"
)
// RateLimiter defines rate limit properties.
type RateLimiter struct {
Requests int
Window time.Duration
ClientMap map[string]*Client
mu sync.Mutex
}
// Client stores request count and window expiration for each client.
type Client struct {
RequestCount int
ExpiresAt time.Time
}
// NewRateLimiterWindow creates a new RateLimiter.
func NewRateLimiterWindow(requests int, window time.Duration) *RateLimiter {
return &RateLimiter{
Requests: requests,
Window: window,
ClientMap: make(map[string]*Client),
}
}
// TokenRateLimiter stores tokenRate limit
type TokenRateLimiter struct {
tokens int
maxTokens int
refillRate time.Duration
lastRefill time.Time
mu sync.Mutex
}
// ProxyResponseError represents the structure of the JSON error response
type ProxyResponseError struct {
Success bool `json:"success"`
Code int `json:"code"`
Message string `json:"message"`
}
// JwtAuth stores JWT configuration
type JwtAuth struct {
AuthURL string
RequiredHeaders []string
Headers map[string]string
Params map[string]string
}
// AuthenticationMiddleware Define struct
type AuthenticationMiddleware struct {
AuthURL string
RequiredHeaders []string
Headers map[string]string
Params map[string]string
}
type AccessListMiddleware struct {
Path string
Destination string
List []string
}
// AuthBasic contains Basic auth configuration
type AuthBasic struct {
Username string
Password string
Headers map[string]string
Params map[string]string
}
// InterceptErrors contains backend status code errors to intercept
type InterceptErrors struct {
Errors []int
}
// responseRecorder intercepts the response body and status code
type responseRecorder struct {
http.ResponseWriter
statusCode int
body *bytes.Buffer
}