metric f ton
This commit is contained in:
230
backend/internal/handlers/auth.go
Normal file
230
backend/internal/handlers/auth.go
Normal file
@@ -0,0 +1,230 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user