db->prepare(' INSERT INTO password_resets (user_id, token_hash, expires_at, created_at) VALUES (:user_id, :token_hash, :expires_at, :created_at) '); $stmt->execute([ ':user_id' => $userId, ':token_hash' => $tokenHash, ':expires_at' => $expiresAt, ':created_at' => date('Y-m-d H:i:s'), ]); } public function findActiveByHash(string $tokenHash): ?array { $stmt = $this->db->prepare( 'SELECT * FROM password_resets WHERE token_hash = :token_hash AND used_at IS NULL' ); $stmt->execute([':token_hash' => $tokenHash]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ?: null; } public function invalidateByUserId(int $userId): void { $stmt = $this->db->prepare( 'UPDATE password_resets SET used_at = :used_at WHERE user_id = :user_id AND used_at IS NULL' ); $stmt->execute([':used_at' => date('Y-m-d H:i:s'), ':user_id' => $userId]); } /** * Atomically consume a token and return the affected row. * Uses UPDATE ... RETURNING to avoid SELECT+UPDATE race conditions. */ public function consumeActiveToken(string $tokenHash, string $usedAt): ?array { $stmt = $this->db->prepare( 'UPDATE password_resets SET used_at = :used_at WHERE token_hash = :token_hash AND used_at IS NULL AND expires_at >= :now RETURNING *' ); $stmt->execute([ ':used_at' => $usedAt, ':token_hash' => $tokenHash, ':now' => $usedAt, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ?: null; } }