metric f ton
This commit is contained in:
@@ -38,6 +38,7 @@ type Config struct {
|
||||
StripePriceIDPro string
|
||||
StripePublishableKey string
|
||||
FrontendURL string
|
||||
JWTSecret string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and returns a Config.
|
||||
@@ -64,6 +65,7 @@ func Load() (*Config, error) {
|
||||
StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
|
||||
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
|
||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
|
||||
}
|
||||
|
||||
if cfg.PollIntervalMS < minPollIntervalMS {
|
||||
|
||||
@@ -63,9 +63,10 @@ func (l TierLimits) ChannelAllowed(channel string) bool {
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
FirebaseUID string `json:"-"`
|
||||
FirebaseUID *string `json:"-"`
|
||||
Email string `json:"email"`
|
||||
DisplayName *string `json:"display_name"` //nolint:tagliatelle
|
||||
PasswordHash *string `json:"-"`
|
||||
StripeCustomerID *string `json:"-"`
|
||||
StripeSubscriptionID *string `json:"-"`
|
||||
SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -145,6 +145,10 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
if fullJSON, marshalErr := json.MarshalIndent(s, "", " "); marshalErr == nil {
|
||||
log.Printf("STRIPE VERIFY CHECKOUT — FULL SESSION:\n%s", string(fullJSON))
|
||||
}
|
||||
|
||||
if s.ClientReferenceID != "" && s.ClientReferenceID != userID {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user")
|
||||
return
|
||||
@@ -260,6 +264,10 @@ func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.
|
||||
return
|
||||
}
|
||||
|
||||
if fullJSON, marshalErr := json.MarshalIndent(s, "", " "); marshalErr == nil {
|
||||
log.Printf("STRIPE ONBOARDING CHECKOUT CREATED — FULL SESSION:\n%s", string(fullJSON))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
|
||||
}
|
||||
|
||||
@@ -327,6 +335,8 @@ func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Event) {
|
||||
log.Printf("STRIPE CHECKOUT COMPLETED — FULL RAW PAYLOAD:\n%s", string(event.Data.Raw))
|
||||
|
||||
var session stripe.CheckoutSession
|
||||
if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
|
||||
log.Printf("Error parsing checkout session: %v", err)
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
fbauth "github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
@@ -31,10 +32,9 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||
}
|
||||
|
||||
// Authenticate verifies the Firebase ID token and auto-provisions a local user
|
||||
// record. The local user UUID (not the Firebase UID) is placed into context so
|
||||
// all downstream handlers use it as the canonical user identifier.
|
||||
func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
|
||||
// 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")
|
||||
@@ -47,9 +47,8 @@ func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if token == "" {
|
||||
log.Println("Empty token")
|
||||
rawToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if rawToken == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "Invalid token format",
|
||||
@@ -57,12 +56,19 @@ func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Verifying Firebase token...")
|
||||
decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token)
|
||||
if err != nil {
|
||||
log.Printf("Token verification failed: %v", err)
|
||||
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 := err.Error()
|
||||
errMsg := ""
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
if strings.Contains(errMsg, "expired") {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "TOKEN_EXPIRED",
|
||||
@@ -78,20 +84,37 @@ func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
firebaseUID := decoded.UID
|
||||
email, _ := decoded.Claims["email"].(string)
|
||||
|
||||
user, err := userModel.FindOrCreateByFirebaseUID(r.Context(), firebaseUID, email)
|
||||
if err != nil {
|
||||
log.Printf("Failed to provision local user for Firebase UID %s: %v", firebaseUID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "Failed to initialize user account",
|
||||
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "Invalid token claims",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Token verified! User UUID: %s, Email: %s", user.ID, email)
|
||||
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)
|
||||
|
||||
@@ -17,14 +17,14 @@ func NewUserModel(pool *pgxpool.Pool) *UserModel {
|
||||
return &UserModel{pool: pool}
|
||||
}
|
||||
|
||||
const userColumns = `id, firebase_uid, email, display_name,
|
||||
const userColumns = `id, firebase_uid, email, display_name, password_hash,
|
||||
stripe_customer_id, stripe_subscription_id, subscription_status,
|
||||
subscription_tier, subscription_created_at, created_at, updated_at`
|
||||
|
||||
func scanUser(row pgx.Row) (*domain.User, error) {
|
||||
var u domain.User
|
||||
err := row.Scan(
|
||||
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
|
||||
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.PasswordHash,
|
||||
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
|
||||
&u.SubscriptionTier, &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
@@ -51,6 +51,23 @@ func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID,
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (m *UserModel) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
row := m.pool.QueryRow(ctx,
|
||||
`SELECT `+userColumns+` FROM users WHERE email = $1`, email,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (m *UserModel) CreateWithPassword(ctx context.Context, email, passwordHash string) (*domain.User, error) {
|
||||
row := m.pool.QueryRow(ctx,
|
||||
`INSERT INTO users (email, password_hash)
|
||||
VALUES ($1, $2)
|
||||
RETURNING `+userColumns,
|
||||
email, passwordHash,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
||||
row := m.pool.QueryRow(ctx,
|
||||
`SELECT `+userColumns+` FROM users WHERE id = $1`, id,
|
||||
|
||||
Reference in New Issue
Block a user