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; }