jobs-monorepo/internal/infrastructure/http/middlewares.go
Elshimy Ziad Magdy Taha c2dd58c019
All checks were successful
Deploy scraper-google / build (push) Successful in 21m26s
Deploy scraper-google / deploy (push) Has been skipped
refactor middleware
2026-08-04 18:17:19 +05:00

311 lines
9.8 KiB
Go

// Package http provides reusable net/http middleware shared by every service
// that exposes an HTTP surface. It holds no domain types — handlers belong in
// the owning service, not here.
package http
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// contextKey is unexported so values stashed by this package cannot collide
// with values stashed by any other package using the same string.
type contextKey string
const (
userIDContextKey contextKey = "user_id"
usernameContextKey contextKey = "username"
)
var (
defaultAllowedMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
defaultAllowedHeaders = []string{"Content-Type", "Authorization", "X-Requested-With", "X-API-Key"}
)
// CORSConfig describes which cross-origin requests are permitted.
//
// AllowedOrigins is an explicit allowlist matched exactly against the request
// Origin. The single entry "*" permits any origin, but only for unauthenticated
// requests: "*" together with AllowCredentials is rejected by CORS, because it
// would let any site on the internet issue credentialed requests and read the
// responses. An empty AllowedOrigins denies every cross-origin request, which
// leaves same-origin traffic working normally.
type CORSConfig struct {
AllowedOrigins []string
AllowedMethods []string
AllowedHeaders []string
ExposedHeaders []string
AllowCredentials bool
MaxAge time.Duration
}
// LoadCORSConfigFromEnv reads CORS settings from the environment.
//
// CORS_ALLOWED_ORIGINS is a comma-separated allowlist and defaults to empty,
// so an unconfigured service denies cross-origin requests rather than allowing
// them. CORS_ALLOW_CREDENTIALS defaults to false.
func LoadCORSConfigFromEnv() CORSConfig {
return CORSConfig{
AllowedOrigins: splitAndTrim(getEnv("CORS_ALLOWED_ORIGINS", "")),
AllowedMethods: splitAndTrim(getEnv("CORS_ALLOWED_METHODS", strings.Join(defaultAllowedMethods, ","))),
AllowedHeaders: splitAndTrim(getEnv("CORS_ALLOWED_HEADERS", strings.Join(defaultAllowedHeaders, ","))),
ExposedHeaders: splitAndTrim(getEnv("CORS_EXPOSED_HEADERS", "Content-Length,Content-Range")),
AllowCredentials: getEnv("CORS_ALLOW_CREDENTIALS", "false") == "true",
MaxAge: 24 * time.Hour,
}
}
// CORS returns a middleware enforcing cfg.
//
// It returns an error for configurations that cannot be served safely, so a
// service fails at startup instead of running with permissive CORS.
func CORS(cfg CORSConfig) (func(http.Handler) http.Handler, error) {
allowAny := len(cfg.AllowedOrigins) == 1 && cfg.AllowedOrigins[0] == "*"
if allowAny && cfg.AllowCredentials {
return nil, fmt.Errorf(`CORS: AllowedOrigins "*" cannot be combined with AllowCredentials; list the permitted origins explicitly`)
}
for _, origin := range cfg.AllowedOrigins {
if origin != "*" && !strings.Contains(origin, "://") {
return nil, fmt.Errorf("CORS: AllowedOrigins entry %q must include a scheme, e.g. https://%s", origin, origin)
}
}
// Origin comparison is case-insensitive on scheme and host, so normalise the
// allowlist once here rather than on every request.
allowed := make(map[string]struct{}, len(cfg.AllowedOrigins))
for _, origin := range cfg.AllowedOrigins {
allowed[strings.ToLower(origin)] = struct{}{}
}
// Defaults are applied here, not only in LoadCORSConfigFromEnv, so a
// directly constructed CORSConfig still answers preflights usefully instead
// of sending empty header values.
if len(cfg.AllowedMethods) == 0 {
cfg.AllowedMethods = defaultAllowedMethods
}
if len(cfg.AllowedHeaders) == 0 {
cfg.AllowedHeaders = defaultAllowedHeaders
}
methods := strings.Join(cfg.AllowedMethods, ", ")
headers := strings.Join(cfg.AllowedHeaders, ", ")
exposed := strings.Join(cfg.ExposedHeaders, ", ")
maxAge := ""
if cfg.MaxAge > 0 {
maxAge = strconv.Itoa(int(cfg.MaxAge.Seconds()))
}
if len(cfg.AllowedOrigins) == 0 {
slog.Warn("CORS: no allowed origins configured, cross-origin requests will be denied (set CORS_ALLOWED_ORIGINS)")
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
isPreflight := r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != ""
// Responses vary by Origin whenever the allowlist is consulted, so
// caches must not serve one origin's response to another.
if !allowAny {
w.Header().Add("Vary", "Origin")
}
if origin != "" && originAllowed(allowed, allowAny, origin) {
if allowAny {
w.Header().Set("Access-Control-Allow-Origin", "*")
} else {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
if cfg.AllowCredentials {
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
if exposed != "" {
w.Header().Set("Access-Control-Expose-Headers", exposed)
}
if isPreflight {
w.Header().Set("Access-Control-Allow-Methods", methods)
w.Header().Set("Access-Control-Allow-Headers", headers)
if maxAge != "" {
w.Header().Set("Access-Control-Max-Age", maxAge)
}
}
}
// Preflights are answered here whether or not the origin passed: with
// no Allow-Origin header the browser blocks the real request anyway,
// and handlers never have to deal with OPTIONS.
if isPreflight {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}, nil
}
func originAllowed(allowed map[string]struct{}, allowAny bool, origin string) bool {
if allowAny {
return true
}
_, ok := allowed[strings.ToLower(origin)]
return ok
}
// Logs logs each request with its method, path, status and duration.
func Logs(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r)
slog.Info("http request",
"method", r.Method,
"path", r.URL.Path,
"status", recorder.status,
"duration", time.Since(start),
)
})
}
// statusRecorder captures the response status for logging.
type statusRecorder struct {
http.ResponseWriter
status int
wroteHeader bool
}
func (s *statusRecorder) WriteHeader(status int) {
if s.wroteHeader {
return
}
s.status = status
s.wroteHeader = true
s.ResponseWriter.WriteHeader(status)
}
// AuthConfig supplies the credential checks used by Auth. Every validator is
// injected by the calling service — this package deliberately ships no
// credentials of its own. A nil validator disables that scheme.
type AuthConfig struct {
// ValidateBearerToken resolves a Bearer token to a user ID.
ValidateBearerToken func(token string) (userID string, err error)
// ValidateAPIKey resolves an X-API-Key value to a user ID.
ValidateAPIKey func(apiKey string) (userID string, ok bool)
// ValidateBasicAuth reports whether HTTP basic credentials are valid.
ValidateBasicAuth func(username, password string) bool
// SkipPrefixes are path prefixes served without authentication, e.g.
// "/swagger/" or "/health".
SkipPrefixes []string
}
// Auth authenticates requests via Bearer token, API key or basic auth, in that
// order, and rejects anything else with 401.
func Auth(cfg AuthConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, prefix := range cfg.SkipPrefixes {
if strings.HasPrefix(r.URL.Path, prefix) {
next.ServeHTTP(w, r)
return
}
}
if cfg.ValidateBearerToken != nil {
if token, ok := bearerToken(r); ok {
if userID, err := cfg.ValidateBearerToken(token); err == nil {
next.ServeHTTP(w, r.WithContext(
context.WithValue(r.Context(), userIDContextKey, userID)))
return
}
}
}
if cfg.ValidateAPIKey != nil {
if apiKey := r.Header.Get("X-API-Key"); apiKey != "" {
if userID, ok := cfg.ValidateAPIKey(apiKey); ok {
next.ServeHTTP(w, r.WithContext(
context.WithValue(r.Context(), userIDContextKey, userID)))
return
}
}
}
if cfg.ValidateBasicAuth != nil {
if username, password, ok := r.BasicAuth(); ok {
if cfg.ValidateBasicAuth(username, password) {
next.ServeHTTP(w, r.WithContext(
context.WithValue(r.Context(), usernameContextKey, username)))
return
}
}
}
slog.Warn("unauthorized request", "method", r.Method, "path", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]any{
"error": "Unauthorized",
"message": "Valid authentication required. Use Bearer token, API key, or basic auth.",
"code": http.StatusUnauthorized,
})
})
}
}
func bearerToken(r *http.Request) (string, bool) {
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
return "", false
}
token := strings.TrimPrefix(header, "Bearer ")
return token, token != ""
}
// UserIDFromContext returns the user ID stored by Auth, if any.
func UserIDFromContext(ctx context.Context) (string, bool) {
userID, ok := ctx.Value(userIDContextKey).(string)
return userID, ok
}
// UsernameFromContext returns the username stored by Auth, if any.
func UsernameFromContext(ctx context.Context) (string, bool) {
username, ok := ctx.Value(usernameContextKey).(string)
return username, ok
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func splitAndTrim(value string) []string {
if value == "" {
return nil
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
if trimmed := strings.TrimSpace(part); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}