mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
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:
204
app/public/validate-access.php
Normal file
204
app/public/validate-access.php
Normal 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;
|
||||
Reference in New Issue
Block a user