migrate file-access tokens onto shared OneTimeToken model (hash-at-rest)

This commit is contained in:
Pontoporeia
2026-08-24 11:36:02 +02:00
parent 3f1dcf5d43
commit d7184e4447
6 changed files with 122 additions and 63 deletions
+1 -3
View File
@@ -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)'
+1 -14
View File
@@ -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é',
+76 -41
View File
@@ -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).
+32
View File
@@ -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.
*/
+1
View File
@@ -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',
+11 -5
View File
@@ -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']);