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,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;