Files
spsays-bff/internal/handler/auth.go
T
2026-06-02 18:37:08 +05:30

292 lines
9.1 KiB
Go

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")
}
}