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()) {
|
if ($e->isRecipientRejected()) {
|
||||||
// SMTP server does not know this address — roll back the approval
|
// SMTP server does not know this address — roll back the approval
|
||||||
// so the user can retry with a valid address.
|
// so the user can retry with a valid address.
|
||||||
$db->getPDO()->exec(
|
$db->deleteAccessTokensForRequest($requestId);
|
||||||
"DELETE FROM file_access_tokens WHERE request_id = {$requestId}"
|
|
||||||
);
|
|
||||||
$db->getPDO()->exec(
|
$db->getPDO()->exec(
|
||||||
"UPDATE file_access_requests
|
"UPDATE file_access_requests
|
||||||
SET status = 'rejected', admin_notes = 'Adresse e-mail inconnue du serveur de messagerie (550)'
|
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?
|
// Minimal pre-check: does the token exist and look valid?
|
||||||
// (Full redemption + one-time mark only happens on POST)
|
// (Full redemption + one-time mark only happens on POST)
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$check = $db->getPDO()->prepare(
|
$valid = $db->isAccessTokenValid($token, $thesisId);
|
||||||
"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) {
|
if (!$valid) {
|
||||||
renderError(403, 'Lien d\'accès invalide ou expiré',
|
renderError(403, 'Lien d\'accès invalide ou expiré',
|
||||||
|
|||||||
+75
-40
@@ -3101,16 +3101,9 @@ class Database
|
|||||||
*/
|
*/
|
||||||
public function generateAccessToken(int $requestId, int $expiryHours = 24): string
|
public function generateAccessToken(int $requestId, int $expiryHours = 24): string
|
||||||
{
|
{
|
||||||
$token = bin2hex(random_bytes(32));
|
require_once APP_ROOT . '/src/OneTimeToken.php';
|
||||||
$expiresAt = date('Y-m-d H:i:s', time() + $expiryHours * 3600);
|
$ot = new OneTimeToken($this->pdo);
|
||||||
|
return $ot->issue('file_access', $expiryHours * 3600, ['request_id' => $requestId]);
|
||||||
$stmt = $this->pdo->prepare(
|
|
||||||
'INSERT INTO file_access_tokens (request_id, token, expires_at)
|
|
||||||
VALUES (?, ?, ?)'
|
|
||||||
);
|
|
||||||
$stmt->execute([$requestId, $token, $expiresAt]);
|
|
||||||
|
|
||||||
return $token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3137,47 +3130,89 @@ class Database
|
|||||||
*/
|
*/
|
||||||
public function redeemAccessToken(string $token, string $ip = '', string $ua = ''): ?array
|
public function redeemAccessToken(string $token, string $ip = '', string $ua = ''): ?array
|
||||||
{
|
{
|
||||||
// Look up the token — only valid if unused, unexpired, and approved
|
require_once APP_ROOT . '/src/OneTimeToken.php';
|
||||||
$stmt = $this->pdo->prepare(
|
$ot = new OneTimeToken($this->pdo);
|
||||||
"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();
|
|
||||||
|
|
||||||
if (!$row) {
|
// Resolve the request bound to this token (regardless of validity).
|
||||||
// Log failed attempt if we can find the token at all
|
$row = $ot->lookup('file_access', $token);
|
||||||
$check = $this->pdo->prepare(
|
if ($row === null) {
|
||||||
'SELECT fat.request_id FROM file_access_tokens fat WHERE fat.token = ? LIMIT 1'
|
return null; // completely unknown token — no audit (mirrors old behaviour)
|
||||||
);
|
|
||||||
$check->execute([$token]);
|
|
||||||
$bad = $check->fetch();
|
|
||||||
if ($bad) {
|
|
||||||
$this->logAccessAudit((int)$bad['request_id'], 'invalid_or_expired', $ip, $ua);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark token as used (one-time)
|
// One-time redemption (validity + expiry + consume).
|
||||||
$this->pdo->prepare(
|
$ctx = $ot->redeem('file_access', $token);
|
||||||
'UPDATE file_access_tokens SET used_at = CURRENT_TIMESTAMP, is_valid = 0 WHERE id = ?'
|
if ($ctx === null) {
|
||||||
)->execute([(int)$row['token_id']]);
|
$this->logAccessAudit($requestId, 'invalid_or_expired', $ip, $ua);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Audit log
|
$this->logAccessAudit($requestId, 'redeemed', $ip, $ua);
|
||||||
$this->logAccessAudit((int)$row['request_id'], 'redeemed', $ip, $ua);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'thesis_id' => (int)$row['thesis_id'],
|
'thesis_id' => (int) $req['thesis_id'],
|
||||||
'request_id' => (int)$row['request_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.
|
* Create a long-lived browser session token after a successful link redemption.
|
||||||
* Stored in file_access_sessions (separate from one-time email tokens).
|
* Stored in file_access_sessions (separate from one-time email tokens).
|
||||||
|
|||||||
@@ -80,6 +80,38 @@ class OneTimeToken
|
|||||||
return json_decode($row['context'], true);
|
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.
|
* Look up a valid (unused, unexpired) token row by purpose + hash.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class TestDatabase
|
|||||||
$pdo = self::getPDO();
|
$pdo = self::getPDO();
|
||||||
// Order matters due to FK constraints
|
// Order matters due to FK constraints
|
||||||
$tables = [
|
$tables = [
|
||||||
|
'one_time_tokens',
|
||||||
'file_access_audit',
|
'file_access_audit',
|
||||||
'file_access_sessions',
|
'file_access_sessions',
|
||||||
'file_access_tokens',
|
'file_access_tokens',
|
||||||
|
|||||||
@@ -55,12 +55,16 @@ class FileAccessTokenTest extends TestCase
|
|||||||
|
|
||||||
$token = $this->db->generateAccessToken($requestId, 24);
|
$token = $this->db->generateAccessToken($requestId, 24);
|
||||||
|
|
||||||
$stmt = $this->pdo->prepare('SELECT * FROM file_access_tokens WHERE request_id = ?');
|
$stmt = $this->pdo->prepare(
|
||||||
$stmt->execute([$requestId]);
|
"SELECT * FROM one_time_tokens WHERE purpose = 'file_access' ORDER BY id DESC LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->execute();
|
||||||
$row = $stmt->fetch();
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
$this->assertNotFalse($row);
|
$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->assertSame(1, (int) $row['is_valid']);
|
||||||
$this->assertNull($row['used_at']);
|
$this->assertNull($row['used_at']);
|
||||||
$this->assertNotNull($row['expires_at']);
|
$this->assertNotNull($row['expires_at']);
|
||||||
@@ -87,8 +91,10 @@ class FileAccessTokenTest extends TestCase
|
|||||||
|
|
||||||
$this->db->redeemAccessToken($token, '1.2.3.4', 'ua');
|
$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 = $this->pdo->prepare(
|
||||||
$stmt->execute([$requestId]);
|
"SELECT used_at, is_valid FROM one_time_tokens WHERE purpose = 'file_access'"
|
||||||
|
);
|
||||||
|
$stmt->execute();
|
||||||
$row = $stmt->fetch();
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
$this->assertNotNull($row['used_at']);
|
$this->assertNotNull($row['used_at']);
|
||||||
|
|||||||
Reference in New Issue
Block a user