feat: add password recovery flow (#358)
This commit is contained in:
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -66,6 +67,48 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, gin.H{"sent": true})
|
||||
})
|
||||
r.POST("/auth/password-reset-code", func(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "password-reset-code-ip:"+c.ClientIP(), policy.Request.EmailCodePerHour, time.Hour) {
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "password-reset-code-account:"+passwordResetRateLimitSubject(req.Email), policy.Request.EmailCodePerHour, time.Hour) {
|
||||
return
|
||||
}
|
||||
if err := svc.SendPasswordResetEmailCode(req.Email); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"sent": true})
|
||||
})
|
||||
r.POST("/auth/password-reset", func(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
|
||||
var req service.PasswordResetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "password-reset-ip:"+c.ClientIP(), policy.Request.LoginIPPerTenMinutes, 10*time.Minute) {
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "password-reset-account:"+passwordResetRateLimitSubject(req.Email), policy.Request.LoginAccountPerTenMinutes, 10*time.Minute) {
|
||||
return
|
||||
}
|
||||
if err := svc.ResetPassword(req); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"reset": true})
|
||||
})
|
||||
r.POST("/auth/login", func(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
|
||||
var req service.LoginRequest
|
||||
@@ -1069,6 +1112,11 @@ func sessionCookie(c *gin.Context) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func passwordResetRateLimitSubject(value string) string {
|
||||
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(value))))
|
||||
return fmt.Sprintf("%x", sum[:])
|
||||
}
|
||||
|
||||
func setSessionCookie(c *gin.Context, value string, maxAge int) {
|
||||
secure := c.Request.TLS != nil || strings.EqualFold(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")), "https")
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
|
||||
@@ -10,6 +10,17 @@ import (
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestPasswordResetRateLimitSubjectNormalizesAndHashesEmail(t *testing.T) {
|
||||
first := passwordResetRateLimitSubject(" Creator@Example.com ")
|
||||
second := passwordResetRateLimitSubject("creator@example.com")
|
||||
if first != second {
|
||||
t.Fatalf("normalized subjects differ: %q != %q", first, second)
|
||||
}
|
||||
if first == "creator@example.com" || strings.Contains(first, "@") || len(first) != 64 {
|
||||
t.Fatalf("rate limit subject is not a SHA-256 digest: %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeSystemProxyAllowsConfiguredGenerationModel(t *testing.T) {
|
||||
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["gpt-image-1"]`}
|
||||
body := []byte(`{"model":"gpt-image-1","prompt":"test"}`)
|
||||
|
||||
Reference in New Issue
Block a user