首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 相關知識

PHP 單點登入 (SSO) 實作方案:Cookie 共享、中心化認證、JWT 無狀態架構比較

     編輯:PHP 相關知識

單點登入讓使用者在多個關聯系統間一次登入、處處可用。本文比較三種主流 PHP SSO 實作架構:同主域 Cookie 共享、中心化認證伺服器(CAS/OIDC)、JWT 無狀態驗證,並提供程式碼範例與選型指南。

方案比較總覽

特性同主域 Cookie中心化認證 (CAS/OIDC)JWT 無狀態
架構複雜度低高中
適用場景同主域子系統異構系統、跨域、企業級微服務、API、跨域
單點登出簡單(清除 Cookie)需登出通知端點需黑名單/短效期
安全性中(Cookie 風險)高(標準協定)高(簽名驗證)
效能極快(本地讀取)需網路呼叫驗證快(本地驗證)
擴展性受限於域名最佳優

方案一:同主域 Cookie 共享(最簡單)

前提:所有系統共享同一主域(如 `app1.example.com`, `app2.example.com`)

登入系統(auth.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;
?>

方案二:中心化認證伺服器 (CAS 3.0 協定)

適合異構系統、跨域、需標準協定互通

CAS 伺服器端核心流程

<?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;
    }
}
?>

方案三:JWT 無狀態驗證(微服務首選)

認證中心簽發 Token

<?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");
    }
}
?>

選型建議

  1. 同主域、少量系統、團隊較小:方案一(Cookie 共享),最快上線
  2. 跨域、異構技術棧、需標準協定、企業級:方案二(CAS/OIDC),推薦使用成熟套件如 `cas-server`、`league/oauth2-server`
  3. 微服務架構、API 為主、追求無狀態、高擴展性:方案三(JWT),注意 Token 撤銷機制與金鑰輪換

安全性關鍵點

  • 所有方案必須強制 HTTPS
  • Cookie 設定 `Secure`、`HttpOnly`、`SameSite`
  • JWT 使用非對稱加密(RS256/ES256),定期輪換金鑰
  • 實作登出傳播(Single Logout, SLO)
  • 記錄審計日誌:登入、登出、Token 刷新、驗證失敗
  • 防暴力破解:登入限流、帳號鎖定、MFA

SSO 不是銀彈,選型需考量團隊能力、系統架構、安全合規需求。建議從簡單方案開始,隨業務增長演進架構。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved