Files
Critters/lib/ticket.php
T
pauljd 9805d5056d
pauljd/davidson-ci: Davidson CI / quality (push) Successful in 14s
pauljd/davidson-ci: Davidson CI / ai-review (push) Successful in 1m5s
issues identified by Davidson CI
2026-09-09 22:49:46 +01:00

125 lines
3.9 KiB
PHP

<?php
define('TICKET_LIFETIME_SECONDS', 20 * 60);
define('EMAIL_VERIFICATION_LIFETIME_SECONDS', 60 * 60 * 24 * 3);
function getTicketSecret(): string
{
$secret = getenv('TICKET_SECRET');
if ($secret === false || $secret === '') {
error_log('ticket.php: TICKET_SECRET is not configured');
throw new RuntimeException('Server misconfiguration');
}
return $secret;
}
function createTicket(int $userId, string $purpose = 'default', ?int $lifetimeSeconds = null): string
{
$expiresAt = time() + ($lifetimeSeconds ?? TICKET_LIFETIME_SECONDS);
$payload = $userId . '|' . $expiresAt . '|' . $purpose;
$signature = hash_hmac('sha256', $payload, getTicketSecret());
return base64_encode($payload . '.' . $signature);
}
function decodeTicket(string $ticket, string $purpose = 'default'): ?int
{
$decoded = base64_decode($ticket, true);
if ($decoded === false) {
return null;
}
$separatorPosition = strrpos($decoded, '.');
if ($separatorPosition === false) {
return null;
}
$payload = substr($decoded, 0, $separatorPosition);
$signature = substr($decoded, $separatorPosition + 1);
$expectedSignature = hash_hmac('sha256', $payload, getTicketSecret());
if (!hash_equals($expectedSignature, $signature)) {
return null;
}
$parts = explode('|', $payload, 3);
if (count($parts) !== 3 || !ctype_digit($parts[0]) || !ctype_digit($parts[1])) {
return null;
}
[$userId, $expiresAt, $ticketPurpose] = $parts;
if (!hash_equals($purpose, $ticketPurpose)) {
return null;
}
if ((int) $expiresAt < time()) {
return null;
}
return (int) $userId;
}
function ensurePasswordResetTicketsTable(PDO $pdo): void
{
$pdo->exec('CREATE TABLE IF NOT EXISTS password_reset_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES users(id),
expires_at INTEGER NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)');
}
function createPasswordResetTicket(PDO $pdo, int $userId): string
{
ensurePasswordResetTicketsTable($pdo);
// Opportunistic cleanup: every new ticket also sweeps out rows that are long past
// useful (expired for over a day), so this table doesn't grow unbounded without
// needing a separate cron job.
$pdo->prepare('DELETE FROM password_reset_tickets WHERE expires_at < :cutoff')
->execute(['cutoff' => time() - 86400]);
$token = createTicket($userId, 'password_reset');
$stmt = $pdo->prepare('INSERT INTO password_reset_tickets (token_hash, user_id, expires_at) VALUES (:token_hash, :user_id, :expires_at)');
$stmt->execute([
'token_hash' => hash('sha256', $token),
'user_id' => $userId,
'expires_at' => time() + TICKET_LIFETIME_SECONDS,
]);
return $token;
}
// Validates signature/purpose/expiry only. Safe to call on GET to decide page
// state, since it never marks the ticket used.
function isPasswordResetTicketWellFormed(string $token): bool
{
return decodeTicket($token, 'password_reset') !== null;
}
// Atomically claims the ticket: only the first caller to reach this for a given
// token gets a user id back, so a leaked or replayed reset link can reset the
// password at most once.
function consumePasswordResetTicket(PDO $pdo, string $token): ?int
{
$userId = decodeTicket($token, 'password_reset');
if ($userId === null) {
return null;
}
ensurePasswordResetTicketsTable($pdo);
$stmt = $pdo->prepare('UPDATE password_reset_tickets SET used_at = CURRENT_TIMESTAMP WHERE token_hash = :token_hash AND user_id = :user_id AND used_at IS NULL AND expires_at > :now');
$stmt->execute([
'token_hash' => hash('sha256', $token),
'user_id' => $userId,
'now' => time(),
]);
return $stmt->rowCount() === 1 ? $userId : null;
}