完全跨域的單點登入(SSO)解決方案原始碼解析

Frank611發表於2018-10-13

本文介紹的是一種PHP的開源SSO解決方案,可完全跨域,實現較簡潔,原始碼地址:github.com/legalthings…

實現原理

一共分為3個角色:

  • Client - 使用者的瀏覽器

  • Broker - 使用者訪問的網站

  • Server - 儲存使用者資訊和憑據的地方

每個Broker有一個ID和密碼,Broker和Server事先已知道。

  1. 當Client第一次訪問Broker時,它會建立一個隨機令牌,該令牌儲存在cookie中。然後Broker將Client重定向到Server,傳遞Broker的ID和令牌。Server使用Broker的ID、密碼和令牌建立雜湊,此雜湊作為Key鍵儲存當前使用者會話的ID。之後Server會將Client重定向回Broker。

  2. Broker可以使用令牌(來自cookie)、自己的ID和密碼建立相同的雜湊。在執行請求時包含此雜湊。

  3. Server收到請求會提取雜湊,然後根據雜湊獲取之前儲存的使用者會話ID,然後將其設定成當前會話ID。因此,Broker和Client使用相同的會話。當另一個Broker加入時,它也將使用相同的會話。它們可以共享會話中儲存的使用者資訊,進而實現了單點登入功能。

背景知識說明

Session代表著伺服器和客戶端一次會話的過程。直到session失效(服務端關閉),或者客戶端關閉時結束。Session 是儲存在服務端的,並針對每個客戶端(客戶),通過Session ID來區別不同使用者的。關於session的詳細介紹請看這篇文章。下面說的會話即指Session。

詳細實現說明

以下是其GitHub中的過程圖:

第一次訪問流程圖
第一次訪問流程圖

首次訪問Broker時會進行attach操作,attach主要有以下幾個動作:

  1. 生成token並儲存到cookie當中。
  2. 將Broker ID和token作為URL引數跳轉到Server。
  3. Server根據Broker ID查詢到Broker的密碼,再加上傳過來的token生成一個雜湊,作為Key儲存當前使用者的瀏覽器與Server的會話ID。此資料需要持久儲存,可指定失效時間。
  4. 最後返回最初使用者訪問的地址。

Broker側attach程式碼片段:

   /**
     * Attach our session to the user's session on the SSO server.
     *
     * @param string|true $returnUrl  The URL the client should be returned to after attaching
     */
    public function attach($returnUrl = null)
    {
        /* 通過檢測Cookie中是否有token來判斷是否已attach
           若已經attach,就不再進行attach操作了 */
        if ($this->isAttached()) return;

        /* 將當前訪問的地址作為返回地址,attach結束之後會返回到returnUrl */
        if ($returnUrl === true) {
            $protocol = !empty($_SERVER['HTTPS']) ? 'https://' : 'http://';
            $returnUrl = $protocol . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
        }

        $params = ['return_url' => $returnUrl];
        /* 在getAttachUrl函式中會生成token並儲存到cookie中,
           同時將Broker ID和token作為url的引數傳遞給Server */
        $url = $this->getAttachUrl($params);

        /* 跳轉到SSO Server並退出 */
        header("Location: $url", true, 307);
        echo "You're redirected to <a href='$url'>$url</a>";
        exit();
    }
複製程式碼

Server側attach程式碼片段:

   /**
     * Attach a user session to a broker session
     */
    public function attach()
    {
        /* 檢測返回型別 */
        $this->detectReturnType();

        /* 檢測attach的url上是否帶有Broker ID和token資訊 */
        if (empty($_REQUEST['broker'])) return $this->fail("No broker specified", 400);
        if (empty($_REQUEST['token'])) return $this->fail("No token specified", 400);

        if (!$this->returnType) return $this->fail("No return url specified", 400);

        /* 根據Broker ID對應的密碼和token生成校驗碼,與請求引數中的校驗碼匹配,如果相同則認為
           attach的Broker是已在SSO Server註冊過的 */
        $checksum = $this->generateAttachChecksum($_REQUEST['broker'], $_REQUEST['token']);

        if (empty($_REQUEST['checksum']) || $checksum != $_REQUEST['checksum']) {
            return $this->fail("Invalid checksum", 400);
        }

        /* 開啟session */
        $this->startUserSession();
        /* 根據Broker ID對應的密碼和token生成雜湊sid */
        $sid = $this->generateSessionId($_REQUEST['broker'], $_REQUEST['token']);

        /* 將雜湊sid作為鍵值儲存session id到cache中,cache具有持久儲存能力,文字檔案或資料庫均可 */
        $this->cache->set($sid, $this->getSessionData('id'));
        /* 根據返回型別返回 */
        $this->outputAttachSuccess();
    }
複製程式碼

當再次訪問Broker時,由於可以從cookie中獲取token,所以不會再進行attach操作了。當Broker試圖獲取使用者資訊(getUserInfo)時,會通過CURL方式和Server通訊,引數中會攜帶雜湊Key值作為Broker合法身份的驗證。

   /**
     * Execute on SSO server.
     *
     * @param string       $method  HTTP method: 'GET', 'POST', 'DELETE'
     * @param string       $command Command
     * @param array|string $data    Query or post parameters
     * @return array|object
     */
    protected function request($method, $command, $data = null)
    {
        /* 判斷是否已attach */
        if (!$this->isAttached()) {
            throw new NotAttachedException('No token');
        }
        /* 獲取SSO Server地址 */
        $url = $this->getRequestUrl($command, !$data || $method === 'POST' ? [] : $data);

        /* 初始化CURL並設定引數 */
        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
        /* 新增雜湊Key值作為身份驗證 */
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/json', 'Authorization: Bearer '. $this->getSessionID()]);

        if ($method === 'POST' && !empty($data)) {
            $post = is_string($data) ? $data : http_build_query($data);
            curl_setopt($ch, CURLOPT_POSTFIELDS, $post);
        }

        /* 執行CURL並獲取返回值 */
        $response = curl_exec($ch);
        if (curl_errno($ch) != 0) {
            $message = 'Server request failed: ' . curl_error($ch);
            throw new Exception($message);
        }

        /* 對返回資料進行判斷及失敗處理 */
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        list($contentType) = explode(';', curl_getinfo($ch, CURLINFO_CONTENT_TYPE));

        if ($contentType != 'application/json') {
            $message = 'Expected application/json response, got ' . $contentType;
            throw new Exception($message);
        }

        /* 對返回值按照json格式解析 */
        $data = json_decode($response, true);
        if ($httpCode == 403) {
            $this->clearToken();
            throw new NotAttachedException($data['error'] ?: $response, $httpCode);
        }
        if ($httpCode >= 400) throw new Exception($data['error'] ?: $response, $httpCode);

        return $data;
    }
複製程式碼

Server端對getUserInfo的響應片段:

   /**
     * Start the session for broker requests to the SSO server
     */
    public function startBrokerSession()
    {
        /* 判斷Broker ID是否已設定 */
        if (isset($this->brokerId)) return;

        /* 從CURL的引數中獲取雜湊Key值sid */
        $sid = $this->getBrokerSessionID();

        if ($sid === false) {
            return $this->fail("Broker didn't send a session key", 400);
        }

        /* 嘗試從cache中通過雜湊Key值獲取儲存的會話ID */
        $linkedId = $this->cache->get($sid);

        if (!$linkedId) {
            return $this->fail("The broker session id isn't attached to a user session", 403);
        }

        if (session_status() === PHP_SESSION_ACTIVE) {
            if ($linkedId !== session_id()) throw new \Exception("Session has already started", 400);
            return;
        }

        /******** 下面這句程式碼是整個SSO登入實現的核心 ********
         * 將當前會話的ID設定為之前儲存的會話ID,然後啟動會話
         * 這樣就可以獲取之前會話中儲存的資料,從而達到共享登入資訊的目的
         * */
        session_id($linkedId);
        session_start();

        /* 驗證CURL的引數中獲取雜湊Key值sid,得到Broker ID */
        $this->brokerId = $this->validateBrokerSessionId($sid);
    }

   /**
     * Ouput user information as json.
     */
    public function userInfo()
    {
        /* 啟動之前儲存的ID的會話 */
        $this->startBrokerSession();
        $user = null;

        /* 從之前的會話中獲取使用者資訊 */
        $username = $this->getSessionData('sso_user');

        if ($username) {
            $user = $this->getUserInfo($username);
            if (!$user) return $this->fail("User not found", 500); // Shouldn't happen
        }

        /* 響應CURL,返回使用者資訊 */
        header('Content-type: application/json; charset=UTF-8');
        echo json_encode($user);
    }
複製程式碼

如果使用者沒有登入,那麼獲取到的userInfo將是null,此時在Broker側會觸發登入程式,頁面會跳轉到登入介面,請求使用者登入。使用者登入的校驗是在Server側完成的,同時將使用者資訊儲存到之前的ID的會話當中,等到下次再訪問的時候就可以直接獲取到使用者資訊了。

   /**
     * Authenticate
     */
    public function login()
    {
        /* 啟動之前儲存的ID的會話 */
        $this->startBrokerSession();

        /* 檢查使用者名稱和密碼是否為空 */
        if (empty($_POST['username'])) $this->fail("No username specified", 400);
        if (empty($_POST['password'])) $this->fail("No password specified", 400);

        /* 校驗使用者名稱和密碼是否正確 */
        $validation = $this->authenticate($_POST['username'], $_POST['password']);

        if ($validation->failed()) {
            return $this->fail($validation->getError(), 400);
        }

        /* 將使用者資訊儲存到當前會話中 */
        $this->setSessionData('sso_user', $_POST['username']);
        $this->userInfo();
    }
複製程式碼

該解決方案的改進思考

  1. 登入介面部署在Broker中,意味著每一個Broker都要維護一套登入邏輯,可以將登入介面部署在Server端,需要登入時跳轉到Server進行登入,這時需要傳遞登入完成之後跳轉的地址。
  2. 每次獲取userInfo時都要訪問Server,如果訪問量較大,對Server的負載能力要求比較高。可改為每個Broker只從Server端獲取一次userInfo,然後將其儲存到Broker的會話當中。不過這樣有兩點需要注意:
    • 使用者登出各個Broker不會同步,如果對此要求較高,必須對各個Broker單獨呼叫登出程式。
    • 如果使用者Broker和Server部署在同一個域名下,那麼curl_exec執行之前要先關閉會話,執行之後再開啟。否則在Server中無法啟動一個正在使用的會話,導致長時間等待。

相關文章