當前位置: 妍妍網 > 碼農

老板:公司產品太多了,怎麽實作一次登入產品互通?

2024-01-29碼農

點選關註公眾號,Java幹貨 及時送達 👇

背景

最近開發新產品,然後老板說我們現在系統太多了,每次切換系統登入太麻煩了,能不能做個最佳化,同一帳號互通掉。作為一個資深架構獅,老板的要求肯定要滿足,安排!

一個公司產品矩陣比較豐富的時候,使用者在不同系統之間來回切換,固然對產品使用者體驗上較差,並且增加使用者密碼管理成本。

也沒有很好地利用內部流量進行使用者打通,並且每個產品的獨立體系會導致產品安全度下降。

因此實作集團產品的單點登入對使用者使用體驗以及效率提升有很大的幫助。那麽如何實作統一認證呢?我們先了解一下傳統的身份驗證方式。

傳統 Session 機制及身份認證方案

Cookie 與伺服器的互動

眾所周知,http 是無狀態的協定,因此客戶每次透過瀏覽器存取 web。

頁面,請求到伺服端時,伺服器都會新建執行緒,開啟新的會話,而且伺服器也不會自動維護客戶的上下文資訊。

比如我們現在要實作一個電商內的購物車功能,要怎麽才能知道哪些購物車請求對應的是來自同一個客戶的請求呢?

因此出現了 session 這個概念,session 就是一種保存上下文資訊的機制,他是面向使用者的,每一個 SessionID 對應著一個使用者,並且保存在伺服端中。

session 主要以 cookie 或 URL 重寫為基礎的來實作的,預設使用 cookie 來實作,系統會創造一個名為 JSESSIONID 的變量輸出到 cookie 中。

JSESSIONID 是儲存於瀏覽器記憶體中的,並不是寫到硬碟上的,如果我們把瀏覽器的cookie 禁止,則 web 伺服器會采用 URL 重寫的方式傳遞 Sessionid,我們就可以在位址列看到 sessionid=KWJHUG6JJM65HS2K6 之類的字串。

通常 JSESSIONID 是不能跨視窗使用的,當你新開了一個瀏覽器視窗進入相同頁面時,系統會賦予你一個新的 sessionid,這樣我們資訊共享的目的就達不到了。

伺服器端的 session 的機制

當伺服端收到客戶端的請求時候,首先判斷請求裏是否包含了 JSESSIONID 的 sessionId,如果存在說明已經建立過了,直接從記憶體中拿出來使用,如果查詢不到,說明是無效的。

如果客戶請求不包含 sessionid,則為此客戶建立一個 session 並且生成一個與此 session 相關聯的 sessionid,這個 sessionid 將在本次響應中返回給客戶端保存。

對每次 http 請求,都經歷以下步驟處理:

  • 伺服端首先尋找對應的 cookie 的值(sessionid)。

  • 根據 sessionid,從伺服器端 session 儲存中獲取對應 id 的 session 數據,進行返回。

  • 如果找不到 sessionid,伺服器端就建立 session,生成 sessionid 對應的 cookie,寫入到響應頭中。

  • session 是由伺服端生成的,並且以雜湊表的形式保存在記憶體中。

  • 基於 session 的身份認證流程

    基於 seesion 的身份認證主要流程如下:

    因為 http 請求是無狀態請求,所以在 Web 領域,大部份都是透過這種方式解決。但是這麽做有什麽問題呢?我們接著看。

    集群環境下的 Session 困境及解決方案

    隨著技術的發展,使用者流量增大,單個伺服器已經不能滿足系統的需要了,分布式架構開始流行。

    通常都會把系統部署在多台伺服器上,透過負載均衡把請求分發到其中的一台伺服器上,這樣很可能同一個使用者的請求被分發到不同的伺服器上。

    因為 session 是保存在伺服器上的,那麽很有可能第一次請求存取的 A 伺服器,建立了 session,但是第二次存取到了 B 伺服器,這時就會出現取不到 session 的情況。

    我們知道,Session 一般是用來存會話全域的使用者資訊(不僅僅是登陸方面的問題),用來簡化/加速後續的業務請求。

    傳統的 session 由伺服器端生成並儲存,當套用進行分布式集群部署的時候,如何保證不同伺服器上 session 資訊能夠共享呢?

    Session 共享方案

    Session 共享一般有兩種思路:

  • session 復制

  • session 集中儲存

  • ①session 復制

    session 復制即將不同伺服器上 session 數據進行復制,使用者登入,修改,登出時,將 session 資訊同時也復制到其他機器上面去。

    這種實作的問題就是實作成本高,維護難度大,並且會存在延遲登問題。

    ②session 集中儲存

    集中儲存就是將獲取 session 單獨放在一個服務中進行儲存,所有獲取 session 的統一來這個服務中去取。

    這樣就避免了同步和維護多套 session 的問題。一般我們都是使用 redis 進行集中式儲存 session。

    多服務下的登陸困境及 SSO 方案

    SSO 的產生背景

    如果企業做大了之後,一般都有很多的業務支持系統為其提供相應的管理和 IT 服務,按照傳統的驗證方式存取多系統,每個單獨的系統都會有自己的安全體系和身份認證系統。

    進入每個系統都需要進行登入,獲取 session,再透過 session 存取對應系統資源。

    這樣的局面不僅給管理上帶來了很大的困難,對客戶來說也極不友好,那麽如何讓客戶只需登陸一次,就可以進入多個系統,而不需要重新登入呢?

    「單點登入」就是專為解決此類問題的。其大致思想流程如下:透過一個 ticket 進行串接各系統間的使用者資訊。

    SSO 的底層原理 CAS

    ①CAS 實作單點登入流程

    我們知道對於完全不同網域名稱的系統,cookie 是無法跨網域名稱共享的,因此 sessionId 在頁面端也無法共享,因此需要實作單店登入,就需要啟用一個專門用來登入的網域名稱如(ouath.com)來提供所有系統的 sessionId。

    當業務系統被開啟時,借助中心授權系統進行登入,整體流程如下:

  • 當 b.com 開啟時,發現自己未登陸,於是跳轉到 ouath.com 去登陸

  • ouath.com 登陸頁面被開啟,使用者輸入帳戶/密碼登陸成功

  • ouath.com 登陸成功,種 cookie 到 ouath.com 網域名稱下

  • 把 sessionid 放入後台 redis,存放<ticket,sesssionid>數據結構,然後頁面重新導向到 A 系統

  • 當 b.com 重新被開啟,發現仍然是未登陸,但是有了一個 ticket 值

  • 當 b.com 用 ticket 值,到 redis 裏查到 sessionid,並做 session 同步,然後種 cookie 給自己,頁面原地重新導向

  • 當 b.com 開啟自己頁面,此時有了 cookie,後台校驗登陸狀態,成功

  • 整個互動流程圖如下:

    ②單點登入流程演示

    CAS 登入服務 demo 核心程式碼如下:

    使用者實體類:

    public classUserFormimplementsSerializable{
    privatestaticfinallong serialVersionUID = 1L;
    private String username;
    private String password;
    private String backurl;
    public String getUsername(){
    return username;
    }
    publicvoidsetUsername(String username){
    this.username = username;
    }
    public String getPassword(){
    return password;
    }
    publicvoidsetPassword(String password){
    this.password = password;
    }
    public String getBackurl(){
    return backurl;
    }
    publicvoidsetBackurl(String backurl){
    this.backurl = backurl;
    }
    }






    登入控制器:

    @Controller
    public classIndexController{
    @Autowired
    private RedisTemplate redisTemplate;
    @GetMapping("/toLogin")
    public String toLogin(Model model,HttpServletRequest request){
    Object userInfo = request.getSession().getAttribute(LoginFilter.USER_INFO);
    //不為空,則是已登陸狀態
    if (null != userInfo){
    String ticket = UUID.randomUUID().toString();
    redisTemplate.opsForValue().set(ticket,userInfo,2, TimeUnit.SECONDS);
    return"redirect:"+request.getParameter("url")+"?ticket="+ticket;
    }
    UserForm user = new UserForm();
    user.setUsername("laowang");
    user.setPassword("laowang");
    user.setBackurl(request.getParameter("url"));
    model.addAttribute("user", user);
    return"login";
    }
    @PostMapping("/login")
    publicvoidlogin(@ModelAttribute UserForm user,HttpServletRequest request,HttpServletResponse response)throws IOException, ServletException {
    System.out.println("backurl:"+user.getBackurl());
    request.getSession().setAttribute(LoginFilter.USER_INFO,user);
    //登陸成功,建立使用者資訊票據
    String ticket = UUID.randomUUID().toString();
    redisTemplate.opsForValue().set(ticket,user,20, TimeUnit.SECONDS);
    //重新導向,回原url ---a.com
    if (null == user.getBackurl() || user.getBackurl().length()==0){
    response.sendRedirect("/index");
    else {
    response.sendRedirect(user.getBackurl()+"?ticket="+ticket);
    }
    }
    @GetMapping("/index")
    public ModelAndView index(HttpServletRequest request){
    ModelAndView modelAndView = new ModelAndView();
    Object user = request.getSession().getAttribute(LoginFilter.USER_INFO);
    UserForm userInfo = (UserForm) user;
    modelAndView.setViewName("index");
    modelAndView.addObject("user", userInfo);
    request.getSession().setAttribute("test","123");
    return modelAndView;
    }
    }



    登入過濾器:

    public classLoginFilterimplementsFilter{
    publicstaticfinal String USER_INFO = "user";
    @Override
    publicvoidinit(FilterConfig filterConfig)throws ServletException {
    }
    @Override
    publicvoiddoFilter(ServletRequest servletRequest,
    ServletResponse servletResponse, FilterChain filterChain)

    throws IOException, ServletException 
    {
    HttpServletRequest request = (HttpServletRequest) servletRequest;
    HttpServletResponse response = (HttpServletResponse)servletResponse;
    Object userInfo = request.getSession().getAttribute(USER_INFO);;
    //如果未登陸,則拒絕請求,轉向登陸頁面
    String requestUrl = request.getServletPath();
    if (!"/toLogin".equals(requestUrl)//不是登陸頁面
    &amp;&amp; !requestUrl.startsWith("/login")//不是去登陸
    &amp;&amp; null == userInfo) {//不是登陸狀態
    request.getRequestDispatcher("/toLogin").forward(request,response);
    return ;
    }
    filterChain.doFilter(request,servletResponse);
    }
    @Override
    publicvoiddestroy(){
    }
    }







    配置過濾器:

    @Configuration
    public classLoginConfig{
    //配置filter生效
    @Bean
    public FilterRegistrationBean sessionFilterRegistration(){
    FilterRegistrationBean registration = new FilterRegistrationBean();
    registration.setFilter(new LoginFilter());
    registration.addUrlPatterns("/*");
    registration.addInitParameter("paramName""paramValue");
    registration.setName("sessionFilter");
    registration.setOrder(1);
    return registration;
    }
    }

    登入頁面:

    <!DOCTYPE HTML>
    <htmlxmlns:th="http://www.thymeleaf.org">
    <head>
    <title>enjoy login</title>
    <metahttp-equiv="Content-Type"content="text/html; charset=UTF-8" />
    </head>
    <body>
    <divtext-align="center">
    <h1>請登陸</h1>
    <formaction="#"th:action="@{/login}"th:object="${user}"method="post">
    <p>使用者名稱: <inputtype="text"th:field="*{username}" /></p>
    <p>密 碼: <inputtype="text"th:field="*{password}" /></p>
    <p><inputtype="submit"value="Submit" /><inputtype="reset"value="Reset" /></p>
    <inputtype="text"th:field="*{backurl}"hidden="hidden" />
    </form>
    </div>

    </body>
    </html>

    web 系統 demo 核心程式碼如下:

    過濾器:

    public classSSOFilterimplementsFilter{
    private RedisTemplate redisTemplate;
    publicstaticfinal String USER_INFO = "user";
    publicSSOFilter(RedisTemplate redisTemplate){
    this.redisTemplate = redisTemplate;
    }
    @Override
    publicvoidinit(FilterConfig filterConfig)throws ServletException {
    }
    @Override
    publicvoiddoFilter(ServletRequest servletRequest,
    ServletResponse servletResponse, FilterChain filterChain)

    throws IOException, ServletException 
    {
    HttpServletRequest request = (HttpServletRequest) servletRequest;
    HttpServletResponse response = (HttpServletResponse)servletResponse;
    Object userInfo = request.getSession().getAttribute(USER_INFO);;
    //如果未登陸,則拒絕請求,轉向登陸頁面
    String requestUrl = request.getServletPath();
    if (!"/toLogin".equals(requestUrl)//不是登陸頁面
    &amp;&amp; !requestUrl.startsWith("/login")//不是去登陸
    &amp;&amp; null == userInfo) {//不是登陸狀態
    String ticket = request.getParameter("ticket");
    //有票據,則使用票據去嘗試拿取使用者資訊
    if (null != ticket){
    userInfo = redisTemplate.opsForValue().get(ticket);
    }
    //無法得到使用者資訊,則去登陸頁面
    if (null == userInfo){
    response.sendRedirect("http://127.0.0.1:8080/toLogin?url="+request.getRequestURL().toString());
    return ;
    }
    /**
    * 將使用者資訊,載入進session中
    */

    UserForm user = (UserForm) userInfo;
    request.getSession().setAttribute(SSOFilter.USER_INFO,user);
    redisTemplate.delete(ticket);
    }
    filterChain.doFilter(request,servletResponse);
    }
    @Override
    publicvoiddestroy(){
    }
    }










    控制器:

    @Controller
    public classIndexController{
    @Autowired
    private RedisTemplate redisTemplate;
    @GetMapping("/index")
    public ModelAndView index(HttpServletRequest request){
    ModelAndView modelAndView = new ModelAndView();
    Object userInfo = request.getSession().getAttribute(SSOFilter.USER_INFO);
    UserForm user = (UserForm) userInfo;
    modelAndView.setViewName("index");
    modelAndView.addObject("user", user);
    request.getSession().setAttribute("test","123");
    return modelAndView;
    }
    }

    首頁:

    <!DOCTYPE HTML>
    <htmlxmlns:th="http://www.thymeleaf.org">
    <head>
    <title>enjoy index</title>
    <metahttp-equiv="Content-Type"content="text/html; charset=UTF-8" />
    </head>
    <body>
    <divth:object="${user}">
    <h1>cas-website:歡迎你"></h1>
    </div>
    </body>
    </html>

    ③CAS 的單點登入和 OAuth2 的區別

    OAuth2: 三方授權協定,允許使用者在不提供帳號密碼的情況下,透過信任的套用進行授權,使其客戶端可以存取許可權範圍內的資源。

    CAS: 中央認證服務(Central Authentication Service),一個基於 Kerberos 票據方式實作 SSO 單點登入的框架,為 Web 套用系統提供一種可靠的單點登入解決方法(屬於 Web SSO )。

    CAS 的單點登入時保障客戶端的使用者資源的安全 ;OAuth2 則是保障伺服端的使用者資源的安全 。

    CAS 客戶端要獲取的最終資訊是,這個使用者到底有沒有許可權存取我(CAS 客戶端)的資源;OAuth2 獲取的最終資訊是,我(oauth2 服務提供方)的使用者的資源到底能不能讓你(oauth2 的客戶端)存取。

    因此,需要統一的帳號密碼進行身份認證,用 CAS;需要授權第三方服務使用我方資源,使用 OAuth2。

    好了,不知道大家對 SSO 是否有了更深刻的理解,歡迎留言。

    來源:https://juejin.cn/post/7123787027652280356

    END


    看完本文有收獲?請轉發分享給更多人

    關註「Java編程鴨」,提升Java技能

    關註Java編程鴨微信公眾號,後台回復:碼農大禮包可以獲取最新整理的技術資料一份。涵蓋Java 框架學習、架構師學習等

    文章有幫助的話,在看,轉發吧。

    謝謝支持喲 (*^__^*)