1.簡介
SSO單點登錄
在多個相互信任的系統中,用戶只需要登錄一次就可以訪問其他受信任的系統。
新浪微博與新浪博客是相互信任的應用系統。
*當用戶首次訪問新浪微博時,新浪微博識別到用戶未登錄,將請求重定向到認證中心,認證中心也識別到用戶未登錄,則將請求重定向到登錄頁。
*當用戶已登錄新浪微博訪問新浪博客時,新浪博客識別到用戶未登錄,將請求重定向到認證中心,認證中心識別到用戶已登錄,返回用戶的身份,此時用戶無需登錄即可使用新浪博客。
*只要多個系統使用同一套單點登錄框架那么它們將是相互信任的。
CAS
Yale 大學發起的一個開源項目,旨在為 Web 應用系統提供一種可靠的單點登錄方法, CAS 在 2004 年 12 月正式成為 JA-SIG 的一個項目。
CAS包含CAS Client 和 CAS Server兩部分
CAS Client:要使用單點登錄的Web應用,將與同組下的Web應用構成相互信任的關系,只需在web應用中添加CAS提供的Listener和Filter即可成為CAS Client ,其主要負責對客戶端的請求進行登錄校驗、重定向和校驗ticket工作。
CAS Server:主要負責對用戶的用戶名/密碼進行認證,頒發票據等,需要單獨的進行部署。
*同組下的任意一個Web應用登錄后其他應用都不需要登錄即可使用。
2.CAS服務器搭建
2.1 去CAS官網下載CAS源碼包
將下載的源碼包中的cas-server-webapp工程導入ide中,將工程打包為war包,直接放入tomcat下的webapp中運行。
*CAS 5.0版本以上需要jdk1.8和gradle進行構建、4.X版本使用maven進行構建(maven 3.3+)
2.2 在Tomcat中開啟HTTPS協議
*由於CAS Server默認使用HTTPS協議進行訪問,因此需要在Tomcat中開啟HTTPS協議。
1.使用JDK提供的keytool命令生成秘鑰庫。
2.修改tomcat配置並開啟8443端口
在tomcat/conf/server.xml中添加:
<!-- 單向認證 -->
<Connector port="8443" protocol="org.apache.coyote.http11.Http11NioProtocol" maxThreads="150" SSLEnabled="true" scheme="https" secure="true" clientAuth="false" sslProtocol="TLS" keystoreFile="www.gimc.cn.keystore" keystorePass="123456" />
校驗Tomcat是否支持HTTPS協議: https://localhost:8443/
2.3 進入CAS認證中心
登錄處理地址:https://localhost:8443/cas-server-webapp-4.2.7/login
*由於首次訪問,客戶端瀏覽器進程所占用的內存中不存在TGC Cookie,所以CAS Server認為用戶未進行登錄,因此將請求轉發到登錄頁面。
*默認賬號:casuser/Mellon
*當登錄后再次訪問登錄處理時,將會直接轉發到已登錄頁面。
*CAS Server根據Cookie (TGC是否能夠匹配TGT)來判斷用戶是否已進行登錄,默認情況下TGC Cookie位於瀏覽器進程所占用的內存中,因此當關閉瀏覽器時Cookie失效(TGC失效),此時再訪問CAS登錄處理時將需要重新進行登錄,當CAS服務器重啟時,TGT將會失效(位於服務器內存),此時也需要重新進行登錄。
*當用戶登錄后,CAS Server會維護TGT與用戶身份信息的關系,所有CAS Client可以從CAS Server中獲取當前登錄的用戶的身份信息。
注銷處理地址:https://localhost:8443/cas-server-webapp-4.2.7/logout
*在已登錄的狀態下訪問注銷地址將會提示注銷成功,其經過以下步驟:
1.清除保存在客戶端瀏覽器進程所占用的內存中的TGC Cookie(設空)
2.清除保存在服務器的TGT。
3.通過HTTP請求分別通知當前用戶所有已登錄的CAS Client進行注銷登錄操作,銷毀用戶對應的Session對象。
*當注銷成功后,此時再訪問登錄頁面時需重新登錄。
2.4 修改為自定義數據源
1.修改cas-server-webapp/WEB-INF/deployerConfigContext.xml
注釋配置:
<!-- <alias name="acceptUsersAuthenticationHandler" alias="primaryAuthenticationHandler" /> -->
新增配置:
<!-- 對密碼進行加密 -->
<bean id="passwordEncoder" class="org.jasig.cas.authentication.handler.DefaultPasswordEncoder">
<constructor-arg value="MD5"></constructor-arg>
<property name="characterEncoding" value="UTF-8"></property>
</bean>
<!-- 自定義數據源 -->
<bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource">
<property name="driverClass" value="com.mysql.jdbc.Driver"></property>
<property name="jdbcUrl" value="jdbc:mysql://127.0.0.1:3306/cas?useUnicode=true&characterEncoding=UTF-8"></property>
<property name="user" value="root"></property>
<property name="password" value="root"></property>
</bean>
<!-- 認證控制器 -->
<bean id="queryDatabaseAuthenticationHandler" name="primaryAuthenticationHandler" class="org.jasig.cas.adaptors.jdbc.QueryDatabaseAuthenticationHandler">
<property name="passwordEncoder" ref="passwordEncoder" />
<property name="dataSource" ref="dataSource" />
<!-- 通過用戶名查詢密碼的SQL -->
<property name="sql" value="select password from sys_user where username =?" />
</bean>
2.在cas-server-webapp/WEB-INF/lib包中添加:cas-server-support-jdbc.jar、mysql-connector-java.jar
2.5 修改為HTTP方式訪問
1.修改cas-server-webapp/WEB-INF/cas.properties
tgc.secure=false warn.cookie.secure=false
2.修改cas-server-webapp/WEB-INF/classes/services/HTTPSandIMAPS-10000001.json
"serviceId" : "^(https|imaps|http)://.*"
*修改serviceId的值即可。
3.刪除cas-server-webapp/WEB-INF/view/jsp/default/ui/casLoginView.jsp頁面中校驗是否是HTTPS協議的標簽塊。
<c:if test="${not pageContext.request.secure}">
<div id="msg" class="errors">
<h2><spring:message code="screen.nonsecure.title" /></h2>
<p><spring:message code="screen.nonsecure.message" /></p>
</div>
</c:if>
3.CAS客戶端搭建
3.1 引入Maven依賴
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.2.0</version>
</dependency>
3.2 在web.xml中配置CAS提供的Listener、Filter
<!-- 單點退出Listener -->
<listener>
<listener-class>org.jasig.cas.client.session.SingleSignOutHttpSessionListener</listener-class>
</listener>
<!-- 單點退出Filter -->
<filter>
<filter-name>CAS Single Sign Out Filter</filter-name>
<filter-class>org.jasig.cas.client.session.SingleSignOutFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>CAS Single Sign Out Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- CAS認證Filter -->
<filter>
<filter-name>CASFilter</filter-name>
<filter-class>org.jasig.cas.client.authentication.AuthenticationFilter</filter-class>
<init-param>
<!-- CAS登錄頁面,當SessionId無法匹配Session時,跳轉到CAS登錄頁面 -->
<param-name>casServerLoginUrl</param-name>
<param-value>http://localhost:8080/cas-server-webapp-4.2.7/login</param-value>
</init-param>
<init-param>
<param-name>serverName</param-name>
<param-value>http://localhost:8080</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CASFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- CAS Ticket校驗Filter -->
<filter>
<filter-name>CAS Validation Filter</filter-name>
<filter-class>org.jasig.cas.client.validation.Cas20ProxyReceivingTicketValidationFilter</filter-class>
<init-param>
<param-name>casServerUrlPrefix</param-name>
<param-value>http://localhost:8080/cas-server-webapp-4.2.7</param-value>
</init-param>
<init-param>
<param-name>serverName</param-name>
<param-value>http://localhost:8080</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CAS Validation Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- 使客戶端支持通過AssertionHolder來獲取用戶的登錄名 -->
<filter>
<filter-name>CAS Assertion Thread Local Filter</filter-name>
<filter-class>org.jasig.cas.client.util.AssertionThreadLocalFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>CAS Assertion Thread Local Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
*各個客戶端可通過AssertionHolder.getAssertion().getPrincipal().getName()獲取當前登錄用戶的用戶名。
4.CAS原理分析
4.1 項目架構圖
4.2 用戶第一次訪問項目A
http://localhost:8080/A/testCas
1.請求將到達項目A的CAS認證Filter。
2.CAS認證Filter判斷是否能通過SessionId Cookie匹配到Session對象,並且Session對象中是否存在name為_const_cas_assertion_的屬性(該屬性中存放着Assertion實體)
3.若存在Assertion實體,則放行,將請求交給下一個過濾器進行處理( ticket檢驗filter ),若不存在Assertion實體,則構造Service參數,並且判斷請求中是否攜帶了ticket參數。
4.若存在ticket參數,則放行,將請求交給下一個過濾器進行處理( ticket檢驗filter ),若不存在ticket參數,則將請求重定向到CAS Server登錄處理。
CAS認證Filter
public final void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse, final FilterChain filterChain) throws IOException, ServletException { final HttpServletRequest request = (HttpServletRequest) servletRequest; final HttpServletResponse response = (HttpServletResponse) servletResponse; //當SessionId Cookie無法匹配Session時返回null,並不會創建新的Session對象. final HttpSession session = request.getSession(false); //判斷Session中是否存在name為_const_cas_assertion_的屬性,存在則返回Assertion實體,否則返回Null. final Assertion assertion = session != null ? (Assertion) session.getAttribute(CONST_CAS_ASSERTION) : null; //存在Assertion實體則直接放行,將請求交給下一個過濾器處理. if (assertion != null) { filterChain.doFilter(request, response); return; } //構造ServiceUrl用於封裝在service參數中. final String serviceUrl = constructServiceUrl(request, response); //判斷請求中是否存在ticket參數,若存在則說明是CAS Server的回調請求. final String ticket = CommonUtils.safeGetParameter(request,getArtifactParameterName()); final boolean wasGatewayed = this.gatewayStorage.hasGatewayedAlready(request, serviceUrl); //存在ticket參數則直接放行,將請求交給下一個過濾器處理,否則將請求重定向到CAS Server登錄處理,並在請求URL后追加service參數傳遞原訪問的URL. if (CommonUtils.isNotBlank(ticket) || wasGatewayed) { filterChain.doFilter(request, response); return; } final String modifiedServiceUrl; log.debug("no ticket and no assertion found"); if (this.gateway) { log.debug("setting gateway attribute in session"); modifiedServiceUrl = this.gatewayStorage.storeGatewayInformation(request, serviceUrl); } else { modifiedServiceUrl = serviceUrl; } if (log.isDebugEnabled()) { log.debug("Constructed service url: " + modifiedServiceUrl); } final String urlToRedirectTo = CommonUtils.constructRedirectUrl(this.casServerLoginUrl, getServiceParameterName(), modifiedServiceUrl, this.renew, this.gateway); if (log.isDebugEnabled()) { log.debug("redirecting to \"" + urlToRedirectTo + "\""); } response.sendRedirect(urlToRedirectTo); }
*由於用戶第一次訪問項目A,並沒有攜帶SessionId Cookie,因此無法成功匹配Session,所以Assertion實體為null,請求中也不存在ticket參數,則此時項目A認為該用戶未登錄,返回302狀態碼示意瀏覽器將請求重定向到CAS Server進行登錄處理,並在請求URL后追加service參數傳遞原訪問項目A的URL。
*CAS Client根據Session中是否存在Assertion屬性判斷當前用戶是否已登錄。
*當瀏覽器收到項目A返回的302重定向請求后,對重定向目標地址重新發起HTTP請求,最終到達CAS Server進行登錄處理,由於瀏覽器不存在TGC Cookie,CAS Server認為用戶未進行登錄,因此將請求轉發到登錄頁面。
*輸入用戶名/密碼進行提交
*CAS Server對用戶輸入的用戶名/密碼進行校驗,若校驗成功則返回302狀態碼示意瀏覽器將請求重定向到原訪問項目A的URL地址並在URL后追加ticket參數傳遞ST,並且最終保存TGC Cookie在客戶端瀏覽器進程所占用的內存中。
TGC:Ticket Granted Cookie , 以Cookie的形式保存在客戶端瀏覽器所占用的內存中(Cookie值)
TGT:Ticket Granted Ticket,保存在CAS服務器的內存中,其可以簽發ST。
ST:Service Ticket,由TGT簽發,最終通過URL傳給CAS Client。
*CAS Server根據TGC匹配TGT,TGT又與用戶的身份信息相關聯。
*當用戶登錄成功后,此時客戶端就存在TGC Cookie,CAS服務端就存在對應的TGT。
*當瀏覽器收到CAS Server返回的302重定向請求后,對重定向目標地址重新發起HTTP請求( 攜帶ticket參數 ),此時請求將會首先進入項目A的CAS認證Filter,由於當前不存在SeesionId Cookie,不存在Session對象包含name為_const_cas_assertion_的屬性,但由於請求中包含了ticket參數,此時就會放行,將請求交給下一個過濾器處理。
5.請求將進入CAS Ticket驗證Filter。
6.判斷請求中是否存在ticket參數,若存在則進入Ticket校驗流程,否則直接放行,將請求交給下一個過濾器或直接到達目標資源。
7.若存在ticket,則通過HTTP的方式訪問CAS Server進行ticket的合法性校驗,若校驗成功則生成Session對象並且將Assertion實體放入Session中,最終將請求重定向原訪問項目的地址,若校驗失敗則返回403狀態碼,標識無權限訪問資源。
CAS Ticket校驗Filter
public final void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse, final FilterChain filterChain) throws IOException, ServletException { if (!preFilter(servletRequest, servletResponse, filterChain)) { return; } final HttpServletRequest request = (HttpServletRequest) servletRequest; final HttpServletResponse response = (HttpServletResponse) servletResponse; final String ticket = CommonUtils.safeGetParameter(request, getArtifactParameterName()); //如果HttpServletRequest中包含ticket參數則進行ticket的合法性校驗,否則直接放行. if (CommonUtils.isNotBlank(ticket)) { if (log.isDebugEnabled()) { log.debug("Attempting to validate ticket: " + ticket); } try { //通過HTTP訪問CAS Server進行ticket的合法性校驗 final Assertion assertion = this.ticketValidator.validate(ticket, constructServiceUrl(request, response)); if (log.isDebugEnabled()) { log.debug("Successfully authenticated user: " + assertion.getPrincipal().getName()); } request.setAttribute(CONST_CAS_ASSERTION, assertion); if (this.useSession) { //當ticket校驗成功則將Assertion實體放入Session中 request.getSession().setAttribute(CONST_CAS_ASSERTION, assertion); } onSuccessfulValidation(request, response, assertion); if (this.redirectAfterValidation) { log. debug("Redirecting after successful ticket validation."); //將請求重定向到原訪問的URL response.sendRedirect(constructServiceUrl(request, response)); return; } } catch (final TicketValidationException e) { response.setStatus(HttpServletResponse.SC_FORBIDDEN); log.warn(e, e); onFailedValidation(request, response); if (this.exceptionOnValidationFailure) { throw new ServletException(e); } return; } } filterChain.doFilter(request, response); }
*當瀏覽器收到項目A返回的302重定向請求后,重新請求最初訪問項目A的URL地址。
*由於攜帶了SessionId Cookie並且能成功匹配Session對象,由於已登錄過,Session中存在name為_const_cas_assertion_的屬性,因此允許訪問資源。
4.3 用戶再次訪問項目A
*由於攜帶了SessionId Cookie並且能成功匹配Session對象,由於已登錄過,Session中存在name為_const_cas_assertion_的屬性,因此允許訪問資源。
4.4 用戶第一次訪問項目B
http://localhost:8080/B/testCas
*用戶第一次訪問項目B,並沒有攜帶SessionId Cookie,因此無法成功匹配Session,所以Assertion實體為null,請求中也不存在ticket參數,此時項目B認為該用戶未登錄,返回302狀態碼示意瀏覽器將請求重定向到CAS Server進行登錄處理,並在請求URL后追加service參數傳遞原訪問項目B的URL。
*當瀏覽器收到項目B返回的302重定向請求后,對重定向目標地址重新發起HTTP請求,最終到達CAS Server進行登錄處理,由於客戶端瀏覽器中存在TGC Cookie,並且CAS Server成功根據TGC匹配TGT,所以CAS Server認為該用戶已經進行登錄,最終通過TGT簽發ST,返回302狀態碼示意瀏覽器將請求重定向到原訪問項目B的URL,並在URL追加ticket參數傳遞ST。
*當瀏覽器收到CAS Server返回的302重定向請求后,對重定向目標地址重新發起HTTP請求( 攜帶ticket參數 ),此時請求將會進入項目B的ticket認證Filter中,項目B將對ticket進行有效性校驗( 內部訪問Cas Server進行校驗 ),若校驗成功則生成Session對象並將Assertion實體放入Session中,最終將請求重定向到原訪問項目B的地址。
*當瀏覽器收到項目B返回的302重定向請求后,重新請求最初訪問項目B的URL地址。
*由於攜帶了SessionId Cookie並且能成功匹配Session對象,由於已登錄過,Session中存在name為_const_cas_assertion_的屬性,因此允許訪問資源。
4.5 注銷
訪問CAS Server注銷處理地址:http://localhost:8080/cas-server-webapp-4.2.7/logout
*當訪問CAS注銷地址后:
1.清除位於客戶端瀏覽器進程所占用的內存中的TGC Cookie (設空)
2.清除位於CAS Server中對應的TGT。
3.通過HTTP請求分別通知當前用戶所有已登錄的CAS Client進行注銷登錄操作,此時請求將會進入CAS Client的單點登出Filter,單點登出Filter中判斷當前請求是否是POST請求方式並且是否攜帶了logoutRequest參數,若不屬於則放行,將請求交給下一個過濾器進行處理,若屬於則進行Session對象的銷毀。
*當注銷后,TGC、TGT、CAS Client用戶對應的Session對象將會失效,此時再訪問項目A和項目B需要重新登錄。
CAS單點登出Filter
public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse, final FilterChain filterChain) throws IOException, ServletException { final HttpServletRequest request = (HttpServletRequest) servletRequest; //判斷請求參數是否攜帶ticket參數,即CAS Server的回調URL,用於Session的記錄操作. if (handler.isTokenRequest(request)) { handler.recordSession(request); //判斷請求參數是否攜帶logoutRequest參數,即CAS Server注銷時通知CAS Client的URL,用於Session的銷毀. } else if (handler.isLogoutRequest(request)) { handler.destroySession(request); // Do not continue up filter chain return; } else { log.trace("Ignoring URI " + request.getRequestURI()); } filterChain.doFilter(servletRequest, servletResponse); }
public void destroySession(final HttpServletRequest request) { //獲取HTTP請求中的logoutRequest參數 final String logoutMessage = CommonUtils.safeGetParameter(request, this.logoutParameterName); if (log.isTraceEnabled()) { log.trace ("Logout request:\n" + logoutMessage); } //解析XML獲取ticket值 final String token = XmlUtils.getTextForElement(logoutMessage, "SessionIndex"); //如果ticket值不為空則執行Session的invalidate()方法銷毀Session對象. if (CommonUtils.isNotBlank(token)) { final HttpSession session = this.sessionMappingStorage.removeSessionByMappingId(token); if (session != null) { String sessionID = session.getId(); if (log.isDebugEnabled()) { log.debug ("Invalidating session [" + sessionID + "] for token [" + token + "]"); } try { session.invalidate(); } catch (final IllegalStateException e) { log.debug("Error invalidating session.", e); } } } }
*logoutRequest參數的值是XML:
<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="LR-1-1zjgcguvShbJrsNLbbfQ5Rk5LbfHblgGHep" Version="2.0" IssueInstant="2018-07-23T16:46:32Z">
<saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">@NOT_USED@</saml:NameID>
<samlp:SessionIndex>ST-1-2EiBwiJuD5vbhYghmMS5-cas01.example.org</samlp:SessionIndex>
</samlp:LogoutRequest>
4.6 關閉瀏覽器
*當關閉瀏覽器后Cookie失效(SessionId、TGC失效),此時再訪問項目A和項目B時將需要重新登錄。
4.7 重啟CAS Server
*當CAS Server重啟后,TGT將會失效(位於服務器內存),TGC無法成功匹配TGT,但此時訪問項目A和項目B時不需要重新登錄,因為其Session對象中仍存在Assertion實體。
*當CAS Client重啟后,無須再登錄也可以使用。