公司產品太多了,怎麼實現一次登入產品互通?

語言: CN / TW / HK
w.thymeleaf.org">

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

image.png

一個公司產品矩陣比較豐富的時候,使用者在不同系統之間來回切換,固然對產品使用者體驗上較差,並且增加使用者密碼管理成本。也沒有很好地利用內部流量進行使用者打通,並且每個產品的獨立體系會導致產品安全度下降。因此實現集團產品的單點登入對使用者使用體驗以及效率提升有很大的幫助。那麼如何實現統一認證呢?我們先了解一下傳統的身份驗證方式。

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

1.1 Cookie與伺服器的互動

image.png

眾所周知,http是無狀態的協議,因此客戶每次通過瀏覽器訪問web\ 頁面,請求到服務端時,伺服器都會新建執行緒,開啟新的會話,而且伺服器也不會自動維護客戶的上下文資訊。比如我們現在要實現一個電商內的購物車功能,要怎麼才能知道哪些購物車請求對應的是來自同一個客戶的請求呢?

image.png

因此出現了session這個概念,session 就是一種儲存上下文資訊的機制,他是面向使用者的,每一個SessionID 對應著一個使用者,並且儲存在服務端中。session主要 以 cookie 或 URL 重寫為基礎的來實現的,預設使用 cookie 來實現,系統會創造一個名為JSESSIONID的變數輸出到cookie中。

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

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

1.2 伺服器端的session的機制

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

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

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

-服務端首先查詢對應的cookie的值(sessionid)。\ -根據sessionid,從伺服器端session儲存中獲取對應id的session資料,進行返回。\ -如果找不到sessionid,伺服器端就建立session,生成sessionid對應的cookie,寫入到響應頭中。

session是由服務端生成的,並且以散列表的形式儲存在記憶體中

1.3 基於 session 的身份認證流程

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

image.png

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

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

image.png

隨著技術的發展,使用者流量增大,單個伺服器已經不能滿足系統的需要了,分散式架構開始流行。通常都會把系統部署在多臺伺服器上,通過負載均衡把請求分發到其中的一臺伺服器上,這樣很可能同一個使用者的請求被分發到不同的伺服器上,因為 session 是儲存在伺服器上的,那麼很有可能第一次請求訪問的 A 伺服器,建立了 session,但是第二次訪問到了 B 伺服器,這時就會出現取不到 session 的情況。

我們知道,Session 一般是用來存會話全域性的使用者資訊(不僅僅是登陸方面的問題),用來簡化/加速後續的業務請求。\ 傳統的 session 由伺服器端生成並存儲,當應用進行分散式叢集部署的時候,如何保證不同伺服器上 session 資訊能夠共享呢?

2.1 Session共享方案

Session共享一般有兩種思路

  • session複製
  • session集中儲存

2.1.1 session複製

session複製即將不同伺服器上 session 資料進行復制,使用者登入,修改,登出時,將session資訊同時也複製到其他機器上面去\ image.png

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

2.1.2 session集中儲存

image.png

集中儲存就是將獲取session單獨放在一個服務中進行儲存,所有獲取session的統一來這個服務中去取。這樣就避免了同步和維護多套session的問題。一般我們都是使用redis進行集中式儲存session。

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

3.1 SSO的產生背景

image.png

如果企業做大了之後,一般都有很多的業務支援系統為其提供相應的管理和 IT 服務,按照傳統的驗證方式訪問多系統,每個單獨的系統都會有自己的安全體系和身份認證系統。進入每個系統都需要進行登入,獲取session,再通過session訪問對應系統資源。這樣的局面不僅給管理上帶來了很大的困難,對客戶來說也極不友好,那麼如何讓客戶只需登陸一次,就可以進入多個系統,而不需要重新登入呢?

image.png

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

3.2 SSO的底層原理 CAS

3.2.1 CAS實現單點登入流程

我們知道對於完全不同域名的系統,cookie 是無法跨域名共享的,因此 sessionId 在頁面端也無法共享,因此需要實現單店登入,就需要啟用一個專門用來登入的域名如(ouath.com)來提供所有系統的sessionId。當業務系統被開啟時,藉助中心授權系統進行登入,整體流程如下:

1.當b.com開啟時,發現自己未登陸,於是跳轉到ouath.com去登陸\ 2. ouath.com登陸頁面被開啟,使用者輸入帳戶/密碼登陸成功\ 3. ouath.com登陸成功,種 cookie 到ouath.com域名下\ 4. 把 sessionid 放入後臺redis,存放資料結構,然後頁面重定向到A系統\ 5.當b.com重新被開啟,發現仍然是未登陸,但是有了一個 ticket值\ 6. 當b.com用ticket 值,到 redis 裡查到 sessionid,並做 session 同步,然後種cookie給自己,頁面原地重定向\ 7. 當b.com開啟自己頁面,此時有了 cookie,後臺校驗登陸狀態,成功

整個互動流程圖如下:

image.png

3.2.2 單點登入流程演示

3.2.2.1 CAS登入服務demo核心程式碼

1.使用者實體類

```

public class UserForm implements Serializable{ private static final long serialVersionUID = 1L;

private String username; private String password; private String backurl;

public String getUsername() { return username; }

public void setUsername(String username) { this.username = username; }

public String getPassword() { return password; }

public void setPassword(String password) { this.password = password; }

public String getBackurl() { return backurl; }

public void setBackurl(String backurl) { this.backurl = backurl; }

} ```

2.登入控制器

``` @Controller public class IndexController { @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") public void login(@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; } } ```

3.登入過濾器

``` public class LoginFilter implements Filter { public static final String USER_INFO = "user"; @Override public void init(FilterConfig filterConfig) throws ServletException {

}

@Override public void doFilter(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)//不是登陸頁面
        && !requestUrl.startsWith("/login")//不是去登陸
        && null == userInfo) {//不是登陸狀態

    request.getRequestDispatcher("/toLogin").forward(request,response);
    return ;
}

filterChain.doFilter(request,servletResponse);

}

@Override public void destroy() {

} } ```

4.配置過濾器

``` @Configuration public class LoginConfig {

//配置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;

} } ```

5.登入頁面

```

enjoy login

請登陸

使用者名稱:

密 碼:

```

3.2.2.2 web系統demo核心程式碼

1.過濾器

``` public class SSOFilter implements Filter { private RedisTemplate redisTemplate;

public static final String USER_INFO = "user";

public SSOFilter(RedisTemplate redisTemplate){ this.redisTemplate = redisTemplate; } @Override public void init(FilterConfig filterConfig) throws ServletException {

}

@Override public void doFilter(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)//不是登陸頁面
        && !requestUrl.startsWith("/login")//不是去登陸
        && 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 public void destroy() {

} } ```

2.控制器

``` @Controller public class IndexController { @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;

} } ```

3.首頁

```

enjoy index

cas-website:歡迎你">

```

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

OAuth2:三方授權協議,允許使用者在不提供賬號密碼的情況下,通過信任的應用進行授權,使其客戶端可以訪問許可權範圍內的資源。

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

  1. CAS的單點登入時保障客戶端的使用者資源的安全 ;OAuth2則是保障服務端的使用者資源的安全 。
  2. CAS客戶端要獲取的最終資訊是,這個使用者到底有沒有許可權訪問我(CAS客戶端)的資源;OAuth2獲取的最終資訊是,我(oauth2服務提供方)的使用者的資源到底能不能讓你(oauth2的客戶端)訪問。

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

好了,不知道大家對SSO是否有了更深刻的理解,大家有問題可以私信我。我是王老獅,一個有想法有內涵的工程獅,關注我,學習更多技術知識。