166 lines
6.0 KiB
PHP
166 lines
6.0 KiB
PHP
<?php
|
|
define('TICKET_LIFETIME_SECONDS', 20 * 60);
|
|
define('EMAIL_VERIFICATION_LIFETIME_SECONDS', 60 * 60 * 24 * 3);
|
|
// A Paddle checkout can sit open well past the default 20-minute ticket lifetime before
|
|
// the buyer completes payment, so the ticket that maps the resulting webhook back to a
|
|
// user needs a much longer window than a login-flow ticket. The ticket only carries an
|
|
// identity claim — actually crediting an account still requires a validly signed Paddle
|
|
// webhook for a real transaction — so a long lifetime here doesn't weaken anything.
|
|
define('PURCHASE_TICKET_LIFETIME_SECONDS', 60 * 60 * 24);
|
|
|
|
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);
|
|
// The random nonce is what makes the payload (and therefore the resulting token and
|
|
// its hash) unique even when two tickets are minted for the same user/purpose within
|
|
// the same second — otherwise identical inputs produce an identical HMAC, and a second
|
|
// password-reset ticket in the same second collides with the first's UNIQUE token_hash
|
|
// row and throws instead of just minting a second, independently usable link.
|
|
$nonce = bin2hex(random_bytes(16));
|
|
$payload = $userId . '|' . $expiresAt . '|' . $purpose . '|' . $nonce;
|
|
$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, 4);
|
|
if (count($parts) !== 4 || !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);
|
|
|
|
$token = createTicket($userId, 'password_reset');
|
|
$tokenHash = hash('sha256', $token);
|
|
$expiresAt = time() + TICKET_LIFETIME_SECONDS;
|
|
$cutoff = time() - 86400;
|
|
|
|
// The cleanup delete and the insert run as one transaction so they can't be split by
|
|
// a concurrent writer. Retried on a transient SQLITE_BUSY: two password-reset requests
|
|
// landing at nearly the same instant can still surface "database is locked" here even
|
|
// with the connection's busy_timeout, so back off briefly and retry rather than let it
|
|
// bubble up as an uncaught 500.
|
|
$maxAttempts = 3;
|
|
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
|
try {
|
|
$pdo->exec('BEGIN IMMEDIATE');
|
|
|
|
// 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' => $cutoff]);
|
|
|
|
$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' => $tokenHash,
|
|
'user_id' => $userId,
|
|
'expires_at' => $expiresAt,
|
|
]);
|
|
|
|
$pdo->exec('COMMIT');
|
|
|
|
return $token;
|
|
} catch (PDOException $e) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->exec('ROLLBACK');
|
|
}
|
|
|
|
if ($attempt === $maxAttempts || !str_contains($e->getMessage(), 'database is locked')) {
|
|
throw $e;
|
|
}
|
|
|
|
usleep(50000 * $attempt);
|
|
}
|
|
}
|
|
|
|
throw new RuntimeException('Unreachable');
|
|
}
|
|
|
|
// 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;
|
|
}
|