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.
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.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
// 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