Files
xamxam/app/src/OneTimeToken.php
T

144 lines
4.7 KiB
PHP

<?php
/**
* OneTimeToken — generic single-use, expiring token store.
*
* Extracted from the file-access email-link flow so the same, secure
* token lifecycle can be reused by other features (e.g. an admin
* password-reset link). A token is a 256-bit random value; only its
* SHA-256 hash is persisted, so a DB leak does not expose live tokens.
*
* Lifecycle:
* issue() → generate token, store hash + expiry, return plaintext (send it)
* isValid() → check a token exists, is unused and unexpired
* redeem() → mark a token used (one-time) after a successful redemption
*
* Tokens carry a `purpose` tag so distinct features can share one table
* without colliding (e.g. 'password_reset', 'share_link').
*/
class OneTimeToken
{
private const TABLE = 'one_time_tokens';
public function __construct(private PDO $pdo)
{
}
/**
* Create a new token and return its plaintext value.
*
* @param string $purpose Feature tag, e.g. 'password_reset'.
* @param int $ttlSeconds Lifetime in seconds (default 1 hour).
* @param mixed $context Optional JSON-encodable payload (non-secret).
*/
public function issue(string $purpose, int $ttlSeconds = 3600, mixed $context = null): string
{
$plain = bin2hex(random_bytes(32)); // 256-bit
$hash = self::hash($plain);
$expiresAt = date('Y-m-d H:i:s', time() + $ttlSeconds);
$contextJson = $context !== null ? json_encode($context) : null;
$stmt = $this->pdo->prepare(
'INSERT INTO ' . self::TABLE . ' (purpose, token_hash, context, expires_at, is_valid, created_at)
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP)'
);
$stmt->execute([$purpose, $hash, $contextJson, $expiresAt]);
return $plain;
}
/**
* Whether a token is currently redeemable (unused, unexpired).
*/
public function isValid(string $purpose, string $token): bool
{
$row = $this->findValid($purpose, $token);
return $row !== null;
}
/**
* Redeem a token, marking it used. Returns the stored context (or an
* empty array when none was set). Returns null when the token is unknown,
* expired, or already used.
*
* @return mixed|null
*/
public function redeem(string $purpose, string $token): mixed
{
$row = $this->findValid($purpose, $token);
if ($row === null) {
return null;
}
$stmt = $this->pdo->prepare(
'UPDATE ' . self::TABLE . ' SET used_at = CURRENT_TIMESTAMP, is_valid = 0 WHERE id = ?'
);
$stmt->execute([(int) $row['id']]);
if ($row['context'] === null || $row['context'] === '') {
return []; // consumed successfully, no payload attached
}
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.
*/
private function findValid(string $purpose, string $token): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, token_hash, context, expires_at
FROM ' . self::TABLE . '
WHERE purpose = ?
AND token_hash = ?
AND is_valid = 1
AND used_at IS NULL
AND expires_at > CURRENT_TIMESTAMP
LIMIT 1'
);
$stmt->execute([$purpose, self::hash($token)]);
$row = $stmt->fetch();
return $row !== false ? $row : null;
}
/**
* Constant-time-comparable token hash (SHA-256 hex).
*/
private static function hash(string $token): string
{
return hash('sha256', $token);
}
}