diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 82f8c4c..1a838a6 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -10,7 +10,6 @@ import ( "github.com/joho/godotenv" "github.com/kjannette/koin-ping/backend/internal/config" "github.com/kjannette/koin-ping/backend/internal/database" - "github.com/kjannette/koin-ping/backend/internal/firebase" "github.com/kjannette/koin-ping/backend/internal/handlers" "github.com/kjannette/koin-ping/backend/internal/middleware" "github.com/kjannette/koin-ping/backend/internal/models" @@ -38,10 +37,6 @@ func main() { log.Fatalf("Failed to connect to database: %v", err) } - if err := firebase.Init(cfg.FirebaseProjectID); err != nil { - log.Fatalf("Failed to initialize Firebase: %v", err) - } - defer database.Close() userModel := models.NewUserModel(pool) @@ -63,8 +58,9 @@ func main() { statusHandler := handlers.NewStatusHandler(checkpointModel) stripeHandler := handlers.NewStripeHandler(userModel, cfg) accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg) + authHandler := handlers.NewAuthHandler(userModel, cfg) - authenticate := middleware.Authenticate(userModel) + authenticate := middleware.Authenticate(userModel, cfg.JWTSecret) requireSub := middleware.RequireSubscription(userModel) // authAndSub chains authentication + subscription check for protected routes. @@ -79,6 +75,10 @@ func main() { mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck) mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus) + // Auth routes (public — no token required) + mux.HandleFunc("POST "+b+"/auth/login", authHandler.Login) + mux.HandleFunc("POST "+b+"/auth/register", authHandler.RegisterAfterCheckout) + // Stripe webhook (public — called by Stripe, not authenticated) mux.HandleFunc("POST "+b+"/stripe/webhook", stripeHandler.HandleWebhook) diff --git a/backend/go.mod b/backend/go.mod index d04b862..6a9413b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -32,6 +32,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect @@ -54,12 +55,12 @@ require ( go.opentelemetry.io/otel/sdk v1.39.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.50.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.51.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect diff --git a/backend/go.sum b/backend/go.sum index 7289fa9..04a83b2 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -61,6 +61,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= @@ -128,18 +130,24 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -147,6 +155,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -155,6 +165,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/backend/infra/migrations/010_add_password_auth.sql b/backend/infra/migrations/010_add_password_auth.sql new file mode 100644 index 0000000..a7448d4 --- /dev/null +++ b/backend/infra/migrations/010_add_password_auth.sql @@ -0,0 +1,11 @@ +-- Migration 010: Add password-based authentication +-- Adds password_hash column, makes firebase_uid optional, ensures email uniqueness. + +ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255); + +ALTER TABLE users ALTER COLUMN firebase_uid DROP NOT NULL; + +-- Ensure email is unique so it can serve as the login identifier. +-- Drop the old index first if it exists, then create a unique one. +DROP INDEX IF EXISTS idx_users_email_unique; +CREATE UNIQUE INDEX idx_users_email_unique ON users (email); diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f67c592..4af74dc 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 { diff --git a/backend/internal/domain/types.go b/backend/internal/domain/types.go index 09e0299..2efa01f 100644 --- a/backend/internal/domain/types.go +++ b/backend/internal/domain/types.go @@ -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 diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go new file mode 100644 index 0000000..a48cfc7 --- /dev/null +++ b/backend/internal/handlers/auth.go @@ -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) +} diff --git a/backend/internal/handlers/stripe.go b/backend/internal/handlers/stripe.go index 83cb61b..6bc1066 100644 --- a/backend/internal/handlers/stripe.go +++ b/backend/internal/handlers/stripe.go @@ -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) diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index ba3585a..d2f0f2b 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -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) diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index 1481c68..454afe6 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -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, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 52afc9c..43b3137 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,31 +1,28 @@ import { Routes, Route, Navigate } from "react-router-dom"; import { useAuth } from "./contexts/AuthContext"; -import { useContext } from 'react' import Navbar from "./components/Navbar"; import Login from "./pages/login/Login"; -import Signup from "./pages/Signup"; import Subscribe from "./pages/subscribe/Subscribe"; import Addresses from "./pages/addresses/Addresses"; import Alerts from "./pages/alerts/Alerts"; import AlertHistory from "./pages/alertHistory/AlertHistory"; import Account from "./pages/user_account/Account"; -import { AuthProvider } from "./ShopContext"; -import useAuth from "./ShopContext"; export default function App() { - const { context } = useContext(ShopContext) + const { isAuthenticated, isSubscribed, user } = useAuth(); - if (!currentUser) { + // Unauthenticated: show login + onboarding routes only + if (!isAuthenticated) { return ( } /> - } /> } /> } /> ); } + // Authenticated but no active subscription: force through subscribe flow if (!isSubscribed) { return ( @@ -36,6 +33,7 @@ export default function App() { ); } + // Fully authenticated + subscribed: main app return (
diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..ee8453a --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,27 @@ +import { API_BASE } from "./config"; + +export async function loginUser(email, password) { + const res = await fetch(`${API_BASE}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.message || "Login failed"); + } + return data; +} + +export async function registerAfterCheckout(email, password, sessionId) { + const res = await fetch(`${API_BASE}/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, session_id: sessionId || "" }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.message || "Registration failed"); + } + return data; +} diff --git a/frontend/src/api/authHeaders.js b/frontend/src/api/authHeaders.js index 6ccecaa..21ffed3 100644 --- a/frontend/src/api/authHeaders.js +++ b/frontend/src/api/authHeaders.js @@ -1,46 +1,31 @@ /** * Auth Headers Helper * - * Provides authentication headers for API calls - * Includes Firebase ID token in Authorization header + * Provides authentication headers for API calls. + * Reads the JWT from localStorage (set by AuthContext on login). */ -import { auth } from "../firebase/config"; +export function getAuthHeaders() { + const token = localStorage.getItem("kp_token"); -/** - * Get headers with authentication token - * @returns {Promise} Headers object with Authorization - */ -export async function getAuthHeaders() { - const currentUser = auth.currentUser; + if (!token) { + throw new Error("No authenticated user"); + } - if (!currentUser) { - throw new Error("No authenticated user"); - } - - // Get Firebase ID token - const token = await currentUser.getIdToken(); - - return { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }; + return { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }; } -/** - * Get headers for non-JSON requests (e.g., DELETE with no body) - * @returns {Promise} Headers object with Authorization - */ -export async function getAuthHeadersSimple() { - const currentUser = auth.currentUser; +export function getAuthHeadersSimple() { + const token = localStorage.getItem("kp_token"); - if (!currentUser) { - throw new Error("No authenticated user"); - } + if (!token) { + throw new Error("No authenticated user"); + } - const token = await currentUser.getIdToken(); - - return { - Authorization: `Bearer ${token}`, - }; + return { + Authorization: `Bearer ${token}`, + }; } diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 0cafede..cd7c6e3 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -11,11 +11,11 @@ const navLinks = [ ]; export default function Navbar() { - const { currentUser, logout } = useAuth(); + const { user, logout } = useAuth(); const location = useLocation(); const [isNavPanelOpen, setIsNavPanelOpen] = useState(false); - if (!currentUser) return null; + if (!user) return null; return ( <> @@ -24,7 +24,7 @@ export default function Navbar() {
Koin Ping - {currentUser.email} + {user.email}
@@ -45,7 +45,7 @@ export default function Navbar() {
- {currentUser.email} + {user.email} diff --git a/frontend/src/pages/user_account/Account.jsx b/frontend/src/pages/user_account/Account.jsx index 52493be..4df0bc8 100644 --- a/frontend/src/pages/user_account/Account.jsx +++ b/frontend/src/pages/user_account/Account.jsx @@ -1,7 +1,5 @@ import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import { updatePassword } from "firebase/auth"; -import { auth } from "../../firebase/config"; import { getAccount, createPortalSession } from "../../api/account"; import "./Account.css"; @@ -55,7 +53,8 @@ export default function Account() { try { setChangingPassword(true); - await updatePassword(auth.currentUser, newPassword); + // TODO: implement password change endpoint on backend + throw new Error("Password change not yet implemented"); setPasswordMsg("Password updated successfully"); setNewPassword(""); setConfirmPassword(""); diff --git a/frontend/src/reducers/authReducer.jsx b/frontend/src/reducers/authReducer.jsx index dd1c505..4017f8d 100644 --- a/frontend/src/reducers/authReducer.jsx +++ b/frontend/src/reducers/authReducer.jsx @@ -1,32 +1,56 @@ -import { useReducer } from "react" - -const UserAuthContext = createContext(); - -export const initialState = { - authUser: null, - AuthTier: null +export const ACTION_TYPES = { + LOGIN_SUCCESS: "LOGIN_SUCCESS", + SET_USER: "SET_USER", + LOGOUT: "LOGOUT", + AUTH_ERROR: "AUTH_ERROR", }; -const authReducer = (state, action) => { - const { type, payload = "FREE_TIER" } = action +export const initialState = { + user: null, + token: localStorage.getItem("kp_token") || null, + isAuthenticated: !!localStorage.getItem("kp_token"), + error: null, +}; - switch (type) { - case "ADD_AUTH_USER": - console.log("ADD_AUTH_USER", payload); +export default function authReducer(state, action) { + switch (action.type) { + case ACTION_TYPES.LOGIN_SUCCESS: + localStorage.setItem("kp_token", action.payload.token); return { ...state, - auth: payload + user: { + id: action.payload.user_id, + email: action.payload.email, + subscriptionStatus: action.payload.subscription_status, + subscriptionTier: action.payload.subscription_tier, + }, + token: action.payload.token, + isAuthenticated: true, + error: null, }; + + case ACTION_TYPES.SET_USER: + return { + ...state, + user: action.payload, + error: null, + }; + + case ACTION_TYPES.LOGOUT: + localStorage.removeItem("kp_token"); + return { + ...initialState, + token: null, + isAuthenticated: false, + }; + + case ACTION_TYPES.AUTH_ERROR: + return { + ...state, + error: action.payload, + }; + default: - throw new Error(`No case for type ${type} found.`); - } - switch (type) { - case "DELETE_AUTH_USER": - return { - ...state, - users: state.users.filter(user => user.id !== action.userIdToDelete) - }; + return state; } } - -export default shopReducer \ No newline at end of file