diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 0e3e0a2f..53c94b6a 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -48,6 +48,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { RegistrationEnabled: settings.RegistrationEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled, PromoCodeEnabled: settings.PromoCodeEnabled, + PasswordResetEnabled: settings.PasswordResetEnabled, SMTPHost: settings.SMTPHost, SMTPPort: settings.SMTPPort, SMTPUsername: settings.SMTPUsername, @@ -89,9 +90,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { // UpdateSettingsRequest 更新设置请求 type UpdateSettingsRequest struct { // 注册设置 - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - PromoCodeEnabled bool `json:"promo_code_enabled"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` // 邮件服务设置 SMTPHost string `json:"smtp_host"` @@ -243,6 +245,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { RegistrationEnabled: req.RegistrationEnabled, EmailVerifyEnabled: req.EmailVerifyEnabled, PromoCodeEnabled: req.PromoCodeEnabled, + PasswordResetEnabled: req.PasswordResetEnabled, SMTPHost: req.SMTPHost, SMTPPort: req.SMTPPort, SMTPUsername: req.SMTPUsername, @@ -318,6 +321,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { RegistrationEnabled: updatedSettings.RegistrationEnabled, EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, PromoCodeEnabled: updatedSettings.PromoCodeEnabled, + PasswordResetEnabled: updatedSettings.PasswordResetEnabled, SMTPHost: updatedSettings.SMTPHost, SMTPPort: updatedSettings.SMTPPort, SMTPUsername: updatedSettings.SMTPUsername, @@ -384,6 +388,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.EmailVerifyEnabled != after.EmailVerifyEnabled { changed = append(changed, "email_verify_enabled") } + if before.PasswordResetEnabled != after.PasswordResetEnabled { + changed = append(changed, "password_reset_enabled") + } if before.SMTPHost != after.SMTPHost { changed = append(changed, "smtp_host") } diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 89f34aae..e73593bd 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -247,3 +247,85 @@ func (h *AuthHandler) ValidatePromoCode(c *gin.Context) { BonusAmount: promoCode.BonusAmount, }) } + +// ForgotPasswordRequest 忘记密码请求 +type ForgotPasswordRequest struct { + Email string `json:"email" binding:"required,email"` + TurnstileToken string `json:"turnstile_token"` +} + +// ForgotPasswordResponse 忘记密码响应 +type ForgotPasswordResponse struct { + Message string `json:"message"` +} + +// ForgotPassword 请求密码重置 +// POST /api/v1/auth/forgot-password +func (h *AuthHandler) ForgotPassword(c *gin.Context) { + var req ForgotPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + // Turnstile 验证 + if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil { + response.ErrorFrom(c, err) + return + } + + // Build frontend base URL from request + scheme := "https" + if c.Request.TLS == nil { + // Check X-Forwarded-Proto header (common in reverse proxy setups) + if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { + scheme = proto + } else { + scheme = "http" + } + } + frontendBaseURL := scheme + "://" + c.Request.Host + + // Request password reset (async) + // Note: This returns success even if email doesn't exist (to prevent enumeration) + if err := h.authService.RequestPasswordResetAsync(c.Request.Context(), req.Email, frontendBaseURL); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, ForgotPasswordResponse{ + Message: "If your email is registered, you will receive a password reset link shortly.", + }) +} + +// ResetPasswordRequest 重置密码请求 +type ResetPasswordRequest struct { + Email string `json:"email" binding:"required,email"` + Token string `json:"token" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=6"` +} + +// ResetPasswordResponse 重置密码响应 +type ResetPasswordResponse struct { + Message string `json:"message"` +} + +// ResetPassword 重置密码 +// POST /api/v1/auth/reset-password +func (h *AuthHandler) ResetPassword(c *gin.Context) { + var req ResetPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + // Reset password + if err := h.authService.ResetPassword(c.Request.Context(), req.Email, req.Token, req.NewPassword); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, ResetPasswordResponse{ + Message: "Your password has been reset successfully. You can now log in with your new password.", + }) +} diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 01f39478..f98c7750 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -2,9 +2,10 @@ package dto // SystemSettings represents the admin settings API response payload. type SystemSettings struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - PromoCodeEnabled bool `json:"promo_code_enabled"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` SMTPHost string `json:"smtp_host"` SMTPPort int `json:"smtp_port"` @@ -54,21 +55,22 @@ type SystemSettings struct { } type PublicSettings struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` - Version string `json:"version"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + Version string `json:"version"` } // StreamTimeoutSettings 流超时处理配置 DTO diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 8723c746..9c0bde33 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -32,20 +32,21 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { } response.Success(c, dto.PublicSettings{ - RegistrationEnabled: settings.RegistrationEnabled, - EmailVerifyEnabled: settings.EmailVerifyEnabled, - PromoCodeEnabled: settings.PromoCodeEnabled, - TurnstileEnabled: settings.TurnstileEnabled, - TurnstileSiteKey: settings.TurnstileSiteKey, - SiteName: settings.SiteName, - SiteLogo: settings.SiteLogo, - SiteSubtitle: settings.SiteSubtitle, - APIBaseURL: settings.APIBaseURL, - ContactInfo: settings.ContactInfo, - DocURL: settings.DocURL, - HomeContent: settings.HomeContent, - HideCcsImportButton: settings.HideCcsImportButton, - LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, - Version: h.version, + RegistrationEnabled: settings.RegistrationEnabled, + EmailVerifyEnabled: settings.EmailVerifyEnabled, + PromoCodeEnabled: settings.PromoCodeEnabled, + PasswordResetEnabled: settings.PasswordResetEnabled, + TurnstileEnabled: settings.TurnstileEnabled, + TurnstileSiteKey: settings.TurnstileSiteKey, + SiteName: settings.SiteName, + SiteLogo: settings.SiteLogo, + SiteSubtitle: settings.SiteSubtitle, + APIBaseURL: settings.APIBaseURL, + ContactInfo: settings.ContactInfo, + DocURL: settings.DocURL, + HomeContent: settings.HomeContent, + HideCcsImportButton: settings.HideCcsImportButton, + LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, + Version: h.version, }) } diff --git a/backend/internal/repository/email_cache.go b/backend/internal/repository/email_cache.go index e00e35dd..8f2b8eca 100644 --- a/backend/internal/repository/email_cache.go +++ b/backend/internal/repository/email_cache.go @@ -9,13 +9,27 @@ import ( "github.com/redis/go-redis/v9" ) -const verifyCodeKeyPrefix = "verify_code:" +const ( + verifyCodeKeyPrefix = "verify_code:" + passwordResetKeyPrefix = "password_reset:" + passwordResetSentAtKeyPrefix = "password_reset_sent:" +) // verifyCodeKey generates the Redis key for email verification code. func verifyCodeKey(email string) string { return verifyCodeKeyPrefix + email } +// passwordResetKey generates the Redis key for password reset token. +func passwordResetKey(email string) string { + return passwordResetKeyPrefix + email +} + +// passwordResetSentAtKey generates the Redis key for password reset email sent timestamp. +func passwordResetSentAtKey(email string) string { + return passwordResetSentAtKeyPrefix + email +} + type emailCache struct { rdb *redis.Client } @@ -50,3 +64,45 @@ func (c *emailCache) DeleteVerificationCode(ctx context.Context, email string) e key := verifyCodeKey(email) return c.rdb.Del(ctx, key).Err() } + +// Password reset token methods + +func (c *emailCache) GetPasswordResetToken(ctx context.Context, email string) (*service.PasswordResetTokenData, error) { + key := passwordResetKey(email) + val, err := c.rdb.Get(ctx, key).Result() + if err != nil { + return nil, err + } + var data service.PasswordResetTokenData + if err := json.Unmarshal([]byte(val), &data); err != nil { + return nil, err + } + return &data, nil +} + +func (c *emailCache) SetPasswordResetToken(ctx context.Context, email string, data *service.PasswordResetTokenData, ttl time.Duration) error { + key := passwordResetKey(email) + val, err := json.Marshal(data) + if err != nil { + return err + } + return c.rdb.Set(ctx, key, val, ttl).Err() +} + +func (c *emailCache) DeletePasswordResetToken(ctx context.Context, email string) error { + key := passwordResetKey(email) + return c.rdb.Del(ctx, key).Err() +} + +// Password reset email cooldown methods + +func (c *emailCache) IsPasswordResetEmailInCooldown(ctx context.Context, email string) bool { + key := passwordResetSentAtKey(email) + exists, err := c.rdb.Exists(ctx, key).Result() + return err == nil && exists > 0 +} + +func (c *emailCache) SetPasswordResetEmailCooldown(ctx context.Context, email string, ttl time.Duration) error { + key := passwordResetSentAtKey(email) + return c.rdb.Set(ctx, key, "1", ttl).Err() +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index c3e395e4..8a6eb195 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -452,6 +452,7 @@ func TestAPIContracts(t *testing.T) { "registration_enabled": true, "email_verify_enabled": false, "promo_code_enabled": true, + "password_reset_enabled": false, "smtp_host": "smtp.example.com", "smtp_port": 587, "smtp_username": "user", diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index aa691eba..24690f5c 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -31,6 +31,14 @@ func RegisterAuthRoutes( auth.POST("/validate-promo-code", rateLimiter.LimitWithOptions("validate-promo", 10, time.Minute, middleware.RateLimitOptions{ FailureMode: middleware.RateLimitFailClose, }), h.Auth.ValidatePromoCode) + // 忘记密码接口添加速率限制:每分钟最多 5 次(Redis 故障时 fail-close) + auth.POST("/forgot-password", rateLimiter.LimitWithOptions("forgot-password", 5, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.ForgotPassword) + // 重置密码接口添加速率限制:每分钟最多 10 次(Redis 故障时 fail-close) + auth.POST("/reset-password", rateLimiter.LimitWithOptions("reset-password", 10, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.ResetPassword) auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart) auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback) } diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 854e7732..f51fae24 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -580,3 +580,149 @@ func (s *AuthService) RefreshToken(ctx context.Context, oldTokenString string) ( // 生成新token return s.GenerateToken(user) } + +// IsPasswordResetEnabled 检查是否启用密码重置功能 +// 要求:必须同时开启邮件验证且 SMTP 配置正确 +func (s *AuthService) IsPasswordResetEnabled(ctx context.Context) bool { + if s.settingService == nil { + return false + } + // Must have email verification enabled and SMTP configured + if !s.settingService.IsEmailVerifyEnabled(ctx) { + return false + } + return s.settingService.IsPasswordResetEnabled(ctx) +} + +// preparePasswordReset validates the password reset request and returns necessary data +// Returns (siteName, resetURL, shouldProceed) +// shouldProceed is false when we should silently return success (to prevent enumeration) +func (s *AuthService) preparePasswordReset(ctx context.Context, email, frontendBaseURL string) (string, string, bool) { + // Check if user exists (but don't reveal this to the caller) + user, err := s.userRepo.GetByEmail(ctx, email) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + // Security: Log but don't reveal that user doesn't exist + log.Printf("[Auth] Password reset requested for non-existent email: %s", email) + return "", "", false + } + log.Printf("[Auth] Database error checking email for password reset: %v", err) + return "", "", false + } + + // Check if user is active + if !user.IsActive() { + log.Printf("[Auth] Password reset requested for inactive user: %s", email) + return "", "", false + } + + // Get site name + siteName := "Sub2API" + if s.settingService != nil { + siteName = s.settingService.GetSiteName(ctx) + } + + // Build reset URL base + resetURL := fmt.Sprintf("%s/reset-password", strings.TrimSuffix(frontendBaseURL, "/")) + + return siteName, resetURL, true +} + +// RequestPasswordReset 请求密码重置(同步发送) +// Security: Returns the same response regardless of whether the email exists (prevent user enumeration) +func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendBaseURL string) error { + if !s.IsPasswordResetEnabled(ctx) { + return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled") + } + if s.emailService == nil { + return ErrServiceUnavailable + } + + siteName, resetURL, shouldProceed := s.preparePasswordReset(ctx, email, frontendBaseURL) + if !shouldProceed { + return nil // Silent success to prevent enumeration + } + + if err := s.emailService.SendPasswordResetEmail(ctx, email, siteName, resetURL); err != nil { + log.Printf("[Auth] Failed to send password reset email to %s: %v", email, err) + return nil // Silent success to prevent enumeration + } + + log.Printf("[Auth] Password reset email sent to: %s", email) + return nil +} + +// RequestPasswordResetAsync 异步请求密码重置(队列发送) +// Security: Returns the same response regardless of whether the email exists (prevent user enumeration) +func (s *AuthService) RequestPasswordResetAsync(ctx context.Context, email, frontendBaseURL string) error { + if !s.IsPasswordResetEnabled(ctx) { + return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled") + } + if s.emailQueueService == nil { + return ErrServiceUnavailable + } + + siteName, resetURL, shouldProceed := s.preparePasswordReset(ctx, email, frontendBaseURL) + if !shouldProceed { + return nil // Silent success to prevent enumeration + } + + if err := s.emailQueueService.EnqueuePasswordReset(email, siteName, resetURL); err != nil { + log.Printf("[Auth] Failed to enqueue password reset email for %s: %v", email, err) + return nil // Silent success to prevent enumeration + } + + log.Printf("[Auth] Password reset email enqueued for: %s", email) + return nil +} + +// ResetPassword 重置密码 +// Security: Increments TokenVersion to invalidate all existing JWT tokens +func (s *AuthService) ResetPassword(ctx context.Context, email, token, newPassword string) error { + // Check if password reset is enabled + if !s.IsPasswordResetEnabled(ctx) { + return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled") + } + + if s.emailService == nil { + return ErrServiceUnavailable + } + + // Verify and consume the reset token (one-time use) + if err := s.emailService.ConsumePasswordResetToken(ctx, email, token); err != nil { + return err + } + + // Get user + user, err := s.userRepo.GetByEmail(ctx, email) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return ErrInvalidResetToken // Token was valid but user was deleted + } + log.Printf("[Auth] Database error getting user for password reset: %v", err) + return ErrServiceUnavailable + } + + // Check if user is active + if !user.IsActive() { + return ErrUserNotActive + } + + // Hash new password + hashedPassword, err := s.HashPassword(newPassword) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + + // Update password and increment TokenVersion + user.PasswordHash = hashedPassword + user.TokenVersion++ // Invalidate all existing tokens + + if err := s.userRepo.Update(ctx, user); err != nil { + log.Printf("[Auth] Database error updating password for user %d: %v", user.ID, err) + return ErrServiceUnavailable + } + + log.Printf("[Auth] Password reset successful for user: %s", email) + return nil +} diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index bc8f6f68..e31ca561 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -71,6 +71,26 @@ func (s *emailCacheStub) DeleteVerificationCode(ctx context.Context, email strin return nil } +func (s *emailCacheStub) GetPasswordResetToken(ctx context.Context, email string) (*PasswordResetTokenData, error) { + return nil, nil +} + +func (s *emailCacheStub) SetPasswordResetToken(ctx context.Context, email string, data *PasswordResetTokenData, ttl time.Duration) error { + return nil +} + +func (s *emailCacheStub) DeletePasswordResetToken(ctx context.Context, email string) error { + return nil +} + +func (s *emailCacheStub) IsPasswordResetEmailInCooldown(ctx context.Context, email string) bool { + return false +} + +func (s *emailCacheStub) SetPasswordResetEmailCooldown(ctx context.Context, email string, ttl time.Duration) error { + return nil +} + func newAuthService(repo *userRepoStub, settings map[string]string, emailCache EmailCache) *AuthService { cfg := &config.Config{ JWT: config.JWTConfig{ diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 3bb63ffa..0eaa23ca 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -69,9 +69,10 @@ const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid" // Setting keys const ( // 注册设置 - SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册 - SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证 - SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 + SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册 + SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证 + SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 + SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) // 邮件服务设置 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 diff --git a/backend/internal/service/email_queue_service.go b/backend/internal/service/email_queue_service.go index 1c22702c..6c975c69 100644 --- a/backend/internal/service/email_queue_service.go +++ b/backend/internal/service/email_queue_service.go @@ -8,11 +8,18 @@ import ( "time" ) +// Task type constants +const ( + TaskTypeVerifyCode = "verify_code" + TaskTypePasswordReset = "password_reset" +) + // EmailTask 邮件发送任务 type EmailTask struct { Email string SiteName string - TaskType string // "verify_code" + TaskType string // "verify_code" or "password_reset" + ResetURL string // Only used for password_reset task type } // EmailQueueService 异步邮件队列服务 @@ -73,12 +80,18 @@ func (s *EmailQueueService) processTask(workerID int, task EmailTask) { defer cancel() switch task.TaskType { - case "verify_code": + case TaskTypeVerifyCode: if err := s.emailService.SendVerifyCode(ctx, task.Email, task.SiteName); err != nil { log.Printf("[EmailQueue] Worker %d failed to send verify code to %s: %v", workerID, task.Email, err) } else { log.Printf("[EmailQueue] Worker %d sent verify code to %s", workerID, task.Email) } + case TaskTypePasswordReset: + if err := s.emailService.SendPasswordResetEmailWithCooldown(ctx, task.Email, task.SiteName, task.ResetURL); err != nil { + log.Printf("[EmailQueue] Worker %d failed to send password reset to %s: %v", workerID, task.Email, err) + } else { + log.Printf("[EmailQueue] Worker %d sent password reset to %s", workerID, task.Email) + } default: log.Printf("[EmailQueue] Worker %d unknown task type: %s", workerID, task.TaskType) } @@ -89,7 +102,7 @@ func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string) error { task := EmailTask{ Email: email, SiteName: siteName, - TaskType: "verify_code", + TaskType: TaskTypeVerifyCode, } select { @@ -101,6 +114,24 @@ func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string) error { } } +// EnqueuePasswordReset 将密码重置邮件任务加入队列 +func (s *EmailQueueService) EnqueuePasswordReset(email, siteName, resetURL string) error { + task := EmailTask{ + Email: email, + SiteName: siteName, + TaskType: TaskTypePasswordReset, + ResetURL: resetURL, + } + + select { + case s.taskChan <- task: + log.Printf("[EmailQueue] Enqueued password reset task for %s", email) + return nil + default: + return fmt.Errorf("email queue is full") + } +} + // Stop 停止队列服务 func (s *EmailQueueService) Stop() { close(s.stopChan) diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 55e137d6..1abc6bfd 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -3,11 +3,14 @@ package service import ( "context" "crypto/rand" + "crypto/subtle" "crypto/tls" + "encoding/hex" "fmt" "log" "math/big" "net/smtp" + "net/url" "strconv" "time" @@ -19,6 +22,9 @@ var ( ErrInvalidVerifyCode = infraerrors.BadRequest("INVALID_VERIFY_CODE", "invalid or expired verification code") ErrVerifyCodeTooFrequent = infraerrors.TooManyRequests("VERIFY_CODE_TOO_FREQUENT", "please wait before requesting a new code") ErrVerifyCodeMaxAttempts = infraerrors.TooManyRequests("VERIFY_CODE_MAX_ATTEMPTS", "too many failed attempts, please request a new code") + + // Password reset errors + ErrInvalidResetToken = infraerrors.BadRequest("INVALID_RESET_TOKEN", "invalid or expired password reset token") ) // EmailCache defines cache operations for email service @@ -26,6 +32,16 @@ type EmailCache interface { GetVerificationCode(ctx context.Context, email string) (*VerificationCodeData, error) SetVerificationCode(ctx context.Context, email string, data *VerificationCodeData, ttl time.Duration) error DeleteVerificationCode(ctx context.Context, email string) error + + // Password reset token methods + GetPasswordResetToken(ctx context.Context, email string) (*PasswordResetTokenData, error) + SetPasswordResetToken(ctx context.Context, email string, data *PasswordResetTokenData, ttl time.Duration) error + DeletePasswordResetToken(ctx context.Context, email string) error + + // Password reset email cooldown methods + // Returns true if in cooldown period (email was sent recently) + IsPasswordResetEmailInCooldown(ctx context.Context, email string) bool + SetPasswordResetEmailCooldown(ctx context.Context, email string, ttl time.Duration) error } // VerificationCodeData represents verification code data @@ -35,10 +51,22 @@ type VerificationCodeData struct { CreatedAt time.Time } +// PasswordResetTokenData represents password reset token data +type PasswordResetTokenData struct { + Token string + CreatedAt time.Time +} + const ( verifyCodeTTL = 15 * time.Minute verifyCodeCooldown = 1 * time.Minute maxVerifyCodeAttempts = 5 + + // Password reset token settings + passwordResetTokenTTL = 30 * time.Minute + + // Password reset email cooldown (prevent email bombing) + passwordResetEmailCooldown = 30 * time.Second ) // SMTPConfig SMTP配置 @@ -357,3 +385,157 @@ func (s *EmailService) TestSMTPConnectionWithConfig(config *SMTPConfig) error { return client.Quit() } + +// GeneratePasswordResetToken generates a secure 32-byte random token (64 hex characters) +func (s *EmailService) GeneratePasswordResetToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +// SendPasswordResetEmail sends a password reset email with a reset link +func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteName, resetURL string) error { + var token string + var needSaveToken bool + + // Check if token already exists + existing, err := s.cache.GetPasswordResetToken(ctx, email) + if err == nil && existing != nil { + // Token exists, reuse it (allows resending email without generating new token) + token = existing.Token + needSaveToken = false + } else { + // Generate new token + token, err = s.GeneratePasswordResetToken() + if err != nil { + return fmt.Errorf("generate token: %w", err) + } + needSaveToken = true + } + + // Save token to Redis (only if new token generated) + if needSaveToken { + data := &PasswordResetTokenData{ + Token: token, + CreatedAt: time.Now(), + } + if err := s.cache.SetPasswordResetToken(ctx, email, data, passwordResetTokenTTL); err != nil { + return fmt.Errorf("save reset token: %w", err) + } + } + + // Build full reset URL with URL-encoded token and email + fullResetURL := fmt.Sprintf("%s?email=%s&token=%s", resetURL, url.QueryEscape(email), url.QueryEscape(token)) + + // Build email content + subject := fmt.Sprintf("[%s] 密码重置请求", siteName) + body := s.buildPasswordResetEmailBody(fullResetURL, siteName) + + // Send email + if err := s.SendEmail(ctx, email, subject, body); err != nil { + return fmt.Errorf("send email: %w", err) + } + + return nil +} + +// SendPasswordResetEmailWithCooldown sends password reset email with cooldown check (called by queue worker) +// This method wraps SendPasswordResetEmail with email cooldown to prevent email bombing +func (s *EmailService) SendPasswordResetEmailWithCooldown(ctx context.Context, email, siteName, resetURL string) error { + // Check email cooldown to prevent email bombing + if s.cache.IsPasswordResetEmailInCooldown(ctx, email) { + log.Printf("[Email] Password reset email skipped (cooldown): %s", email) + return nil // Silent success to prevent revealing cooldown to attackers + } + + // Send email using core method + if err := s.SendPasswordResetEmail(ctx, email, siteName, resetURL); err != nil { + return err + } + + // Set cooldown marker (Redis TTL handles expiration) + if err := s.cache.SetPasswordResetEmailCooldown(ctx, email, passwordResetEmailCooldown); err != nil { + log.Printf("[Email] Failed to set password reset cooldown for %s: %v", email, err) + } + + return nil +} + +// VerifyPasswordResetToken verifies the password reset token without consuming it +func (s *EmailService) VerifyPasswordResetToken(ctx context.Context, email, token string) error { + data, err := s.cache.GetPasswordResetToken(ctx, email) + if err != nil || data == nil { + return ErrInvalidResetToken + } + + // Use constant-time comparison to prevent timing attacks + if subtle.ConstantTimeCompare([]byte(data.Token), []byte(token)) != 1 { + return ErrInvalidResetToken + } + + return nil +} + +// ConsumePasswordResetToken verifies and deletes the token (one-time use) +func (s *EmailService) ConsumePasswordResetToken(ctx context.Context, email, token string) error { + // Verify first + if err := s.VerifyPasswordResetToken(ctx, email, token); err != nil { + return err + } + + // Delete after verification (one-time use) + if err := s.cache.DeletePasswordResetToken(ctx, email); err != nil { + log.Printf("[Email] Failed to delete password reset token after consumption: %v", err) + } + return nil +} + +// buildPasswordResetEmailBody builds the HTML content for password reset email +func (s *EmailService) buildPasswordResetEmailBody(resetURL, siteName string) string { + return fmt.Sprintf(` + + + + + + + +
+
+

%s

+
+
+

密码重置请求

+

您已请求重置密码。请点击下方按钮设置新密码:

+ 重置密码 +
+

此链接将在 30 分钟后失效。

+

如果您没有请求重置密码,请忽略此邮件。您的密码将保持不变。

+
+ +
+ +
+ + +`, siteName, resetURL, resetURL) +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index d77dd30d..c2ef9a5d 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -61,6 +61,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyRegistrationEnabled, SettingKeyEmailVerifyEnabled, SettingKeyPromoCodeEnabled, + SettingKeyPasswordResetEnabled, SettingKeyTurnstileEnabled, SettingKeyTurnstileSiteKey, SettingKeySiteName, @@ -86,21 +87,26 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled } + // Password reset requires email verification to be enabled + emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" + passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true" + return &PublicSettings{ - RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", - EmailVerifyEnabled: settings[SettingKeyEmailVerifyEnabled] == "true", - PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 - TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", - TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], - SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), - SiteLogo: settings[SettingKeySiteLogo], - SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), - APIBaseURL: settings[SettingKeyAPIBaseURL], - ContactInfo: settings[SettingKeyContactInfo], - DocURL: settings[SettingKeyDocURL], - HomeContent: settings[SettingKeyHomeContent], - HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", - LinuxDoOAuthEnabled: linuxDoEnabled, + RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", + EmailVerifyEnabled: emailVerifyEnabled, + PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 + PasswordResetEnabled: passwordResetEnabled, + TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", + TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], + SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), + SiteLogo: settings[SettingKeySiteLogo], + SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), + APIBaseURL: settings[SettingKeyAPIBaseURL], + ContactInfo: settings[SettingKeyContactInfo], + DocURL: settings[SettingKeyDocURL], + HomeContent: settings[SettingKeyHomeContent], + HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", + LinuxDoOAuthEnabled: linuxDoEnabled, }, nil } @@ -125,37 +131,39 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any // Return a struct that matches the frontend's expected format return &struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key,omitempty"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo,omitempty"` - SiteSubtitle string `json:"site_subtitle,omitempty"` - APIBaseURL string `json:"api_base_url,omitempty"` - ContactInfo string `json:"contact_info,omitempty"` - DocURL string `json:"doc_url,omitempty"` - HomeContent string `json:"home_content,omitempty"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` - Version string `json:"version,omitempty"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key,omitempty"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo,omitempty"` + SiteSubtitle string `json:"site_subtitle,omitempty"` + APIBaseURL string `json:"api_base_url,omitempty"` + ContactInfo string `json:"contact_info,omitempty"` + DocURL string `json:"doc_url,omitempty"` + HomeContent string `json:"home_content,omitempty"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + Version string `json:"version,omitempty"` }{ - RegistrationEnabled: settings.RegistrationEnabled, - EmailVerifyEnabled: settings.EmailVerifyEnabled, - PromoCodeEnabled: settings.PromoCodeEnabled, - TurnstileEnabled: settings.TurnstileEnabled, - TurnstileSiteKey: settings.TurnstileSiteKey, - SiteName: settings.SiteName, - SiteLogo: settings.SiteLogo, - SiteSubtitle: settings.SiteSubtitle, - APIBaseURL: settings.APIBaseURL, - ContactInfo: settings.ContactInfo, - DocURL: settings.DocURL, - HomeContent: settings.HomeContent, - HideCcsImportButton: settings.HideCcsImportButton, - LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, - Version: s.version, + RegistrationEnabled: settings.RegistrationEnabled, + EmailVerifyEnabled: settings.EmailVerifyEnabled, + PromoCodeEnabled: settings.PromoCodeEnabled, + PasswordResetEnabled: settings.PasswordResetEnabled, + TurnstileEnabled: settings.TurnstileEnabled, + TurnstileSiteKey: settings.TurnstileSiteKey, + SiteName: settings.SiteName, + SiteLogo: settings.SiteLogo, + SiteSubtitle: settings.SiteSubtitle, + APIBaseURL: settings.APIBaseURL, + ContactInfo: settings.ContactInfo, + DocURL: settings.DocURL, + HomeContent: settings.HomeContent, + HideCcsImportButton: settings.HideCcsImportButton, + LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, + Version: s.version, }, nil } @@ -167,6 +175,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled) updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled) updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled) + updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled) // 邮件服务设置(只有非空才更新密码) updates[SettingKeySMTPHost] = settings.SMTPHost @@ -262,6 +271,20 @@ func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool { return value != "false" } +// IsPasswordResetEnabled 检查是否启用密码重置功能 +// 要求:必须同时开启邮件验证 +func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool { + // Password reset requires email verification to be enabled + if !s.IsEmailVerifyEnabled(ctx) { + return false + } + value, err := s.settingRepo.GetValue(ctx, SettingKeyPasswordResetEnabled) + if err != nil { + return false // 默认关闭 + } + return value == "true" +} + // GetSiteName 获取网站名称 func (s *SettingService) GetSiteName(ctx context.Context) string { value, err := s.settingRepo.GetValue(ctx, SettingKeySiteName) @@ -340,10 +363,12 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // parseSettings 解析设置到结构体 func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings { + emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" result := &SystemSettings{ RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", - EmailVerifyEnabled: settings[SettingKeyEmailVerifyEnabled] == "true", + EmailVerifyEnabled: emailVerifyEnabled, PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 + PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", SMTPHost: settings[SettingKeySMTPHost], SMTPUsername: settings[SettingKeySMTPUsername], SMTPFrom: settings[SettingKeySMTPFrom], diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 919344e5..427e64d2 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -1,9 +1,10 @@ package service type SystemSettings struct { - RegistrationEnabled bool - EmailVerifyEnabled bool - PromoCodeEnabled bool + RegistrationEnabled bool + EmailVerifyEnabled bool + PromoCodeEnabled bool + PasswordResetEnabled bool SMTPHost string SMTPPort int @@ -57,21 +58,22 @@ type SystemSettings struct { } type PublicSettings struct { - RegistrationEnabled bool - EmailVerifyEnabled bool - PromoCodeEnabled bool - TurnstileEnabled bool - TurnstileSiteKey string - SiteName string - SiteLogo string - SiteSubtitle string - APIBaseURL string - ContactInfo string - DocURL string - HomeContent string - HideCcsImportButton bool - LinuxDoOAuthEnabled bool - Version string + RegistrationEnabled bool + EmailVerifyEnabled bool + PromoCodeEnabled bool + PasswordResetEnabled bool + TurnstileEnabled bool + TurnstileSiteKey string + SiteName string + SiteLogo string + SiteSubtitle string + APIBaseURL string + ContactInfo string + DocURL string + HomeContent string + HideCcsImportButton bool + LinuxDoOAuthEnabled bool + Version string } // StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制) diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 6e2ade00..76624bb9 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -13,6 +13,7 @@ export interface SystemSettings { registration_enabled: boolean email_verify_enabled: boolean promo_code_enabled: boolean + password_reset_enabled: boolean // Default settings default_balance: number default_concurrency: number @@ -66,6 +67,7 @@ export interface UpdateSettingsRequest { registration_enabled?: boolean email_verify_enabled?: boolean promo_code_enabled?: boolean + password_reset_enabled?: boolean default_balance?: number default_concurrency?: number site_name?: string diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index fddc23ef..8c669c3e 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -133,6 +133,57 @@ export async function validatePromoCode(code: string): Promise { + const { data } = await apiClient.post('/auth/forgot-password', request) + return data +} + +/** + * Reset password request + */ +export interface ResetPasswordRequest { + email: string + token: string + new_password: string +} + +/** + * Reset password response + */ +export interface ResetPasswordResponse { + message: string +} + +/** + * Reset password with token + * @param request - Email, token, and new password + * @returns Response with message + */ +export async function resetPassword(request: ResetPasswordRequest): Promise { + const { data } = await apiClient.post('/auth/reset-password', request) + return data +} + export const authAPI = { login, register, @@ -144,7 +195,9 @@ export const authAPI = { clearAuthToken, getPublicSettings, sendVerifyCode, - validatePromoCode + validatePromoCode, + forgotPassword, + resetPassword } export default authAPI diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index e293491b..1880e4cc 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -271,7 +271,36 @@ export default { code: 'Code', state: 'State', fullUrl: 'Full URL' - } + }, + // Forgot password + forgotPassword: 'Forgot password?', + forgotPasswordTitle: 'Reset Your Password', + forgotPasswordHint: 'Enter your email address and we will send you a link to reset your password.', + sendResetLink: 'Send Reset Link', + sendingResetLink: 'Sending...', + sendResetLinkFailed: 'Failed to send reset link. Please try again.', + resetEmailSent: 'Reset Link Sent', + resetEmailSentHint: 'If an account exists with this email, you will receive a password reset link shortly. Please check your inbox and spam folder.', + backToLogin: 'Back to Login', + rememberedPassword: 'Remembered your password?', + // Reset password + resetPasswordTitle: 'Set New Password', + resetPasswordHint: 'Enter your new password below.', + newPassword: 'New Password', + newPasswordPlaceholder: 'Enter your new password', + confirmPassword: 'Confirm Password', + confirmPasswordPlaceholder: 'Confirm your new password', + confirmPasswordRequired: 'Please confirm your password', + passwordsDoNotMatch: 'Passwords do not match', + resetPassword: 'Reset Password', + resettingPassword: 'Resetting...', + resetPasswordFailed: 'Failed to reset password. Please try again.', + passwordResetSuccess: 'Password Reset Successful', + passwordResetSuccessHint: 'Your password has been reset. You can now sign in with your new password.', + invalidResetLink: 'Invalid Reset Link', + invalidResetLinkHint: 'This password reset link is invalid or has expired. Please request a new one.', + requestNewResetLink: 'Request New Reset Link', + invalidOrExpiredToken: 'The password reset link is invalid or has expired. Please request a new one.' }, // Dashboard @@ -2743,7 +2772,9 @@ export default { emailVerification: 'Email Verification', emailVerificationHint: 'Require email verification for new registrations', promoCode: 'Promo Code', - promoCodeHint: 'Allow users to use promo codes during registration' + promoCodeHint: 'Allow users to use promo codes during registration', + passwordReset: 'Password Reset', + passwordResetHint: 'Allow users to reset their password via email' }, turnstile: { title: 'Cloudflare Turnstile', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index dbeb3819..49b24abb 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -268,7 +268,36 @@ export default { code: '授权码', state: '状态', fullUrl: '完整URL' - } + }, + // 忘记密码 + forgotPassword: '忘记密码?', + forgotPasswordTitle: '重置密码', + forgotPasswordHint: '输入您的邮箱地址,我们将向您发送密码重置链接。', + sendResetLink: '发送重置链接', + sendingResetLink: '发送中...', + sendResetLinkFailed: '发送重置链接失败,请重试。', + resetEmailSent: '重置链接已发送', + resetEmailSentHint: '如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。', + backToLogin: '返回登录', + rememberedPassword: '想起密码了?', + // 重置密码 + resetPasswordTitle: '设置新密码', + resetPasswordHint: '请在下方输入您的新密码。', + newPassword: '新密码', + newPasswordPlaceholder: '输入新密码', + confirmPassword: '确认密码', + confirmPasswordPlaceholder: '再次输入新密码', + confirmPasswordRequired: '请确认您的密码', + passwordsDoNotMatch: '两次输入的密码不一致', + resetPassword: '重置密码', + resettingPassword: '重置中...', + resetPasswordFailed: '重置密码失败,请重试。', + passwordResetSuccess: '密码重置成功', + passwordResetSuccessHint: '您的密码已重置。现在可以使用新密码登录。', + invalidResetLink: '无效的重置链接', + invalidResetLinkHint: '此密码重置链接无效或已过期。请重新请求一个新链接。', + requestNewResetLink: '请求新的重置链接', + invalidOrExpiredToken: '密码重置链接无效或已过期。请重新请求一个新链接。' }, // Dashboard @@ -2896,7 +2925,9 @@ export default { emailVerification: '邮箱验证', emailVerificationHint: '新用户注册时需要验证邮箱', promoCode: '优惠码', - promoCodeHint: '允许用户在注册时使用优惠码' + promoCodeHint: '允许用户在注册时使用优惠码', + passwordReset: '忘记密码', + passwordResetHint: '允许用户通过邮箱重置密码' }, turnstile: { title: 'Cloudflare Turnstile', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 96127063..31c489b4 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -79,6 +79,24 @@ const routes: RouteRecordRaw[] = [ title: 'LinuxDo OAuth Callback' } }, + { + path: '/forgot-password', + name: 'ForgotPassword', + component: () => import('@/views/auth/ForgotPasswordView.vue'), + meta: { + requiresAuth: false, + title: 'Forgot Password' + } + }, + { + path: '/reset-password', + name: 'ResetPassword', + component: () => import('@/views/auth/ResetPasswordView.vue'), + meta: { + requiresAuth: false, + title: 'Reset Password' + } + }, // ==================== User Routes ==================== { diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 9c4db599..c5a1ffc6 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -313,6 +313,7 @@ export const useAppStore = defineStore('app', () => { registration_enabled: false, email_verify_enabled: false, promo_code_enabled: true, + password_reset_enabled: false, turnstile_enabled: false, turnstile_site_key: '', site_name: siteName.value, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 37c9f030..6f4b3e50 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -71,6 +71,7 @@ export interface PublicSettings { registration_enabled: boolean email_verify_enabled: boolean promo_code_enabled: boolean + password_reset_enabled: boolean turnstile_enabled: boolean turnstile_site_key: string site_name: string diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 7ebca114..ed095254 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -338,6 +338,22 @@ + + +
+
+ +

+ {{ t('admin.settings.registration.passwordResetHint') }} +

+
+ +
@@ -1029,6 +1045,7 @@ const form = reactive({ registration_enabled: true, email_verify_enabled: false, promo_code_enabled: true, + password_reset_enabled: false, default_balance: 0, default_concurrency: 1, site_name: 'Sub2API', @@ -1152,6 +1169,7 @@ async function saveSettings() { registration_enabled: form.registration_enabled, email_verify_enabled: form.email_verify_enabled, promo_code_enabled: form.promo_code_enabled, + password_reset_enabled: form.password_reset_enabled, default_balance: form.default_balance, default_concurrency: form.default_concurrency, site_name: form.site_name, diff --git a/frontend/src/views/auth/ForgotPasswordView.vue b/frontend/src/views/auth/ForgotPasswordView.vue new file mode 100644 index 00000000..22799a9a --- /dev/null +++ b/frontend/src/views/auth/ForgotPasswordView.vue @@ -0,0 +1,297 @@ + + + + + diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 6e6cee27..3232048a 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -72,9 +72,19 @@ -

- {{ errors.password }} -

+
+

+ {{ errors.password }} +

+ + + {{ t('auth.forgotPassword') }} + +
@@ -184,6 +194,7 @@ const showPassword = ref(false) const turnstileEnabled = ref(false) const turnstileSiteKey = ref('') const linuxdoOAuthEnabled = ref(false) +const passwordResetEnabled = ref(false) // Turnstile const turnstileRef = ref | null>(null) @@ -216,6 +227,7 @@ onMounted(async () => { turnstileEnabled.value = settings.turnstile_enabled turnstileSiteKey.value = settings.turnstile_site_key || '' linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled + passwordResetEnabled.value = settings.password_reset_enabled } catch (error) { console.error('Failed to load public settings:', error) } diff --git a/frontend/src/views/auth/ResetPasswordView.vue b/frontend/src/views/auth/ResetPasswordView.vue new file mode 100644 index 00000000..94c153e1 --- /dev/null +++ b/frontend/src/views/auth/ResetPasswordView.vue @@ -0,0 +1,355 @@ + + + + +