單點登入讓使用者在多個關聯系統間一次登入、處處可用。本文比較三種主流 PHP SSO 實作架構:同主域 Cookie 共享、中心化認證伺服器(CAS/OIDC)、JWT 無狀態驗證,並提供程式碼範例與選型指南。
| 特性 | 同主域 Cookie | 中心化認證 (CAS/OIDC) | JWT 無狀態 |
|---|---|---|---|
| 架構複雜度 | 低 | 高 | 中 |
| 適用場景 | 同主域子系統 | 異構系統、跨域、企業級 | 微服務、API、跨域 |
| 單點登出 | 簡單(清除 Cookie) | 需登出通知端點 | 需黑名單/短效期 |
| 安全性 | 中(Cookie 風險) | 高(標準協定) | 高(簽名驗證) |
| 效能 | 極快(本地讀取) | 需網路呼叫驗證 | 快(本地驗證) |
| 擴展性 | 受限於域名 | 最佳 | 優 |
前提:所有系統共享同一主域(如 `app1.example.com`, `app2.example.com`)
<?php
// login.php
session_start();
// 驗證帳密
if (validateUser($username, $password)) {
$userId = getUserId($username);
$token = generateSecureToken(); // 32 bytes random
// 存入 Redis:token -> user_id,設定 TTL
$redis->setex("sso_token:$token", 86400 * 7, $userId);
// 設定跨子域 Cookie
setcookie('sso_token', $token, [
'expires' => time() + 86400 * 7,
'path' => '/',
'domain' => 'example.com', // 不加前綴點,現代瀏覽器包含子域
'secure' => true,
'httponly' => true,
'samesite' => 'Lax'
]);
// 同步 Session(可選)
$_SESSION['user_id'] = $userId;
$_SESSION['sso_token'] = $token;
header('Location: https://app1.example.com/dashboard');
exit;
}
?>
<?php
// middleware/SSOAuth.php
class SSOAuth {
private Redis $redis;
private string $cookieName = 'sso_token';
public function __construct(Redis $redis) {
$this->redis = $redis;
}
public function handle(callable $next): void {
$token = $_COOKIE[$this->cookieName] ?? '';
if (!$token) {
$this->redirectToLogin();
return;
}
$userId = $this->redis->get("sso_token:$token");
if (!$userId) {
$this->clearCookie();
$this->redirectToLogin();
return;
}
// 延長 TTL(滑動過期)
$this->redis->expire("sso_token:$token", 86400 * 7);
// 將使用者資訊注入請求
$_SERVER['SSO_USER_ID'] = $userId;
$_SERVER['SSO_TOKEN'] = $token;
$next();
}
private function redirectToLogin(): void {
$loginUrl = 'https://auth.example.com/login?redirect=' . urlencode($_SERVER['REQUEST_URI']);
header("Location: $loginUrl");
exit;
}
private function clearCookie(): void {
setcookie($this->cookieName, '', [
'expires' => time() - 3600,
'path' => '/',
'domain' => 'example.com',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax'
]);
}
}
// 使用
$auth = new SSOAuth($redis);
$auth->handle(function() {
// 正常處理請求
$userId = $_SERVER['SSO_USER_ID'];
});
?>
<?php
// logout.php (auth.example.com)
$token = $_COOKIE['sso_token'] ?? '';
if ($token) {
$redis->del("sso_token:$token");
}
// 清除 Cookie
setcookie('sso_token', '', [
'expires' => time() - 3600,
'path' => '/',
'domain' => 'example.com',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax'
]);
// 重導向至首頁或登入頁
header('Location: https://auth.example.com/login');
exit;
?>
適合異構系統、跨域、需標準協定互通
<?php
// CAS Server 簡化實作
class CAServer {
private PDO $pdo;
private Redis $redis;
// 1. 登入頁面
public function login(string $service = ''): void {
if (isset($_SESSION['cas_user'])) {
$this->redirectWithTicket($service);
return;
}
// 顯示登入表單,隱藏 service 參數
}
// 2. 驗證登入
public function validateLogin(string $username, string $password, string $service): void {
if (validateUser($username, $password)) {
$userId = getUserId($username);
$_SESSION['cas_user'] = $userId;
$this->redirectWithTicket($service);
} else {
// 登入失敗
}
}
// 3. 產生 Service Ticket (ST)
private function redirectWithTicket(string $service): void {
if (!$service) {
header('Location: /dashboard');
return;
}
$st = 'ST-' . bin2hex(random_bytes(16));
$this->redis->setex("cas_st:$st", 300, $_SESSION['cas_user']); // 5 分鐘有效
$redirectUrl = $service . (str_contains($service, '?') ? '&' : '?') . 'ticket=' . $st;
header("Location: $redirectUrl");
exit;
}
// 4. 服務驗證票據 (/serviceValidate)
public function serviceValidate(string $ticket, string $service): void {
header('Content-Type: application/xml; charset=utf-8');
$userId = $this->redis->get("cas_st:$ticket");
if ($userId && $this->verifyService($service)) {
$this->redis->del("cas_st:$ticket"); // 一次性使用
// 可選:產生 Proxy Granting Ticket (PGT) 支援代理
echo $this->buildSuccessXml($userId);
} else {
echo $this->buildFailureXml('INVALID_TICKET', '票據無效或已過期');
}
}
private function buildSuccessXml(string $userId): string {
$user = $this->getUserInfo($userId);
return <<
{$userId}
{$user['email']}
{$user['name']}
XML;
}
}
?>
<?php
// CAS Client 簡化實作
class CASClient {
private string $casServerUrl;
private string $serviceUrl;
public function __construct(string $casServerUrl, string $serviceUrl) {
$this->casServerUrl = rtrim($casServerUrl, '/');
$this->serviceUrl = $serviceUrl;
}
public function authenticate(): ?array {
// 1. 檢查本地 Session
if (isset($_SESSION['cas_user'])) {
return $_SESSION['cas_user'];
}
// 2. 檢查票據參數
$ticket = $_GET['ticket'] ?? null;
if ($ticket) {
return $this->validateTicket($ticket);
}
// 3. 重導向至 CAS 登入
$loginUrl = $this->casServerUrl . '/login?service=' . urlencode($this->serviceUrl);
header("Location: $loginUrl");
exit;
}
private function validateTicket(string $ticket): ?array {
$validateUrl = $this->casServerUrl . '/serviceValidate'
. '?ticket=' . urlencode($ticket)
. '&service=' . urlencode($this->serviceUrl);
$response = file_get_contents($validateUrl);
$xml = simplexml_load_string($response);
$xml->registerXPathNamespace('cas', 'https://www.yale.edu/tp/cas');
$success = $xml->xpath('//cas:authenticationSuccess');
if ($success) {
$user = (string)$success[0]->user;
$attributes = [];
foreach ($success[0]->attributes->children() as $key => $value) {
$attributes[$key] = (string)$value;
}
$_SESSION['cas_user'] = ['id' => $user] + $attributes;
return $_SESSION['cas_user'];
}
return null;
}
public function logout(): void {
$_SESSION = [];
session_destroy();
$logoutUrl = $this->casServerUrl . '/logout?service=' . urlencode($this->serviceUrl);
header("Location: $logoutUrl");
exit;
}
}
?>
<?php
// Auth Server - 簽發 JWT
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
class JWTAuthServer {
private string $privateKey;
private string $publicKey;
private string $issuer = 'auth.example.com';
private int $accessTtl = 900; // 15 分鐘
private int $refreshTtl = 604800; // 7 天
public function __construct() {
$this->privateKey = file_get_contents(__DIR__ . '/keys/private.pem');
$this->publicKey = file_get_contents(__DIR__ . '/keys/public.pem');
}
public function issueTokens(string $userId, array $claims = []): array {
$now = time();
$accessPayload = [
'iss' => $this->issuer,
'sub' => $userId,
'iat' => $now,
'exp' => $now + $this->accessTtl,
'type' => 'access',
'jti' => bin2hex(random_bytes(16))
] + $claims;
$refreshPayload = [
'iss' => $this->issuer,
'sub' => $userId,
'iat' => $now,
'exp' => $now + $this->refreshTtl,
'type' => 'refresh',
'jti' => bin2hex(random_bytes(16))
];
return [
'access_token' => JWT::encode($accessPayload, $this->privateKey, 'RS256'),
'refresh_token' => JWT::encode($refreshPayload, $this->privateKey, 'RS256'),
'expires_in' => $this->accessTtl,
'token_type' => 'Bearer'
];
}
public function refreshAccessToken(string $refreshToken): array {
try {
$payload = JWT::decode($refreshToken, new Key($this->publicKey, 'RS256'));
if ($payload->type !== 'refresh') throw new Exception('Invalid token type');
// 檢查是否在黑名單(登出/撤銷)
if ($this->isRevoked($payload->jti)) throw new Exception('Token revoked');
return $this->issueTokens($payload->sub, (array)$payload->claims ?? []);
} catch (Exception $e) {
throw new Exception('Invalid refresh token: ' . $e->getMessage());
}
}
public function revokeToken(string $jti): void {
// 存入 Redis 黑名單,TTL 設為原 Token 剩餘時間
$redis->setex("jwt_revoked:$jti", 86400 * 7, '1');
}
private function isRevoked(string $jti): bool {
return $redis->exists("jwt_revoked:$jti");
}
}
?>
<?php
// Resource Server - 驗證 JWT
class JWTMiddleware {
private string $publicKey;
private string $issuer;
public function __construct(string $publicKeyPath, string $issuer) {
$this->publicKey = file_get_contents($publicKeyPath);
$this->issuer = $issuer;
}
public function handle(callable $next): void {
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
$this->unauthorized('Missing or invalid Authorization header');
return;
}
$token = $matches[1];
try {
$payload = JWT::decode($token, new Key($this->publicKey, 'RS256'));
// 驗證標準聲明
if ($payload->iss !== $this->issuer) throw new Exception('Invalid issuer');
if ($payload->type !== 'access') throw new Exception('Invalid token type');
if (isset($payload->exp) && $payload->exp < time()) throw new Exception('Token expired');
// 檢查撤銷
if ($this->isRevoked($payload->jti)) throw new Exception('Token revoked');
// 注入使用者資訊
$_SERVER['JWT_USER'] = (array)$payload;
$next();
} catch (Exception $e) {
$this->unauthorized($e->getMessage());
}
}
private function unauthorized(string $message): void {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Unauthorized', 'message' => $message]);
exit;
}
private function isRevoked(string $jti): bool {
return $redis->exists("jwt_revoked:$jti");
}
}
?>
SSO 不是銀彈,選型需考量團隊能力、系統架構、安全合規需求。建議從簡單方案開始,隨業務增長演進架構。