前端關於單點登入的知識

An_an發表於2018-08-24

什麼是單點登入

單點登入(Single Sign On),簡稱為 SSO,是目前比較流行的企業業務整合的解決方案之一。SSO的定義是在多個應用系統中,使用者只需要登入一次就可以訪問所有相互信任的應用系統。

SSO一般都需要一個獨立的認證中心(passport),子系統的登入均得通過passport,子系統本身將不參與登入操作,當一個系統成功登入以後,passport將會頒發一個令牌給各個子系統,子系統可以拿著令牌會獲取各自的受保護資源,為了減少頻繁認證,各個子系統在被passport授權以後,會建立一個區域性會話,在一定時間內可以無需再次向passport發起認證。

舉個例子,比如淘寶、天貓都屬於阿里旗下的產品,當使用者登入淘寶後,再開啟天貓,系統便自動幫使用者登入了天貓,這種現象背後就是用單點登入實現的。

單點登入流程

1.登入

前端關於單點登入的知識

  • 使用者訪問系統1的受保護資源,系統1發現使用者未登入,跳轉至sso認證中心,並將自己的地址作為引數
  • sso認證中心發現使用者未登入,將使用者引導至登入頁面
  • 使用者輸入使用者名稱密碼提交登入申請
  • sso認證中心校驗使用者資訊,建立使用者與sso認證中心之間的會話,稱為全域性會話,同時建立授權令牌
  • sso認證中心帶著令牌跳轉會最初的請求地址(系統1)
  • 系統1拿到令牌,去sso認證中心校驗令牌是否有效
  • sso認證中心校驗令牌,返回有效,註冊系統1
  • 系統1使用該令牌建立與使用者的會話,稱為區域性會話,返回受保護資源
  • 使用者訪問系統2的受保護資源
  • 系統2發現使用者未登入,跳轉至sso認證中心,並將自己的地址作為引數
  • sso認證中心發現使用者已登入,跳轉回系統2的地址,並附上令牌
  • 系統2拿到令牌,去sso認證中心校驗令牌是否有效
  • sso認證中心校驗令牌,返回有效,註冊系統2
  • 系統2使用該令牌建立與使用者的區域性會話,返回受保護資源

使用者登入成功之後,會與sso認證中心及各個子系統建立會話,使用者與sso認證中心建立的會話稱為全域性會話,使用者與各個子系統建立的會話稱為區域性會話,區域性會話建立之後,使用者訪問子系統受保護資源將不再通過sso認證中心,全域性會話與區域性會話有如下約束關係

  • 區域性會話存在,全域性會話一定存在
  • 全域性會話存在,區域性會話不一定存在
  • 全域性會話銷燬,區域性會話必須銷燬
2.登出

前端關於單點登入的知識
sso認證中心一直監聽全域性會話的狀態,一旦全域性會話銷燬,監聽器將通知所有註冊系統執行登出操作。

  • 使用者向系統1發起登出請求
  • 系統1根據使用者與系統1建立的會話id拿到令牌,向sso認證中心發起登出請求
  • sso認證中心校驗令牌有效,銷燬全域性會話,同時取出所有用此令牌註冊的系統地址
  • sso認證中心向所有註冊系統發起登出請求
  • 各註冊系統接收sso認證中心的登出請求,銷燬區域性會話
  • sso認證中心引導使用者至登入頁面

什麼是CAS

CAS是Central Authentication Service的縮寫,中央認證服務,一種獨立開放指令協議。CAS 是 Yale 大學發起的一個開源專案,旨在為 Web 應用系統提供一種可靠的單點登入方法。CAS 包含兩個部分: CAS Server 和 CAS Client。CAS Server 需要獨立部署,主要負責對使用者的認證工作;CAS Client 負責處理對客戶端受保護資源的訪問請求,需要登入時,重定向到 CAS Server。

CAS 最基本的協議過程:

前端關於單點登入的知識
CAS Client 與受保護的客戶端應用部署在一起,以 Filter 方式保護受保護的資源。對於訪問受保護資源的每個 Web 請求,CAS Client 會分析該請求的 Http 請求中是否包含 Service Ticket,如果沒有,則說明當前使用者尚未登入,於是將請求重定向到指定好的 CAS Server 登入地址,並傳遞 Service (也就是要訪問的目的資源地址),以便登入成功過後轉回該地址。使用者在第 3 步中輸入認證資訊,如果登入成功,CAS Server 隨機產生一個相當長度、唯一、不可偽造的 Service Ticket,並快取以待將來驗證,之後系統自動重定向到 Service 所在地址,併為客戶端瀏覽器設定一個 Ticket Granted Cookie(TGC),CAS Client 在拿到 Service 和新產生的 Ticket 過後,在第 5,6 步中與 CAS Server 進行身份核實,以確保 Service Ticket 的合法性。 在該協議中,所有與 CAS 的互動均採用 SSL 協議,確保,ST 和 TGC 的安全性。協議工作過程中會有 2 次重定向的過程,但是 CAS Client 與 CAS Server 之間進行 Ticket 驗證的過程對於使用者是透明的。 另外,CAS 協議中還提供了 Proxy (代理)模式,以適應更加高階、複雜的應用場景,具體介紹可以參考 CAS 官方網站上的相關文件。

什麼是OAuth2

OAuth(開放授權)是一個開放標準,允許使用者讓第三方應用訪問該使用者在某一網站上儲存的私密的資源(如照片,視訊,聯絡人列表),而無需將使用者名稱和密碼提供給第三方應用。

通俗說,OAuth就是一種授權的協議,只要授權方和被授權方遵守這個協議去寫程式碼提供服務,那雙方就是實現了OAuth模式。

詳細說就是,OAuth在"客戶端"與"服務提供商"之間,設定了一個授權層(authorization layer)。"客戶端"不能直接登入"服務提供商",只能登入授權層,以此將使用者與客戶端區分開來。"客戶端"登入授權層所用的令牌(token),與使用者的密碼不同。使用者可以在登入的時候,指定授權層令牌的許可權範圍和有效期。"客戶端"登入授權層以後,"服務提供商"根據令牌的許可權範圍和有效期,向"客戶端"開放使用者儲存的資料。

OAuth2是OAuth1.0的下一個版本,OAuth2關注客戶端開發者的簡易性,同時為Web應用,桌面應用和手機,和起居室裝置提供專門的認證流程。原先的OAuth,會發行一個 有效期非常長的token(典型的是一年有效期或者無有效期限制),在OAuth 2.0中,server將發行一個短有效期的access token和長生命期的refresh token。這將允許客戶端無需使用者再次操作而獲取一個新的access token,並且也限制了access token的有效期。

CAS和OAuth2區別

  • CAS的單點登入時保障客戶端的使用者資源的安全,OAuth2則是保障服務端的使用者資源的安全;
  • CAS客戶端要獲取的最終資訊是,這個使用者到底有沒有許可權訪問我(CAS客戶端)的資源;oauth2獲取的最終資訊是,我(oauth2服務提供方)的使用者的資源到底能不能讓你(oauth2的客戶端)訪問;
  • CAS的單點登入,資源都在客戶端這邊,不在CAS的伺服器那一方。使用者在給CAS服務端提供了使用者名稱密碼後,作為CAS客戶端並不知道這件事。隨便給客戶端個ST,那麼客戶端是不能確定這個ST是使用者偽造還是真的有效,所以要拿著這個ST去服務端再問一下,這個使用者給我的是有效的ST還是無效的ST,是有效的我才能讓這個使用者訪問。
  • OAuth2認證,資源都在OAuth2服務提供者那一方,客戶端是想索取使用者的資源。所以在最安全的模式下,使用者授權之後,服務端並不能直接返回token,通過重定向送給客戶端,因為這個token有可能被黑客截獲,如果黑客截獲了這個token,那使用者的資源也就暴露在這個黑客之下了。於是聰明的服務端傳送了一個認證code給客戶端(通過重定向),客戶端在後臺,通過https的方式,用這個code,以及另一串客戶端和服務端預先商量好的密碼,才能獲取到token和重新整理token,這個過程是非常安全的。如果黑客截獲了code,他沒有那串預先商量好的密碼,他也是無法獲取token的。這樣oauth2就能保證請求資源這件事,是使用者同意的,客戶端也是被認可的,可以放心的把資源發給這個客戶端了。
  • CAS登入和OAuth2在流程上的最大區別就是,通過ST或者code去認證的時候,需不需要預先商量好的密碼。
總結:
CAS:授權伺服器,被授權客戶端
  1. 授權伺服器(一個)儲存了全域性的一份session,客戶端(多個)各自儲存自己的session;
  2. 客戶端登入時判斷自己的session是否已登入,若未登入,則(告訴瀏覽器)重定向到授權伺服器(引數帶上自己的地址,用於回撥);
  3. 授權伺服器判斷全域性的session是否已登入,若未登入則定向到登入頁面,提示使用者登入,登入成功後,授權伺服器重定向到客戶端(引數帶上ticket【一個憑證號】);
  4. 客戶端收到ticket後,請求伺服器獲取使用者資訊;
  5. 伺服器同意客戶端授權後,服務端儲存使用者資訊至全域性session,客戶端將使用者儲存至本地session
OAuth2:主系統,授權系統(給主系統授權用的,也可以跟主系統是同一個系統),第三方系統
  1. 第三方系統需要使用主系統的資源,第三方重定向到授權系統;
  2. 根據不同的授權方式,授權系統提示使用者授權;
  3. 使用者授權後,授權系統返回一個授權憑證(accessToken)給第三方系統【accessToken是有有效期的】;
  4. 第三方使用accessToken訪問主系統資源【accessToken失效後,第三方需重新請求授權系統,以獲取新的accessToken】。

什麼是JWT

JSON Web Token(JWT)是一個開放標準(RFC 7519),它定義了一種緊湊且獨立的方式,可以在各方之間作為JSON物件安全地傳輸資訊。此資訊可以通過數字簽名進行驗證和信任。JWT可以使用祕密(使用HMAC演算法)或使用RSA或ECDSA的公鑰/私鑰對進行簽名。

JSON WEB令牌結構由三部分組成:

  • Header(頭部):包括令牌的型別及正在使用的雜湊演算法。
  • Payload(負載):宣告是關於實體(通常是使用者)和其他資料的宣告。索賠有三種型別:標準註冊宣告,公共的宣告和私有的宣告。
  • Signature(簽名):必須採用編碼標頭,編碼的有效負載,祕密,標頭中指定的演算法,並對其進行簽名。
  1. 負載-標準的宣告:
  • iss:JWT簽發者
  • sub:JWT所面向的使用者
  • aud:接收JWT的一方
  • exp:JWT的過期時間,這個過期時間必須要大於簽發時間,這是一個秒數
  • nbf:定義在什麼時間之前,該JWT都是不可用的
  • iat:JWT的簽發時間
  1. 負載-公共的宣告:可以新增任何資訊,一般新增使用者的相關資訊或其他業務需要的必要資訊,但不建議新增敏感資訊,因為該部分在客戶端可解密。
  2. 負載-私有宣告:提供者和消費者所共同定義的宣告,一般不建議存放敏感資訊,因為base64是對稱解密的,意味著該部分資訊可以歸類為明文資訊。

建立簽名需要使用編碼後的headerpayload以及一個祕鑰,使用header中指定簽名演算法進行簽名。例如如果希望使用HMAC SHA256演算法,那麼簽名應該使用下列方式建立HMACSHA256(base64UrlEncode(header)+"."+base64UrlEncode(payload), secret)簽名用於驗證訊息的傳送者以及訊息是沒有經過篡改的。完整的JWT格式輸出是以.分隔的三段Base64編碼, 金鑰secret是儲存在服務端的,服務端會根據這個金鑰進行生成token和驗證,所以需要保護好,更多資訊請移步官網

單點登入關於前端的部分

此程式碼採用OAuth2。關於token儲存問題,參考了網上許多教程,大部分都是將token儲存在cookie中,然後將cookie設為頂級域來解決跨域問題,但我司業務需求是某些產品頂級域也各不相同。故實現思路是將token儲存在localStorage中,然後通過H5的新屬性postMessage來實現跨域共享,對跨域不瞭解的可以看我這篇文章

實現思路:當使用者訪問公司某系統(如product.html)時,在product中會首先載入一個iframe,iframe中可以獲取儲存在localStorage中的token,如果沒有取到或token過期,iframe中內部將把使用者將重定向到登入頁,使用者在此頁面登入,仍將去認證系統取得token並儲存在iframe頁面的localStorage

<!--product.html-->
<head>
    <script src="auth_1.0.0.js"></script>
</head>
<body>
    <h2>產品頁面</h2>
    <a onClick="login()" id="login">登入</a>
    <h3 id="txt"></h3>
</body>
<script>
var opts = {
    origin: 'http://localhost:8080',
    login_path: '/login.html',
    path: '/cross_domain.html'
}
// 載入iframe,將src值為cross_domain.html的iframe載入到本頁
var auth = new ssoAuth(opts);
function getTokenCallback(data) {
    //如果沒有token則跳到登入頁
    if(!data.value){
        auth.doWebLogin();
    }
    //如果有token,直接在頁面顯示,然後做其它操作
    document.getElementById('txt').innerText = 'token=' + data.value;
}
// 獲取儲存在名為cross_domain的iframe中的token
auth.getToken(getTokenCallback);
</script>
複製程式碼

講解:在product.html中例項化了ssoAuth後,此頁面便將iframe引入了當前頁,名為opts.path的值,即cross_domain.html。auth.getToken()是獲取此iframe頁面中的localStorage值。

//auth_1.0.0.js
function ssoAuth(opts) {
    this._origin = opts.origin,
    this._iframe_path = opts.path,
    this._iframe = null,
    this._iframe_ready = false,
    this._queue = [],
    this._auth = {},
    this._access_token_msg = { type: "get", key: "access_token" },
    this._callback = undefined,
    that = this;
    
    //判斷是否支援postMessage及localStorage
   var supported = (function () {
        try {
            return window.postMessage && window.JSON && 'localStorage' in window && window['localStorage'] !== null;
        } catch (e) {
            return false;
        }
    })();
    
    _iframeLoaded = function () {
        that._iframe_ready = true
        if (that._queue.length) {
            for (var i = 0, len = that._queue.length; i < len; i++) {
                _sendMessage(that._queue[i]);
            }
            that._queue = [];
        }
    }

    _sendMessage = function (data) {
        // 通過contentWindow屬性,指令碼可以訪問iframe元素所包含的HTML頁面的window物件。
        that._iframe.contentWindow.postMessage(JSON.stringify(data), that._origin);
    }
    
    //獲取token,但因為此時iframe還沒有載入完成,先將訊息儲存在佇列_queue中
    this._auth.getToken = function (callback) {
        that._callback = callback
        if (that._access_token_msg && that._iframe_ready) {
            //當iframe載入完成,給iframe所在的頁面傳送訊息
            _sendMessage(that._access_token_msg);
        } else {
            that._queue.push(that._access_token_msg);
        }
    }

    var _handleMessage = function (event) {
        if (event.origin === that._origin) {
            var data = JSON.parse(event.data);
            if (data.error) {
                console.error(event.data)
                that._callback({ value: null });
                return;
            }
            if (that._callback && typeof that._callback === 'function') {
                that._callback(data);
            } else {
                console.error("callback is null or not a function, please ");
            }
        }
    }

    this._auth.doWebLogin = function () {
        window.location.href = opts.origin + opts.login_path + "?redirect_url=" + window.location.href
    }
    //初始化了一個iframe,並追加到父頁面的底部
    if (!this._iframe && supported) {
        this._iframe = document.createElement("iframe");
        this._iframe.style.cssText = "position:absolute;width:1px;height:1px;left:-9999px;";
        document.body.appendChild(this._iframe);

        if (window.addEventListener) {
            this._iframe.addEventListener("load", function () {
                _iframeLoaded();
            }, false);
            window.addEventListener("message", function (event) {
                _handleMessage(event)
            }, false);
        } else if (this._iframe.attachEvent) {
            this._iframe.attachEvent("onload", function () {
                _iframeLoaded();
            }, false);
            window.attachEvent("onmessage", function (event) {
                _handleMessage(event)
            });
        }
        this._iframe.src = this._origin + this._iframe_path;
    }
    return this._auth;
}
複製程式碼
<!--cross_domain.html-->
<script type="text/javascript">
    (function () {

        //白名單
        var whitelist = ["localhost", "127.0.0.1", "^.*\.domain\.com"];

        function verifyOrigin(origin) {
            var domain = origin.replace(/^https?:\/\/|:\d{1,4}$/g, "").toLowerCase(),
                i = 0,
                len = whitelist.length;

            while (i < len) {
                if (domain.match(new RegExp(whitelist[i]))) {
                    return true;
                }
                i++;
            }
            return false;
        }

        function handleRequest(event) {
            // 白名單較驗
            if (verifyOrigin(event.origin)) {
                var request = JSON.parse(event.data);
                if (request.type == 'get') {
                    var idi = sessionStorage.getItem("idi");
                    if (!idi) {
                        // source:對傳送訊息的視窗物件的引用,event.source只是window物件的代理,不能通過它訪問window//的其它資訊
                        event.source.postMessage(JSON.stringify({ key: request.key, value: null }), event.origin);
                        return;
                    }
                    value = JSON.parse(idi)[request.key];
                    event.source.postMessage(JSON.stringify({ key: request.key, value: value }), event.origin);
                } else {
                    event.source.postMessage(JSON.stringify({ error: "Not supported", error_description: "Not supported message type" }), event.origin);
                }
            }
        }
        // 接收iframe傳來的訊息
        if (window.addEventListener) {
            window.addEventListener("message", handleRequest, false);
        } else if (window.attachEvent) {
            window.attachEvent("onmessage", handleRequest);
        }
    })();
</script>
複製程式碼
<!--login.html-->
<head>
    <script src="auth_1.0.0.js"></script>
</head>
<body>
    <form>
        <input type="text" placeholder="使用者名稱" id="user">
        <input type="password" placeholder="密碼" id="pwd">
    </form>
    <button onClick="login()">登 錄</button>
</body>
<script>
    function login() {
        var name = document.getElementById('user')
        var pwd = document.getElementById('pwd')

        var expires_in = 7200
        //假如這是登入成功後,後臺開發人員返回的json資料
        var res = { 
            access_token: "xxxxx.yyyyy.zzzzz", 
            expires_at: expires_in * 1000 + new Date().getTime(), 
            refresh_token: "yyyyyyyyyyyyyyyyyyyyyyyyyyyy" 
        };
        localStorage.setItem("idi", JSON.stringify(res))
        //登入成功後再返回原頁面
        window.location.href = getQueryString("redirect_url")
    }

    function getQueryString(name) {
        var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
        var r = window.location.search.substr(1).match(reg);
        if (r != null) return unescape(r[2]); return null;
    }
</script>
複製程式碼

PS:登出暫時沒做。另外postMessage有相容性問題,如果其它小夥伴有更好的方法,望分享一下,謝謝~

參考:

相關文章