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,124 @@
<?php
/**
* Access Request Action Handler
*
* Approve or reject file access requests.
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (!isset($_POST['csrf_token'], $_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;
}
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SmtpRelay.php';
$db = Database::getInstance();
$requestId = isset($_POST['request_id']) ? (int)$_POST['request_id'] : 0;
$action = $_POST['action'] ?? '';
$notes = $_POST['admin_notes'] ?? null;
if ($requestId <= 0 || !in_array($action, ['approve', 'reject'], true)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Données invalides']);
exit;
}
try {
$request = $db->getAccessRequestById($requestId);
if (!$request) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Demande non trouvée']);
exit;
}
if ($action === 'approve') {
// Generate token
$token = $db->approveAccessRequest($requestId);
// Send access email to user
$thesisTitle = $request['title'];
$thesisAuthors = $request['authors'] ?? '';
$accessUrl = "https://{$_SERVER['HTTP_HOST']}/validate-access?token={$token}&thesis={$request['thesis_id']}";
$subject = "Accès accordé - TFE: {$thesisTitle}";
$body = buildApprovalEmail($thesisTitle, $thesisAuthors, $accessUrl, $notes);
$plain = strip_tags($body);
SmtpRelay::send($db, $request['email'], $subject, $body, $plain);
App::flash('success', "Demande approuvée. Email envoyé à {$request['email']}.");
} elseif ($action === 'reject') {
$db->rejectAccessRequest($requestId, $notes);
// Optionally send rejection email (not implemented for now)
App::flash('success', "Demande rejetée.");
}
header('Location: /admin/file-access.php');
exit;
} catch (Exception $e) {
error_log('Access request action failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Erreur lors du traitement']);
}
/**
* Build approval notification email HTML
*/
function buildApprovalEmail(string $title, string $authors, string $accessUrl, ?string $adminNotes): string {
$notesHtml = '';
if (!empty($adminNotes)) {
$notesHtml = "<p><strong>Note de l'administrateur :</strong><br>" . htmlspecialchars($adminNotes) . "</p>";
}
return <<<HTML
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family: 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é approuvée par un administrateur :</p>
<div style="background: #f7fafc; padding: 15px; border-left: 4px solid #2c5282; margin: 20px 0;">
<strong>Titre :</strong> {$title}<br>
<strong>Auteur(s) :</strong> {$authors}
</div>
{$notesHtml}
<p>Pour accéder aux fichiers attachés, veuillez cliquer sur le lien ci-dessous :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{$accessUrl}"
style="display: inline-block; padding: 12px 30px; background-color: #2c5282;
color: white; text-decoration: none; border-radius: 5px; font-weight: bold;">
Accéder 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: 0.9em;">
Cordialement,<br>
L'équipe XAMXAM - ERG
</p>
</div>
</body>
</html>
HTML;
}

View File

@@ -17,7 +17,12 @@ $db = new Database();
$section = $_POST['section'] ?? '';
if ($section === 'formulaire') {
$allowed = ['access_type_libre_enabled', 'access_type_interne_enabled', 'access_type_interdit_enabled'];
$allowed = [
'access_type_libre_enabled',
'access_type_interne_enabled',
'access_type_interdit_enabled',
'restricted_files_enabled'
];
foreach ($allowed as $key) {
$value = isset($_POST[$key]) ? '1' : '0';
$db->setSetting($key, $value);

View File

@@ -0,0 +1,20 @@
<?php
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Controllers/FileAccessController.php';
$controller = FileAccessController::create();
$vars = $controller->handle();
extract($vars);
$pageTitle = 'Demandes d\'accès aux fichiers';
$isAdmin = true;
$bodyClass = 'admin-body';
require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
echo '<link rel="stylesheet" href="/assets/css/file-access.css">';
include APP_ROOT . '/templates/admin/file-access.php';
require_once APP_ROOT . '/templates/admin/footer.php';

View File

@@ -0,0 +1,282 @@
/* ============================================================
FILE ACCESS REQUESTS — ADMIN PAGE
============================================================ */
@import url("./variables.css");
.access-req-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--space-m);
margin-bottom: var(--space-l);
}
.access-req-stat-card {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-m);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
}
.access-req-stat-number {
font-size: var(--step-2);
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
.access-req-stat-label {
font-size: var(--step--1);
color: var(--text-secondary);
margin-top: var(--space-3xs);
}
/* Tab navigation */
.access-req-tabs {
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-l);
border-bottom: 2px solid var(--border);
}
.access-req-tab {
padding: var(--space-2xs) var(--space-s);
color: var(--text-secondary);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.2s, border-color 0.2s;
}
.access-req-tab:hover {
color: var(--text-primary);
}
.access-req-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 600;
}
/* Empty state */
.access-req-empty {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: var(--space-xl);
text-align: center;
color: var(--text-secondary);
}
/* Request list */
.access-req-list {
display: flex;
flex-direction: column;
gap: var(--space-m);
}
.access-req-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: var(--space-m);
transition: box-shadow 0.2s;
}
.access-req-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.access-req-card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-m);
margin-bottom: var(--space-s);
}
.access-req-card__thesis h3 {
font-size: var(--step-1);
font-weight: 600;
color: var(--text-primary);
margin: 0 0 var(--space-3xs) 0;
line-height: 1.3;
}
.access-req-card__authors {
font-size: var(--step--1);
color: var(--text-secondary);
margin: 0;
}
.access-req-card__meta {
flex-shrink: 0;
}
.access-req-badge {
display: inline-block;
padding: var(--space-3xs) var(--space-2xs);
border-radius: 12px;
font-size: var(--step--2);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.access-req-badge--pending {
background: #fef3c7;
color: #92400e;
}
.access-req-badge--approved {
background: #d1fae5;
color: #065f46;
}
.access-req-badge--rejected {
background: #fee2e2;
color: #991b1b;
}
.access-req-card__body {
display: flex;
flex-direction: column;
gap: var(--space-s);
}
.access-req-card__info {
display: flex;
flex-wrap: wrap;
gap: var(--space-m);
font-size: var(--step--1);
color: var(--text-secondary);
}
.access-req-card__info strong {
color: var(--text-primary);
margin-right: var(--space-3xs);
}
.access-req-card__info a {
color: var(--accent);
text-decoration: none;
}
.access-req-card__info a:hover {
text-decoration: underline;
}
.access-req-card__justification,
.access-req-card__admin-notes {
background: var(--background);
padding: var(--space-s);
border-radius: 4px;
font-size: var(--step--1);
line-height: 1.6;
}
.access-req-card__justification strong,
.access-req-card__admin-notes strong {
display: block;
margin-bottom: var(--space-3xs);
color: var(--text-primary);
}
.access-req-card__justification p,
.access-req-card__admin-notes p {
margin: 0;
}
.access-req-card__actions {
display: flex;
gap: var(--space-s);
margin-top: var(--space-s);
}
.access-req-btn {
font-family: inherit;
font-size: var(--step--1);
font-weight: 600;
padding: var(--space-2xs) var(--space-m);
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, opacity 0.2s;
}
.access-req-btn--approve {
background: var(--accent);
color: white;
}
.access-req-btn--approve:hover {
background: var(--accent-dark);
}
.access-req-btn--reject {
background: #e53e3e;
color: white;
}
.access-req-btn--reject:hover {
background: #c53030;
}
/* Pagination */
.access-req-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: var(--space-m);
margin-top: var(--space-l);
padding: var(--space-s) 0;
}
.access-req-pagination__link {
padding: var(--space-2xs) var(--space-s);
color: var(--accent);
text-decoration: none;
font-weight: 600;
transition: color 0.2s;
}
.access-req-pagination__link:hover {
color: var(--accent-dark);
text-decoration: underline;
}
.access-req-pagination__info {
font-size: var(--step--1);
color: var(--text-secondary);
}
/* Dialog enhancements */
.admin-dialog textarea {
width: 100%;
font-family: inherit;
font-size: var(--step--1);
padding: var(--space-2xs);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--background);
color: var(--text-primary);
resize: vertical;
margin: var(--space-s) 0;
}
.admin-dialog label {
display: block;
font-weight: 600;
margin-bottom: var(--space-3xs);
}
.admin-btn--danger {
background: #e53e3e !important;
}
.admin-btn--danger:hover {
background: #c53030 !important;
}

View File

@@ -167,6 +167,116 @@ aside figcaption {
margin: 0;
}
/* ============================================================
RESTRICTED ACCESS UI
============================================================ */
.tfe-restricted-access {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: var(--space-m);
display: flex;
flex-direction: column;
gap: var(--space-m);
}
.tfe-restricted-message {
font-size: var(--step--1);
color: var(--text-secondary);
line-height: 1.6;
margin: 0;
}
.tfe-restricted-message strong {
color: var(--text-primary);
display: block;
margin-bottom: var(--space-3xs);
font-size: var(--step-0);
}
.tfe-access-request-form {
display: flex;
flex-direction: column;
gap: var(--space-s);
}
.tfe-access-request-form .form-group {
display: flex;
flex-direction: column;
gap: var(--space-3xs);
}
.tfe-access-request-form label {
font-size: var(--step--1);
font-weight: 600;
color: var(--text-primary);
}
.tfe-access-request-form input[type="email"],
.tfe-access-request-form textarea {
font-family: inherit;
font-size: var(--step--1);
padding: var(--space-2xs) var(--space-3xs);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--background);
color: var(--text-primary);
transition: border-color 0.2s;
}
.tfe-access-request-form input[type="email"]:focus,
.tfe-access-request-form textarea:focus {
outline: none;
border-color: var(--accent);
}
.tfe-access-request-form textarea {
resize: vertical;
min-height: 80px;
}
.tfe-btn-request-access {
font-family: inherit;
font-size: var(--step--1);
font-weight: 600;
padding: var(--space-2xs) var(--space-s);
background: var(--accent);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, opacity 0.2s;
margin-top: var(--space-3xs);
}
.tfe-btn-request-access:hover:not(:disabled) {
background: var(--accent-dark);
}
.tfe-btn-request-access:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tfe-access-message {
font-size: var(--step--1);
padding: var(--space-2xs);
border-radius: 4px;
margin-top: var(--space-3xs);
}
.tfe-access-success {
background: #f0fff4;
border: 1px solid #48bb78;
color: #22543d;
}
.tfe-access-error {
background: #fff5f5;
border: 1px solid #fc8181;
color: #742a2a;
}
/* Responsive */
@media (max-width: 900px) {
.tfe-layout {

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

View File

@@ -0,0 +1,204 @@
<?php
/**
* Validate Access Token Endpoint
*
* Security model:
* - GET → show a confirmation page (token in hidden form field, never in logs as a meaningful action)
* - POST → redeem the one-time token, create a long-lived HttpOnly session cookie, redirect
*
* Mitigations implemented:
* - Token is one-time use: marked used_at on first redemption
* - Token short-lived: 24h by default
* - Redemption via POST: avoids token appearing in server-side access logs as a GET action
* - CSRF token required on POST
* - Cookie: HttpOnly; Secure; SameSite=Strict; 30-day session
* - Audit trail: IP, User-Agent, timestamp logged on redemption
*/
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Database.php';
// ── Helpers ───────────────────────────────────────────────────────────────────
function renderError(int $code, string $title, string $body, int $thesisId = 0): never
{
http_response_code($code);
$backLink = $thesisId > 0
? '<a href="/tfe?id=' . $thesisId . '">Retour au TFE</a>'
: '<a href="/">Retour à l\'accueil</a>';
echo '<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>' . htmlspecialchars($title) . ' - XAMXAM</title>
<style>
body{font-family:system-ui,sans-serif;display:flex;justify-content:center;
align-items:center;min-height:100vh;margin:0;background:#f5f5f5}
.box{background:#fff;padding:40px;border-radius:8px;
box-shadow:0 2px 10px rgba(0,0,0,.1);max-width:500px;width:90%}
h1{color:#c53030;font-size:1.5rem;margin:0 0 1rem}
p{color:#555;line-height:1.6;margin:.5rem 0}
a{color:#2c5282}
</style>
</head>
<body>
<div class="box">
<h1>' . htmlspecialchars($title) . '</h1>
' . $body . '
<p>' . $backLink . '</p>
</div>
</body>
</html>';
exit;
}
// ── Route: GET — show confirmation page ──────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$token = isset($_GET['token']) ? trim($_GET['token']) : '';
$thesisId = isset($_GET['thesis']) ? (int)$_GET['thesis'] : 0;
if (empty($token) || $thesisId <= 0) {
renderError(400, 'Lien invalide',
'<p>Ce lien d\'accès est invalide ou incomplet.</p>', $thesisId);
}
// Generate (or reuse) CSRF token in session
App::boot();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// Minimal pre-check: does the token exist and look valid?
// (Full redemption + one-time mark only happens on POST)
$db = Database::getInstance();
$check = $db->getPDO()->prepare(
"SELECT fat.expires_at, fr.thesis_id
FROM file_access_tokens fat
JOIN file_access_requests fr ON fat.request_id = fr.id
WHERE fat.token = ?
AND fat.is_valid = 1
AND fat.used_at IS NULL
AND fat.expires_at > CURRENT_TIMESTAMP
AND fr.status = 'approved'
AND fr.thesis_id = ?
LIMIT 1"
);
$check->execute([$token, $thesisId]);
$valid = $check->fetch();
if (!$valid) {
renderError(403, 'Lien d\'accès invalide ou expiré',
'<p>Ce lien a déjà été utilisé ou a expiré. Veuillez soumettre une nouvelle demande.</p>',
$thesisId);
}
$csrfToken = $_SESSION['csrf_token'];
$safeToken = htmlspecialchars($token, ENT_QUOTES);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Activer l'accès - XAMXAM</title>
<style>
body{font-family:system-ui,sans-serif;display:flex;justify-content:center;
align-items:center;min-height:100vh;margin:0;background:#f5f5f5}
.box{background:#fff;padding:40px;border-radius:8px;
box-shadow:0 2px 10px rgba(0,0,0,.1);max-width:500px;width:90%}
h1{font-size:1.5rem;margin:0 0 1rem;color:#1a202c}
p{color:#555;line-height:1.6;margin:.5rem 0}
.btn{display:inline-block;margin-top:1.5rem;padding:12px 28px;
background:#2c5282;color:#fff;border:none;border-radius:5px;
font-size:1rem;cursor:pointer;text-decoration:none}
.btn:hover{background:#2a4365}
.note{font-size:.85rem;color:#888;margin-top:1rem}
</style>
</head>
<body>
<div class="box">
<h1>Activer l'accès aux fichiers</h1>
<p>Cliquez sur le bouton ci-dessous pour activer l'accès aux fichiers de ce TFE sur cet appareil.</p>
<p class="note">L'accès sera valide pendant 30 jours sur cet appareil et navigateur.</p>
<form method="POST" action="/validate-access">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES) ?>">
<input type="hidden" name="token" value="<?= $safeToken ?>">
<input type="hidden" name="thesis_id" value="<?= (int)$thesisId ?>">
<button type="submit" class="btn">Activer l'accès</button>
</form>
</div>
</body>
</html>
<?php
exit;
}
// ── Route: POST — redeem token, set cookie, redirect ─────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
App::boot();
// CSRF check
if (
empty($_POST['csrf_token'])
|| empty($_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
) {
renderError(403, 'Erreur de sécurité',
'<p>Token de sécurité invalide. Veuillez réessayer.</p>');
}
$token = isset($_POST['token']) ? trim($_POST['token']) : '';
$thesisId = isset($_POST['thesis_id']) ? (int)$_POST['thesis_id'] : 0;
if (empty($token) || $thesisId <= 0) {
renderError(400, 'Données invalides',
'<p>Les données du formulaire sont invalides.</p>');
}
$db = Database::getInstance();
// Redeem the one-time token (marks it used, logs audit trail)
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512);
$redemption = $db->redeemAccessToken($token, $ip, $ua);
if (!$redemption || $redemption['thesis_id'] !== $thesisId) {
renderError(403, 'Lien invalide ou déjà utilisé',
'<p>Ce lien d\'accès est invalide, a déjà été utilisé, ou ne correspond pas au TFE demandé.</p>
<p>Si vous avez besoin d\'un nouvel accès, veuillez soumettre une nouvelle demande.</p>',
$thesisId);
}
// Create a long-lived browser session (separate from the one-time email token)
$sessionToken = $db->createAccessSession($redemption['request_id'], 30);
// Set HttpOnly, Secure, SameSite=Strict cookie (long-lived session)
$cookieName = 'tfe_access_' . $thesisId;
$cookieSecure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
setcookie($cookieName, $sessionToken, [
'expires' => time() + 30 * 86400,
'path' => '/',
'domain' => '',
'secure' => $cookieSecure,
'httponly' => true,
'samesite' => 'Strict',
]);
// Rotate CSRF token after successful action
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
// Redirect to TFE page (no token in URL)
header('Location: /tfe?id=' . $thesisId);
exit;
}
// ── Any other method ──────────────────────────────────────────────────────────
http_response_code(405);
header('Allow: GET, POST');
echo 'Méthode non autorisée';
exit;