Files
MyClub/internal/controllers/password_controller.go
T
Tomáš Dvořák 12cba639b9 upload
2025-10-16 13:32:05 +02:00

630 lines
20 KiB
Go

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})
}