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"}`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
157
backend/internal/service/password_reset.go
Normal file
157
backend/internal/service/password_reset.go
Normal file
@@ -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]
|
||||
}
|
||||
225
backend/internal/service/password_reset_test.go
Normal file
225
backend/internal/service/password_reset_test.go
Normal file
@@ -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 == "验证码无效或已过期"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`。任一步失败都会回滚,不能出现密码已修改但旧会话仍有效,或验证码已消耗但密码未更新的部分状态。
|
||||
|
||||
## 技能包与版本
|
||||
|
||||
| 表 | 用途 | 关键边界 |
|
||||
|
||||
@@ -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 能力插件,用于把模糊的生图或生视频想法整理成可以直接使用的提示词。它复用宿主已经配置的文本模型和渠道,不在浏览器中保存或拼接第三方密钥。
|
||||
|
||||
@@ -5,6 +5,15 @@ description: 影策已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 邮箱找回密码
|
||||
|
||||
- [ ] 后端专项测试验证验证码用途隔离、错误和过期验证码拒绝、单次消费、bcrypt 密码更新,以及重置事务撤销全部旧登录会话。
|
||||
- [x] 前端专项测试和生产构建验证登录页入口、独立找回路由、验证码倒计时、新密码表单及账户邮件设置文案。
|
||||
- [ ] 使用真实 SMTP 账号发送一次注册验证码和一次密码重置验证码,确认发件身份、正文、投递结果和 10 分钟有效期符合配置。
|
||||
- [ ] 在桌面、窄屏和键盘操作下确认“忘记密码?”与密码标签同行右对齐,找回页没有横向溢出,焦点顺序和提示信息清晰。
|
||||
- [ ] 使用两个浏览器会话登录同一账号后重置密码,确认两个旧会话都失效,旧密码无法登录且新密码可登录。
|
||||
- [ ] 分别使用未知邮箱、禁用账号和 Linux.do-only 账号请求验证码,确认公开响应不泄露账号是否存在或是否可找回。
|
||||
|
||||
## 服务升级运行基础
|
||||
|
||||
- 完整架构、状态机、Release 清单、备份和回退边界见 `docs/design/dynamic-update.md`。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
<MailCheck className="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{dirty ? "有未保存的邮件调整" : `注册验证码:${setting.enabled ? "已启用" : "未启用"}`}</strong>
|
||||
<strong>{dirty ? "有未保存的邮件调整" : `账户邮件:${setting.enabled ? "已启用" : "未启用"}`}</strong>
|
||||
<p>{dirty ? "完成当前配置后保存生效。" : formatSettingTime(setting.updatedAt, "使用系统默认值")}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -246,15 +246,15 @@ export default function EmailSettingsPanel() {
|
||||
<SettingsSectionCard
|
||||
className="admin-email-section admin-email-delivery-section"
|
||||
icon={<Send className="size-4" aria-hidden="true" />}
|
||||
title="1. 是否发送注册邮箱验证码"
|
||||
description="这是邮件服务的主开关。关闭时不需要配置 SMTP;开启后再填写连接与发件信息。"
|
||||
title="1. 是否发送账户安全邮件"
|
||||
description="这是注册验证码和密码找回邮件的主开关。关闭时不需要配置 SMTP;开启后再填写连接与发件信息。"
|
||||
status={<AdminStatusBadge label={draftEnabled ? (dirty && !setting.enabled ? "待启用" : "已启用") : dirty && setting.enabled ? "待停用" : "未启用"} tone={dirty ? "warning" : draftEnabled ? "success" : "neutral"} />}
|
||||
footer={
|
||||
!draftEnabled ? (
|
||||
<>
|
||||
<div className="admin-email-footer-note">
|
||||
<BadgeCheck className="size-4" aria-hidden="true" />
|
||||
<span>关闭后,普通邮箱注册不再要求邮箱验证码。</span>
|
||||
<span>关闭后,普通邮箱注册和密码找回都无法发送验证码。</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{dirty ? (
|
||||
@@ -276,13 +276,13 @@ export default function EmailSettingsPanel() {
|
||||
</span>
|
||||
<div className="admin-email-policy-copy">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<strong>发送注册邮箱验证码</strong>
|
||||
<strong>发送注册与密码找回验证码</strong>
|
||||
<AdminStatusBadge label="保存后生效" tone="info" />
|
||||
</div>
|
||||
<p>启用后,普通邮箱注册需要获取并校验 6 位验证码;邮件发送失败时不会创建可用验证码。</p>
|
||||
<span>关闭只停止后续验证码邮件,不改变新用户注册开关,也不影响已有账号。</span>
|
||||
<p>启用后,普通邮箱注册和密码找回使用 6 位验证码;邮件发送失败时不会保留可用验证码。</p>
|
||||
<span>关闭只停止后续账户邮件,不改变新用户注册开关,也不影响已有登录会话。</span>
|
||||
</div>
|
||||
<Switch checked={draftEnabled} disabled={loading || refreshing || saving} aria-label="发送注册邮箱验证码" onChange={toggleEnabled} />
|
||||
<Switch checked={draftEnabled} disabled={loading || refreshing || saving} aria-label="发送账户安全邮件" onChange={toggleEnabled} />
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
@@ -372,7 +372,7 @@ export default function EmailSettingsPanel() {
|
||||
</div>
|
||||
|
||||
<div className="admin-email-form-section">
|
||||
<FormSectionTitle icon={<AtSign className="size-4" />} title="发件人身份" description="这组名称和地址会显示在注册验证码邮件的 From 信息中。" />
|
||||
<FormSectionTitle icon={<AtSign className="size-4" />} title="发件人身份" description="这组名称和地址会显示在注册与密码重置邮件的 From 信息中。" />
|
||||
<div className="admin-email-form-grid">
|
||||
<Form.Item
|
||||
name="fromEmail"
|
||||
@@ -458,7 +458,7 @@ function validateEmailDraft(values: EmailFormValues, setting: EmailSetting | nul
|
||||
const draft = normalizeEmailFormValues(values);
|
||||
if (!draft.enabled) return "";
|
||||
if (/\r|\n/.test(draft.fromName)) return "发件人名称不能包含换行";
|
||||
if (!draft.host || draft.port < 1 || draft.port > 65535 || !draft.fromEmail) return "启用注册邮件前请完整填写 SMTP 主机、端口和发件邮箱";
|
||||
if (!draft.host || draft.port < 1 || draft.port > 65535 || !draft.fromEmail) return "启用邮件服务前请完整填写 SMTP 主机、端口和发件邮箱";
|
||||
if (!isValidEmail(draft.fromEmail)) return "发件邮箱格式不正确";
|
||||
if (draft.username && !draft.password && !setting?.hasPassword) return "SMTP 用户名已填写,请同时填写密码或服务商授权码";
|
||||
return "";
|
||||
|
||||
@@ -24,6 +24,11 @@ const authCopy = {
|
||||
title: "建立你的创作空间",
|
||||
description: "一个账号管理画布、素材、技能和模型偏好。",
|
||||
},
|
||||
recovery: {
|
||||
eyebrow: "ACCOUNT RECOVERY",
|
||||
title: "重新设置密码",
|
||||
description: "验证账号邮箱后,设置一个新的登录密码。",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function LinuxDOIcon() {
|
||||
@@ -43,23 +48,15 @@ export function AuthScene() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const recovery = location.pathname === "/forgot-password";
|
||||
const activeTab = location.pathname === "/register" ? "register" : "login";
|
||||
const copy = activeTab === "register" ? authCopy.register : authCopy.login;
|
||||
const copy = recovery ? authCopy.recovery : activeTab === "register" ? authCopy.register : authCopy.login;
|
||||
|
||||
return (
|
||||
<main className="h-dvh min-h-0 overflow-y-auto bg-[#08090c] text-white lg:overflow-hidden">
|
||||
<div className="grid min-h-full lg:h-full lg:grid-cols-[minmax(0,1.32fr)_minmax(520px,1fr)]">
|
||||
<section className="relative min-h-[250px] overflow-hidden sm:min-h-[320px] lg:min-h-0" aria-label="影策品牌影片">
|
||||
<video
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
src={AUTH_VIDEO_URL}
|
||||
poster={AUTH_VIDEO_POSTER}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
<video className="absolute inset-0 size-full object-cover" src={AUTH_VIDEO_URL} poster={AUTH_VIDEO_POSTER} autoPlay muted loop playsInline preload="metadata" />
|
||||
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(180deg,rgba(4,5,8,.58),transparent_42%,rgba(4,5,8,.74))]" />
|
||||
<div aria-hidden className="absolute inset-y-0 right-0 hidden w-[clamp(120px,14vw,240px)] bg-[linear-gradient(90deg,transparent_0%,rgba(11,12,16,.68)_58%,#0b0c10_100%)] lg:block" />
|
||||
<div className="absolute inset-x-0 top-0 flex items-center justify-between gap-4 p-5 sm:p-7 lg:p-9">
|
||||
@@ -80,14 +77,18 @@ export function AuthScene() {
|
||||
>
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-white/58">YINGCE STUDIO</p>
|
||||
<h1 className="mt-3 max-w-xl text-3xl font-semibold leading-tight sm:text-4xl lg:text-5xl">
|
||||
让一个故事,<br className="hidden sm:inline" />
|
||||
让一个故事,
|
||||
<br className="hidden sm:inline" />
|
||||
从文字走向银幕。
|
||||
</h1>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section className="relative flex min-h-[620px] items-start justify-center overflow-y-auto bg-[#0b0c10] px-4 pb-8 pt-20 sm:px-8 lg:min-h-0 lg:px-10 lg:pb-10 lg:pt-20">
|
||||
<Link to="/" className="absolute right-5 top-5 z-20 inline-flex h-9 items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 text-xs text-white/58 backdrop-blur-xl transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white lg:right-8 lg:top-8">
|
||||
<Link
|
||||
to="/"
|
||||
className="absolute right-5 top-5 z-20 inline-flex h-9 items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 text-xs text-white/58 backdrop-blur-xl transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white lg:right-8 lg:top-8"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
返回首页
|
||||
</Link>
|
||||
@@ -101,20 +102,17 @@ export function AuthScene() {
|
||||
>
|
||||
<ConfigProvider theme={getAntThemeConfig(true)}>
|
||||
<div className="auth-card-dark h-auto overflow-hidden rounded-lg bg-[#121318]/94 shadow-[0_28px_80px_rgba(0,0,0,.34)] backdrop-blur-2xl">
|
||||
<section aria-label={copy.title} className={`flex flex-col ${activeTab === "login" ? "min-h-[500px]" : "min-h-[620px] sm:min-h-[640px]"}`}>
|
||||
<section aria-label={copy.title} className={`flex flex-col ${recovery ? "min-h-[600px]" : activeTab === "login" ? "min-h-[500px]" : "min-h-[620px] sm:min-h-[640px]"}`}>
|
||||
<header className="px-6 pb-5 pt-6 sm:px-8 sm:pt-7">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-blue-300/80">{copy.eyebrow}</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold">{copy.title}</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-white/45">{copy.description}</p>
|
||||
</header>
|
||||
<div className="px-6 sm:px-8">
|
||||
<Tabs
|
||||
className="auth-card-tabs"
|
||||
activeKey={activeTab}
|
||||
items={AUTH_TABS}
|
||||
onChange={(key) => navigate({ pathname: key === "register" ? "/register" : "/login", search: location.search })}
|
||||
/>
|
||||
</div>
|
||||
{!recovery ? (
|
||||
<div className="px-6 sm:px-8">
|
||||
<Tabs className="auth-card-tabs" activeKey={activeTab} items={AUTH_TABS} onChange={(key) => navigate({ pathname: key === "register" ? "/register" : "/login", search: location.search })} />
|
||||
</div>
|
||||
) : null}
|
||||
<div key={location.pathname} className="flex-1 px-6 py-6 sm:px-8 sm:py-7">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
217
web/src/pages/auth/forgot-password.tsx
Normal file
217
web/src/pages/auth/forgot-password.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { type FormEvent, useEffect, useState, type ReactNode } from "react";
|
||||
import { App, Button, Input } from "antd";
|
||||
import { ArrowLeft, ArrowRight, LockKeyhole, Mail, ShieldCheck, TriangleAlert } from "lucide-react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import { getAuthSettings, resetPassword, sendPasswordResetEmailCode } from "@/services/api/auth";
|
||||
|
||||
type RecoveryStage = "request" | "reset";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const { message } = App.useApp();
|
||||
const [stage, setStage] = useState<RecoveryStage>("request");
|
||||
const [email, setEmail] = useState("");
|
||||
const [emailCode, setEmailCode] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [emailEnabled, setEmailEnabled] = useState<boolean | null>(null);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const next = safeNext(params.get("next"));
|
||||
const loginURL = `/login?next=${encodeURIComponent(next)}`;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getAuthSettings()
|
||||
.then((settings) => !cancelled && setEmailEnabled(settings.emailEnabled))
|
||||
.catch(() => !cancelled && setEmailEnabled(null));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return;
|
||||
const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [countdown]);
|
||||
|
||||
const sendCode = async (advance: boolean) => {
|
||||
const normalizedEmail = email.trim();
|
||||
if (!normalizedEmail) {
|
||||
message.warning("请先输入邮箱");
|
||||
return;
|
||||
}
|
||||
setSendingCode(true);
|
||||
try {
|
||||
await sendPasswordResetEmailCode(normalizedEmail);
|
||||
setEmail(normalizedEmail);
|
||||
setCountdown(60);
|
||||
if (advance) setStage("reset");
|
||||
message.success("如果该邮箱已绑定可找回的账号,验证码将发送到邮箱");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "发送验证码失败");
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestCode = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
void sendCode(true);
|
||||
};
|
||||
|
||||
const submitReset = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
message.error("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await resetPassword({ email: email.trim(), emailCode, password });
|
||||
message.success("密码已重置,请使用新密码登录");
|
||||
navigate(loginURL, { replace: true });
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "密码重置失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const editEmail = () => {
|
||||
setStage("request");
|
||||
setEmailCode("");
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
setCountdown(0);
|
||||
};
|
||||
|
||||
if (stage === "request") {
|
||||
return (
|
||||
<form onSubmit={requestCode} className="space-y-5">
|
||||
{emailEnabled === false ? <Notice icon={<TriangleAlert className="size-3.5" />}>管理员尚未启用密码找回,请联系管理员处理。</Notice> : null}
|
||||
<AuthField label="账号邮箱" htmlFor="recovery-email">
|
||||
<Input
|
||||
id="recovery-email"
|
||||
size="large"
|
||||
prefix={<Mail className="size-4 text-white/35" />}
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="请输入绑定邮箱"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
required
|
||||
disabled={emailEnabled === false}
|
||||
/>
|
||||
</AuthField>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={sendingCode} disabled={emailEnabled === false} icon={<ArrowRight className="size-4" />} iconPlacement="end">
|
||||
发送验证码
|
||||
</Button>
|
||||
<BackToLogin to={loginURL} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submitReset} className="space-y-4">
|
||||
<AuthField label="账号邮箱" htmlFor="recovery-email-confirm">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<Input id="recovery-email-confirm" size="large" prefix={<Mail className="size-4 text-white/35" />} value={email} readOnly autoComplete="email" />
|
||||
<Button htmlType="button" size="large" onClick={editEmail}>
|
||||
修改邮箱
|
||||
</Button>
|
||||
</div>
|
||||
</AuthField>
|
||||
<AuthField label="邮箱验证码" htmlFor="recovery-code">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_116px] gap-2">
|
||||
<Input
|
||||
id="recovery-code"
|
||||
size="large"
|
||||
prefix={<ShieldCheck className="size-4 text-white/35" />}
|
||||
value={emailCode}
|
||||
onChange={(event) => setEmailCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="6 位验证码"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
required
|
||||
/>
|
||||
<Button htmlType="button" size="large" loading={sendingCode} disabled={countdown > 0} onClick={() => void sendCode(false)}>
|
||||
{countdown > 0 ? `${countdown}s` : "重新发送"}
|
||||
</Button>
|
||||
</div>
|
||||
</AuthField>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AuthField label="新密码" htmlFor="recovery-password">
|
||||
<Input.Password
|
||||
id="recovery-password"
|
||||
size="large"
|
||||
prefix={<LockKeyhole className="size-4 text-white/35" />}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="至少 8 位"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</AuthField>
|
||||
<AuthField label="确认密码" htmlFor="recovery-confirm-password">
|
||||
<Input.Password
|
||||
id="recovery-confirm-password"
|
||||
size="large"
|
||||
prefix={<LockKeyhole className="size-4 text-white/35" />}
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
placeholder="再次输入密码"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</AuthField>
|
||||
</div>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={submitting} icon={<ArrowRight className="size-4" />} iconPlacement="end">
|
||||
重置密码
|
||||
</Button>
|
||||
<BackToLogin to={loginURL} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthField({ label, htmlFor, children }: { label: string; htmlFor: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={htmlFor} className="block text-xs font-medium text-white/62">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackToLogin({ to }: { to: string }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<Link to={to} className="inline-flex min-h-8 items-center gap-1.5 rounded-sm text-xs text-white/50 transition-colors hover:text-white/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-300/45">
|
||||
<ArrowLeft className="size-3.5" />
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Notice({ icon, children }: { icon: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-300/15 bg-amber-300/[0.06] px-3 py-2.5 text-xs leading-5 text-amber-100/78">
|
||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function safeNext(value: string | null) {
|
||||
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/create";
|
||||
return value;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type FormEvent, useEffect, useState, type ReactNode } from "react";
|
||||
import { App, Button, Divider, Input } from "antd";
|
||||
import { ArrowRight, LockKeyhole, UserRound } from "lucide-react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import { applyUserSession } from "@/lib/user-session";
|
||||
import { getAuthSession, getAuthSettings, linuxDOLoginURL, login } from "@/services/api/auth";
|
||||
@@ -17,6 +17,7 @@ export default function LoginPage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [linuxdoEnabled, setLinuxdoEnabled] = useState(false);
|
||||
const next = safeNext(params.get("next"));
|
||||
const forgotPasswordURL = `/forgot-password?next=${encodeURIComponent(next)}`;
|
||||
const user = useUserStore((state) => state.user);
|
||||
const hydrated = useUserStore((state) => state.hydrated);
|
||||
|
||||
@@ -28,7 +29,9 @@ export default function LoginPage() {
|
||||
}, [hydrated, user, next, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
void getAuthSettings().then((settings) => setLinuxdoEnabled(settings.linuxdoEnabled)).catch(() => undefined);
|
||||
void getAuthSettings()
|
||||
.then((settings) => setLinuxdoEnabled(settings.linuxdoEnabled))
|
||||
.catch(() => undefined);
|
||||
const oauthError = params.get("oauth_error");
|
||||
if (oauthError) message.error(oauthError);
|
||||
}, [message, params]);
|
||||
@@ -50,16 +53,61 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-5">
|
||||
<AuthField label="用户名 / 邮箱"><Input size="large" prefix={<UserRound className="size-4 text-white/35" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="用户名或邮箱" autoComplete="username" required /></AuthField>
|
||||
<AuthField label="密码"><Input.Password size="large" prefix={<LockKeyhole className="size-4 text-white/35" />} value={password} onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" autoComplete="current-password" required /></AuthField>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={submitting} icon={<ArrowRight className="size-4" />} iconPlacement="end">登录</Button>
|
||||
{linuxdoEnabled ? <><Divider plain className="!border-white/10 !text-white/30">或</Divider><Button size="large" block icon={<LinuxDOIcon />} href={linuxDOLoginURL(next)}>使用 Linux.do 登录</Button></> : null}
|
||||
<AuthField label="用户名 / 邮箱" htmlFor="login-account">
|
||||
<Input id="login-account" size="large" prefix={<UserRound className="size-4 text-white/35" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="用户名或邮箱" autoComplete="username" required />
|
||||
</AuthField>
|
||||
<AuthField
|
||||
label="密码"
|
||||
htmlFor="login-password"
|
||||
action={
|
||||
<Link
|
||||
to={forgotPasswordURL}
|
||||
className="-my-2 inline-flex min-h-8 items-center rounded-sm text-xs font-medium text-blue-300/80 transition-colors hover:text-blue-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-300/45"
|
||||
>
|
||||
忘记密码?
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<Input.Password
|
||||
id="login-password"
|
||||
size="large"
|
||||
prefix={<LockKeyhole className="size-4 text-white/35" />}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="请输入密码"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</AuthField>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={submitting} icon={<ArrowRight className="size-4" />} iconPlacement="end">
|
||||
登录
|
||||
</Button>
|
||||
{linuxdoEnabled ? (
|
||||
<>
|
||||
<Divider plain className="!border-white/10 !text-white/30">
|
||||
或
|
||||
</Divider>
|
||||
<Button size="large" block icon={<LinuxDOIcon />} href={linuxDOLoginURL(next)}>
|
||||
使用 Linux.do 登录
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthField({ label, children }: { label: string; children: ReactNode }) {
|
||||
return <label className="block space-y-2"><span className="text-xs font-medium text-white/62">{label}</span>{children}</label>;
|
||||
function AuthField({ label, htmlFor, action, children }: { label: string; htmlFor: string; action?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label htmlFor={htmlFor} className="text-xs font-medium text-white/62">
|
||||
{label}
|
||||
</label>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function safeNext(value: string | null) {
|
||||
|
||||
@@ -34,6 +34,7 @@ const UsersPage = lazy(() => import("@/pages/admin/users/users-page"));
|
||||
const AssetsPage = lazy(loadAssetsPage);
|
||||
const LoginPage = lazy(() => import("@/pages/auth/login"));
|
||||
const RegisterPage = lazy(() => import("@/pages/auth/register"));
|
||||
const ForgotPasswordPage = lazy(() => import("@/pages/auth/forgot-password"));
|
||||
const CanvasPage = lazy(loadCanvasPage);
|
||||
const CanvasProjectPage = lazy(() => import("@/pages/canvas/project"));
|
||||
const SharedCanvasPage = lazy(() => import("@/pages/canvas/shared"));
|
||||
@@ -82,6 +83,7 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{ path: "/login", element: fullScreenDeferred(<LoginPage />) },
|
||||
{ path: "/register", element: fullScreenDeferred(<RegisterPage />) },
|
||||
{ path: "/forgot-password", element: fullScreenDeferred(<ForgotPasswordPage />) },
|
||||
],
|
||||
},
|
||||
{ path: "/share/canvas/:token", element: fullScreenDeferred(<SharedCanvasPage />), errorElement: <RouteErrorPage /> },
|
||||
@@ -107,8 +109,22 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
{ path: "/assets", element: <RequireAuth>{deferred(<AssetsPage />)}</RequireAuth> },
|
||||
{ path: "/skills", element: <RequireAuth>{deferred(<SkillsPage />)}</RequireAuth> },
|
||||
{ path: "/plugins", element: <RequireAuth><RequireFeature feature="pluginCenterEnabled">{deferred(<PluginsPage />)}</RequireFeature></RequireAuth> },
|
||||
{ path: "/plugins/eagle", element: <RequireAuth><RequireFeature feature="pluginCenterEnabled">{deferred(<EagleLibraryPage />)}</RequireFeature></RequireAuth> },
|
||||
{
|
||||
path: "/plugins",
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<RequireFeature feature="pluginCenterEnabled">{deferred(<PluginsPage />)}</RequireFeature>
|
||||
</RequireAuth>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/plugins/eagle",
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<RequireFeature feature="pluginCenterEnabled">{deferred(<EagleLibraryPage />)}</RequireFeature>
|
||||
</RequireAuth>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/wallet",
|
||||
element: (
|
||||
|
||||
@@ -417,6 +417,14 @@ export function sendRegistrationEmailCode(email: string) {
|
||||
return request<{ sent: boolean }>(api.post("/auth/email-code", { email }));
|
||||
}
|
||||
|
||||
export function sendPasswordResetEmailCode(email: string) {
|
||||
return request<{ sent: boolean }>(api.post("/auth/password-reset-code", { email }));
|
||||
}
|
||||
|
||||
export function resetPassword(input: { email: string; emailCode: string; password: string }) {
|
||||
return request<{ reset: boolean }>(api.post("/auth/password-reset", input));
|
||||
}
|
||||
|
||||
export function register(input: { username: string; email?: string; emailCode?: string; displayName?: string; password: string }) {
|
||||
return request<{ user: LocalUser }>(api.post("/auth/register", input));
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ test("analytics keeps fixed range presets distinct and uses enabled channel mode
|
||||
expect(source).toContain("onValuesChange={handlePricingValuesChange}");
|
||||
expect(source).toContain("onChange={handlePricingModelChange}");
|
||||
expect(source).toContain('hasOwnProperty.call(changedValues, "model")');
|
||||
expect(source).toContain("if (matchingChannels.length) form.setFieldValue(\"channelId\", matchingChannels[0].id)");
|
||||
expect(source).toContain('if (matchingChannels.length) form.setFieldValue("channelId", matchingChannels[0].id)');
|
||||
expect(source).toContain("const sourceChannels = channels.filter((channel) => channel.enabled !== false)");
|
||||
expect(source).toContain('inputMode="decimal"');
|
||||
expect(source).toContain('className="admin-analytics-price-input"');
|
||||
@@ -53,7 +53,7 @@ test("analytics keeps fixed range presets distinct and uses enabled channel mode
|
||||
expect(source).toContain("zIndex={1200}");
|
||||
expect(source).toContain("setPricingWorkspaceOpen(false)");
|
||||
expect(source).toContain("validator: validatePriceInput");
|
||||
expect(source).toContain('请输入非负价格,最多 6 位小数');
|
||||
expect(source).toContain("请输入非负价格,最多 6 位小数");
|
||||
expect(source).toContain("function formatPriceInput(micros: number)");
|
||||
expect(source).toContain("function toMicros(value?: string | number)");
|
||||
expect(source).not.toContain("<InputNumber");
|
||||
@@ -123,7 +123,7 @@ test("task-first settings reveal dependent configuration only after the primary
|
||||
|
||||
expect(emailSource).toContain("{draftEnabled ? (");
|
||||
expect(emailSource).toContain('id="admin-email-smtp"');
|
||||
expect(emailSource).toContain('title="1. 是否发送注册邮箱验证码"');
|
||||
expect(emailSource).toContain('title="1. 是否发送账户安全邮件"');
|
||||
expect(emailSource).toContain('title="2. 配置 SMTP 连接与发件身份"');
|
||||
|
||||
expect(accessSource).toContain("{draftLinuxDOEnabled ? (");
|
||||
|
||||
24
web/test/password-reset.test.ts
Normal file
24
web/test/password-reset.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
test("password recovery keeps the approved login placement and dedicated route", async () => {
|
||||
const [loginSource, recoverySource, routerSource, sceneSource, authAPISource, emailSettingsSource] = await Promise.all([
|
||||
Bun.file(new URL("../src/pages/auth/login.tsx", import.meta.url)).text(),
|
||||
Bun.file(new URL("../src/pages/auth/forgot-password.tsx", import.meta.url)).text(),
|
||||
Bun.file(new URL("../src/router.tsx", import.meta.url)).text(),
|
||||
Bun.file(new URL("../src/pages/auth/auth-scene.tsx", import.meta.url)).text(),
|
||||
Bun.file(new URL("../src/services/api/auth.ts", import.meta.url)).text(),
|
||||
Bun.file(new URL("../src/pages/admin/components/email-settings-panel.tsx", import.meta.url)).text(),
|
||||
]);
|
||||
|
||||
expect(loginSource).toContain('label="密码"');
|
||||
expect(loginSource).toContain("忘记密码?");
|
||||
expect(loginSource).toContain("forgotPasswordURL");
|
||||
expect(routerSource).toContain('{ path: "/forgot-password"');
|
||||
expect(sceneSource).toContain('eyebrow: "ACCOUNT RECOVERY"');
|
||||
expect(recoverySource).toContain("如果该邮箱已绑定可找回的账号,验证码将发送到邮箱");
|
||||
expect(recoverySource).toContain('autoComplete="one-time-code"');
|
||||
expect(recoverySource).toContain('autoComplete="new-password"');
|
||||
expect(authAPISource).toContain('api.post("/auth/password-reset-code"');
|
||||
expect(authAPISource).toContain('api.post("/auth/password-reset"');
|
||||
expect(emailSettingsSource).toContain("发送注册与密码找回验证码");
|
||||
});
|
||||
Reference in New Issue
Block a user