單點(diǎn)登錄的原理和實(shí)現(xiàn)

[單點(diǎn)登錄](Single Sign On)丸冕,簡稱為 SSO,是目前比較流行的企業(yè)業(yè)務(wù)整合的解決方案之一叠纷。SSO的定義是在多個應(yīng)用系統(tǒng)中窖贤,用戶只需要登錄一次就可以訪問所有相互信任的應(yīng)用系統(tǒng)。
很早期的公司草雕,一家公司可能只有一個Server巷屿,慢慢的Server開始變多了。每個Server都要進(jìn)行注冊登錄墩虹,退出的時候又要一個個退出嘱巾。用戶體驗(yàn)很不好!

這就是今天要說的單點(diǎn)登錄

登錄的原理

前因

由于 http 是無狀態(tài)的败晴,所以每次請求都相當(dāng)于是新的浓冒,那么系統(tǒng)是怎么記住的呢,這個時候 session 和 cookie這兩個東西出現(xiàn)了尖坤。第一次訪問 server 稳懒,tomcat會生成一個 cookie,記錄 sessionId 并且返回到瀏覽器,比如你訪問你的是 a.com,那么第一次訪問 a.com之后场梆,瀏覽器就會多了一個 cookie墅冷,這個 cookie 的作用域就是 a.com,下次訪問 a.com 的時候就會自動帶上這個 cookie或油,然后服務(wù)器判斷是不是第一次訪問寞忿。cookie 和 session 是相輔相成的。

后果

判斷登錄是否成功有兩種方法:
第一種是:判斷 cookie 中帶的 sessionId 我服務(wù)器存不存在顶岸,存在成功腔彰,反之失敗。
第二種是:第一次登錄成功之后給當(dāng)前 session 設(shè)個值辖佣,然后每次訪問進(jìn)來都判斷進(jìn)來的 sessionId 霹抛,用這個 sessionId 能拿到值則登錄成功,反之失敗卷谈。

流程

image.png

單點(diǎn)登錄原理

image.png
  1. 瀏覽器訪問應(yīng)用1中的受限資源
  2. 判斷沒登錄杯拐,則跳轉(zhuǎn)到認(rèn)證中心登錄頁面
  3. 登錄成功,跳轉(zhuǎn)回應(yīng)用1世蔗,附帶登錄成功的 token
  4. 應(yīng)用通過 http 請求驗(yàn)證 token 是否正確有效
  5. token 有效
  6. 返回受限資源端逼,允許訪問
  7. 訪問應(yīng)用2中的受限資源
  8. 應(yīng)用通過 http 請求驗(yàn)證是否已登錄
  9. 返回已登錄
  10. 返回受限資源,允許訪問

細(xì)心的人可能會問污淋,在第8步的時候顶滩,應(yīng)用2通過什么判斷是否已登錄,如果不同域名的話芙沥,傳不過 cookie诲祸,這個問題的我只能想到兩種解決方案:

    1. 應(yīng)用1和應(yīng)用2保持同一個二級域名,這樣就可以實(shí)現(xiàn) cookie 共享了而昨。例如:app1.sso.com救氯,app2.sso.com
    1. 如果非要跨域的話,我覺得站內(nèi)跳轉(zhuǎn)是可行的歌憨,假如有 app1.com 和 app2.com着憨, 在 app1.com 中加個跳轉(zhuǎn)的按鈕,點(diǎn)擊這個按鈕執(zhí)行的還是 app1.com 的接口务嫡,在接口中跳轉(zhuǎn)到 app2.com甲抖,跳轉(zhuǎn)的時候根據(jù)當(dāng)前 cookie拿到用戶信息,這樣在 app2.com 認(rèn)證成功之后也可設(shè)置 app2.com 的 cookie心铃。
    1. 還有一種方式就是通過父域的方式准谚,cookie 存放在 app.com路徑 中,app1和 app2通過 iframe 嵌套在 app 中去扣,這樣怎么跳都可以拿到 app 的 cookie柱衔,這是我在別的地方看到的,好像是可行。

UML 圖

這次我通過了共享 cookie實(shí)現(xiàn)的單點(diǎn)登錄:

image.png

有個這個 uml 圖就相對比較清晰了唆铐,自己完善個代碼基本不難了
因?yàn)樯婕暗綍捁芾碚芷荩行?session 什么的需要處理,所以我在這邊接管了框架的 session 的存儲艾岂,也就是實(shí)現(xiàn)了HttpSessionListener監(jiān)聽器顺少,監(jiān)聽 session 的創(chuàng)建和刪除,加了一個 map 王浴,根據(jù) sessionId 來存儲和管理脆炎。

image.png
image.png

在 web.xml 中增加監(jiān)聽的配置


image.png

在判斷是否登錄的時候,我可以直接調(diào)用getSession根據(jù) sessionId來拿到HttpSession 對象氓辣,就像這樣:

Object auth =LocalSessionManager.getSession(session.getId()).getAttribute("token_info");

來判斷當(dāng)前應(yīng)用是否有沒有setAttribute值腕窥,有就是登錄過,空就是沒登錄筛婉。這個作用就是防止每次判斷是否登錄都要去認(rèn)證中心,那樣認(rèn)證中心壓力太大了癞松。

關(guān)鍵代碼

我本地設(shè)置的 hosts:
127.0.0.1 app1.sso.com
127.0.0.1 app2.sso.com
127.0.0.1 server.sso.com
app1端口是8083
app2端口是8084
server端口是8082

也在此奉上關(guān)鍵代碼:

  • sso-client的攔截器
    /**
     * springmvc 攔截器
     * @param request
     * @param response
     * @param o
     * @return
     * @throws Exception
     */
  @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object o) throws Exception {
        String url = "http://" + request.getServerName() + ":" + request.getServerPort() + request.getRequestURI();
        HttpSession session = request.getSession();
        Object auth = LocalSessionManager.getSession(session.getId()).getAttribute("token_info");
        String token = request.getParameter("token");

        //loginOut 不攔截
        if (request.getRequestURI().contains("loginOut") || request.getRequestURI().contains("toLoginOut")) {
            return true;
        }
        String userName = UrlUtils.getUserNameByCookies(request.getCookies());

        //說明當(dāng)前用戶為登錄狀態(tài)
        if (auth != null) {
            return true;
        }

        //說明是sso服務(wù)器調(diào)用的爽撒,稍后可能會改成別的判斷
        if (token != null) {
            Map<String, Object> param = new HashMap<>();
            param.put("token", token);
            param.put("appUrl", request.getServerName() + ":" + request.getServerPort());
            String result = HttpUtils.httpPostRequest(serverUrl + "user/checkToken", param);
            if (resultHandle(result, request, response)){
                return true;
            }
        }

        //說明當(dāng)前用戶 有 cookie,可能認(rèn)證中心已經(jīng)登錄了
        if (auth == null && userName != null) {
            //判斷sso服務(wù)器是否已經(jīng)登錄了
            Map<String, Object> params = new HashMap<>();
            params.put("appSessionId", session.getId());
            params.put("appUrl", request.getServerName() + ":" + request.getServerPort());
            params.put("username", userName);
            String result1 = HttpUtils.httpPostRequest(serverUrl + "user/checkLogin", params);
            if (resultHandle(result1, request, response)){
                return true;
            }
        }

        response.sendRedirect(serverUrl + "?back=" + UrlUtils.encodeUrlWithSessionId(url, session.getId()));
        return false;
    }

    /**
     * 處理 server 返回的內(nèi)容
     * @param result
     * @param request
     * @param response
     * @return
     */
    private boolean resultHandle(String result, HttpServletRequest request, HttpServletResponse response) {
        JSONObject jsonResult = JSONObject.parseObject(result);
        if (jsonResult.getBoolean("success")) {
            UserInfo info = JSONObject.parseObject(jsonResult.getString("data"), UserInfo.class);
            HttpSession sessionLocal = LocalSessionManager.getSession(info.getLocalSessionId());
            if (sessionLocal != null) {
                Cookie cookie = new Cookie("_token_security", UrlUtils.encodeUrl(Base64Utils.encodeBase64(info.getUserName())));
                cookie.setPath("/");
                cookie.setDomain(".sso.com");
                response.addCookie(cookie);
                System.out.println("url:" + request.getServerName());
                sessionLocal.setAttribute("token_info", info);
                return true;
            }
            return true;
        }
        return false;
    }
  • sso-server 的登錄 controller
@RequestMapping(value = "/login", method = RequestMethod.GET)
    @ResponseBody
    public void login(String username, String password, String target,
                      HttpServletRequest request, HttpServletResponse response) throws IOException {
        if (username == null) {
            username = request.getParameter("name");
        }
        if (password == null) {
            password = request.getParameter("pwd");
        }
        if (target == null) {
            target = request.getParameter("target");
        }


        User user = userService.login(username, password);
        if (user != null) {
            String appSessionId = target.split("&")[1];
            UserInfo info = new UserInfo(request.getSession().getId(), appSessionId, username, null);
            String token = TokenUtils.takeTokenWithUserInfo(info);
            response.sendRedirect(URLUtils.decodeUrl(target.split("&")[0]) + "?token=" + token);
            return;
        }
        response.sendRedirect("/login.jsp?target=" + target);
    }

    @RequestMapping(value = "/checkToken", method = RequestMethod.POST)
    @ResponseBody
    public Map<String, Object> checkToken(String token, String appUrl) {
        Map<String, Object> map = new HashMap();
        map.put("success", false);
        UserInfo user = TokenUtils.getUserInfo(token);
        if (user != null) {
            user.setAppUrl(appUrl);
            //存入用戶和應(yīng)用的關(guān)聯(lián)
            UserAppManager.add(user.getUserName(), appUrl);
            //重新初始化 token响蓉,是剛才的 token 失效硕勿,目的是 token 驗(yàn)證只能用一次
            TokenUtils.updataTokenWithUserInfo(token, user);
            map.put("success", true);
            map.put("data", user);
        }
        return map;
    }

    @RequestMapping(value = "/checkLogin", method = RequestMethod.POST)
    @ResponseBody
    public Map<String, Object> checkLogin(String appUrl, String username,String appSessionId, HttpServletRequest request, HttpServletResponse response) {
        username = Base64Utils.decodeBase64(URLUtils.decodeUrl(username));
        Map<String, Object> map = new HashMap();
        map.put("success", false);
        UserInfo user = TokenUtils.getUserInfoByUserName(username);
        //當(dāng)前賬號已登錄
        if (user != null) {
            //當(dāng)前應(yīng)用沒登錄
            if (TokenUtils.getUserInfoByNameUrl(username,appUrl)==null){
                UserInfo info=new UserInfo(user.getGloalSessionId(),appSessionId,username,appUrl);
                //存入用戶和應(yīng)用的關(guān)聯(lián)
                UserAppManager.add(username, appUrl);
                TokenUtils.takeTokenWithUserInfo(info);
                map.put("data", info);
                map.put("success", true);
                return map;
            }else {
                map.put("data", TokenUtils.getUserInfoByNameUrl(username,appUrl));
                map.put("success", true);
                return map;
            }
        }
        return map;
    }

    @RequestMapping(value = "/loginOut", method = RequestMethod.POST)
    @ResponseBody
    public Map<String, Object> loginOut(String username) {
        username = Base64Utils.decodeBase64(URLUtils.decodeUrl(username));
        Map<String, Object> map = new HashMap();
        map.put("success", false);
        try {
            Set<String> urls = UserAppManager.getByName(username);
            if (urls != null) {
                for (String url : urls) {
                    //遠(yuǎn)程刪除 session
                    UserInfo info = TokenUtils.getUserInfoByNameUrl(username, url);
                    Map<String, Object> params = new HashMap();
                    params.put("sessionId", info.getLocalSessionId());
                    String targetUrl = "http://" + url + "/user/toLoginOut";
                    HttpUtils.httpPostRequest(targetUrl, params);
                }
            }
            TokenUtils.deleteByName(username);
            UserAppManager.deleteByName(username);
            map.put("success", true);
            return map;
        } catch (Exception e) {
            logger.error("loginOut error",e);
        }
        return map;
    }

github 地址:
https://github.com/thecattle/sso-client
https://github.com/thecattle/sso-server


ps:自己瞎寫的,要是有大佬看到枫甲,還請多多指出錯誤

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末源武,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子想幻,更是在濱河造成了極大的恐慌粱栖,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,042評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件脏毯,死亡現(xiàn)場離奇詭異闹究,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)食店,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 89,996評論 2 384
  • 文/潘曉璐 我一進(jìn)店門渣淤,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人吉嫩,你說我怎么就攤上這事价认。” “怎么了自娩?”我有些...
    開封第一講書人閱讀 156,674評論 0 345
  • 文/不壞的土叔 我叫張陵用踩,是天一觀的道長。 經(jīng)常有香客問我,道長捶箱,這世上最難降的妖魔是什么智什? 我笑而不...
    開封第一講書人閱讀 56,340評論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮丁屎,結(jié)果婚禮上荠锭,老公的妹妹穿的比我還像新娘。我一直安慰自己晨川,他們只是感情好证九,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,404評論 5 384
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著共虑,像睡著了一般愧怜。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上妈拌,一...
    開封第一講書人閱讀 49,749評論 1 289
  • 那天拥坛,我揣著相機(jī)與錄音,去河邊找鬼尘分。 笑死猜惋,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的培愁。 我是一名探鬼主播著摔,決...
    沈念sama閱讀 38,902評論 3 405
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼定续!你這毒婦竟也來了谍咆?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,662評論 0 266
  • 序言:老撾萬榮一對情侶失蹤私股,失蹤者是張志新(化名)和其女友劉穎摹察,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體庇茫,經(jīng)...
    沈念sama閱讀 44,110評論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡港粱,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,451評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了旦签。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片查坪。...
    茶點(diǎn)故事閱讀 38,577評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖宁炫,靈堂內(nèi)的尸體忽然破棺而出偿曙,到底是詐尸還是另有隱情,我是刑警寧澤羔巢,帶...
    沈念sama閱讀 34,258評論 4 328
  • 正文 年R本政府宣布望忆,位于F島的核電站罩阵,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏启摄。R本人自食惡果不足惜稿壁,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,848評論 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望歉备。 院中可真熱鬧傅是,春花似錦、人聲如沸蕾羊。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,726評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽龟再。三九已至书闸,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間利凑,已是汗流浹背浆劲。 一陣腳步聲響...
    開封第一講書人閱讀 31,952評論 1 264
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留哀澈,地道東北人梳侨。 一個月前我還...
    沈念sama閱讀 46,271評論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像日丹,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子蚯嫌,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,452評論 2 348

推薦閱讀更多精彩內(nèi)容