Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
95 lines
2.7 KiB
Go
95 lines
2.7 KiB
Go
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"})
|
|
}
|