package handler import ( "encoding/json" "errors" "net/http" "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/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 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 } auth.Set(w, res.Token, res.Expires, h.cookies) response.JSON(w, http.StatusCreated, res.User) } // LogIn authenticates an existing user. 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 } 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) } // 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()) default: response.Error(w, http.StatusInternalServerError, "something went wrong") } }