package httpapi import ( "errors" "log" "net/http" "strings" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/auth" "github.com/descrybe/descrybe-v2/apps/api/internal/mail" ) const ( forgotPasswordIPPerMin = 10 forgotPasswordEmailPerHour = 3 ) func (s *Server) ensureForgotPasswordLimiters() { s.forgotPasswordOnce.Do(func() { s.forgotPasswordIPRL = newSlidingWindowLimiter(forgotPasswordIPPerMin, time.Minute) s.forgotPasswordEmailRL = newSlidingWindowLimiter(forgotPasswordEmailPerHour, time.Hour) }) } func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) { if s.Mail == nil || s.Auth == nil { Error(w, http.StatusServiceUnavailable, "mailer unavailable") return } var body struct { Email string `json:"email"` } if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } email := strings.ToLower(strings.TrimSpace(body.Email)) if email == "" { Error(w, http.StatusBadRequest, "email is required") return } s.ensureForgotPasswordLimiters() ipKey := "forgot-password-ip:" + strings.TrimSpace(r.RemoteAddr) if ipKey == "forgot-password-ip:" { ipKey = "forgot-password-ip:unknown" } emailKey := "forgot-password-email:" + email if !s.forgotPasswordIPRL.allow(ipKey) || !s.forgotPasswordEmailRL.allow(emailKey) { w.Header().Set("Retry-After", "60") Error(w, http.StatusTooManyRequests, "rate limit exceeded") return } // Opaque success for unknown / inactive / synthetic / send failures (anti-enumeration). issue, err := s.Auth.IssuePasswordReset(r.Context(), email, 0) if err == nil { msg := mail.ForgotPasswordMessage(s.Config.WebOrigin, issue.Email, issue.Token) if sendErr := s.Mail.Send(msg); sendErr != nil { log.Printf("forgot-password send failed") } } else if !errors.Is(err, auth.ErrUserNotFound) && !errors.Is(err, auth.ErrSyntheticEmail) && !errors.Is(err, auth.ErrEmailRequired) { log.Printf("forgot-password issue failed") } JSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) { if s.Auth == nil { Error(w, http.StatusServiceUnavailable, "auth unavailable") return } var body struct { Token string `json:"token"` Password string `json:"password"` } if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } if err := s.Auth.ResetPasswordWithToken(r.Context(), body.Token, body.Password); err != nil { if errors.Is(err, auth.ErrTokenInvalid) { Error(w, http.StatusBadRequest, "invalid or expired token") return } ClientOrLog(w, http.StatusBadRequest, "could not reset password", err, auth.ClientError) return } JSON(w, http.StatusOK, map[string]string{"status": "ok"}) }