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>
379 lines
16 KiB
PHP
379 lines
16 KiB
PHP
<?php
|
|
// Identity / SSO integration with id.andmore.pt.
|
|
//
|
|
// Two modes, picked by IDENTITY_MODE in .env:
|
|
//
|
|
// standalone — every function in this file is a no-op (returns null/false).
|
|
// Local users.id is the only identity that matters.
|
|
// federated — register/login flows on this install also write to the central
|
|
// andmore_id DB so users.andmore_id is always populated. The
|
|
// am_sso cookie is issued by this install so the user lands
|
|
// already-logged-in on any other .andmore.pt platform.
|
|
//
|
|
// Direct DB access is used rather than HTTP for now — easier and matches how
|
|
// sso_resolve_session() already works. Swap to an HTTP API later if the
|
|
// platforms stop sharing a DB host.
|
|
|
|
// SSO_COOKIE_NAME is defined in config/config.php (defaults to 'am_sso',
|
|
// override via env so prod and dev can coexist on the same parent domain).
|
|
|
|
// Cached central-DB connection — federated calls reuse it within a request.
|
|
function _sso_open_pdo(): ?PDO {
|
|
static $cached = null;
|
|
if ($cached !== null) return $cached ?: null; // false sentinel = tried and failed
|
|
|
|
if (!identity_is_federated()) { $cached = false; return null; }
|
|
|
|
$host = env('SSO_DB_HOST') ?: env('DB_HOST', '127.0.0.1');
|
|
$port = (int) (env('SSO_DB_PORT') ?: env('DB_PORT', 3306));
|
|
$name = env('SSO_DB_NAME', '');
|
|
$user = env('SSO_DB_USER') ?: env('DB_USER', 'root');
|
|
$pass = env('SSO_DB_PASS') ?: env('DB_PASS', '');
|
|
|
|
if (!$name) { $cached = false; return null; }
|
|
|
|
try {
|
|
$cached = new PDO(
|
|
"mysql:host={$host};port={$port};dbname={$name};charset=utf8mb4",
|
|
$user, $pass,
|
|
[
|
|
PDO::ATTR_ERRMODE => 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'];
|
|
|
|
// 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 the hub), 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']);
|
|
}
|
|
|
|
_sso_provision_local($pdo, $ssoRow);
|
|
}
|
|
|
|
// 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. 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;
|
|
|
|
$secret = (string) env('HUB_CONTENT_SECRET', '');
|
|
if ($secret === '') { error_log('[sso] ticket redeem: HUB_CONTENT_SECRET not set'); 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',
|
|
];
|
|
|
|
// 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;
|
|
}
|
|
|
|
$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, $ssoRow);
|
|
}
|
|
|
|
// Find-or-create the local users row for a central identity, sync name/phone,
|
|
// stamp last_login, and set the local PHP session. $ssoRow carries the central
|
|
// columns: user_id (canonical andmore id), email, name, phone. Returns true
|
|
// when a session was established. Shared by sso_resolve_session() (cookie path,
|
|
// same-site siblings) and sso_redeem_ticket() (ticket path, this install).
|
|
function _sso_provision_local(PDO $pdo, array $ssoRow): bool {
|
|
$canonicalId = (int) $ssoRow['user_id'];
|
|
$email = strtolower(trim((string) $ssoRow['email']));
|
|
|
|
// ─── 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 false;
|
|
}
|
|
$stmt = $pdo->prepare("SELECT * FROM users WHERE andmore_id = ? LIMIT 1");
|
|
$stmt->execute([$canonicalId]);
|
|
$user = $stmt->fetch();
|
|
}
|
|
}
|
|
|
|
if (!$user || !$user['is_active']) return false;
|
|
|
|
// ─── 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 — the hub 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);
|
|
return true;
|
|
}
|
|
|
|
// ─── 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());
|
|
}
|
|
}
|