// Package middleware provides HTTP middleware for authentication and context injection. package middleware import ( "context" "encoding/json" "log" "net/http" "strings" "github.com/golang-jwt/jwt/v5" "github.com/kjannette/koin-ping/backend/internal/models" ) type contextKey string const ( UserIDKey contextKey = "user_id" UserEmailKey contextKey = "user_email" UserTierKey contextKey = "user_tier" ) type errorResponse struct { Error string `json:"error"` Message string `json:"message"` } func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) //nolint:errcheck } // Authenticate verifies the JWT from the Authorization header, loads the local // user from Postgres, and injects the user UUID + email + tier into context. func Authenticate(userModel *models.UserModel, jwtSecret string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { log.Println("No Authorization header or invalid format") writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", Message: "No authentication token provided", }) return } rawToken := strings.TrimPrefix(authHeader, "Bearer ") if rawToken == "" { writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", Message: "Invalid token format", }) return } parsed, err := jwt.Parse(rawToken, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, jwt.ErrSignatureInvalid } return []byte(jwtSecret), nil }) if err != nil || !parsed.Valid { log.Printf("JWT verification failed: %v", err) errMsg := "" if err != nil { errMsg = err.Error() } if strings.Contains(errMsg, "expired") { writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "TOKEN_EXPIRED", Message: "Authentication token has expired", }) return } writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", Message: "Failed to verify authentication token", }) return } claims, ok := parsed.Claims.(jwt.MapClaims) if !ok { writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", Message: "Invalid token claims", }) return } userID, _ := claims["sub"].(string) email, _ := claims["email"].(string) if userID == "" { writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", Message: "Invalid token: missing user ID", }) return } user, err := userModel.GetByID(r.Context(), userID) if err != nil || user == nil { log.Printf("JWT auth: user %s not found in DB: %v", userID, err) writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", Message: "User account not found", }) return } log.Printf("JWT verified — User UUID: %s, Email: %s", user.ID, email) ctx := context.WithValue(r.Context(), UserIDKey, user.ID) ctx = context.WithValue(ctx, UserEmailKey, email) ctx = context.WithValue(ctx, UserTierKey, string(user.SubscriptionTier)) next.ServeHTTP(w, r.WithContext(ctx)) }) } } // RequireSubscription blocks requests from users without an active subscription. // Must be applied after Authenticate. func RequireSubscription(userModel *models.UserModel) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userID := GetUserID(r.Context()) if userID == "" { writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", Message: "Authentication required", }) return } user, err := userModel.GetByID(r.Context(), userID) if err != nil || user == nil { log.Printf("RequireSubscription: failed to load user %s: %v", userID, err) writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", Message: "Failed to verify subscription", }) return } if user.SubscriptionStatus != "active" && user.SubscriptionStatus != "trialing" { writeJSON(w, http.StatusForbidden, errorResponse{ Error: "SUBSCRIPTION_REQUIRED", Message: "An active subscription is required to use this feature", }) return } next.ServeHTTP(w, r) }) } } func GetUserID(ctx context.Context) string { if v, ok := ctx.Value(UserIDKey).(string); ok { return v } return "" } func GetUserEmail(ctx context.Context) string { if v, ok := ctx.Value(UserEmailKey).(string); ok { return v } return "" } func GetUserTier(ctx context.Context) string { if v, ok := ctx.Value(UserTierKey).(string); ok { return v } return "free" }