package handler import ( "context" "encoding/json" "errors" "net/http" "time" "github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/pkg/auth" "github.com/prabhupadasays/spsays-bff/pkg/geoip" "github.com/prabhupadasays/spsays-bff/pkg/response" "github.com/prabhupadasays/spsays-bff/pkg/validator" ) // AuthHandler exposes the auth-related HTTP endpoints. // // Every handler is a 5-line shim: parse → validate → service → render. // All branching (already exists? wrong password? expired link?) lives in // AuthService and surfaces here only as sentinel errors mapped to status // codes via mapAuthErr. type AuthHandler struct { svc *service.AuthService cookies auth.CookieOptions } func NewAuthHandler(svc *service.AuthService, cookies auth.CookieOptions) *AuthHandler { return &AuthHandler{svc: svc, cookies: cookies} } // --- request shapes --- type signUpReq struct { Email string `json:"email"` Password string `json:"password"` Name string `json:"name"` } type logInReq struct { Email string `json:"email"` Password string `json:"password"` } type forgotReq struct { Email string `json:"email"` } type resetReq struct { Token string `json:"token"` NewPassword string `json:"new_password"` } type verifyReq struct { Token string `json:"token"` } type verifyLoginOTPReq struct { Email string `json:"email"` Password string `json:"password"` OTP string `json:"otp"` } type setEmailReq struct { Email string `json:"email"` } type changePasswordReq struct { CurrentPassword string `json:"current_password"` NewPassword string `json:"new_password"` } // --- handlers --- // SignUp creates a user, plants the session cookie, returns the user. func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) { var req signUpReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } v := validator.New() validator.Required(v, "email", req.Email) validator.Email(v, "email", req.Email) validator.Required(v, "password", req.Password) validator.MinLength(v, "password", req.Password, 8) validator.Required(v, "name", req.Name) validator.MaxLength(v, "name", req.Name, 120) if !v.Valid() { response.Error(w, http.StatusBadRequest, v.Error()) return } res, err := h.svc.SignUp(r.Context(), req.Email, req.Password, req.Name) if err != nil { h.renderAuthErr(w, err) return } h.captureCountry(r, res.User.ID) auth.Set(w, res.Token, res.Expires, h.cookies) response.JSON(w, http.StatusCreated, res.User) } // captureCountry records the new account's country off the critical path. // Cloudflare's CF-IPCountry header is preferred (no network); when absent // — as in the current DNS-only setup — we fall back to a GeoIP lookup on // the client IP. The header read + IP extraction happen synchronously // (they need the live request), then the network lookup + DB write run in // a goroutine so signup latency is untouched. Any failure is silent: the // account simply keeps country = NULL until the user sets it themselves. func (h *AuthHandler) captureCountry(r *http.Request, userID string) { cfCountry := geoip.CountryFromCloudflare(r) ip := geoip.ClientIP(r) go func() { cc := cfCountry if cc == "" { lookupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) cc = geoip.LookupCountryByIP(lookupCtx, ip) cancel() } if cc == "" { return } writeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() h.svc.CaptureCountry(writeCtx, userID, cc) }() } // LogIn authenticates an existing user. The response is one of: // // - 200 OK with the User JSON — credentials verified, session cookie set, // frontend treats this as fully signed in. // - 202 Accepted with `{ "otp_required": true }` — credentials verified // but the account has been unverified for over 30 days. An OTP has been // emailed; frontend must collect it and call VerifyLoginOTP next. No // session cookie is set on this branch. func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) { var req logInReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } v := validator.New() validator.Required(v, "email", req.Email) validator.Required(v, "password", req.Password) if !v.Valid() { response.Error(w, http.StatusBadRequest, v.Error()) return } res, err := h.svc.LogIn(r.Context(), req.Email, req.Password) if err != nil { h.renderAuthErr(w, err) return } if res.OTPRequired { response.JSON(w, http.StatusAccepted, map[string]any{ "otp_required": true, }) return } auth.Set(w, res.Auth.Token, res.Auth.Expires, h.cookies) response.JSON(w, http.StatusOK, res.Auth.User) } // VerifyLoginOTP completes a login that was gated by the stale-unverified // OTP path. Re-checks the password (the OTP alone isn't enough — an // attacker who steals the OTP email still needs the password), validates // the OTP, flips email_verified_at to now(), and sets the session cookie. func (h *AuthHandler) VerifyLoginOTP(w http.ResponseWriter, r *http.Request) { var req verifyLoginOTPReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } v := validator.New() validator.Required(v, "email", req.Email) validator.Required(v, "password", req.Password) validator.Required(v, "otp", req.OTP) if !v.Valid() { response.Error(w, http.StatusBadRequest, v.Error()) return } res, err := h.svc.VerifyLoginOTP(r.Context(), req.Email, req.Password, req.OTP) if err != nil { h.renderAuthErr(w, err) return } auth.Set(w, res.Token, res.Expires, h.cookies) response.JSON(w, http.StatusOK, res.User) } // LogOut just clears the cookie. JWTs remain technically valid until expiry, // but with the cookie gone the browser can't present them. func (h *AuthHandler) LogOut(w http.ResponseWriter, r *http.Request) { auth.Clear(w, h.cookies) response.JSON(w, http.StatusOK, map[string]bool{"ok": true}) } // Me returns the authenticated user. Protected by middleware.RequireAuth. func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) tv := middleware.TokenVersion(r.Context()) u, err := h.svc.Me(r.Context(), uid, tv) if err != nil { response.Error(w, http.StatusUnauthorized, "not authenticated") return } response.JSON(w, http.StatusOK, u) } // ForgotPassword emails a reset link if the address exists. We always return // 200 to avoid revealing whether the email is registered. func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) { var req forgotReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } v := validator.New() validator.Required(v, "email", req.Email) validator.Email(v, "email", req.Email) if !v.Valid() { response.Error(w, http.StatusBadRequest, v.Error()) return } if err := h.svc.RequestPasswordReset(r.Context(), req.Email); err != nil { response.Error(w, http.StatusInternalServerError, "could not process request") return } response.JSON(w, http.StatusOK, map[string]bool{"ok": true}) } // ResetPassword consumes a reset token and sets a new password. func (h *AuthHandler) ResetPassword(w http.ResponseWriter, r *http.Request) { var req resetReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } v := validator.New() validator.Required(v, "token", req.Token) validator.Required(v, "new_password", req.NewPassword) validator.MinLength(v, "new_password", req.NewPassword, 8) if !v.Valid() { response.Error(w, http.StatusBadRequest, v.Error()) return } if err := h.svc.ResetPassword(r.Context(), req.Token, req.NewPassword); err != nil { h.renderAuthErr(w, err) return } // Force the user to log in again after a reset (their token_version is now stale). auth.Clear(w, h.cookies) response.JSON(w, http.StatusOK, map[string]bool{"ok": true}) } // ChangePassword — protected — replaces the caller's password after // verifying the current one. The new password bumps token_version, which // invalidates every previously-issued JWT for this user. We immediately // re-issue a fresh session cookie so the caller's current device stays // logged in; every other device gets signed out on next request. func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) var req changePasswordReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } // current_password is optional — accounts with no password on file // (OAuth-only signups setting their first one) skip it. The service // enforces verification when a hash exists. v := validator.New() validator.Required(v, "new_password", req.NewPassword) validator.MinLength(v, "new_password", req.NewPassword, 8) if !v.Valid() { response.Error(w, http.StatusBadRequest, v.Error()) return } res, err := h.svc.ChangePassword(r.Context(), uid, req.CurrentPassword, req.NewPassword) if err != nil { h.renderAuthErr(w, err) return } auth.Set(w, res.Token, res.Expires, h.cookies) response.JSON(w, http.StatusOK, res.User) } // VerifyEmail flips email_verified_at after the user clicks the link. func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) { var req verifyReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } if req.Token == "" { response.Error(w, http.StatusBadRequest, "token is required") return } if err := h.svc.VerifyEmail(r.Context(), req.Token); err != nil { h.renderAuthErr(w, err) return } response.JSON(w, http.StatusOK, map[string]bool{"ok": true}) } // SetEmail — protected — sets (or replaces) the caller's email address. // Used by OAuth-signup users who landed without one, and by anyone wanting // to change their address. The new value is stored as unverified and a // verification link is mailed out immediately. func (h *AuthHandler) SetEmail(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) var req setEmailReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } v := validator.New() validator.Required(v, "email", req.Email) validator.Email(v, "email", req.Email) if !v.Valid() { response.Error(w, http.StatusBadRequest, v.Error()) return } u, err := h.svc.SetEmail(r.Context(), uid, req.Email) if err != nil { h.renderAuthErr(w, err) return } response.JSON(w, http.StatusOK, u) } // LinkedProviders — protected — returns the OAuth provider names already // linked to the caller's account. Settings UI uses this to flip each // social-connect row between "Connect" and "Connected". func (h *AuthHandler) LinkedProviders(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) providers, err := h.svc.LinkedProviders(r.Context(), uid) if err != nil { response.Error(w, http.StatusInternalServerError, "could not load linked providers") return } response.JSON(w, http.StatusOK, map[string]any{"providers": providers}) } // ResendVerification — protected — refires the verification email for the // currently-logged-in user. func (h *AuthHandler) ResendVerification(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) if err := h.svc.ResendVerification(r.Context(), uid); err != nil { h.renderAuthErr(w, err) return } response.JSON(w, http.StatusOK, map[string]bool{"ok": true}) } // --- error mapping --- func (h *AuthHandler) renderAuthErr(w http.ResponseWriter, err error) { switch { case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrInvalidOrExpiredLink): response.Error(w, http.StatusUnauthorized, err.Error()) case errors.Is(err, service.ErrEmailAlreadyTaken): response.Error(w, http.StatusConflict, err.Error()) case errors.Is(err, service.ErrEmailAlreadyVerified), errors.Is(err, service.ErrWeakPassword): response.Error(w, http.StatusBadRequest, err.Error()) case errors.Is(err, service.ErrUserNotFound): response.Error(w, http.StatusNotFound, err.Error()) case errors.Is(err, service.ErrAccountSuspended): response.Error(w, http.StatusForbidden, err.Error()) default: response.Error(w, http.StatusInternalServerError, "something went wrong") } }