From 7885b0b6ac0df3cbc545c3a84dd6c80cc4fdde83 Mon Sep 17 00:00:00 2001 From: andre Date: Fri, 12 Jun 2026 23:55:13 +0100 Subject: [PATCH] SSO: redeem ticket over HTTPS instead of the central DB This install is on external hosting and can't reach the central DB's private LAN address (192.168.1.3), so the direct-DB burn always failed with ECONNREFUSED. Redeem via the hub's api/sso_redeem.php over HTTPS (HMAC with HUB_CONTENT_SECRET, ticket folded into the signature) and provision the returned user into the local DB. Co-Authored-By: Claude Opus 4.8 --- config/sso.php | 92 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 63 insertions(+), 29 deletions(-) diff --git a/config/sso.php b/config/sso.php index 426129b..349f984 100644 --- a/config/sso.php +++ b/config/sso.php @@ -107,47 +107,81 @@ function sso_resolve_session(PDO $pdo): void { // Cross-domain handoff: redeem a one-time ticket minted by the hub. This is the // real login path for this install, because fitme3070.pt is NOT under // .andmore.pt — the am_sso cookie never reaches it, so sso_resolve_session()'s -// cookie lookup always comes up empty. Instead the hub appends ?sso_ticket=… to -// the return URL after authenticating; we burn it here against the central -// sso_tickets table (atomic single-use, bound to this PLATFORM_SLUG, ~60s TTL) -// and provision the local row. Returns true when a session was established. +// cookie lookup always comes up empty. The hub appends ?sso_ticket=… to the +// return URL after authenticating. +// +// We redeem over HTTPS, NOT the direct central DB: this install lives on +// external hosting and can't reach the central DB's private LAN address. The +// hub's api/sso_redeem.php burns the ticket against its local DB (single-use, +// bound to PLATFORM_SLUG, ~60s TTL) and returns the central user, which we +// provision into the LOCAL DB. Auth reuses the content-pull HMAC scheme with +// the ticket folded in. Returns true when a session was established. function sso_redeem_ticket(PDO $pdo, string $ticket): bool { if (!identity_is_federated()) return false; if (strlen($ticket) !== 64 || !ctype_xdigit($ticket)) return false; - $ssoPdo = _sso_open_pdo(); - if (!$ssoPdo) return false; + $secret = (string) env('HUB_CONTENT_SECRET', ''); + if ($secret === '') { error_log('[sso] ticket redeem: HUB_CONTENT_SECRET not set'); return false; } - try { - // Burn first: the UPDATE only affects a row when the ticket is unused, - // unexpired and bound to this platform — so a replay or a ticket minted - // for a sibling platform changes nothing and we bail. - $burn = $ssoPdo->prepare(" - UPDATE sso_tickets SET used_at = NOW() - WHERE ticket = ? AND platform = ? AND used_at IS NULL AND expires_at > NOW() - "); - $burn->execute([$ticket, PLATFORM_SLUG]); - if ($burn->rowCount() !== 1) return false; + $url = rtrim(ANDMORE_ID_URL, '/') . '/api/sso_redeem.php'; + $path = (string) (parse_url($url, PHP_URL_PATH) ?? '/'); + $date = gmdate('c'); + $token = hash_hmac('sha256', $date . $path . $ticket, $secret); + $body = http_build_query(['ticket' => $ticket]); + $headers = [ + 'X-Hub-Platform: ' . PLATFORM_SLUG, + 'X-Hub-Date: ' . $date, + 'X-Hub-Token: ' . $token, + 'Accept: application/json', + 'Content-Type: application/x-www-form-urlencoded', + ]; - $stmt = $ssoPdo->prepare(" - SELECT t.user_id, u.email, u.name, u.phone, u.is_active - FROM sso_tickets t - JOIN users u ON u.id = t.user_id - WHERE t.ticket = ? - LIMIT 1 - "); - $stmt->execute([$ticket]); - $row = $stmt->fetch(); - } catch (PDOException $e) { - error_log('[sso] ticket redeem failed: ' . $e->getMessage()); + // cURL first (same transport the notify/content clients use); stream + // fallback for installs without the extension. + $raw = false; + if (function_exists('curl_init')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_TIMEOUT => 12, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_USERAGENT => 'fitme3070-SSO/1.0', + ]); + $raw = curl_exec($ch); + curl_close($ch); + } + if ($raw === false) { + $ctx = stream_context_create(['http' => [ + 'method' => 'POST', + 'timeout' => 12, + 'header' => implode("\r\n", $headers), + 'content' => $body, + 'ignore_errors' => true, + ]]); + $raw = @file_get_contents($url, false, $ctx); + } + if ($raw === false) { error_log('[sso] ticket redeem: no response from hub'); return false; } + + $data = json_decode((string) $raw, true); + if (!is_array($data) || empty($data['ok']) || empty($data['user'])) { + error_log('[sso] ticket redeem rejected: ' . substr((string) $raw, 0, 200)); return false; } - if (!$row || !$row['is_active']) return false; + $u = $data['user']; + $ssoRow = [ + 'user_id' => (int) ($u['user_id'] ?? 0), + 'email' => (string) ($u['email'] ?? ''), + 'name' => (string) ($u['name'] ?? ''), + 'phone' => $u['phone'] ?? null, + ]; + if ($ssoRow['user_id'] <= 0 || $ssoRow['email'] === '') return false; // Explicit fresh-login intent — drop any stale local identity, then provision. unset($_SESSION['user_id'], $_SESSION['user']); - return _sso_provision_local($pdo, $row); + return _sso_provision_local($pdo, $ssoRow); } // Find-or-create the local users row for a central identity, sync name/phone,