package services import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "encoding/hex" "fmt" "time" "github.com/google/uuid" "veza-backend-api/internal/database" "go.uber.org/zap" ) // EmailVerificationService gère la génération, le stockage et la validation des tokens de vérification email // T0182: Service pour gérer les tokens de vérification email avec expiration et invalidation type EmailVerificationService struct { db *database.Database logger *zap.Logger } // NewEmailVerificationService crée une nouvelle instance d'EmailVerificationService func NewEmailVerificationService(db *database.Database, logger *zap.Logger) *EmailVerificationService { return &EmailVerificationService{ db: db, logger: logger, } } // GenerateToken génère un token aléatoire sécurisé de 32 bytes encodé en base64 URL-safe // T0182: Génère un token aléatoire pour la vérification d'email func (s *EmailVerificationService) GenerateToken() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { s.logger.Error("Failed to generate random token", zap.Error(err)) return "", fmt.Errorf("failed to generate token: %w", err) } return base64.URLEncoding.EncodeToString(bytes), nil } // hashToken helper pour hasher le token func (s *EmailVerificationService) hashToken(token string) string { hash := sha256.Sum256([]byte(token)) return hex.EncodeToString(hash[:]) } // StoreToken stocke un token de vérification en base de données avec une expiration de 24h // T0182: Sauvegarde le token avec expiration 24h // MIGRATION UUID: userID est maintenant int64 // MIGRATION UUID: userID migré vers uuid.UUID func (s *EmailVerificationService) StoreToken(userID uuid.UUID, email, token string) error { ctx := context.Background() expiresAt := time.Now().Add(24 * time.Hour) tokenHash := s.hashToken(token) // Note: On insère le token hash dans token_hash et NULL dans token (si la colonne existe et est nullable) // ou on garde token plain text si schema legacy l'exige, mais l'erreur dit token_hash NOT NULL. // On ne connait pas la contrainte sur 'token'. On va supposer qu'on peut migrer vers le hash. // Si 'token' est aussi NOT NULL, il faudra le remplir. Mais pour la sécurité, on ne devrait pas. // Essayons de remplir les deux pour compatibilité si besoin, ou juste le hash si 'token' est nullable. // D'apres le code existant qui insérait 'token', la colonne 'token' existe. // On va insérer le hash dans 'token_hash' ET le token dans 'token' (pour l'instant, pour éviter une erreur not-null sur 'token' si elle existe). // EDIT: Secure practice -> token should be hashed. Plain token column should be removed or nullable. // L'erreur précédente était "null value in column token_hash". // Supposons que 'token' column est NULLABLE ou supprimé? // Tente d'écrire dans 'token_hash' et 'token'. _, err := s.db.ExecContext(ctx, "INSERT INTO email_verification_tokens (user_id, email, token, token_hash, expires_at, used) VALUES ($1, $2, $3, $4, $5, FALSE)", userID, email, token, tokenHash, expiresAt, ) if err != nil { s.logger.Error("Failed to store verification token", zap.String("user_id", userID.String()), zap.Error(err), ) return fmt.Errorf("failed to store token: %w", err) } s.logger.Info("Verification token stored", zap.String("user_id", userID.String()), zap.Time("expires_at", expiresAt), ) return nil } // VerifyToken valide un token de vérification, vérifie son expiration et le marque comme utilisé // T0182: Valide le token, vérifie l'expiration et marque comme utilisé // MIGRATION UUID: retourne uuid.UUID au lieu de int64 func (s *EmailVerificationService) VerifyToken(token string) (uuid.UUID, error) { ctx := context.Background() var userID uuid.UUID var expiresAt time.Time var used bool tokenHash := s.hashToken(token) err := s.db.QueryRowContext(ctx, "SELECT user_id, expires_at, used FROM email_verification_tokens WHERE token_hash = $1", tokenHash, ).Scan(&userID, &expiresAt, &used) if err == sql.ErrNoRows { tokenPreview := token if len(token) > 8 { tokenPreview = token[:8] + "..." } s.logger.Warn("Verification token not found", zap.String("token", tokenPreview)) return uuid.Nil, fmt.Errorf("invalid token") } if err != nil { s.logger.Error("Failed to verify token", zap.Error(err)) return uuid.Nil, fmt.Errorf("failed to verify token: %w", err) } if used { tokenPreview := token if len(token) > 8 { tokenPreview = token[:8] + "..." } s.logger.Warn("Verification token already used", zap.String("user_id", userID.String()), zap.String("token", tokenPreview), ) return uuid.Nil, fmt.Errorf("token already used") } if time.Now().After(expiresAt) { s.logger.Warn("Verification token expired", zap.String("user_id", userID.String()), zap.Time("expires_at", expiresAt), ) return uuid.Nil, fmt.Errorf("token expired") } // Mark as used _, err = s.db.ExecContext(ctx, "UPDATE email_verification_tokens SET used = TRUE WHERE token_hash = $1", tokenHash) if err != nil { s.logger.Error("Failed to mark token as used", zap.String("user_id", userID.String()), zap.Error(err), ) return uuid.Nil, fmt.Errorf("failed to mark token as used: %w", err) } s.logger.Info("Verification token verified successfully", zap.String("user_id", userID.String()), ) return userID, nil } // InvalidateOldTokens invalide tous les tokens de vérification précédents pour un utilisateur // T0182: Invalide les tokens précédents pour un utilisateur // MIGRATION UUID: userID migré vers uuid.UUID func (s *EmailVerificationService) InvalidateOldTokens(userID uuid.UUID) error { ctx := context.Background() result, err := s.db.ExecContext(ctx, "UPDATE email_verification_tokens SET used = TRUE WHERE user_id = $1 AND used = FALSE", userID, ) if err != nil { s.logger.Error("Failed to invalidate old tokens", zap.String("user_id", userID.String()), zap.Error(err), ) return fmt.Errorf("failed to invalidate old tokens: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { s.logger.Warn("Failed to get rows affected", zap.Error(err)) } else { s.logger.Info("Old verification tokens invalidated", zap.String("user_id", userID.String()), zap.Int64("tokens_invalidated", rowsAffected), ) } return nil }