mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-07 03:29:19 +02:00
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
205 lines
7.8 KiB
PHP
205 lines
7.8 KiB
PHP
<?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;
|