diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 6194a55ec..5dc6c2075 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -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{ diff --git a/backend/internal/handler/security_test.go b/backend/internal/handler/security_test.go index 82506ca26..047047817 100644 --- a/backend/internal/handler/security_test.go +++ b/backend/internal/handler/security_test.go @@ -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"}`) diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 23acfe9db..eb5b4adc7 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -26,6 +26,8 @@ var ErrTextReplayQuotaExceeded = errors.New("text replay quota exceeded") var ErrTextReplayClosed = errors.New("text replay task is closed") +var ErrEmailVerificationCodeInvalid = errors.New("email verification code is no longer valid") + var ErrProjectAssetFolderNotEmpty = errors.New("project asset folder is not empty") var ErrProjectHasActiveTasks = errors.New("project has active tasks") @@ -307,6 +309,36 @@ func (r *Repository) CreateUserWithEmailVerification(user *model.User, verificat }) } +func (r *Repository) ResetUserPasswordWithEmailVerification(userID string, email string, purpose string, verificationCodeID string, passwordHash string, usedAt time.Time) error { + return r.db.Transaction(func(tx *gorm.DB) error { + codeResult := tx.Model(&model.EmailVerificationCode{}). + Where("id = ? AND email = ? AND purpose = ? AND used_at IS NULL AND expires_at > ?", verificationCodeID, email, purpose, usedAt). + Update("used_at", usedAt) + if codeResult.Error != nil { + return codeResult.Error + } + if codeResult.RowsAffected != 1 { + return ErrEmailVerificationCodeInvalid + } + + userResult := tx.Model(&model.User{}). + Where("id = ? AND email <> '' AND lower(email) = lower(?) AND status = ? AND password_hash <> ''", userID, email, model.UserStatusActive). + Updates(map[string]any{"password_hash": passwordHash, "updated_at": usedAt}) + if userResult.Error != nil { + return userResult.Error + } + if userResult.RowsAffected != 1 { + return ErrEmailVerificationCodeInvalid + } + if err := tx.Delete(&model.AuthSession{}, "user_id = ?", userID).Error; err != nil { + return err + } + return tx.Model(&model.EmailVerificationCode{}). + Where("email = ? AND purpose = ? AND used_at IS NULL", email, purpose). + Update("used_at", usedAt).Error + }) +} + func (r *Repository) DeleteExpiredEmailVerificationCodes(now time.Time) error { return r.db.Delete(&model.EmailVerificationCode{}, "expires_at <= ? OR used_at IS NOT NULL", now).Error } diff --git a/backend/internal/service/email.go b/backend/internal/service/email.go index ba33637c6..dded80aa7 100644 --- a/backend/internal/service/email.go +++ b/backend/internal/service/email.go @@ -159,7 +159,7 @@ func (s *Service) SendRegistrationEmailCode(rawEmail string) error { if err != nil { return err } - codeHash, err := s.emailVerificationCodeHash(email, code) + codeHash, err := s.emailVerificationCodeHash(registrationEmailPurpose, email, code) if err != nil { return err } @@ -168,7 +168,7 @@ func (s *Service) SendRegistrationEmailCode(rawEmail string) error { if err := s.repo.Create(&record); err != nil { return err } - if err := sendSMTPMail(setting, email, "影策注册验证码", registrationEmailBody(code)); err != nil { + if err := s.deliverEmail(setting, email, "影策注册验证码", registrationEmailBody(code)); err != nil { cleanupErr := s.repo.DeleteEmailVerificationCode(record.ID) if cleanupErr != nil { return errors.Join( @@ -206,7 +206,7 @@ func (s *Service) VerifyRegistrationEmailCode(email string, rawCode string) (*mo if time.Now().After(record.ExpiresAt) { return nil, BadAuthRequest("邮箱验证码已过期,请重新获取") } - hash, err := s.emailVerificationCodeHash(email, code) + hash, err := s.emailVerificationCodeHash(registrationEmailPurpose, email, code) if err != nil { return nil, err } @@ -216,13 +216,13 @@ func (s *Service) VerifyRegistrationEmailCode(email string, rawCode string) (*mo return record, nil } -func (s *Service) emailVerificationCodeHash(email string, code string) (string, error) { +func (s *Service) emailVerificationCodeHash(purpose string, email string, code string) (string, error) { key, err := s.settingsEncryptionKey() if err != nil { return "", err } mac := hmac.New(sha256.New, key) - _, _ = mac.Write([]byte(registrationEmailPurpose + ":" + email + ":" + code)) + _, _ = mac.Write([]byte(strings.TrimSpace(purpose) + ":" + normalizeEmail(email) + ":" + strings.TrimSpace(code))) return hex.EncodeToString(mac.Sum(nil)), nil } @@ -347,6 +347,13 @@ func sendSMTPMail(setting emailSettingValue, recipient string, subject string, b return client.Quit() } +func (s *Service) deliverEmail(setting emailSettingValue, recipient string, subject string, body string) error { + if s.mailSender != nil { + return s.mailSender(setting, recipient, subject, body) + } + return sendSMTPMail(setting, recipient, subject, body) +} + func randomNumericCode(length int) (string, error) { limit := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(length)), nil) value, err := rand.Int(rand.Reader, limit) diff --git a/backend/internal/service/password_reset.go b/backend/internal/service/password_reset.go new file mode 100644 index 000000000..f5bfdd196 --- /dev/null +++ b/backend/internal/service/password_reset.go @@ -0,0 +1,157 @@ +package service + +import ( + "crypto/hmac" + "errors" + "log" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/gorm" +) + +const passwordResetEmailPurpose = "password_reset" +const passwordResetCodeTTL = 10 * time.Minute + +type PasswordResetRequest struct { + Email string `json:"email"` + EmailCode string `json:"emailCode"` + Password string `json:"password"` +} + +func (s *Service) SendPasswordResetEmailCode(rawEmail string) error { + email := normalizeEmail(rawEmail) + if err := validateEmail(email); err != nil { + return err + } + _, setting, err := s.readEmailSetting() + if err != nil { + return err + } + if !setting.Enabled || setting.Host == "" || setting.Port < 1 || setting.FromEmail == "" { + return Forbidden("管理员尚未启用密码找回,请联系管理员") + } + + s.emailCodeMu.Lock() + defer s.emailCodeMu.Unlock() + + user, err := s.repo.UserByEmail(email) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + if err != nil { + return err + } + if user.Status != model.UserStatusActive || strings.TrimSpace(user.PasswordHash) == "" { + return nil + } + if latest, latestErr := s.repo.LatestEmailVerificationCode(email, passwordResetEmailPurpose); latestErr == nil && time.Since(latest.CreatedAt) < time.Minute { + return nil + } else if latestErr != nil && !errors.Is(latestErr, gorm.ErrRecordNotFound) { + return latestErr + } + + code, err := randomNumericCode(6) + if err != nil { + return err + } + codeHash, err := s.emailVerificationCodeHash(passwordResetEmailPurpose, email, code) + if err != nil { + return err + } + now := time.Now() + record := model.EmailVerificationCode{ + ID: newID(), + Email: email, + CodeHash: codeHash, + Purpose: passwordResetEmailPurpose, + ExpiresAt: now.Add(passwordResetCodeTTL), + CreatedAt: now, + } + if err := s.repo.Create(&record); err != nil { + return err + } + if err := s.deliverEmail(setting, email, "影策密码重置验证码", passwordResetEmailBody(code)); err != nil { + if cleanupErr := s.repo.DeleteEmailVerificationCode(record.ID); cleanupErr != nil { + log.Printf("password reset email cleanup failed: recipient=%s error=%v", maskedEmail(email), cleanupErr) + } + log.Printf("password reset email delivery failed: recipient=%s error=%v", maskedEmail(email), err) + return nil + } + if cleanupErr := s.repo.DeleteExpiredEmailVerificationCodes(now.Add(-24 * time.Hour)); cleanupErr != nil { + log.Printf("expired password reset code cleanup failed: error=%v", cleanupErr) + } + return nil +} + +func (s *Service) ResetPassword(req PasswordResetRequest) error { + email := normalizeEmail(req.Email) + if err := validateEmail(email); err != nil { + return err + } + if err := validatePassword(req.Password); err != nil { + return err + } + code := strings.TrimSpace(req.EmailCode) + if len(code) != 6 { + return invalidPasswordResetCode() + } + + user, err := s.repo.UserByEmail(email) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return invalidPasswordResetCode() + } + return err + } + if user.Status != model.UserStatusActive || strings.TrimSpace(user.PasswordHash) == "" { + return invalidPasswordResetCode() + } + record, err := s.repo.LatestEmailVerificationCode(email, passwordResetEmailPurpose) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return invalidPasswordResetCode() + } + return err + } + if time.Now().After(record.ExpiresAt) { + return invalidPasswordResetCode() + } + expectedHash, err := s.emailVerificationCodeHash(passwordResetEmailPurpose, email, code) + if err != nil { + return err + } + if !hmac.Equal([]byte(expectedHash), []byte(record.CodeHash)) { + return invalidPasswordResetCode() + } + passwordHash, err := hashPassword(req.Password) + if err != nil { + return err + } + if err := s.repo.ResetUserPasswordWithEmailVerification(user.ID, email, passwordResetEmailPurpose, record.ID, passwordHash, time.Now()); err != nil { + if errors.Is(err, repository.ErrEmailVerificationCodeInvalid) { + return invalidPasswordResetCode() + } + return err + } + return nil +} + +func invalidPasswordResetCode() *AuthError { + return BadAuthRequest("验证码无效或已过期") +} + +func passwordResetEmailBody(code string) string { + return "你正在重置影策账号密码。\n\n验证码:" + code + "\n\n验证码 10 分钟内有效。若非本人操作,请忽略本邮件,并确保邮箱账号安全。" +} + +func maskedEmail(email string) string { + parts := strings.SplitN(email, "@", 2) + if len(parts) != 2 || parts[0] == "" { + return "***" + } + return string([]rune(parts[0])[0]) + "***@" + parts[1] +} diff --git a/backend/internal/service/password_reset_test.go b/backend/internal/service/password_reset_test.go new file mode 100644 index 000000000..ce9979214 --- /dev/null +++ b/backend/internal/service/password_reset_test.go @@ -0,0 +1,225 @@ +package service + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestPasswordResetChangesPasswordConsumesCodeAndRevokesSessions(t *testing.T) { + svc, db := newPasswordResetTestService(t) + oldHash, err := hashPassword("old-password") + if err != nil { + t.Fatal(err) + } + user := model.User{ID: "user-1", Username: "creator", Email: "creator@example.com", DisplayName: "Creator", Role: model.UserRoleUser, Status: model.UserStatusActive, PasswordHash: oldHash} + if err := db.Create(&user).Error; err != nil { + t.Fatal(err) + } + sessions := []model.AuthSession{ + {ID: "session-1", UserID: user.ID, TokenHash: "one", ExpiresAt: time.Now().Add(time.Hour)}, + {ID: "session-2", UserID: user.ID, TokenHash: "two", ExpiresAt: time.Now().Add(time.Hour)}, + } + if err := db.Create(&sessions).Error; err != nil { + t.Fatal(err) + } + + var deliveredCode string + svc.mailSender = func(_ emailSettingValue, recipient string, subject string, body string) error { + if recipient != user.Email || subject != "影策密码重置验证码" { + t.Fatalf("unexpected reset email: recipient=%q subject=%q", recipient, subject) + } + deliveredCode = codeFromEmailBody(body) + return nil + } + if err := svc.SendPasswordResetEmailCode(" Creator@Example.com "); err != nil { + t.Fatal(err) + } + if err := svc.SendPasswordResetEmailCode(user.Email); err != nil { + t.Fatalf("repeat send should keep the public response generic: %v", err) + } + if len(deliveredCode) != 6 { + t.Fatalf("delivered code = %q", deliveredCode) + } + var storedCode model.EmailVerificationCode + if err := db.Where("email = ? AND purpose = ?", user.Email, passwordResetEmailPurpose).First(&storedCode).Error; err != nil { + t.Fatal(err) + } + if storedCode.CodeHash == deliveredCode || storedCode.CodeHash == "" { + t.Fatalf("verification code was not hashed: %#v", storedCode) + } + + if err := svc.ResetPassword(PasswordResetRequest{Email: user.Email, EmailCode: deliveredCode, Password: "new-password"}); err != nil { + t.Fatal(err) + } + var updated model.User + if err := db.First(&updated, "id = ?", user.ID).Error; err != nil { + t.Fatal(err) + } + if bcrypt.CompareHashAndPassword([]byte(updated.PasswordHash), []byte("new-password")) != nil { + t.Fatal("new password hash does not match") + } + if bcrypt.CompareHashAndPassword([]byte(updated.PasswordHash), []byte("old-password")) == nil { + t.Fatal("old password still matches") + } + var sessionCount int64 + if err := db.Model(&model.AuthSession{}).Where("user_id = ?", user.ID).Count(&sessionCount).Error; err != nil { + t.Fatal(err) + } + if sessionCount != 0 { + t.Fatalf("auth session count = %d, want 0", sessionCount) + } + if err := db.First(&storedCode, "id = ?", storedCode.ID).Error; err != nil { + t.Fatal(err) + } + if storedCode.UsedAt == nil { + t.Fatal("password reset code was not consumed") + } + if err := svc.ResetPassword(PasswordResetRequest{Email: user.Email, EmailCode: deliveredCode, Password: "another-password"}); !isInvalidPasswordResetError(err) { + t.Fatalf("reused code error = %v", err) + } +} + +func TestPasswordResetSendDoesNotRevealAccountEligibility(t *testing.T) { + svc, db := newPasswordResetTestService(t) + hash, err := hashPassword("strong-password") + if err != nil { + t.Fatal(err) + } + users := []model.User{ + {ID: "oauth-user", Username: "oauth-user", Email: "oauth@example.com", Status: model.UserStatusActive}, + {ID: "disabled-user", Username: "disabled-user", Email: "disabled@example.com", Status: model.UserStatusDisabled, PasswordHash: hash}, + } + if err := db.Create(&users).Error; err != nil { + t.Fatal(err) + } + deliveries := 0 + svc.mailSender = func(_ emailSettingValue, _ string, _ string, _ string) error { + deliveries++ + return nil + } + for _, email := range []string{"missing@example.com", "oauth@example.com", "disabled@example.com"} { + if err := svc.SendPasswordResetEmailCode(email); err != nil { + t.Fatalf("SendPasswordResetEmailCode(%q) error = %v", email, err) + } + } + if deliveries != 0 { + t.Fatalf("mail deliveries = %d, want 0", deliveries) + } + var codeCount int64 + if err := db.Model(&model.EmailVerificationCode{}).Count(&codeCount).Error; err != nil { + t.Fatal(err) + } + if codeCount != 0 { + t.Fatalf("verification code count = %d, want 0", codeCount) + } +} + +func TestPasswordResetRejectsWrongExpiredAndPurposeMismatchedCodes(t *testing.T) { + svc, db := newPasswordResetTestService(t) + hash, err := hashPassword("old-password") + if err != nil { + t.Fatal(err) + } + user := model.User{ID: "user-1", Username: "creator", Email: "creator@example.com", Status: model.UserStatusActive, PasswordHash: hash} + if err := db.Create(&user).Error; err != nil { + t.Fatal(err) + } + + resetHash, err := svc.emailVerificationCodeHash(passwordResetEmailPurpose, user.Email, "123456") + if err != nil { + t.Fatal(err) + } + registrationHash, err := svc.emailVerificationCodeHash(registrationEmailPurpose, user.Email, "123456") + if err != nil { + t.Fatal(err) + } + if resetHash == registrationHash { + t.Fatal("email verification hashes must be separated by purpose") + } + record := model.EmailVerificationCode{ID: "expired", Email: user.Email, Purpose: passwordResetEmailPurpose, CodeHash: resetHash, ExpiresAt: time.Now().Add(-time.Minute), CreatedAt: time.Now().Add(-11 * time.Minute)} + if err := db.Create(&record).Error; err != nil { + t.Fatal(err) + } + if err := svc.ResetPassword(PasswordResetRequest{Email: user.Email, EmailCode: "123456", Password: "new-password"}); !isInvalidPasswordResetError(err) { + t.Fatalf("expired code error = %v", err) + } + record.ID = "active" + record.ExpiresAt = time.Now().Add(time.Minute) + record.CreatedAt = time.Now() + if err := db.Create(&record).Error; err != nil { + t.Fatal(err) + } + if err := svc.ResetPassword(PasswordResetRequest{Email: user.Email, EmailCode: "654321", Password: "new-password"}); !isInvalidPasswordResetError(err) { + t.Fatalf("wrong code error = %v", err) + } +} + +func TestPasswordResetDeliveryFailureRemovesUnusableCode(t *testing.T) { + svc, db := newPasswordResetTestService(t) + hash, err := hashPassword("old-password") + if err != nil { + t.Fatal(err) + } + user := model.User{ID: "user-1", Username: "creator", Email: "creator@example.com", Status: model.UserStatusActive, PasswordHash: hash} + if err := db.Create(&user).Error; err != nil { + t.Fatal(err) + } + svc.mailSender = func(_ emailSettingValue, _ string, _ string, _ string) error { return errors.New("smtp unavailable") } + if err := svc.SendPasswordResetEmailCode(user.Email); err != nil { + t.Fatalf("delivery failure should keep the public response generic: %v", err) + } + var count int64 + if err := db.Model(&model.EmailVerificationCode{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("verification code count = %d, want 0", count) + } +} + +func newPasswordResetTestService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.EmailVerificationCode{}, &model.SystemSetting{}); err != nil { + t.Fatal(err) + } + settingJSON, err := json.Marshal(emailSettingValue{Enabled: true, Host: "smtp.example.com", Port: 587, Encryption: "starttls", FromEmail: "noreply@example.com", FromName: "影策"}) + if err != nil { + t.Fatal(err) + } + if err := db.Create(&model.SystemSetting{Key: emailSettingKey, ValueJSON: string(settingJSON)}).Error; err != nil { + t.Fatal(err) + } + return &Service{repo: repository.New(db), dataDir: t.TempDir()}, db +} + +func codeFromEmailBody(body string) string { + marker := "验证码:" + start := strings.Index(body, marker) + if start < 0 { + return "" + } + value := body[start+len(marker):] + if end := strings.IndexByte(value, '\n'); end >= 0 { + value = value[:end] + } + return strings.TrimSpace(value) +} + +func isInvalidPasswordResetError(err error) bool { + var appErr *AppError + return errors.As(err, &appErr) && appErr.Status == 400 && appErr.Message == "验证码无效或已过期" +} diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index bb404db09..38d6e57bf 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -53,6 +53,7 @@ type Service struct { routeHealthBlocked map[string]time.Time workers *workerRuntime updateManager UpdateManager + mailSender func(emailSettingValue, string, string, string) error } const taskWorkerConcurrency = 3 diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index cf370d2c3..25948a275 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -17,6 +17,16 @@ description: 影策后端核心持久化表与状态边界 当前结构版本为 2。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引,用于验证在线更新中的顺序迁移、事务提交和版本健康检查。 +## 账号认证与密码找回 + +| 表 | 用途 | 关键边界 | +| --- | --- | --- | +| `users` | 保存用户名、唯一非空邮箱、本地密码哈希、角色和状态 | 密码只保存 bcrypt 哈希;密码找回只面向状态正常且已有本地密码的账号 | +| `auth_sessions` | 保存登录 Cookie 对应的服务端会话和令牌哈希 | 密码重置成功时删除该用户全部会话,旧 Cookie 立即失效 | +| `email_verification_codes` | 保存注册和密码重置的一次性邮箱验证码 | 只保存带用途隔离的 HMAC 哈希;验证码 10 分钟过期、只能消费一次,明文不进入数据库 | + +密码重置在单个数据库事务中条件消费 `password_reset` 验证码、更新 `users.password_hash` 并删除 `auth_sessions`。任一步失败都会回滚,不能出现密码已修改但旧会话仍有效,或验证码已消耗但密码未更新的部分状态。 + ## 技能包与版本 | 表 | 用途 | 关键边界 | diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 1c1df30b0..8506d727a 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -5,6 +5,18 @@ description: 影策当前已实现的核心工作台能力与使用入口 # 功能清单 +## 邮箱找回密码 + +登录页在密码字段标题行提供“忘记密码?”入口。已配置账户邮件服务时,本地密码账号可以通过绑定邮箱获取 6 位验证码,并在 10 分钟内设置新密码;发送接口对存在和不存在的邮箱返回相同结果,避免公开账号状态。 + +密码更新、验证码消费和该账号全部旧登录会话撤销在同一个数据库事务内完成。验证码按注册与密码重置用途隔离,不能跨流程复用;Linux.do 单点登录账号继续通过原身份提供方登录,不会由找回流程隐式创建本地密码。 + +### 相关实现 + +- `web/src/pages/auth/login.tsx`、`web/src/pages/auth/forgot-password.tsx`:登录入口与两阶段邮箱验证界面。 +- `backend/internal/service/password_reset.go`:发送策略、验证码校验和密码重置业务边界。 +- `backend/internal/repository/repository.go`:密码更新、验证码消费和登录会话撤销事务。 + ## AI 提示词优化器 提示词优化器是一个内置 AI 能力插件,用于把模糊的生图或生视频想法整理成可以直接使用的提示词。它复用宿主已经配置的文本模型和渠道,不在浏览器中保存或拼接第三方密钥。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index b8265f145..28b33f5df 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,15 @@ description: 影策已实现功能的手工验证清单与未确认边界 # 待测试 +## 邮箱找回密码 + +- [ ] 后端专项测试验证验证码用途隔离、错误和过期验证码拒绝、单次消费、bcrypt 密码更新,以及重置事务撤销全部旧登录会话。 +- [x] 前端专项测试和生产构建验证登录页入口、独立找回路由、验证码倒计时、新密码表单及账户邮件设置文案。 +- [ ] 使用真实 SMTP 账号发送一次注册验证码和一次密码重置验证码,确认发件身份、正文、投递结果和 10 分钟有效期符合配置。 +- [ ] 在桌面、窄屏和键盘操作下确认“忘记密码?”与密码标签同行右对齐,找回页没有横向溢出,焦点顺序和提示信息清晰。 +- [ ] 使用两个浏览器会话登录同一账号后重置密码,确认两个旧会话都失效,旧密码无法登录且新密码可登录。 +- [ ] 分别使用未知邮箱、禁用账号和 Linux.do-only 账号请求验证码,确认公开响应不泄露账号是否存在或是否可找回。 + ## 服务升级运行基础 - 完整架构、状态机、Release 清单、备份和回退边界见 `docs/design/dynamic-update.md`。 diff --git a/web/package.json b/web/package.json index 0eb42fff0..458adcfb2 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "build:bridge": "bun ../canvas-agent/scripts/build-comfy-bridge.ts", "dev": "vite --host 0.0.0.0 --port 3000", "build": "bun run build:bridge && tsc --noEmit && vite build", - "pretest": "bun test test/admin-ui-regressions.test.ts", + "pretest": "bun test test/admin-ui-regressions.test.ts test/password-reset.test.ts", "typecheck": "tsc --noEmit", "test:canvas": "bun test test/canvas-media-performance.test.ts test/canvas-node-title-interaction.test.ts", "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/model-capabilities.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-generation-mentions.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-frame.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", diff --git a/web/src/pages/admin/components/email-settings-panel.tsx b/web/src/pages/admin/components/email-settings-panel.tsx index b8553310c..b791f3cee 100644 --- a/web/src/pages/admin/components/email-settings-panel.tsx +++ b/web/src/pages/admin/components/email-settings-panel.tsx @@ -141,7 +141,7 @@ export default function EmailSettingsPanel() { form.setFieldsValue(toEmailFormValues(result.setting)); setDraftEnabled(result.setting.enabled); setDirty(false); - message.success("注册邮件配置已保存"); + message.success("邮件服务配置已保存"); } catch (error) { const errorMessage = error instanceof Error ? error.message : "保存邮件配置失败"; setSaveError(`${errorMessage}。未自动重试,请重新读取当前配置后再决定是否保存。`); @@ -219,7 +219,7 @@ export default function EmailSettingsPanel() {