feat: add password recovery flow (#358)

This commit is contained in:
MasterChiefCN
2026-08-31 09:58:36 +08:00
committed by GitHub
parent 8ebceecbb6
commit e124a9ea04
19 changed files with 875 additions and 52 deletions

View File

@@ -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{

View File

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