Files

95 lines
2.7 KiB
Go
Raw Permalink Normal View History

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