Implement TFE file access restriction feature (complete)

Requirements:
- parametres.php toggle: 'restricted_files_enabled' enables/disables the feature
- Public TFE page: when enabled + access_type=Interne, hides files, shows French
  restriction message + access request form (metadata/synopsis still visible)
- ERG emails (@erg.school / @erg.be): auto-approve, send 24h access link immediately
- External emails: show justification textarea, create pending request, notify admin
- Admin panel /admin/file-access.php: approve/reject requests with optional notes,
  sends access email on approval (linked from admin nav with pending count badge)

Security:
- One-time 24h email tokens (used_at + is_valid=0 on first click)
- Token redeemed via POST /validate-access (GET shows confirmation page only)
- Long-lived 30-day browser session in file_access_sessions table
- Cookie: HttpOnly + Secure + SameSite=Strict
- CSRF on all mutations, rate limiting on request submission
- Audit trail: IP, UA, event, timestamp in file_access_audit

Bug fixes:
- admin/file-access.php: $vars never extract()ed → page was blank
- Template had self-contained head/footer includes (double-include)
- Admin approval URL used $requestId instead of $request['thesis_id']
- App::boot() now starts session so CSRF token works on public pages
- Dispatcher routes /validate-access and /request-access through front controller
This commit is contained in:
Pontoporeia
2026-04-27 20:12:43 +02:00
parent 5c776dd39e
commit 27e1b6828d
21 changed files with 6256 additions and 15 deletions

View File

@@ -0,0 +1,328 @@
<?php
/**
* Request Access Endpoint
*
* Handles file access requests for restricted TFEs.
* - Validates CSRF token
* - Rate limiting (per IP)
* - Auto-approves @erg.school and @erg.be emails (sends 24h one-time link)
* - Creates pending requests for external emails (admin approval)
* - Sends notification emails via SMTP
*
* Security:
* - CSRF required (POST only)
* - Rate limit: 3 requests per 10 min per IP
* - Token sent in email body only (never in URL query as a direct action)
* - Token is short-lived (24h) and one-time use
* - Redemption link goes to GET /validate-access (confirmation page) then POST to redeem
*/
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/RateLimit.php';
require_once APP_ROOT . '/src/SmtpRelay.php';
header('Content-Type: application/json');
// Only accept POST requests
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Méthode non autorisée']);
exit;
}
// Boot session for CSRF
App::boot();
// Validate CSRF token
if (
empty($_POST['csrf_token'])
|| empty($_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Token de sécurité invalide']);
exit;
}
// Validate input
$thesisId = isset($_POST['thesis_id']) ? (int)$_POST['thesis_id'] : 0;
$email = isset($_POST['email']) ? trim($_POST['email']) : '';
$justification = isset($_POST['justification']) ? trim($_POST['justification']) : '';
if ($thesisId <= 0 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Données invalides']);
exit;
}
// Truncate justification to a safe length
if (mb_strlen($justification) > 2000) {
$justification = mb_substr($justification, 0, 2000);
}
$db = Database::getInstance();
// Check if thesis exists and is published
$thesis = $db->getThesis($thesisId);
if (!$thesis || !$thesis['is_published']) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'TFE non trouvé']);
exit;
}
// Check if restricted files feature is enabled
if (!$db->isRestrictedFilesEnabled()) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Cette fonctionnalité est désactivée']);
exit;
}
// Check if thesis actually has restricted access (access_type_id = 2)
$accessTypeId = $db->getThesisAccessTypeId($thesisId);
if ($accessTypeId !== 2) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Ce TFE ne nécessite pas d\'accès restreint']);
exit;
}
// Rate limiting: max 3 requests per 10 minutes per IP
$rateLimitKey = 'access_request_' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
if (!RateLimit::check($rateLimitKey, 3, 600)) {
http_response_code(429);
echo json_encode(['success' => false, 'message' => 'Trop de requêtes. Veuillez réessayer dans quelques minutes.']);
exit;
}
// Check for existing request
$existingRequest = $db->getExistingAccessRequest($thesisId, $email);
if ($existingRequest) {
if ($existingRequest['status'] === 'approved') {
// Re-generate and re-send a fresh 24h token
try {
$newToken = $db->generateAccessToken((int)$existingRequest['id'], 24);
$host = $_SERVER['HTTP_HOST'] ?? 'xamxam.erg.be';
$accessUrl = "https://{$host}/validate-access?token={$newToken}&thesis={$thesisId}";
$subject = "Accès (renvoi) - TFE : " . $thesis['title'];
$body = buildAutoApprovalEmail($thesis['title'], $thesis['authors'] ?? '', $accessUrl);
$plain = htmlToPlain($body);
SmtpRelay::send($db, $email, $subject, $body, $plain);
echo json_encode([
'success' => true,
'message' => 'Un nouvel email d\'accès vous a été envoyé.',
'status' => 'resent',
]);
} catch (Exception $e) {
error_log('Access request resend failed: ' . $e->getMessage());
echo json_encode(['success' => true, 'message' => 'Votre accès est déjà approuvé. Si vous n\'avez pas reçu l\'email, contactez l\'administrateur.']);
}
exit;
}
if ($existingRequest['status'] === 'pending') {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'Une demande est déjà en cours de traitement pour cet email.',
'status' => 'already_pending',
]);
exit;
}
}
// Determine if auto-approved (ERG domain)
$emailLower = strtolower($email);
$isErgEmail = (
str_ends_with($emailLower, '@erg.school') ||
str_ends_with($emailLower, '@erg.be')
);
$host = $_SERVER['HTTP_HOST'] ?? 'xamxam.erg.be';
try {
if ($isErgEmail) {
// Auto-approve: create request + short-lived 24h token
$requestId = $db->createFileAccessRequest($thesisId, $email, null);
$token = $db->approveAccessRequest($requestId, null, 24);
$accessUrl = "https://{$host}/validate-access?token={$token}&thesis={$thesisId}";
$subject = "Accès accordé - TFE : " . $thesis['title'];
$body = buildAutoApprovalEmail($thesis['title'], $thesis['authors'] ?? '', $accessUrl);
$plain = htmlToPlain($body);
SmtpRelay::send($db, $email, $subject, $body, $plain);
http_response_code(200);
echo json_encode([
'success' => true,
'message' => 'Un email avec le lien d\'accès vous a été envoyé. Le lien est valable 24 heures.',
'status' => 'auto_approved',
]);
} else {
// External email: justification required, create pending request
if (empty($justification)) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'Une justification est requise pour les emails externes à l\'ERG.',
'status' => 'justification_required',
]);
exit;
}
$requestId = $db->createFileAccessRequest($thesisId, $email, $justification);
// Notify admins
$thesisYear = $thesis['year'] ?? '';
$subject = "Nouvelle demande d'accès - TFE : " . $thesis['title'];
$body = buildAdminNotificationEmail(
$thesis['title'],
$thesis['authors'] ?? '',
$thesisYear,
$email,
$justification,
$host
);
$plain = htmlToPlain($body);
$settings = SmtpRelay::getSettings($db);
if (!empty($settings['from_email'])) {
SmtpRelay::send($db, $settings['from_email'], $subject, $body, $plain);
}
http_response_code(200);
echo json_encode([
'success' => true,
'message' => 'Votre demande a été envoyée et sera examinée par un administrateur.',
'status' => 'pending',
]);
}
} catch (Exception $e) {
error_log('Access request failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Erreur lors du traitement de la demande']);
}
// ─────────────────────────────────────────────────────────────────────────────
// Email template helpers
// ─────────────────────────────────────────────────────────────────────────────
/**
* Build the auto-approval email sent to ERG-domain users.
* The link leads to a confirmation page (GET) that then requires a POST to redeem.
*/
function buildAutoApprovalEmail(string $title, string $authors, string $accessUrl): string
{
$safeTitle = htmlspecialchars($title, ENT_QUOTES);
$safeAuthors = htmlspecialchars($authors, ENT_QUOTES);
$safeUrl = htmlspecialchars($accessUrl, ENT_QUOTES);
return <<<HTML
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,Arial,sans-serif;line-height:1.6;color:#333">
<div style="max-width:600px;margin:0 auto;padding:20px">
<h2 style="color:#2c5282">Accès accordé au TFE</h2>
<p>Bonjour,</p>
<p>Votre demande d'accès au TFE suivant a été automatiquement approuvée :</p>
<div style="background:#f7fafc;padding:15px;border-left:4px solid #2c5282;margin:20px 0">
<strong>Titre :</strong> {$safeTitle}<br>
<strong>Auteur(s) :</strong> {$safeAuthors}
</div>
<p>Cliquez sur le bouton ci-dessous pour activer l'accès aux fichiers de ce TFE :</p>
<div style="text-align:center;margin:30px 0">
<a href="{$safeUrl}"
style="display:inline-block;padding:12px 30px;background-color:#2c5282;
color:#fff;text-decoration:none;border-radius:5px;font-weight:bold">
Activer l'accès au TFE
</a>
</div>
<p><strong>Ce lien est valable 24 heures et à usage unique.</strong>
L'accès sur votre appareil sera ensuite conservé 30 jours.</p>
<p style="margin-top:30px;color:#666;font-size:.9em">
Cordialement,<br>L'équipe XAMXAM ERG
</p>
</div>
</body>
</html>
HTML;
}
/**
* Build the admin notification email for external (non-ERG) access requests.
*/
function buildAdminNotificationEmail(
string $title,
string $authors,
string $year,
string $email,
string $justification,
string $host
): string {
$safeTitle = htmlspecialchars($title, ENT_QUOTES);
$safeAuthors = htmlspecialchars($authors, ENT_QUOTES);
$safeYear = htmlspecialchars($year, ENT_QUOTES);
$safeEmail = htmlspecialchars($email, ENT_QUOTES);
$safeJustification = nl2br(htmlspecialchars($justification, ENT_QUOTES));
$adminUrl = htmlspecialchars("https://{$host}/admin/file-access.php", ENT_QUOTES);
return <<<HTML
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,Arial,sans-serif;line-height:1.6;color:#333">
<div style="max-width:600px;margin:0 auto;padding:20px">
<h2 style="color:#c53030">Nouvelle demande d'accès</h2>
<p>Une nouvelle demande d'accès a été soumise :</p>
<div style="background:#fff5f5;padding:15px;border-left:4px solid #c53030;margin:20px 0">
<strong>TFE :</strong> {$safeTitle}<br>
<strong>Auteur(s) :</strong> {$safeAuthors}<br>
<strong>Année :</strong> {$safeYear}
</div>
<div style="background:#f7fafc;padding:15px;margin:20px 0">
<strong>Email du demandeur :</strong> {$safeEmail}<br><br>
<strong>Justification :</strong><br>
{$safeJustification}
</div>
<div style="margin:30px 0;text-align:center">
<a href="{$adminUrl}"
style="display:inline-block;padding:12px 30px;background-color:#2c5282;
color:#fff;text-decoration:none;border-radius:5px;font-weight:bold">
Gérer les demandes d'accès
</a>
</div>
<p style="color:#666;font-size:.9em">
Connectez-vous au panneau d'administration pour approuver ou rejeter cette demande.
</p>
</div>
</body>
</html>
HTML;
}
/**
* Convert HTML to plain text for the email text/plain part.
*/
function htmlToPlain(string $html): string
{
$text = strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $html));
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = preg_replace('/\n{3,}/', "\n\n", $text);
return trim($text);
}