單點登入 (SSO) 讓用戶在多個子系統 (App1, App2, ...) 中**僅需登入一次**,即可存取所有受信任系統。本文實作基於 **Cookie + Callback + Iframe 心跳** 的經典 PHP SSO 架構,涵蓋:
<?php
// app1.example.com/index.php
session_start();
$ssoServer = 'https://sso.example.com';
$appUrl = 'https://app1.example.com';
// 1. 檢查本地 Session
if (isset($_SESSION['user'])) {
echo "歡迎,{$_SESSION['user']['name']} | 登出";
} else {
// 2. 未登入:導向 SSO 登入頁,攜帶 callback
$callback = $appUrl . '/sso_callback.php';
$loginUrl = $ssoServer . '/login.php?callback=' . urlencode($callback);
echo "請登入";
}
// 3. 嵌入 Iframe 心跳檢測 SSO 狀態 (隱藏、零尺寸)
$heartbeatUrl = $ssoServer . '/heartbeat.php?callback=' . urlencode($appUrl . '/sso_heartbeat.php');
echo "";
<?php
// sso.example.com/login.php
session_start();
header('Content-Type: text/html; charset=utf-8');
$callback = $_GET['callback'] ?? '';
// 處理登出
if (isset($_GET['logout'])) {
// 清除 SSO Session
$_SESSION = [];
session_destroy();
// 清除 SSO Cookie (父域)
setcookie('sso_token', '', time() - 3600, '/', '.example.com', true, true);
// 重導回原系統首頁
header('Location: ' . $callback);
exit;
}
// 已有 SSO Session,直接回調
if (isset($_SESSION['sso_user'])) {
$user = $_SESSION['sso_user'];
// 簽發一次性 Token 給 Callback
$token = bin2hex(random_bytes(32));
$_SESSION['sso_tokens'][$token] = $user;
header('Location: ' . $callback . '?sso_token=' . $token);
exit;
}
// 顯示登入表單
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
// 驗證帳密 (示範)
$valid = ($username === 'demo' && $password === '123456'); // 實際查資料庫 password_verify
if ($valid) {
$user = ['id' => 1, 'name' => $username, 'email' => "$username@example.com"];
$_SESSION['sso_user'] = $user;
// 設定 SSO Cookie (父域,所有子系統可讀)
setcookie('sso_token', bin2hex(random_bytes(32)), [
'expires' => time() + 3600, // 1 小時
'path' => '/',
'domain' => '.example.com',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
// 簽發一次性 Token 回調
$token = bin2hex(random_bytes(32));
$_SESSION['sso_tokens'][$token] = $user;
header('Location: ' . $callback . '?sso_token=' . $token);
exit;
} else {
$error = '帳號或密碼錯誤';
}
}
?>
SSO 登入
統一身份認證
= $error ?>
<?php
// app1.example.com/sso_callback.php
session_start();
header('Content-Type: text/html; charset=utf-8');
$token = $_GET['sso_token'] ?? '';
if (!$token) {
die('缺少 SSO Token');
}
// 向 SSO Server 驗證 Token (Server-to-Server,安全)
$ssoVerifyUrl = 'https://sso.example.com/verify_token.php?token=' . urlencode($token);
$ch = curl_init($ssoVerifyUrl);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5]);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
if (!$data || empty($data['user'])) {
die('SSO Token 無效或已過期');
}
// 建立本地 Session
$_SESSION['user'] = $data['user'];
$_SESSION['sso_token'] = $token; // 保留以便心跳驗證
// 導向首頁
header('Location: /index.php');
exit;
<?php
// sso.example.com/verify_token.php
session_start();
header('Content-Type: application/json; charset=utf-8');
$token = $_GET['token'] ?? '';
if (!$token || empty($_SESSION['sso_tokens'][$token])) {
echo json_encode(['error' => 'invalid_token']);
exit;
}
// 一次性 Token:驗證後立即銷毀
$user = $_SESSION['sso_tokens'][$token];
unset($_SESSION['sso_tokens'][$token]);
echo json_encode(['user' => $user]);
SSO 端 heartbeat.php:
<?php
// sso.example.com/heartbeat.php
session_start();
header('Content-Type: text/html; charset=utf-8');
$callback = $_GET['callback'] ?? '';
if (isset($_SESSION['sso_user'])) {
// 已登入:產生一次性 Token 讓子系統更新心跳
$token = bin2hex(random_bytes(16));
$_SESSION['heartbeat_tokens'][$token] = true;
$url = $callback . '?heartbeat_token=' . $token;
} else {
// 未登入:通知子系統登出
$url = $callback . '?logout=1';
}
?>
子系統心跳接收端 sso_heartbeat.php:
<?php
// app1.example.com/sso_heartbeat.php
session_start();
if (isset($_GET['logout'])) {
// SSO 已登出,清除本地 Session
session_destroy();
exit;
}
$token = $_GET['heartbeat_token'] ?? '';
if ($token) {
// 可選:向 SSO 驗證 heartbeat_token (省略)
// 更新本地 Session 過期時間
$_SESSION['last_heartbeat'] = time();
}
exit;
<?php
// app1.example.com/logout.php
session_start();
// 1. 清除本地 Session
session_destroy();
// 2. 導向 SSO 登出頁 (SSO 會清除自己的 Session 與 Cookie)
header('Location: https://sso.example.com/login.php?logout=1&callback=' . urlencode('https://app1.example.com/'));
exit;
| 風險 | 對策 |
|---|---|
| Callback URL 開放重導向 | SSO 端維護**白名單**,僅允許預註冊的子系統域名。 |
| Token 被竊取重放 | Token **一次性**、短效期 (30 秒)、HTTPS 傳輸、HttpOnly Cookie。 |
| CSRF 攻擊登入/登出 | 登入表單加入 CSRF Token;登出需 POST 或確認頁。 |
| Iframe 點擊劫持 | SSO 頁面發送 `X-Frame-Options: SAMEORIGIN` 或 `Content-Security-Policy: frame-ancestors https://app1.example.com https://app2.example.com`。 |
| Session 固定攻擊 | 登入成功後 `session_regenerate_id(true)`。 |