Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
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"})
|
||||
}
|
||||
Reference in New Issue
Block a user