package controllers import ( cryptorand "crypto/rand" "encoding/hex" "fmt" "net/http" "strconv" "strings" "time" "fotbal-club/internal/config" "fotbal-club/internal/models" "fotbal-club/pkg/email" "fotbal-club/pkg/logger" "fotbal-club/pkg/utils" "github.com/gin-gonic/gin" "gorm.io/gorm" ) const ( verificationCodeLength = 6 verificationCodeExpiry = 10 * time.Minute maxVerificationAttempts = 3 ) type PasswordController struct { DB *gorm.DB EmailService email.EmailService } // generateVerificationCode generates a random 6-digit verification code (crypto-secure) func generateVerificationCode() string { const digits = "0123456789" b := make([]byte, verificationCodeLength) for i := 0; i < verificationCodeLength; i++ { // draw a random byte and map into 0-9 range without modulo bias concerns for small range var rb [1]byte if _, err := cryptorand.Read(rb[:]); err != nil { // fallback to time-based digit if crypto fails (extremely unlikely) b[i] = digits[int(time.Now().UnixNano())%10] continue } b[i] = digits[int(rb[0])%10] } return string(b) } // InitiatePasswordReset starts the password reset process by sending a verification code func (pc *PasswordController) InitiatePasswordReset(c *gin.Context) { var req InitiatePasswordResetRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, PasswordResetResponse{ Success: false, Message: "Neplatný formát požadavku: " + err.Error(), }) return } emailAddr := strings.TrimSpace(strings.ToLower(req.Email)) if emailAddr == "" || !strings.Contains(emailAddr, "@") { c.JSON(http.StatusBadRequest, PasswordResetResponse{ Success: false, Message: "Neplatná e-mailová adresa", }) return } // Check if email service is available if pc.EmailService == nil { c.JSON(http.StatusInternalServerError, PasswordResetResponse{ Success: false, Message: "E-mailová služba není k dispozici. Kontaktujte prosím správce.", }) return } // Check if user exists var user models.User if err := pc.DB.Where("LOWER(email) = LOWER(?)", emailAddr).First(&user).Error; err != nil { if err == gorm.ErrRecordNotFound { // Return success to prevent email enumeration c.JSON(http.StatusOK, PasswordResetResponse{ Success: true, Message: "Pokud účet s tímto e-mailem existuje, byl odeslán ověřovací kód.", NextStep: "verify_code", }) return } // Other database error logger.Error("Database error when checking user: error=%v email=%s", err, emailAddr) c.JSON(http.StatusInternalServerError, PasswordResetResponse{ Success: false, Message: "Došlo k chybě při ověřování e-mailu", }) return } // Generate a secure random token token := make([]byte, 32) if _, err := cryptorand.Read(token); err != nil { logger.Error("Failed to generate random token: %v", err) c.JSON(http.StatusInternalServerError, PasswordResetResponse{ Success: false, Message: "Nepodařilo se vygenerovat bezpečnostní token", }) return } tokenStr := hex.EncodeToString(token) // Generate verification code verificationCode := generateVerificationCode() expiresAt := time.Now().Add(verificationCodeExpiry) // Create or update password reset record var pr models.PasswordReset err := pc.DB.Transaction(func(tx *gorm.DB) error { // Invalidate any existing reset tokens for this user if err := tx.Model(&models.PasswordReset{}). Where("user_id = ? AND used_at IS NULL", user.ID). Update("used_at", time.Now()).Error; err != nil { return err } // Create new reset record pr = models.PasswordReset{ UserID: user.ID, Token: tokenStr, ExpiresAt: time.Now().Add(time.Hour), VerificationCode: verificationCode, VerificationCodeExpires: &expiresAt, VerificationAttempts: 0, IP: c.ClientIP(), UserAgent: c.Request.UserAgent(), } return tx.Create(&pr).Error }) if err != nil { logger.Error("Failed to create password reset record: %v", err) c.JSON(http.StatusInternalServerError, PasswordResetResponse{ Success: false, Message: "Nepodařilo se vytvořit záznam pro obnovu hesla", }) return } // Send verification code via email if seCode, ok := pc.EmailService.(interface{ SendPasswordResetCode(to, code string) error }); ok { if err := seCode.SendPasswordResetCode(user.Email, verificationCode); err != nil { // Fallback to legacy method if available if seLegacy, ok2 := pc.EmailService.(interface { SendPasswordReset(to, resetLink string, useOverride bool) error }); ok2 { if err2 := seLegacy.SendPasswordReset(user.Email, verificationCode, true); err2 != nil { logger.Error("Failed to send verification code (both methods): error=%v email=%s", err2, emailAddr) if !config.AppConfig.Debug { c.JSON(http.StatusInternalServerError, PasswordResetResponse{ Success: false, Message: "Nepodařilo se odeslat ověřovací kód", }) return } } } else { logger.Error("No available email method for sending verification code: email=%s error=%v", emailAddr, err) if !config.AppConfig.Debug { c.JSON(http.StatusInternalServerError, PasswordResetResponse{ Success: false, Message: "Nepodařilo se odeslat ověřovací kód", }) return } } } } else if seLegacy, ok := pc.EmailService.(interface { SendPasswordReset(to, resetLink string, useOverride bool) error }); ok { if err := seLegacy.SendPasswordReset(user.Email, verificationCode, true); err != nil { logger.Error("Failed to send verification code (legacy): error=%v email=%s", err, emailAddr) if !config.AppConfig.Debug { c.JSON(http.StatusInternalServerError, PasswordResetResponse{ Success: false, Message: "Nepodařilo se odeslat ověřovací kód", }) return } } } c.JSON(http.StatusOK, PasswordResetResponse{ Success: true, Message: "Ověřovací kód byl odeslán na váš e-mail.", NextStep: "verify_code", CodeRequired: true, }) } // VerifyResetCode verifies the reset code and returns a reset token if valid func (pc *PasswordController) VerifyResetCode(c *gin.Context) { var req VerifyResetCodeRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, PasswordResetResponse{ Success: false, Message: "Neplatný formát požadavku: " + err.Error(), }) return } emailAddr := strings.TrimSpace(strings.ToLower(req.Email)) if emailAddr == "" || !strings.Contains(emailAddr, "@") { c.JSON(http.StatusBadRequest, PasswordResetResponse{ Success: false, Message: "Neplatná e-mailová adresa", }) return } // Find the user var user models.User if err := pc.DB.Where("LOWER(email) = LOWER(?)", emailAddr).First(&user).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, PasswordResetResponse{ Success: false, Message: "Uživatel s tímto e-mailem nebyl nalezen", }) return } logger.Error("Database error when finding user: error=%v email=%s", err, emailAddr) c.JSON(http.StatusInternalServerError, PasswordResetResponse{ Success: false, Message: "Došlo k chybě při ověřování kódu", }) return } // Find the active reset record var pr models.PasswordReset now := time.Now() err := pc.DB.Transaction(func(tx *gorm.DB) error { // Find the most recent active reset record if err := tx.Where("user_id = ? AND used_at IS NULL AND expires_at > ?", user.ID, now). Order("created_at DESC").First(&pr).Error; err != nil { return err } // Check if verification code has expired if pr.VerificationCodeExpires == nil || pr.VerificationCodeExpires.Before(now) { return fmt.Errorf("verification code expired") } // Check if max attempts reached if pr.VerificationAttempts >= maxVerificationAttempts { return fmt.Errorf("max verification attempts reached") } // Check if code matches if pr.VerificationCode != req.Code { // Increment attempt counter if err := tx.Model(&pr). Update("verification_attempts", pr.VerificationAttempts+1).Error; err != nil { return err } return fmt.Errorf("invalid verification code") } // Code is valid; do not expire it here. Allow completion step to validate within original expiry window. return nil }) if err != nil { if err.Error() == "record not found" { c.JSON(http.StatusNotFound, PasswordResetResponse{ Success: false, Message: "Neplatný nebo expirovaný odkaz pro obnovení hesla", }) return } else if err.Error() == "verification code expired" { c.JSON(http.StatusBadRequest, PasswordResetResponse{ Success: false, Message: "Platnost ověřovacího kódu vypršela. Požádejte o nový kód.", }) return } else if err.Error() == "max verification attempts reached" { c.JSON(http.StatusTooManyRequests, PasswordResetResponse{ Success: false, Message: "Překročen maximální počet pokusů. Požádejte o nový kód.", }) return } else if err.Error() == "invalid verification code" { attemptsLeft := maxVerificationAttempts - pr.VerificationAttempts - 1 message := fmt.Sprintf("Neplatný ověřovací kód. Zbývající počet pokusů: %d", attemptsLeft) c.JSON(http.StatusBadRequest, PasswordResetResponse{ Success: false, Message: message, }) return } logger.Error("Error verifying reset code: error=%v email=%s", err, emailAddr) c.JSON(http.StatusInternalServerError, PasswordResetResponse{ Success: false, Message: "Došlo k chybě při ověřování kódu", }) return } // Return success with the reset token c.JSON(http.StatusOK, PasswordResetResponse{ Success: true, Message: "Kód byl úspěšně ověřen. Můžete nastavit nové heslo.", NextStep: "reset_password", }) } // CompletePasswordReset verifies the reset code and updates the password func (pc *PasswordController) CompletePasswordReset(c *gin.Context) { var req CompletePasswordResetRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, PasswordResetResponse{ Success: false, Message: "Neplatný formát požadavku: " + err.Error(), }) return } emailAddr := strings.TrimSpace(strings.ToLower(req.Email)) if emailAddr == "" || !strings.Contains(emailAddr, "@") { c.JSON(http.StatusBadRequest, PasswordResetResponse{ Success: false, Message: "Neplatná e-mailová adresa", }) return } // Find the user var user models.User if err := pc.DB.Where("LOWER(email) = LOWER(?)", emailAddr).First(&user).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, PasswordResetResponse{ Success: false, Message: "Uživatel s tímto e-mailem nebyl nalezen", }) return } logger.Error("Database error when finding user: error=%v email=%s", err, emailAddr) c.JSON(http.StatusInternalServerError, PasswordResetResponse{ Success: false, Message: "Došlo k chybě při obnovování hesla", }) return } // Verify the reset code and get the reset record var pr models.PasswordReset now := time.Now() err := pc.DB.Transaction(func(tx *gorm.DB) error { // Find the active reset record if err := tx.Where("user_id = ? AND used_at IS NULL AND expires_at > ?", user.ID, now). Order("created_at DESC").First(&pr).Error; err != nil { return err } // Check if verification code matches and is not expired if pr.VerificationCode != req.Code || pr.VerificationCodeExpires == nil || pr.VerificationCodeExpires.Before(now) { return fmt.Errorf("invalid or expired verification code") } // Update user's password hashedPassword, err := utils.HashPassword(req.NewPassword) if err != nil { return fmt.Errorf("failed to hash password: %w", err) } if err := tx.Model(&user).Update("password", hashedPassword).Error; err != nil { return fmt.Errorf("failed to update password: %w", err) } // Mark the reset record as used now := time.Now() pr.UsedAt = &now return tx.Save(&pr).Error }) if err != nil { if err.Error() == "record not found" || err.Error() == "invalid or expired verification code" { c.JSON(http.StatusBadRequest, PasswordResetResponse{ Success: false, Message: "Neplatný nebo expirovaný ověřovací kód. Zkuste to znovu.", }) return } logger.Error("Error completing password reset: error=%v email=%s", err, emailAddr) c.JSON(http.StatusInternalServerError, PasswordResetResponse{ Success: false, Message: "Došlo k chybě při obnovování hesla", }) return } c.JSON(http.StatusOK, PasswordResetResponse{ Success: true, Message: "Vaše heslo bylo úspěšně změněno. Nyní se můžete přihlásit.", NextStep: "login", }) } // AdminSendResetByID sends a reset email for a specific user ID using override SMTP func (pc *PasswordController) AdminSendResetByID(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var user models.User if err := pc.DB.First(&user, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) } return } // Check if email service is available if pc.EmailService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Email service not configured", "message": "SMTP settings must be configured in the admin panel or via environment variables (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM) before sending password reset emails.", }) return } var b [16]byte _, _ = cryptorand.Read(b[:]) token := hex.EncodeToString(b[:]) pr := &models.PasswordReset{ UserID: user.ID, Token: token, ExpiresAt: time.Now().Add(1 * time.Hour), IP: c.ClientIP(), UserAgent: c.Request.UserAgent(), } if err := pc.DB.Create(pr).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create reset record"}) return } base := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/") if base == "" { base = "http://localhost:3000" } link := base + "/reset-password?token=" + token if se, ok := pc.EmailService.(interface { SendPasswordReset(to, link string, useOverride bool) error }); ok { if err := se.SendPasswordReset(user.Email, link, true); err != nil { logger.Error("Failed to send password reset email: error=%v user_id=%d", err, user.ID) c.JSON(http.StatusInternalServerError, gin.H{ "error": "failed to send reset email", "message": "SMTP configuration may be invalid or incomplete. Check SMTP_HOST, SMTP_USER, SMTP_PASSWORD, SMTP_FROM in .env file or configure in admin settings.", "details": err.Error(), }) return } } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Email service does not support password reset"}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } func NewPasswordController(db *gorm.DB, es email.EmailService) *PasswordController { return &PasswordController{DB: db, EmailService: es} } type forgotPasswordRequest struct { Email string `json:"email" binding:"required"` } // ForgotPassword creates a reset token and sends email func (pc *PasswordController) ForgotPassword(c *gin.Context) { var req forgotPasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } emailAddr := strings.TrimSpace(strings.ToLower(req.Email)) if emailAddr == "" || !strings.Contains(emailAddr, "@") { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid email"}) return } // Lookup user; don't reveal whether exists var user models.User if err := pc.DB.Where("LOWER(email) = LOWER(?)", emailAddr).First(&user).Error; err != nil { // Respond success to avoid user enumeration c.JSON(http.StatusOK, gin.H{"success": true}) return } // Create token var b [16]byte _, _ = cryptorand.Read(b[:]) token := hex.EncodeToString(b[:]) pr := &models.PasswordReset{ UserID: user.ID, Token: token, ExpiresAt: time.Now().Add(1 * time.Hour), IP: c.ClientIP(), UserAgent: c.Request.UserAgent(), } if err := pc.DB.Create(pr).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create reset"}) return } // Build reset link base := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/") link := base + "/reset-password?token=" + token // Send email via default SMTP if se, ok := pc.EmailService.(interface { SendPasswordReset(to, link string, useOverride bool) error }); ok { if err := se.SendPasswordReset(user.Email, link, false); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send reset email"}) return } } c.JSON(http.StatusOK, gin.H{"success": true}) } type resetPasswordRequest struct { Token string `json:"token" binding:"required"` NewPassword string `json:"new_password" binding:"required,min=8"` } // ResetPassword consumes a token and sets a new password func (pc *PasswordController) ResetPassword(c *gin.Context) { var req resetPasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var pr models.PasswordReset if err := pc.DB.Where("token = ?", req.Token).First(&pr).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token"}) return } if pr.UsedAt != nil || time.Now().After(pr.ExpiresAt) { c.JSON(http.StatusBadRequest, gin.H{"error": "token expired or used"}) return } var user models.User if err := pc.DB.First(&user, pr.UserID).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "user not found"}) return } hashed, err := utils.HashPassword(req.NewPassword) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash"}) return } user.Password = hashed if err := pc.DB.Save(&user).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}) return } now := time.Now() pr.UsedAt = &now _ = pc.DB.Save(&pr).Error c.JSON(http.StatusOK, gin.H{"success": true}) } type adminSendResetRequest struct { Email string `json:"email" binding:"required"` } // AdminSendReset sends a reset email using special SMTP when key matches func (pc *PasswordController) AdminSendReset(c *gin.Context) { var req adminSendResetRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } emailAddr := strings.TrimSpace(strings.ToLower(req.Email)) var user models.User if err := pc.DB.Where("LOWER(email) = LOWER(?)", emailAddr).First(&user).Error; err != nil { // respond ok regardless c.JSON(http.StatusOK, gin.H{"success": true}) return } // Create token always fresh var b [16]byte _, _ = cryptorand.Read(b[:]) token := hex.EncodeToString(b[:]) pr := &models.PasswordReset{ UserID: user.ID, Token: token, ExpiresAt: time.Now().Add(1 * time.Hour), IP: c.ClientIP(), UserAgent: c.Request.UserAgent(), } if err := pc.DB.Create(pr).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed"}) return } base := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/") link := base + "/reset-password?token=" + token if se, ok := pc.EmailService.(interface { SendPasswordReset(to, link string, useOverride bool) error }); ok { if err := se.SendPasswordReset(user.Email, link, true); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send reset email"}) return } } c.JSON(http.StatusOK, gin.H{"success": true}) }