1. 程式人生 > >【SSO】單點登入系統

【SSO】單點登入系統

一、單點登入系統介紹

  對於一個開發專案來說,每個專案都必不可少要有登入的這個功能。但是隨著專案的變大,變大,再變大。系統可能會被拆分成多個小系統,咱們就拿支付寶和淘寶來說,咱們在淘寶上購物,然後就可以直接連線到自己的支付寶,這個過程不需要我們再次登入系統,自動就完成了跳轉。這個操作就是小編這次向大家介紹的——單點登入。

1.1 什麼是單點登入?

   SSO英文全稱Single Sign On,單點登入。SSO是在多個應用系統中,使用者只需要登入一次就可以訪問所有相互信任的應用系統它包括可以將這次主要的登入對映到其他應用中用於同一個使用者的登入的機制。它是目前比較流行的企業業務整合的解決方案之一。

  可以看出,咱們使用了單點登入系統,在分散式架構APP中,咱們的系統和系統之間的跳轉就可以達到無縫連結了。使用者體驗度非常好,使用者根本沒有覺察出是多個系統。這個就達到了我們的目的,即提高了效能,又增加了我們系統之間跳轉的靈活度。

1.2 為什麼使用單點登入

  下面小編從一個技術小白做訂餐系統,一步一步演變框架。

  首先呢,小編做了一個java Web的專案,這個專案使用者進入訂餐介面,選擇要定的飯後,提交的時候,會判斷是否登入。如果沒有登入,就會跳轉到使用者登入介面,然後進行登入的判斷;如果已經登入了,就可以直接提交資訊了。

  這個小系統非常的簡單,操作也很流暢。但是它由一個適應範圍:非高併發使用流暢。

    

  當我們的這個系統有了很高的併發,就像美團外面一樣,每天的使用者很多。我們能做的就是把我們的APP多釋出到幾個tomcat上,然後通過Nginx反向代理來均分權重。

  隨之而來的就是Session共享問題了:  使用者1登入後,nginx給他分配到tomcat1上,session被儲存在tomcat1上。當下次登入可能分配到tomcat2上,這樣還需要重新登入。

  解決Session共享的問題: 

  1. tomcat有一個session同步方案,就是一個傳播機制,打個比方有A B C 3臺tomcat,這3臺tomcat的user資訊都在session中並且保持一致,如果其中一臺的user資訊變化了,那麼就會傳播至另外兩臺,則實現同步,這樣做沒問題,但是僅僅只是在做tomcat叢集的時候tomcat很少的時候會用,一旦叢集增大,有100臺,那麼就互相傳播吧,傳播是需要效能損耗的,那麼整個網站的效能就會被拉低,形成網路風暴。推薦節點數量不要超過5個。 

  2、分散式架構。拆分成多個子系統。 獨立建立一個單點登入系統,登入後,把使用者資訊儲存到redis,把key值儲存到cookie中,當其他系統需要使用者資訊的時候,就可以通過讀取redis中的資訊,如果redis中存在,就直接使用。如果不存在就跳轉到單點登入系統進行登入。

   

 二、單點登入系統的實現

2.1 環境準備

  • eclipse
  • redis

2.2 單點登入流程圖

  這個是簡單的單點登入流程圖,就那淘寶來說,當我們進步淘寶首頁的時候是沒有登入的,點選登入的時候,會跳轉到使用者登入介面。此時的使用者登入介面就是咱們SSO系統的一部分,根據登入的要求,會接收使用者名稱和密碼,然後根據使用者名稱查詢密碼是否正確。

  • 如果不正確就跳轉到登入頁,提示不正確;
  • 如果正確就要進行以下步驟:
    •  生成一個uuid,作為token;
    • 把使用者資訊序列化儲存到redis,儲存的key為token,儲存成功後,返回token; 
    • 把token儲存到cookie; 
    • 判斷是否有回撥url,如果有,跳轉到指定url;如果沒有,跳轉到系統首頁;

  

 2.3 登入邏輯實現

2.3.1 DAO層:

  單表查詢,可以直接使用Mybatis逆向工程產生的程式碼。

2.3.2 Service層 

  • 引數:

    • 使用者名稱:String username

    • 密碼:String password

    • 返回值:E3Result,包裝token

  • 業務邏輯 :

    1. 判斷使用者名稱密碼是否正確。

    2. 登入成功後生成token。Token相當於原來的jsessionid,字串,可以使用uuid。

    3. 把使用者資訊儲存到redis。Key就是token,value就是TbUser物件轉換成json。

    4. 使用String型別儲存Session資訊。可以使用“字首:token”為key

    5. 設定key的過期時間。模擬Session的過期時間。一般半個小時。

    6. 返回e3Result包裝token。

public e3Result login(String username, String password) {
        // 1、判斷使用者名稱密碼是否正確。
        TbUserExample example = new TbUserExample();
        Criteria criteria = example.createCriteria();
        criteria.andUsernameEqualTo(username);
        //查詢使用者資訊
        List<TbUser> list = userMapper.selectByExample(example);
        if (list == null || list.size() == 0) {
            return e3Result.build(400, "使用者名稱或密碼錯誤");
        }
        TbUser user = list.get(0);
        //校驗密碼
        if (!user.getPassword().equals(DigestUtils.md5DigestAsHex(password.getBytes()))) {
            return e3Result.build(400, "使用者名稱或密碼錯誤");
        }
        // 2、登入成功後生成token。Token相當於原來的jsessionid,字串,可以使用uuid。
        String token = UUID.randomUUID().toString();
        // 3、把使用者資訊儲存到redis。Key就是token,value就是TbUser物件轉換成json。
        // 4、使用String型別儲存Session資訊。可以使用“字首:token”為key
        user.setPassword(null);
        jedisClient.set(USER_INFO + ":" + token, JsonUtils.objectToJson(user));
        // 5、設定key的過期時間。模擬Session的過期時間。一般半個小時。
        jedisClient.expire(USER_INFO + ":" + token, SESSION_EXPIRE);
        // 6、返回e3Result包裝token。
        return e3Result.ok(token);
    }
  • 釋出服務:
<!-- 使用dubbo釋出服務 -->
    <!-- 提供方應用資訊,用於計算依賴關係 -->
    <dubbo:application name="e3-sso"/>
    <dubbo:registry protocol="zookeeper" address="192.168.25.128:2181"/>
    <!-- 用dubbo協議在20880埠暴露服務 -->
    <dubbo:protocol name="dubbo" port="20883"/>
    <!-- 宣告需要暴露的服務介面 -->
    <dubbo:service interface="cn.e3mall.sso.service.LoginService" ref="loginServiceImpl" timeout="300000"/>

 

2.3.3 Controller層

功能分析:

  • 請求的url:/user/login

  • 請求的方法:POST

  • 引數:username、password,表單提交的資料。可以使用方法的形參接收。

  • 返回值:json資料,使用e3Result包含一個token。

  • 業務邏輯:

    • 接收兩個引數。

    • 呼叫Service進行登入。

    • 從返回結果中取token,寫入cookie。Cookie要跨域。

引用服務:

<!-- 引用dubbo服務 -->
    <dubbo:application name="e3-sso-web"/>
    <dubbo:registry protocol="zookeeper" address="192.168.25.128:2181"/>
    <dubbo:reference interface="cn.e3mall.sso.service.TokenService" id="tokenServiceImpl"/>

LoginController:

@Controller
public class LoginController {
    @Autowired
    private LoginService loginService;
    @Value("${COOKIE_TOKEN_KEY}")
    private String COOKIE_TOKEN_KEY;
    
    @RequestMapping(value="/user/login", method=RequestMethod.POST)
    @ResponseBody
    public E3Result login(String username, String password,
            HttpServletRequest request, HttpServletResponse response) {
        // 1、接收兩個引數。
        // 2、呼叫Service進行登入。
        E3Result result = loginService.login(username, password);
        // 3、從返回結果中取token,寫入cookie。Cookie要跨域。
        String token = result.getData().toString();
        CookieUtils.setCookie(request, response, COOKIE_TOKEN_KEY, token);
        // 4、響應資料。Json資料。e3Result,其中包含Token。
        return result;
    }
}

2.4 通過token查詢使用者資訊

2.4.1 功能分析

  • 請求的url:/user/token/{token}

  • 引數:String token需要從url中取。

  • 返回值:json資料。使用e3Result包裝Tbuser物件。

  • 業務邏輯:

    1. 從url中取引數。

    2. 根據token查詢redis。

    3. 如果查詢不到資料。返回使用者已經過期。

    4. 如果查詢到資料,說明使用者已經登入。

    5. 需要重置key的過期時間。

    6. 把json資料轉換成TbUser物件,然後使用e3Result包裝並返回。

2.4.2 DAO層

  使用JedisClient物件。

2.4.3 Service層

resource.properties:

#SESSION在redis的過期時間
SESSION_EXPIRE=1800

實現類:

@Service
public class TokenServiceImpl implements TokenService {

    @Autowired
    private JedisClient jedisClient;
    @Value("${SESSION_EXPIRE}")
    private Integer SESSION_EXPIRE;
    
    @Override
    public E3Result getUserByToken(String token) {
        // 根據token查詢redis
        String json = jedisClient.get("SESSION"+token);
        if(StringUtils.isBlank(json)){
            // 如果查詢不到資料,返回使用者已經過期
            return E3Result.build(400, "使用者登入已經過期,請重新登入");
        }
        // 如果查詢到資料,說明使用者已經登入
        // 需要重置key的過期時間
        jedisClient.expire("SESSION"+token, SESSION_EXPIRE);
        // 把json資料轉換成user物件,然後使用e3Result包裝並返回。
        TbUser user = JsonUtils.jsonToPojo(json, TbUser.class);
        return E3Result.ok(user);
    }
}

2.4.4 Controller層:

  • 請求的url:/user/token/{token}

  • 引數:String token需要從url中取。

  • 返回值:json資料。使用e3Result包裝Tbuser物件。
Controller
public class TokenController {
    @Autowired
    private TokenService tokenService;
    
    @RequestMapping("/user/token/{token}")
    @ResponseBody
    public Object getUserByToken(@PathVariable String token,String callback){
        E3Result result = tokenService.getUserByToken(token);
        // 響應結果之前,判斷是否為jsonp請求
        if(StringUtils.isNotBlank(callback)){
            // 把結果封裝成一個js語句響應
            MappingJacksonValue mappingJacksonValue = new MappingJacksonValue(result);
            mappingJacksonValue.setJsonpFunction(callback);
            return mappingJacksonValue;
        }
        return result;
    }
}

2.5 首頁展示使用者名稱 

功能分析:

  1. 當用戶登入成功後,在cookie中有token資訊。

  2. 從cookie中取token根據token查詢使用者資訊。

  3. 把使用者名稱展示到首頁。

問題:服務介面在sso系統中。Sso.e3.com(localhost:8088),在首頁顯示使用者名稱稱,首頁的域名是www.e3.com(localhost:8082),使用ajax請求跨域了。而JS不可以跨域請求資料

什麼是跨域:

  1. 域名不同
  2. 域名相同,埠號不同

解決js的跨域問題可以使用jsonp

Jsonp不是新技術,跨域的解決方案。使用js的特性繞過跨域請求。JS可以跨域載入js檔案

2.6 Jsonp原理

  

2.7 Jsonp實現

2.7.1 客戶端

var E3MALL = {
    checkLogin : function(){
        var _ticket = $.cookie("token");
        if(!_ticket){
            return ;
        }
        $.ajax({
            url : "http://localhost:8087/user/token/" + _ticket,
            dataType : "jsonp",
            type : "GET",
            success : function(data){
                if(data.status == 200){
                    var username = data.data.username;
                    var html = username + ",歡迎來到宜立方購物網!<a href=\"http://www.e3mall.cn/user/logout.html\" class=\"link-logout\">[退出]</a>";
                    $("#loginbar").html(html);
                }
            }
        });
    }
}

$(function(){
    // 檢視是否已經登入,如果已經登入查詢登入資訊
    E3MALL.checkLogin();
});

 

 2.7.2 服務端

  • 功能分析:
    1. 接收callback引數,取回調的js的方法名。

    2. 業務邏輯處理。

    3. 響應結果,拼接一個js語句。

  • 實現方法一:
        @RequestMapping(value="/user/token/{token}",produces=MediaType.APPLICATION_JSON_UTF8_VALUE"application/json;charset=utf-8")
        @ResponseBody
        public String getUserByToken(@PathVariable String token, String callback) {
            E3Result result = tokenService.getUserByToken(token);
            //響應結果之前,判斷是否為jsonp請求
            if (StringUtils.isNotBlank(callback)) {
                //把結果封裝成一個js語句響應
                return callback + "(" + JsonUtils.objectToJson(result)  + ");";
            }
            return JsonUtils.objectToJson(result);
        } 
  • 如果spring是4.1以上的版本,可以使用方法二:
      @RequestMapping(value="/user/token/{token}")
        @ResponseBody
        public Object getUserByToken(@PathVariable String token, String callback) {
            E3Result result = tokenService.getUserByToken(token);
            //響應結果之前,判斷是否為jsonp請求
            if (StringUtils.isNotBlank(callback)) {
                //把結果封裝成一個js語句響應
                MappingJacksonValue mappingJacksonValue = new MappingJacksonValue(result);
                mappingJacksonValue.setJsonpFunction(callback);
                return mappingJacksonValue;
            }
            return result;
        }

     

 

參考博文:https://blog.csdn.net/kisscatforever/article/details/76409250