352 lines
11 KiB
Go
352 lines
11 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 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
|
|
}
|
|
auth.Set(w, res.Token, res.Expires, h.cookies)
|
|
response.JSON(w, http.StatusCreated, res.User)
|
|
}
|
|
|
|
// 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())
|
|
default:
|
|
response.Error(w, http.StatusInternalServerError, "something went wrong")
|
|
}
|
|
}
|