From d7184e4447eaa7446048775b3628c24c6dd3159b Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Wed, 19 Aug 2026 12:23:03 +0200 Subject: [PATCH] migrate file-access tokens onto shared OneTimeToken model (hash-at-rest) --- app/public/request-access.php | 4 +- app/public/validate-access.php | 15 +--- app/src/Database.php | 117 +++++++++++++++++--------- app/src/OneTimeToken.php | 32 +++++++ tests/TestDatabase.php | 1 + tests/phpunit/FileAccessTokenTest.php | 16 ++-- 6 files changed, 122 insertions(+), 63 deletions(-) diff --git a/app/public/request-access.php b/app/public/request-access.php index a094b92..029bf60 100644 --- a/app/public/request-access.php +++ b/app/public/request-access.php @@ -171,9 +171,7 @@ try { if ($e->isRecipientRejected()) { // SMTP server does not know this address — roll back the approval // so the user can retry with a valid address. - $db->getPDO()->exec( - "DELETE FROM file_access_tokens WHERE request_id = {$requestId}" - ); + $db->deleteAccessTokensForRequest($requestId); $db->getPDO()->exec( "UPDATE file_access_requests SET status = 'rejected', admin_notes = 'Adresse e-mail inconnue du serveur de messagerie (550)' diff --git a/app/public/validate-access.php b/app/public/validate-access.php index f2a8af8..497a705 100644 --- a/app/public/validate-access.php +++ b/app/public/validate-access.php @@ -72,20 +72,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') { // 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(); + $valid = $db->isAccessTokenValid($token, $thesisId); if (!$valid) { renderError(403, 'Lien d\'accès invalide ou expiré', diff --git a/app/src/Database.php b/app/src/Database.php index 593f5c6..c96e0d7 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -3101,16 +3101,9 @@ class Database */ public function generateAccessToken(int $requestId, int $expiryHours = 24): string { - $token = bin2hex(random_bytes(32)); - $expiresAt = date('Y-m-d H:i:s', time() + $expiryHours * 3600); - - $stmt = $this->pdo->prepare( - 'INSERT INTO file_access_tokens (request_id, token, expires_at) - VALUES (?, ?, ?)' - ); - $stmt->execute([$requestId, $token, $expiresAt]); - - return $token; + require_once APP_ROOT . '/src/OneTimeToken.php'; + $ot = new OneTimeToken($this->pdo); + return $ot->issue('file_access', $expiryHours * 3600, ['request_id' => $requestId]); } /** @@ -3137,47 +3130,89 @@ class Database */ public function redeemAccessToken(string $token, string $ip = '', string $ua = ''): ?array { - // Look up the token — only valid if unused, unexpired, and approved - $stmt = $this->pdo->prepare( - "SELECT fat.id AS token_id, fat.request_id, 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'" - ); - $stmt->execute([$token]); - $row = $stmt->fetch(); + require_once APP_ROOT . '/src/OneTimeToken.php'; + $ot = new OneTimeToken($this->pdo); - if (!$row) { - // Log failed attempt if we can find the token at all - $check = $this->pdo->prepare( - 'SELECT fat.request_id FROM file_access_tokens fat WHERE fat.token = ? LIMIT 1' - ); - $check->execute([$token]); - $bad = $check->fetch(); - if ($bad) { - $this->logAccessAudit((int)$bad['request_id'], 'invalid_or_expired', $ip, $ua); - } + // Resolve the request bound to this token (regardless of validity). + $row = $ot->lookup('file_access', $token); + if ($row === null) { + return null; // completely unknown token — no audit (mirrors old behaviour) + } + + $requestId = (int) ($row['context']['request_id'] ?? 0); + + // Approved-status gate (mirrors the old JOIN … WHERE fr.status='approved'). + $stmt = $this->pdo->prepare( + 'SELECT thesis_id, status FROM file_access_requests WHERE id = ?' + ); + $stmt->execute([$requestId]); + $req = $stmt->fetch(); + + if ($req === false || $req['status'] !== 'approved') { + $this->logAccessAudit($requestId, 'invalid_or_expired', $ip, $ua); return null; } - // Mark token as used (one-time) - $this->pdo->prepare( - 'UPDATE file_access_tokens SET used_at = CURRENT_TIMESTAMP, is_valid = 0 WHERE id = ?' - )->execute([(int)$row['token_id']]); + // One-time redemption (validity + expiry + consume). + $ctx = $ot->redeem('file_access', $token); + if ($ctx === null) { + $this->logAccessAudit($requestId, 'invalid_or_expired', $ip, $ua); + return null; + } - // Audit log - $this->logAccessAudit((int)$row['request_id'], 'redeemed', $ip, $ua); + $this->logAccessAudit($requestId, 'redeemed', $ip, $ua); return [ - 'thesis_id' => (int)$row['thesis_id'], - 'request_id' => (int)$row['request_id'], + 'thesis_id' => (int) $req['thesis_id'], + 'request_id' => $requestId, ]; } + /** + * Non-destructive validity check for the one-time file-access token, + * scoped to a specific thesis (used by the GET confirmation page). + * + * Does NOT consume the token — redemption happens later on POST. + */ + public function isAccessTokenValid(string $token, int $thesisId): bool + { + require_once APP_ROOT . '/src/OneTimeToken.php'; + $ot = new OneTimeToken($this->pdo); + + $row = $ot->lookup('file_access', $token); + if ($row === null) { + return false; + } + $requestId = (int) ($row['context']['request_id'] ?? 0); + + $stmt = $this->pdo->prepare( + "SELECT thesis_id FROM file_access_requests WHERE id = ? AND status = 'approved'" + ); + $stmt->execute([$requestId]); + $req = $stmt->fetch(); + + if ($req === false || (int) $req['thesis_id'] !== $thesisId) { + return false; + } + + return $ot->isValid('file_access', $token); + } + + /** + * Delete every one-time token bound to a file-access request + * (e.g. when an approval is rolled back because the recipient address + * was rejected). Tokens live in one_time_tokens with context.request_id. + */ + public function deleteAccessTokensForRequest(int $requestId): void + { + $stmt = $this->pdo->prepare( + "DELETE FROM one_time_tokens + WHERE purpose = 'file_access' + AND json_extract(context, '$.request_id') = ?" + ); + $stmt->execute([$requestId]); + } + /** * Create a long-lived browser session token after a successful link redemption. * Stored in file_access_sessions (separate from one-time email tokens). diff --git a/app/src/OneTimeToken.php b/app/src/OneTimeToken.php index eac946e..01c2faf 100644 --- a/app/src/OneTimeToken.php +++ b/app/src/OneTimeToken.php @@ -80,6 +80,38 @@ class OneTimeToken return json_decode($row['context'], true); } + /** + * Look up a token by purpose + value WITHOUT applying validity checks. + * + * Used by callers that need to attribute a redemption attempt (audit + * logging, resolving a bound resource) even when the token is expired + * or already used. + * + * @return array{id:int, context:mixed, is_valid:int, used_at:?string, expires_at:string}|null + */ + public function lookup(string $purpose, string $token): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT id, context, is_valid, used_at, expires_at + FROM ' . self::TABLE . ' + WHERE purpose = ? AND token_hash = ? + LIMIT 1' + ); + $stmt->execute([$purpose, self::hash($token)]); + $row = $stmt->fetch(); + if ($row === false) { + return null; + } + + $row['id'] = (int) $row['id']; + $row['is_valid'] = (int) $row['is_valid']; + $row['context'] = ($row['context'] === null || $row['context'] === '') + ? null + : json_decode($row['context'], true); + + return $row; + } + /** * Look up a valid (unused, unexpired) token row by purpose + hash. */ diff --git a/tests/TestDatabase.php b/tests/TestDatabase.php index dfc2c3e..4b24452 100644 --- a/tests/TestDatabase.php +++ b/tests/TestDatabase.php @@ -76,6 +76,7 @@ class TestDatabase $pdo = self::getPDO(); // Order matters due to FK constraints $tables = [ + 'one_time_tokens', 'file_access_audit', 'file_access_sessions', 'file_access_tokens', diff --git a/tests/phpunit/FileAccessTokenTest.php b/tests/phpunit/FileAccessTokenTest.php index a92b269..2af53bf 100644 --- a/tests/phpunit/FileAccessTokenTest.php +++ b/tests/phpunit/FileAccessTokenTest.php @@ -55,12 +55,16 @@ class FileAccessTokenTest extends TestCase $token = $this->db->generateAccessToken($requestId, 24); - $stmt = $this->pdo->prepare('SELECT * FROM file_access_tokens WHERE request_id = ?'); - $stmt->execute([$requestId]); + $stmt = $this->pdo->prepare( + "SELECT * FROM one_time_tokens WHERE purpose = 'file_access' ORDER BY id DESC LIMIT 1" + ); + $stmt->execute(); $row = $stmt->fetch(); $this->assertNotFalse($row); - $this->assertSame($token, $row['token'], 'Current impl stores the plaintext token'); + $this->assertSame(hash('sha256', $token), $row['token_hash'], 'Only the hash is stored, never the plaintext'); + $this->assertNotSame($token, $row['token_hash']); + $this->assertSame(['request_id' => $requestId], json_decode($row['context'], true)); $this->assertSame(1, (int) $row['is_valid']); $this->assertNull($row['used_at']); $this->assertNotNull($row['expires_at']); @@ -87,8 +91,10 @@ class FileAccessTokenTest extends TestCase $this->db->redeemAccessToken($token, '1.2.3.4', 'ua'); - $stmt = $this->pdo->prepare('SELECT used_at, is_valid FROM file_access_tokens WHERE request_id = ?'); - $stmt->execute([$requestId]); + $stmt = $this->pdo->prepare( + "SELECT used_at, is_valid FROM one_time_tokens WHERE purpose = 'file_access'" + ); + $stmt->execute(); $row = $stmt->fetch(); $this->assertNotNull($row['used_at']);