PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ] ); return $cached; } catch (PDOException $e) { error_log('[sso] DB connection failed: ' . $e->getMessage()); $cached = false; return null; } } // Called by require_login() (and login.php / register.php) before any // redirect-to-login fallback. When a valid am_sso cookie is present it // makes sure the local PHP session reflects that exact identity: // - same andmore_id as the cookie → no-op (fast path). // - different andmore_id (or no andmore_id) → clear the local session and // re-provision from the SSO row so a stale Ana-Paula-style login doesn't // mask the real federated user. // No-op in standalone mode (the parent-domain cookie wouldn't reach this // install anyway). function sso_resolve_session(PDO $pdo): void { if (!identity_is_federated()) return; $token = $_COOKIE[SSO_COOKIE_NAME] ?? ''; if (strlen($token) !== 64 || !ctype_xdigit($token)) return; $ssoPdo = _sso_open_pdo(); if (!$ssoPdo) return; try { $stmt = $ssoPdo->prepare(" SELECT s.user_id, u.email, u.name, u.phone, u.is_active FROM sso_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > NOW() AND u.is_active = 1 LIMIT 1 "); $stmt->execute([$token]); $ssoRow = $stmt->fetch(); } catch (PDOException $e) { error_log('[sso] token lookup failed: ' . $e->getMessage()); return; } if (!$ssoRow) return; $canonicalId = (int) $ssoRow['user_id']; $email = strtolower(trim((string) $ssoRow['email'])); // If a local session is already set, decide whether to keep it or replace. if (!empty($_SESSION['user_id'])) { $sessionAndmoreId = (int) ($_SESSION['user']['andmore_id'] ?? 0); if ($sessionAndmoreId === $canonicalId) { return; // Same person — keep the existing session. } // Either: (a) session is for an unrelated andmore_id (user switched // identities at id.andmore.pt), or (b) session is for a local-only // user that never got linked. In both cases the SSO cookie reflects // the real intent — clear the stale identity bits and re-provision. unset($_SESSION['user_id'], $_SESSION['user']); } // ─── Find or create the local row ──────────────────────────────────── // Lookup order: andmore_id (fast, set on prior logins) → email (canonical // identity per the SSO-only model). The email branch claims any matching // row regardless of password_hash — the central platform vouches for the // email so there is no "another person owns this address" scenario to // worry about. UNIQUE(email) on the users table guarantees at most one // match. $stmt = $pdo->prepare("SELECT * FROM users WHERE andmore_id = ? LIMIT 1"); $stmt->execute([$canonicalId]); $user = $stmt->fetch(); if (!$user) { $stmt = $pdo->prepare("SELECT * FROM users WHERE email = ? LIMIT 1"); $stmt->execute([$email]); $user = $stmt->fetch(); if (!$user) { // No local row yet — provision a fresh client row stamped with // andmore_id. Role default = client; staff promotion happens by // an admin editing the row later. try { $pdo->prepare(" INSERT INTO users (name, email, phone, role, andmore_id, is_active) VALUES (?, ?, ?, 'client', ?, 1) ")->execute([ (string) $ssoRow['name'], $email, $ssoRow['phone'] ?: null, $canonicalId, ]); } catch (PDOException $e) { error_log('[sso] provisioning insert failed: ' . $e->getMessage()); return; } $stmt = $pdo->prepare("SELECT * FROM users WHERE andmore_id = ? LIMIT 1"); $stmt->execute([$canonicalId]); $user = $stmt->fetch(); } } if (!$user || !$user['is_active']) return; // ─── Sync per-tenant row with central data ─────────────────────────── // Stamp the link if missing, refresh name/phone when the central record // has a newer value. Local role is preserved — id.andmore.pt does not // know about per-tenant roles. $patch = []; if (empty($user['andmore_id']) || (int) $user['andmore_id'] !== $canonicalId) { $patch['andmore_id'] = $canonicalId; } if (!empty($ssoRow['name']) && $ssoRow['name'] !== $user['name']) { $patch['name'] = (string) $ssoRow['name']; } if (!empty($ssoRow['phone']) && $ssoRow['phone'] !== ($user['phone'] ?? '')) { $patch['phone'] = (string) $ssoRow['phone']; } if ($patch) { $set = implode(', ', array_map(fn ($k) => "$k = ?", array_keys($patch))); $pdo->prepare("UPDATE users SET $set WHERE id = ?") ->execute([...array_values($patch), (int) $user['id']]); $user = array_merge($user, $patch); } $ip = $_SERVER['REMOTE_ADDR'] ?? null; $ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512) ?: null; $pdo->prepare("UPDATE users SET last_login_at = NOW(), last_login_ip = ?, last_login_ua = ? WHERE id = ?") ->execute([$ip, $ua, $user['id']]); auth_set_session($user); } // ─── Upstream sync (federated mode) ────────────────────────────────────────── // // Only the admin-vouched immediate link + the cookie issuer remain. The older // two-phase verify-email flow (request_link / redeem_pending / has_pending) // was used by the local register/login pages, which were removed when the // platform became SSO-only. sso_resolve_session() does the email-match work // directly now — no token round-trip needed. // Admin-vouched immediate link. Used by auth_create_admin() / auth_create_staff() // when a sysadmin/admin creates a backoffice user on this satellite. Instead of // the verify-email round-trip, we trust the admin's vouching: the password the // admin set on the local form is also written to the central row so the new // person can immediately use SSO on any sibling .andmore.pt platform. // // - No central row for this email → create central row WITH the password // (admin-set, email_verified_at stamped). // - Passwordless central row exists → claim it AND set the password. // - Passworded central row exists → refuse with 'andmore_email_taken'. // An admin can't silently reset // someone else's Andmore ID. // // Outcomes: // ['canonical_id' => int] ok — stamp this on the local row // ['error' => 'andmore_email_taken'] central row exists with a password // ['error' => 'unavailable'] central DB unreachable // ['error' => 'standalone'] IDENTITY_MODE != federated function identity_upstream_link_immediate(string $email, string $name, ?string $phone, ?string $password = null): array { if (!identity_is_federated()) return ['error' => 'standalone']; $ssoPdo = _sso_open_pdo(); if (!$ssoPdo) return ['error' => 'unavailable']; $email = strtolower(trim($email)); $hash = ($password !== null && $password !== '') ? password_hash($password, PASSWORD_DEFAULT) : null; try { $stmt = $ssoPdo->prepare("SELECT id, password_hash FROM users WHERE email = ? LIMIT 1"); $stmt->execute([$email]); $row = $stmt->fetch(); if ($row) { if (!empty($row['password_hash'])) { return ['error' => 'andmore_email_taken']; } // Passwordless placeholder — claim it AND set the admin-vouched password. if ($hash !== null) { $ssoPdo->prepare(" UPDATE users SET name = COALESCE(NULLIF(?, ''), name), phone = COALESCE(NULLIF(?, ''), phone), password_hash = ?, email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = ? ")->execute([$name, (string) $phone, $hash, (int) $row['id']]); } return ['canonical_id' => (int) $row['id']]; } $ssoPdo->prepare(" INSERT INTO users (name, email, phone, password_hash, email_verified_at) VALUES (?, ?, ?, ?, ?) ")->execute([ $name, $email, $phone, $hash, $hash !== null ? date('Y-m-d H:i:s') : null, ]); return ['canonical_id' => (int) $ssoPdo->lastInsertId()]; } catch (PDOException $e) { error_log('[sso] upstream link-immediate failed: ' . $e->getMessage()); return ['error' => 'unavailable']; } } // Issue an am_sso cookie scoped to the parent domain by inserting a row into // the central sso_sessions table. Lets the user land already-logged-in on any // other .andmore.pt platform. No-op when standalone or when SSO_COOKIE_DOMAIN // isn't set. function identity_upstream_issue_cookie(int $canonicalUserId): void { if (!identity_is_federated() || SSO_COOKIE_DOMAIN === '') return; $ssoPdo = _sso_open_pdo(); if (!$ssoPdo) return; try { $token = bin2hex(random_bytes(32)); $ttl = max(1, SSO_SESSION_TTL_DAYS) * 86400; $ip = $_SERVER['REMOTE_ADDR'] ?? null; $ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512) ?: null; $ssoPdo->prepare(" INSERT INTO sso_sessions (token, user_id, expires_at, ip, ua, last_seen_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND), ?, ?, NOW()) ")->execute([$token, $canonicalUserId, $ttl, $ip, $ua]); setcookie(SSO_COOKIE_NAME, $token, [ 'expires' => time() + $ttl, 'path' => '/', 'domain' => SSO_COOKIE_DOMAIN, // request_is_https() respects X-Forwarded-Proto when TRUSTED_PROXY=true // — needed so the Secure flag isn't dropped behind nginx/Cloudflare. 'secure' => request_is_https(), 'httponly' => true, 'samesite' => 'Lax', ]); } catch (PDOException $e) { error_log('[sso] upstream cookie issue failed: ' . $e->getMessage()); } }