verified 14. Verificação de Compra de Cursos - Sistema de Acesso

Depois de implementar pagamentos, o próximo passo crítico é proteger o conteúdo. Não basta apenas processar pagamentos - você precisa garantir que apenas usuários que pagaram consigam acessar os cursos. Essa verificação deve acontecer tanto no backend (segurança real) quanto no frontend (experiência do usuário).

Nesta seção, você vai implementar: middleware de verificação de compra, sistema de expiração de acesso (caso venda assinaturas), proteção de rotas no frontend, e endpoints para verificar se o usuário tem acesso a um curso específico. Tudo isso com performance otimizada usando cache.

error
CRÍTICO: NUNCA confie apenas no frontend para controlar acesso. Sempre valide no backend antes de retornar dados sensíveis. Atacantes podem manipular JavaScript facilmente.

account_tree Fluxo de Verificação de Acesso

┌─────────────────────────────────────────────────────────────┐
│         FLUXO DE VERIFICAÇÃO DE ACESSO A CURSOS             │
└─────────────────────────────────────────────────────────────┘

1. USUÁRIO TENTA ACESSAR CURSO
   React → GET /courses/123
   
2. FRONTEND VERIFICA AUTENTICAÇÃO
   ProtectedRoute → Verifica se user está logado
   Se NÃO → Redireciona para /login
   Se SIM → Continua
   
3. BACKEND VERIFICA COMPRA
   PHP Middleware → Valida sessão
   PHP → Query: SELECT * FROM user_courses WHERE user_id=? AND course_id=?
   
   SE encontrou registro:
     - Verifica se não expirou (expires_at > NOW())
     - Retorna conteúdo do curso
   
   SE NÃO encontrou:
     - HTTP 403 Forbidden
     - { error: "Você precisa comprar este curso" }
   
4. FRONTEND EXIBE CONTEÚDO OU PAYWALL
   SE tem acesso:
     → Renderiza 
   
   SE NÃO tem acesso:
     → Renderiza 
     → Botões: "Comprar com PIX" / "Comprar com Cartão"
   
5. VERIFICAÇÕES ADICIONAIS
   - Cache de verificações (evita queries repetidas)
   - Logs de tentativas de acesso não autorizado
   - Rate limiting para evitar ataques

storage Estrutura de Tabela user_courses

-- Tabela que registra quem comprou o que
CREATE TABLE user_courses (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    course_id INT NOT NULL,
    
    -- Datas de acesso
    purchased_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP NULL COMMENT 'NULL = acesso vitalício',
    last_accessed_at TIMESTAMP NULL,
    
    -- Metadados
    order_id INT NULL COMMENT 'Referência ao pedido que liberou o acesso',
    access_type ENUM('purchase', 'subscription', 'admin_grant') DEFAULT 'purchase',
    
    -- Constraints
    UNIQUE KEY unique_user_course (user_id, course_id),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE,
    FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE SET NULL,
    
    -- Índices para performance
    INDEX idx_user_id (user_id),
    INDEX idx_course_id (course_id),
    INDEX idx_expires_at (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- View útil: Cursos com acesso ativo
CREATE VIEW active_user_courses AS
SELECT 
    uc.*,
    u.name as user_name,
    u.email as user_email,
    c.title as course_title
FROM user_courses uc
JOIN users u ON uc.user_id = u.id
JOIN courses c ON uc.course_id = c.id
WHERE uc.expires_at IS NULL OR uc.expires_at > NOW();

code Backend PHP - Middleware de Verificação

// middleware/CourseAccessMiddleware.php
db = $db;
    }
    
    /**
     * Verifica se o usuário tem acesso ao curso
     */
    public function requireCourseAccess(int $courseId) {
        return function($request, $response, $next) use ($courseId) {
            $userId = $_SESSION['user_id'] ?? null;
            
            // 1. Verificar autenticação
            if (!$userId) {
                return $response->json([
                    'error' => 'Não autenticado',
                    'code' => 'UNAUTHENTICATED'
                ], 401);
            }
            
            // 2. Verificar se tem acesso ao curso
            if (!$this->hasAccess($userId, $courseId)) {
                return $response->json([
                    'error' => 'Você precisa comprar este curso para acessá-lo',
                    'code' => 'ACCESS_DENIED',
                    'course_id' => $courseId
                ], 403);
            }
            
            // 3. Atualizar último acesso
            $this->updateLastAccess($userId, $courseId);
            
            // 4. Permitir acesso
            return $next($request, $response);
        };
    }
    
    /**
     * Verifica se o usuário tem acesso ao curso
     */
    public function hasAccess(int $userId, int $courseId): bool {
        // Cache key
        $cacheKey = "access_{$userId}_{$courseId}";
        
        // Verificar cache
        if (isset($this->cache[$cacheKey])) {
            return $this->cache[$cacheKey];
        }
        
        // Query no banco
        $sql = "
            SELECT 
                id,
                expires_at
            FROM user_courses
            WHERE user_id = ? AND course_id = ?
        ";
        
        $stmt = $this->db->prepare($sql);
        $stmt->execute([$userId, $courseId]);
        $access = $stmt->fetch(PDO::FETCH_ASSOC);
        
        if (!$access) {
            $this->cache[$cacheKey] = false;
            return false;
        }
        
        // Verificar expiração
        if ($access['expires_at'] !== null) {
            $expired = strtotime($access['expires_at']) < time();
            if ($expired) {
                $this->cache[$cacheKey] = false;
                return false;
            }
        }
        
        $this->cache[$cacheKey] = true;
        return true;
    }
    
    /**
     * Retorna todos os cursos que o usuário tem acesso
     */
    public function getUserCourses(int $userId): array {
        $sql = "
            SELECT 
                c.id,
                c.title,
                c.description,
                c.thumbnail_url,
                uc.purchased_at,
                uc.expires_at,
                uc.last_accessed_at,
                uc.access_type
            FROM user_courses uc
            JOIN courses c ON uc.course_id = c.id
            WHERE uc.user_id = ?
                AND (uc.expires_at IS NULL OR uc.expires_at > NOW())
            ORDER BY uc.purchased_at DESC
        ";
        
        $stmt = $this->db->prepare($sql);
        $stmt->execute([$userId]);
        
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
    
    /**
     * Atualiza último acesso do usuário ao curso
     */
    private function updateLastAccess(int $userId, int $courseId): void {
        $sql = "
            UPDATE user_courses
            SET last_accessed_at = NOW()
            WHERE user_id = ? AND course_id = ?
        ";
        
        $stmt = $this->db->prepare($sql);
        $stmt->execute([$userId, $courseId]);
    }
    
    /**
     * Concede acesso a um curso (usado por admins)
     */
    public function grantAccess(
        int $userId, 
        int $courseId, 
        ?string $expiresAt = null,
        string $accessType = 'admin_grant'
    ): bool {
        $sql = "
            INSERT INTO user_courses (user_id, course_id, expires_at, access_type)
            VALUES (?, ?, ?, ?)
            ON DUPLICATE KEY UPDATE
                expires_at = VALUES(expires_at),
                access_type = VALUES(access_type)
        ";
        
        $stmt = $this->db->prepare($sql);
        return $stmt->execute([$userId, $courseId, $expiresAt, $accessType]);
    }
    
    /**
     * Revoga acesso a um curso
     */
    public function revokeAccess(int $userId, int $courseId): bool {
        $sql = "DELETE FROM user_courses WHERE user_id = ? AND course_id = ?";
        $stmt = $this->db->prepare($sql);
        return $stmt->execute([$userId, $courseId]);
    }
}

api API Endpoints de Verificação

// api/courses/[id]/check-access.php
 'course_id é obrigatório']);
    exit;
}

if (!isset($_SESSION['user_id'])) {
    echo json_encode(['hasAccess' => false, 'reason' => 'not_authenticated']);
    exit;
}

try {
    $db = getDBConnection();
    $accessMiddleware = new App\Middleware\CourseAccessMiddleware($db);
    
    $hasAccess = $accessMiddleware->hasAccess($_SESSION['user_id'], $courseId);
    
    echo json_encode([
        'hasAccess' => $hasAccess,
        'userId' => $_SESSION['user_id'],
        'courseId' => (int) $courseId
    ]);
    
} catch (Exception $e) {
    error_log("Erro ao verificar acesso: " . $e->getMessage());
    http_response_code(500);
    echo json_encode(['error' => 'Erro ao verificar acesso']);
}


// api/courses/[id]/content.php
 'course_id é obrigatório']);
    exit;
}

try {
    $db = getDBConnection();
    $accessMiddleware = new App\Middleware\CourseAccessMiddleware($db);
    
    $userId = $_SESSION['user_id'] ?? null;
    
    // 1. Verificar autenticação
    if (!$userId) {
        http_response_code(401);
        echo json_encode(['error' => 'Não autenticado']);
        exit;
    }
    
    // 2. Verificar acesso
    if (!$accessMiddleware->hasAccess($userId, $courseId)) {
        http_response_code(403);
        echo json_encode([
            'error' => 'Acesso negado',
            'message' => 'Você precisa comprar este curso'
        ]);
        exit;
    }
    
    // 3. Buscar conteúdo do curso
    $stmt = $db->prepare("
        SELECT 
            id,
            title,
            description,
            full_content,
            video_urls,
            attachments,
            duration_hours
        FROM courses
        WHERE id = ?
    ");
    $stmt->execute([$courseId]);
    $course = $stmt->fetch(PDO::FETCH_ASSOC);
    
    if (!$course) {
        http_response_code(404);
        echo json_encode(['error' => 'Curso não encontrado']);
        exit;
    }
    
    // 4. Buscar lições/módulos do curso
    $stmt = $db->prepare("
        SELECT id, title, order_index, video_url, duration_minutes
        FROM course_lessons
        WHERE course_id = ?
        ORDER BY order_index ASC
    ");
    $stmt->execute([$courseId]);
    $lessons = $stmt->fetchAll(PDO::FETCH_ASSOC);
    
    $course['lessons'] = $lessons;
    
    // 5. Retornar conteúdo completo
    echo json_encode([
        'success' => true,
        'course' => $course
    ]);
    
} catch (Exception $e) {
    error_log("Erro ao buscar conteúdo: " . $e->getMessage());
    http_response_code(500);
    echo json_encode(['error' => 'Erro ao buscar conteúdo']);
}


// api/users/my-courses.php
 'Não autenticado']);
    exit;
}

try {
    $db = getDBConnection();
    $accessMiddleware = new App\Middleware\CourseAccessMiddleware($db);
    
    $courses = $accessMiddleware->getUserCourses($_SESSION['user_id']);
    
    echo json_encode([
        'success' => true,
        'courses' => $courses,
        'total' => count($courses)
    ]);
    
} catch (Exception $e) {
    error_log("Erro ao buscar cursos do usuário: " . $e->getMessage());
    http_response_code(500);
    echo json_encode(['error' => 'Erro ao buscar cursos']);
}

code Frontend React - Hook useHasAccess

// hooks/useHasAccess.js
import { useState, useEffect } from 'react';
import axios from 'axios';

const API_URL = import.meta.env.VITE_API_URL;

export const useHasAccess = (courseId) => {
    const [hasAccess, setHasAccess] = useState(false);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        if (!courseId) {
            setLoading(false);
            return;
        }

        checkAccess();
    }, [courseId]);

    const checkAccess = async () => {
        try {
            const response = await axios.get(
                `${API_URL}/courses/${courseId}/check-access`,
                { withCredentials: true }
            );

            setHasAccess(response.data.hasAccess);
        } catch (err) {
            console.error('Erro ao verificar acesso:', err);
            setError(err.response?.data?.error || 'Erro ao verificar acesso');
            setHasAccess(false);
        } finally {
            setLoading(false);
        }
    };

    return { hasAccess, loading, error, checkAccess };
};


// hooks/useMyCourses.js
import { useState, useEffect } from 'react';
import axios from 'axios';

const API_URL = import.meta.env.VITE_API_URL;

export const useMyCourses = () => {
    const [courses, setCourses] = useState([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        fetchMyCourses();
    }, []);

    const fetchMyCourses = async () => {
        try {
            const response = await axios.get(
                `${API_URL}/users/my-courses`,
                { withCredentials: true }
            );

            setCourses(response.data.courses);
        } catch (err) {
            console.error('Erro ao buscar cursos:', err);
            setError(err.response?.data?.error || 'Erro ao buscar cursos');
        } finally {
            setLoading(false);
        }
    };

    return { courses, loading, error, refetch: fetchMyCourses };
};


// components/ProtectedCourse.jsx
import { useParams, Navigate } from 'react-router-dom';
import { useHasAccess } from '../hooks/useHasAccess';
import { CoursePlayer } from './CoursePlayer';
import { PurchasePage } from './PurchasePage';

export const ProtectedCourse = () => {
    const { courseId } = useParams();
    const { hasAccess, loading } = useHasAccess(courseId);

    if (loading) {
        return (
            

Verificando acesso...

); } if (!hasAccess) { // Mostrar página de compra return ; } // Usuário tem acesso, mostrar conteúdo return ; }; // pages/MyCourses.jsx import { useMyCourses } from '../hooks/useMyCourses'; import { Link } from 'react-router-dom'; export const MyCourses = () => { const { courses, loading, error } = useMyCourses(); if (loading) { return
Carregando seus cursos...
; } if (error) { return
{error}
; } if (courses.length === 0) { return (

Você ainda não possui cursos

Explore nosso catálogo e comece a aprender hoje!

Ver Cursos Disponíveis
); } return (

Meus Cursos

Você possui {courses.length} curso(s)

{courses.map(course => (
{course.title}

{course.title}

{course.description}

Comprado em: {new Date(course.purchased_at).toLocaleDateString()} {course.expires_at && ( Expira em: {new Date(course.expires_at).toLocaleDateString()} )} {course.last_accessed_at && ( Último acesso: {new Date(course.last_accessed_at).toLocaleDateString()} )}
Continuar Aprendendo
))}
); };

security Proteção Adicional: Rate Limiting

warning
Atenção: Atacantes podem tentar acessar cursos fazendo milhares de requisições. Implemente rate limiting para proteger sua API.
// middleware/RateLimiter.php
increment($key, $windowSeconds);
            
            // Verificar limite
            if ($requests > $maxRequests) {
                return $response->json([
                    'error' => 'Muitas requisições. Tente novamente em breve.',
                    'retry_after' => $windowSeconds
                ], 429);
            }
            
            return $next($request, $response);
        };
    }
    
    private function increment(string $key, int $ttl): int {
        // Implementação simples com arquivo
        $file = sys_get_temp_dir() . '/' . md5($key) . '.txt';
        
        if (!file_exists($file)) {
            file_put_contents($file, json_encode([
                'count' => 1,
                'expires' => time() + $ttl
            ]));
            return 1;
        }
        
        $data = json_decode(file_get_contents($file), true);
        
        // Expirou? Resetar
        if ($data['expires'] < time()) {
            $data = ['count' => 1, 'expires' => time() + $ttl];
        } else {
            $data['count']++;
        }
        
        file_put_contents($file, json_encode($data));
        return $data['count'];
    }
}

check_circle Checklist de Segurança

✅ Backend

  • Sempre verificar acesso no backend
  • Validar sessão do usuário
  • Usar prepared statements (SQL Injection)
  • Implementar rate limiting
  • Logs de tentativas de acesso negado
  • Cache de verificações (performance)
  • Verificar expiração de acesso

✅ Frontend

  • Proteger rotas com ProtectedRoute
  • Verificar acesso antes de renderizar conteúdo
  • Exibir paywall se não tiver acesso
  • Não exibir links de cursos não comprados
  • Feedback claro ao usuário (por que não tem acesso)
  • Redirecionar para login se não autenticado
rocket_launch
Próximo Passo: Agora seu conteúdo está protegido! Na próxima seção, vamos implementar upload seguro de arquivos para permitir que usuários enviem avatares, certificados, etc.