背景
最近開發新産品,然後老闆說我們現在系統太多了,每次切換系統登入太麻煩了,能不能做個優化,同一賬号互通掉。作為一個資深架構獅,老闆的要求肯定要滿足,安排!
一個公司産品矩陣比較豐富的時候,使用者在不同系統之間來回切換,固然對産品使用者體驗上較差,并且增加使用者密碼管理成本。
也沒有很好地利用内部流量進行使用者打通,并且每個産品的獨立體系會導緻産品安全度下降。
是以實作集團産品的單點登入對使用者使用體驗以及效率提升有很大的幫助。那麼如何實作統一認證呢?我們先了解一下傳統的身份驗證方式。
基于 Spring Boot + MyBatis Plus + Vue & Element 實作的背景管理系統 + 使用者小程式,支援 RBAC 動态權限、多租戶、資料權限、工作流、三方登入、支付、短信、商城等功能
- 項目位址:https://gitee.com/zhijiantianya/ruoyi-vue-pro
- 視訊教程:https://doc.iocoder.cn/video/
傳統 Session 機制及身份認證方案

衆所周知,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 是由服務端生成的,并且以散清單的形式儲存在記憶體中。
基于 seesion 的身份認證主要流程如下:
因為 http 請求是無狀态請求,是以在 Web 領域,大部分都是通過這種方式解決。但是這麼做有什麼問題呢?我們接着看。
基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 實作的背景管理系統 + 使用者小程式,支援 RBAC 動态權限、多租戶、資料權限、工作流、三方登入、支付、短信、商城等功能
- 項目位址:https://gitee.com/zhijiantianya/yudao-cloud
- 視訊教程:https://doc.iocoder.cn/video/
随着技術的發展,使用者流量增大,單個伺服器已經不能滿足系統的需要了,分布式架構開始流行。
通常都會把系統部署在多台伺服器上,通過負載均衡把請求分發到其中的一台伺服器上,這樣很可能同一個使用者的請求被分發到不同的伺服器上。
因為 session 是儲存在伺服器上的,那麼很有可能第一次請求通路的 A 伺服器,建立了 session,但是第二次通路到了 B 伺服器,這時就會出現取不到 session 的情況。
我們知道,Session 一般是用來存會話全局的使用者資訊(不僅僅是登陸方面的問題),用來簡化/加速後續的業務請求。
傳統的 session 由伺服器端生成并存儲,當應用進行分布式叢集部署的時候,如何保證不同伺服器上 session 資訊能夠共享呢?
Session 共享一般有兩種思路:
- session 複制
- session 集中存儲
①session 複制
session 複制即将不同伺服器上 session 資料進行複制,使用者登入,修改,登出時,将 session 資訊同時也複制到其他機器上面去。
這種實作的問題就是實作成本高,維護難度大,并且會存在延遲登問題。
②session 集中存儲
集中存儲就是将擷取 session 單獨放在一個服務中進行存儲,所有擷取 session 的統一來這個服務中去取。
這樣就避免了同步和維護多套 session 的問題。一般我們都是使用 redis 進行集中式存儲 session。
多服務下的登陸困境及 SSO 方案
如果企業做大了之後,一般都有很多的業務支援系統為其提供相應的管理和 IT 服務,按照傳統的驗證方式通路多系統,每個單獨的系統都會有自己的安全體系和身份認證系統。
進入每個系統都需要進行登入,擷取 session,再通過 session 通路對應系統資源。
這樣的局面不僅給管理上帶來了很大的困難,對客戶來說也極不友好,那麼如何讓客戶隻需登陸一次,就可以進入多個系統,而不需要重新登入呢?
“單點登入”就是專為解決此類問題的。其大緻思想流程如下:通過一個 ticket 進行串接各系統間的使用者資訊。
SSO 的底層原理 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 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;
}
}
登入控制器:
@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;
}
}
登入過濾器:
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() {
}
}
配置過濾器:
@Configuration
public class LoginConfig {
//配置filter生效
@Bean
public FilterRegistrationBean sessionFilterRegistration() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter( new LoginFilter());
registration.addUrlPatterns( "
UserForm user = (UserForm) userInfo;
request.getSession().setAttribute(SSOFilter.USER_INFO,user);
redisTemplate.delete(ticket);
}
filterChain.doFilter(request,servletResponse);
}
@Override
public void destroy() {
}
}
控制器:
@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;
}
}
首頁:
<!DOCTYPE HTML>
<html xmlns:th= "http://www.thymeleaf.org">
<head>
<title>enjoy index</title>
<meta http-equiv= "Content-Type" content= "text/html; charset=UTF-8" />
</head>
<body>
<div th: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 是否有了更深刻的了解,歡迎留言。