mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
migrate file-access tokens onto shared OneTimeToken model (hash-at-rest)
This commit is contained in:
@@ -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)'
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user