231 lines
7.5 KiB
Go
231 lines
7.5 KiB
Go
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)
|
|
}
|