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 <noreply@anthropic.com>
This commit is contained in:
1 parent
9ad0c2b763
commit
7885b0b6ac
1 file changed
+63
-29
+63
-29
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user