SSO: redeem one-time ticket on return from the hub
fitme3070.pt is off the .andmore.pt cookie scope, so am_sso never arrives and login never provisioned the user. Redeem the ?sso_ticket=… the hub now appends to the return URL instead. - sso_redeem_ticket(): atomic single-use burn of sso_tickets in the central DB, bound to PLATFORM_SLUG, then provision the local row + FIT_SESS session. - Extract _sso_provision_local() shared with the cookie path. - views/auth/login.php redeems the ticket before the cookie fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
3e3382df57
commit
9ad0c2b763
2 files changed
+76
-11
No files matched your search
+66
-8
@@ -87,7 +87,6 @@ function sso_resolve_session(PDO $pdo): void {
|
||||
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'])) {
|
||||
@@ -96,12 +95,70 @@ function sso_resolve_session(PDO $pdo): void {
|
||||
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.
|
||||
// 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. 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.
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
$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());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$row || !$row['is_active']) 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);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -134,7 +191,7 @@ function sso_resolve_session(PDO $pdo): void {
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('[sso] provisioning insert failed: ' . $e->getMessage());
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT * FROM users WHERE andmore_id = ? LIMIT 1");
|
||||
$stmt->execute([$canonicalId]);
|
||||
@@ -142,12 +199,12 @@ function sso_resolve_session(PDO $pdo): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (!$user || !$user['is_active']) return;
|
||||
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 — id.andmore.pt does not
|
||||
// know about per-tenant roles.
|
||||
// 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;
|
||||
@@ -171,6 +228,7 @@ function sso_resolve_session(PDO $pdo): void {
|
||||
->execute([$ip, $ua, $user['id']]);
|
||||
|
||||
auth_set_session($user);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Upstream sync (federated mode) ──────────────────────────────────────────
|
||||
|
||||
+10
-3
@@ -16,10 +16,17 @@
|
||||
require __DIR__ . '/../../config/db.php';
|
||||
require_once __DIR__ . '/../../config/functions.php';
|
||||
|
||||
// Visitor may already have an am_sso cookie — resolve it instead of bouncing
|
||||
// (saves a round-trip).
|
||||
// Establish the session on return from the hub. fitme3070.pt is off the
|
||||
// .andmore.pt cookie scope, so the real handoff is the one-time ?sso_ticket=…
|
||||
// the hub appended to this return URL — redeem it. (sso_resolve_session() is
|
||||
// kept as a fallback for the rare case a same-site am_sso cookie is present.)
|
||||
if (empty($_SESSION['user_id']) && identity_is_federated()) {
|
||||
sso_resolve_session($pdo);
|
||||
if (!empty($_GET['sso_ticket'])) {
|
||||
sso_redeem_ticket($pdo, (string) $_GET['sso_ticket']);
|
||||
}
|
||||
if (empty($_SESSION['user_id'])) {
|
||||
sso_resolve_session($pdo);
|
||||
}
|
||||
}
|
||||
|
||||
$next = $_GET['next'] ?? '';
|
||||
|
||||
Reference in new issue
Block a user