From 78a2b41329d0cb8e06541df983e636e1392e8454 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 05:28:17 +0000 Subject: [PATCH 1/5] Fix HTTP 403 on login: add Origin and Referer headers UniFi OS enforces CSRF protection on the login endpoint itself and returns 403 if Origin/Referer headers are absent, regardless of whether the credentials are correct. Required headers: Origin: https://controller:port Referer: https://controller:port/login Documented in Art-of-WiFi UniFi-API-client reference implementation. https://claude.ai/code/session_01UsuvFAmmeagtQa14QA4iaq --- includes/UniFiController.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/includes/UniFiController.php b/includes/UniFiController.php index 9ee254f..28a4e96 100644 --- a/includes/UniFiController.php +++ b/includes/UniFiController.php @@ -38,7 +38,11 @@ class UniFiController { CURLOPT_COOKIEFILE => $this->cookieFile, CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 5, - CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Origin: ' . $this->controllerUrl, + 'Referer: ' . $this->controllerUrl . '/login', + ], CURLOPT_HEADERFUNCTION => function($ch, $header) { $parts = explode(':', $header, 2); if (count($parts) === 2) { From dbce0299d60784e4ca1ff2ef4070e3dc602b0086 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 05:31:04 +0000 Subject: [PATCH 2/5] Add response body detail to login error messages for debugging https://claude.ai/code/session_01UsuvFAmmeagtQa14QA4iaq --- includes/UniFiController.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/includes/UniFiController.php b/includes/UniFiController.php index 28a4e96..58ed54f 100644 --- a/includes/UniFiController.php +++ b/includes/UniFiController.php @@ -61,7 +61,9 @@ class UniFiController { curl_close($ch); if ($httpCode !== 200) { - throw new Exception("Login fehlgeschlagen: HTTP $httpCode"); + $body = json_decode($response, true); + $detail = $body['meta']['msg'] ?? $body['errors'][0] ?? substr(strip_tags($response), 0, 120); + throw new Exception("Login fehlgeschlagen: HTTP $httpCode" . ($detail ? " – $detail" : '')); } $data = json_decode($response, true); From e705458af390db115f83bc303929999d3bd9316b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 05:35:15 +0000 Subject: [PATCH 3/5] Add UniFi API diagnostic section to test.php Shows raw login HTTP code, all response headers, X-CSRF-Token extraction (header + cookie file fallback), login response body, cookie file contents, and a follow-up stat/voucher GET to verify the full auth+API flow. https://claude.ai/code/session_01UsuvFAmmeagtQa14QA4iaq --- test.php | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/test.php b/test.php index 472b59c..6beca36 100644 --- a/test.php +++ b/test.php @@ -84,6 +84,157 @@ echo "

7. Session

"; echo "Session Status: " . session_status() . " (1=disabled, 2=active)
"; echo "Session ID: " . session_id() . "
"; +// 8. UniFi API Diagnose +echo "

8. UniFi API Diagnose

"; +try { + $db2 = Database::getInstance(); + $site = $db2->fetchOne("SELECT * FROM sites WHERE is_active = 1 ORDER BY id ASC LIMIT 1"); + if (!$site) { + echo "✗ Keine aktive Site in der Datenbank gefunden
"; + } else { + echo "Site: " . htmlspecialchars($site['name']) . "
"; + echo "Controller URL: " . htmlspecialchars($site['unifi_controller_url']) . "
"; + echo "Site ID: " . htmlspecialchars($site['site_id']) . "
"; + echo "Username: " . htmlspecialchars($site['unifi_username']) . "
"; + echo "
"; + + // --- Raw Login Test --- + echo "Login-Test (raw cURL):
"; + $cookieFile = tempnam(sys_get_temp_dir(), 'UNIFI_TEST_'); + $csrfToken = null; + $controllerUrl = rtrim($site['unifi_controller_url'], '/'); + + $ch = curl_init(); + $responseHeaders = []; + curl_setopt_array($ch, [ + CURLOPT_URL => $controllerUrl . "/api/auth/login", + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode([ + 'username' => $site['unifi_username'], + 'password' => $site['unifi_password'] + ]), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_COOKIEJAR => $cookieFile, + CURLOPT_COOKIEFILE => $cookieFile, + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Origin: ' . $controllerUrl, + 'Referer: ' . $controllerUrl . '/login', + ], + CURLOPT_HEADERFUNCTION => function($ch, $header) use (&$csrfToken, &$responseHeaders) { + $responseHeaders[] = rtrim($header); + $parts = explode(':', $header, 2); + if (count($parts) === 2 && strtolower(trim($parts[0])) === 'x-csrf-token') { + $csrfToken = trim($parts[1]); + } + return strlen($header); + } + ]); + + $body = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErr = curl_error($ch); + $info = curl_getinfo($ch); + curl_close($ch); + + if ($curlErr) { + echo "✗ cURL Fehler: " . htmlspecialchars($curlErr) . "
"; + } else { + $icon = ($httpCode === 200) ? '✓' : '✗'; + echo "$icon HTTP Code: $httpCode
"; + echo "Effective URL: " . htmlspecialchars($info['url']) . "
"; + } + + // Response headers + echo "
Response-Header:
";
+        foreach ($responseHeaders as $h) {
+            if ($h !== '') echo htmlspecialchars($h) . "\n";
+        }
+        echo "
"; + + // CSRF token + if ($csrfToken !== null) { + echo "✓ X-CSRF-Token aus Header: " . htmlspecialchars($csrfToken) . "
"; + } else { + echo "✗ Kein X-CSRF-Token im Login-Response-Header gefunden
"; + // Check cookie file fallback + if (file_exists($cookieFile)) { + $tokenFromCookie = null; + foreach (file($cookieFile) as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') continue; + $parts = explode("\t", $line); + if (count($parts) >= 7 && strtoupper($parts[5]) === 'TOKEN') { + $tokenFromCookie = $parts[6]; + } + } + if ($tokenFromCookie) { + echo "✓ CSRF-Token aus Cookie-Datei (Fallback): " . htmlspecialchars($tokenFromCookie) . "
"; + $csrfToken = $tokenFromCookie; + } else { + echo "✗ Auch kein TOKEN-Cookie in der Cookie-Datei gefunden
"; + } + } + } + + // Response body + echo "
Login Response Body:
";
+        echo htmlspecialchars(substr($body, 0, 2000));
+        echo "
"; + + // Cookie file contents + if (file_exists($cookieFile)) { + $cookieContents = file_get_contents($cookieFile); + echo "Cookie-Datei:
";
+            echo htmlspecialchars($cookieContents ?: '(leer)');
+            echo "
"; + } + + // --- API Test (only if login succeeded) --- + if ($httpCode === 200) { + echo "API-Test (stat/voucher GET):
"; + $apiHeaders = ['Content-Type: application/json']; + if ($csrfToken !== null) { + $apiHeaders[] = 'X-CSRF-Token: ' . $csrfToken; + } + + $ch2 = curl_init(); + curl_setopt_array($ch2, [ + CURLOPT_URL => $controllerUrl . "/proxy/network/api/s/" . $site['site_id'] . "/stat/voucher", + CURLOPT_HTTPGET => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_COOKIEFILE => $cookieFile, + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_HTTPHEADER => $apiHeaders, + ]); + $apiBody = curl_exec($ch2); + $apiCode = curl_getinfo($ch2, CURLINFO_HTTP_CODE); + $apiErr = curl_error($ch2); + curl_close($ch2); + + if ($apiErr) { + echo "✗ cURL Fehler: " . htmlspecialchars($apiErr) . "
"; + } else { + $icon2 = ($apiCode === 200) ? '✓' : '✗'; + echo "$icon2 HTTP Code: $apiCode
"; + } + echo "API Response Body:
";
+            echo htmlspecialchars(substr($apiBody, 0, 2000));
+            echo "
"; + } + + @unlink($cookieFile); + } +} catch (Exception $e) { + echo "✗ Diagnose-Fehler: " . htmlspecialchars($e->getMessage()) . "
"; +} + echo "
"; echo "

✓ Test abgeschlossen

"; echo "

Zum Login | Zur Startseite

"; From 897392041ab19e071e50fa1fb68c2427153d2ccd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 05:41:42 +0000 Subject: [PATCH 4/5] Fix session cookie not persisting due to Partitioned attribute The TOKEN cookie set by UniFi OS includes the 'Partitioned' attribute (CHIPS), which some libcurl versions do not write to the Netscape cookie jar file. This caused every API request to go out unauthenticated, resulting in 401/403 errors even after a successful login. Fix: extract the TOKEN value directly from the Set-Cookie response header in login() and pass it via CURLOPT_COOKIE in apiRequest(), bypassing the broken file-based cookie jar. Cookie file remains as fallback for environments where extraction fails. Also update test.php section 8 to validate this fix and show the extracted cookie value. https://claude.ai/code/session_01UsuvFAmmeagtQa14QA4iaq --- includes/UniFiController.php | 22 +++++++++++++++++++--- test.php | 33 ++++++++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/includes/UniFiController.php b/includes/UniFiController.php index 58ed54f..90dce55 100644 --- a/includes/UniFiController.php +++ b/includes/UniFiController.php @@ -6,6 +6,7 @@ class UniFiController { private $siteId; private $cookieFile; private $csrfToken = null; + private $sessionCookie = null; public function __construct($controllerUrl, $username, $password, $siteId) { $this->controllerUrl = rtrim($controllerUrl, '/'); @@ -46,10 +47,18 @@ class UniFiController { CURLOPT_HEADERFUNCTION => function($ch, $header) { $parts = explode(':', $header, 2); if (count($parts) === 2) { - $name = trim($parts[0]); + $name = strtolower(trim($parts[0])); $value = trim($parts[1]); - if (strtolower($name) === 'x-csrf-token') { + if ($name === 'x-csrf-token') { $this->csrfToken = $value; + } elseif ($name === 'set-cookie') { + // Extract TOKEN value directly — cookie jar may not persist + // cookies with the 'Partitioned' attribute on some libcurl versions + $cookieParts = explode(';', $value); + $first = trim($cookieParts[0]); + if (strpos($first, 'TOKEN=') === 0) { + $this->sessionCookie = $first; + } } } return strlen($header); @@ -100,12 +109,19 @@ class UniFiController { CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false, - CURLOPT_COOKIEFILE => $this->cookieFile, CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_HTTPHEADER => $headers ]; + // Prefer manually extracted cookie over file-based jar — the Partitioned + // attribute on the TOKEN cookie prevents some libcurl versions from writing it + if ($this->sessionCookie !== null) { + $options[CURLOPT_COOKIE] = $this->sessionCookie; + } else { + $options[CURLOPT_COOKIEFILE] = $this->cookieFile; + } + if ($method === 'GET') { $options[CURLOPT_HTTPGET] = true; } elseif ($method === 'POST') { diff --git a/test.php b/test.php index 6beca36..485c158 100644 --- a/test.php +++ b/test.php @@ -190,29 +190,52 @@ try { if (file_exists($cookieFile)) { $cookieContents = file_get_contents($cookieFile); echo "Cookie-Datei:
";
-            echo htmlspecialchars($cookieContents ?: '(leer)');
+            echo htmlspecialchars($cookieContents ?: '(leer — TOKEN hat Partitioned-Attribut, libcurl schreibt es nicht in die Jar-Datei)');
             echo "
"; } + // Extract TOKEN from Set-Cookie header (the fix for Partitioned cookie issue) + $tokenCookie = null; + foreach ($responseHeaders as $h) { + if (stripos($h, 'set-cookie:') === 0) { + $cookieVal = trim(substr($h, strlen('set-cookie:'))); + $cookieParts = explode(';', $cookieVal); + $first = trim($cookieParts[0]); + if (strpos($first, 'TOKEN=') === 0) { + $tokenCookie = $first; + } + } + } + if ($tokenCookie) { + echo "✓ TOKEN aus Set-Cookie-Header extrahiert: " . htmlspecialchars(substr($tokenCookie, 0, 40)) . "…
"; + } else { + echo "✗ TOKEN nicht in Set-Cookie-Header gefunden
"; + } + // --- API Test (only if login succeeded) --- if ($httpCode === 200) { - echo "API-Test (stat/voucher GET):
"; + echo "
API-Test (stat/voucher GET) — mit extrahiertem Cookie:
"; $apiHeaders = ['Content-Type: application/json']; if ($csrfToken !== null) { $apiHeaders[] = 'X-CSRF-Token: ' . $csrfToken; } $ch2 = curl_init(); - curl_setopt_array($ch2, [ + $apiOpts = [ CURLOPT_URL => $controllerUrl . "/proxy/network/api/s/" . $site['site_id'] . "/stat/voucher", CURLOPT_HTTPGET => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false, - CURLOPT_COOKIEFILE => $cookieFile, CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_HTTPHEADER => $apiHeaders, - ]); + ]; + if ($tokenCookie !== null) { + $apiOpts[CURLOPT_COOKIE] = $tokenCookie; + } else { + $apiOpts[CURLOPT_COOKIEFILE] = $cookieFile; + } + curl_setopt_array($ch2, $apiOpts); $apiBody = curl_exec($ch2); $apiCode = curl_getinfo($ch2, CURLINFO_HTTP_CODE); $apiErr = curl_error($ch2); From e0a999b27bf8b44de32b190ec6232546a8ba4852 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 05:52:45 +0000 Subject: [PATCH 5/5] Login only once per UniFiController instance to avoid rate limiting createVoucher() calls apiRequest() twice (cmd/hotspot + stat/voucher), which previously triggered two separate login requests in rapid succession. The UniFi controller was rate-limiting the second attempt with 403. Fix: $loggedIn flag ensures login() is a no-op after the first successful authentication, reusing the existing session cookie for all API calls. https://claude.ai/code/session_01UsuvFAmmeagtQa14QA4iaq --- includes/UniFiController.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/includes/UniFiController.php b/includes/UniFiController.php index 90dce55..d0da04b 100644 --- a/includes/UniFiController.php +++ b/includes/UniFiController.php @@ -7,6 +7,7 @@ class UniFiController { private $cookieFile; private $csrfToken = null; private $sessionCookie = null; + private $loggedIn = false; public function __construct($controllerUrl, $username, $password, $siteId) { $this->controllerUrl = rtrim($controllerUrl, '/'); @@ -24,6 +25,10 @@ class UniFiController { // Login zum Controller private function login() { + if ($this->loggedIn) { + return true; + } + $ch = curl_init(); curl_setopt_array($ch, [ @@ -90,6 +95,7 @@ class UniFiController { $this->csrfToken = $this->readCsrfFromCookieFile(); } + $this->loggedIn = true; return true; }