package handlers import ( "encoding/json" "log" "net/http" "time" "github.com/golang-jwt/jwt/v5" checkoutsession "github.com/stripe/stripe-go/v82/checkout/session" "golang.org/x/crypto/bcrypt" "github.com/kjannette/koin-ping/backend/internal/config" "github.com/kjannette/koin-ping/backend/internal/domain" "github.com/kjannette/koin-ping/backend/internal/models" ) const ( bcryptCost = 12 jwtTTLHours = 72 minPasswordLen = 6 ) type AuthHandler struct { users *models.UserModel cfg *config.Config } func NewAuthHandler(users *models.UserModel, cfg *config.Config) *AuthHandler { return &AuthHandler{users: users, cfg: cfg} } type authResponse struct { Token string `json:"token"` UserID string `json:"user_id"` //nolint:tagliatelle Email string `json:"email"` SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle SubscriptionTier string `json:"subscription_tier"` //nolint:tagliatelle } func (h *AuthHandler) issueJWT(userID, email string) (string, error) { claims := jwt.MapClaims{ "sub": userID, "email": email, "iat": time.Now().Unix(), "exp": time.Now().Add(jwtTTLHours * time.Hour).Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(h.cfg.JWTSecret)) } // Login authenticates an existing user by email + password. func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { var body struct { Email string `json:"email"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body") return } if body.Email == "" || body.Password == "" { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Email and password are required") return } user, err := h.users.FindByEmail(r.Context(), body.Email) if err != nil { log.Printf("Login: DB error looking up %s: %v", body.Email, err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Login failed") return } if user == nil { writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid email or password") return } if user.PasswordHash == nil || *user.PasswordHash == "" { writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid email or password") return } if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(body.Password)); err != nil { writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid email or password") return } token, err := h.issueJWT(user.ID, user.Email) if err != nil { log.Printf("Login: failed to issue JWT for %s: %v", user.ID, err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Login failed") return } log.Printf("Login successful for user %s (%s)", user.ID, user.Email) writeJSON(w, http.StatusOK, authResponse{ Token: token, UserID: user.ID, Email: user.Email, SubscriptionStatus: user.SubscriptionStatus, SubscriptionTier: string(user.SubscriptionTier), }) } // RegisterAfterCheckout creates a new user account after a successful Stripe // checkout. It verifies the Stripe session was paid, hashes the password, // inserts the user into Postgres, links the Stripe customer/subscription, // and returns a JWT so the frontend is immediately authenticated. func (h *AuthHandler) RegisterAfterCheckout(w http.ResponseWriter, r *http.Request) { var body struct { Email string `json:"email"` Password string `json:"password"` SessionID string `json:"session_id"` //nolint:tagliatelle } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body") return } if body.Email == "" || body.Password == "" { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Email and password are required") return } if len(body.Password) < minPasswordLen { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Password must be at least 6 characters") return } existing, err := h.users.FindByEmail(r.Context(), body.Email) if err != nil { log.Printf("Register: DB error looking up %s: %v", body.Email, err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Registration failed") return } if existing != nil { writeError(w, http.StatusConflict, "EMAIL_IN_USE", "An account with this email already exists") return } hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcryptCost) if err != nil { log.Printf("Register: bcrypt error: %v", err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Registration failed") return } user, err := h.users.CreateWithPassword(r.Context(), body.Email, string(hash)) if err != nil { log.Printf("Register: failed to create user %s: %v", body.Email, err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Registration failed") return } // If a Stripe session_id was provided (paid tier checkout), link the // Stripe customer and activate the subscription immediately. if body.SessionID != "" { h.linkStripeSession(r, user.ID, body.SessionID) } // Re-fetch to pick up updated subscription fields after Stripe link. user, err = h.users.GetByID(r.Context(), user.ID) if err != nil || user == nil { log.Printf("Register: failed to re-fetch user %s: %v", body.Email, err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Registration failed") return } token, err := h.issueJWT(user.ID, user.Email) if err != nil { log.Printf("Register: failed to issue JWT for %s: %v", user.ID, err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Registration failed") return } log.Printf("Registration successful for user %s (%s)", user.ID, user.Email) writeJSON(w, http.StatusOK, authResponse{ Token: token, UserID: user.ID, Email: user.Email, SubscriptionStatus: user.SubscriptionStatus, SubscriptionTier: string(user.SubscriptionTier), }) } // linkStripeSession retrieves the Stripe checkout session by ID, verifies // payment, and writes the Stripe customer + subscription to the user record. func (h *AuthHandler) linkStripeSession(r *http.Request, userID, sessionID string) { s, err := checkoutsession.Get(sessionID, nil) if err != nil { log.Printf("linkStripeSession: failed to retrieve session %s: %v", sessionID, err) return } fullJSON, _ := json.MarshalIndent(s, "", " ") log.Printf("STRIPE REGISTER LINK — FULL SESSION:\n%s", string(fullJSON)) if s.PaymentStatus != "paid" { log.Printf("linkStripeSession: session %s not paid (status=%s)", sessionID, s.PaymentStatus) return } tier := domain.TierPremium if t, ok := s.Metadata["tier"]; ok && domain.IsValidTier(t) { tier = domain.SubscriptionTier(t) } customerID := "" if s.Customer != nil { customerID = s.Customer.ID } subscriptionID := "" if s.Subscription != nil { subscriptionID = s.Subscription.ID } if customerID != "" { if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil { log.Printf("linkStripeSession: failed to save customer ID: %v", err) } } if subscriptionID != "" && customerID != "" { if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil { log.Printf("linkStripeSession: failed to activate subscription: %v", err) } } log.Printf("linkStripeSession: linked user %s → customer %s, subscription %s, tier %s", userID, customerID, subscriptionID, tier) }