CAS 4.1.x 單點登出(退出登陸)的原理解析

    咱們在項目中使用了cas做爲單點登陸的解決方案,當在集成shiro作統一權限控制的時候,發現單點退出登陸有坑,因此啃了一下CAS的單點登出的源碼,在此分享一下。java

一、回顧單點登陸中一些關鍵事件

    在解析CAS單點登出的原理以前,咱們先回顧一下在單點登陸過程當中,CAS服務器和CAS客戶端都作了一些什麼事,這些事在後面解析單點登出時有助於理解。web

    通常狀況下,在項目中使用cas client提供的幾個過濾器實現WEB APP的單點登陸、退出功能,配置以下:json

<listener>
        <listener-class>org.jasig.cas.client.session.SingleSignOutHttpSessionListener
        </listener-class>
    </listener>

    <filter>
        <filter-name>CAS Single Sign Out Filter</filter-name>
        <filter-class>org.jasig.cas.client.session.SingleSignOutFilter</filter-class>
        <init-param>
            <param-name>casServerUrlPrefix</param-name>
            <param-value>http://passport.edu:18080</param-value>
        </init-param>
    </filter>

    <filter-mapping>
        <filter-name>CAS Single Sign Out Filter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>


    <filter>
        <filter-name>CAS Authentication Filter</filter-name>
        <filter-class>org.jasig.cas.client.authentication.AuthenticationFilter</filter-class>
        <init-param>
            <param-name>casServerLoginUrl</param-name>
            <param-value>http://passport.edu:18080/login</param-value>
        </init-param>
        <init-param>
            <param-name>serverName</param-name>
            <param-value>http://jd.edu:9443</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>CAS Authentication Filter</filter-name>
        <url-pattern>/groupon/*</url-pattern>
    </filter-mapping>

    <filter>
        <filter-name>CAS Validation Filter</filter-name>
        <filter-class>org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter
        </filter-class>
        <init-param>
            <param-name>casServerUrlPrefix</param-name>
            <param-value>http://passport.edu:18080</param-value>
        </init-param>
        <init-param>
            <param-name>serverName</param-name>
            <param-value>http://jd.edu:9443</param-value>
        </init-param>

        <init-param>
            <param-name>redirectAfterValidation</param-name>
            <param-value>true</param-value>
        </init-param>

    </filter>
    <filter-mapping>
        <filter-name>CAS Validation Filter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <filter>
        <filter-name>CAS HttpServletRequest Wrapper Filter</filter-name>
        <filter-class>org.jasig.cas.client.util.HttpServletRequestWrapperFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>CAS HttpServletRequest Wrapper Filter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    (1)CAS服務器在用戶填入表單登陸成功後,會在用戶瀏覽器的cas 服務器所在域的cookie中存入TGC,即ticket granting cookie,它是加密的,裏面包含TGT的id,以及瀏覽器的信息。瀏覽器

清單:TGC未加密前的信息緩存

TGT-**********************************************aPD6RZNcJg-passport.edu@127.0.0.1@Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36]

清單:TGC加密後的信息服務器

    另外,CAS服務器內部會建立一個緩存存放TGT對象。TGT對象的ID就是TGC的ID,它還保存了一個很是重要的一個map:services cookie

    services ,這個名詞是否是很熟悉?咱們的應用服務器APP對於CAS服務器就是一個service。在cas server的配置文件中能夠限定哪些service能夠訪問CAS服務器,另外,在咱們的重定向到CAS登陸的URL中,也必須告訴CAS當前訪問它的service是誰。扯遠了,解釋一下,當web app應用系統得到登陸認證後,須要在CAS上註冊它已經被受權登陸了,這時應用服務器將獲取被受權登陸的票據ST(service ticket),CAS服務器爲應用服務器建立了Service對象用於保存它的一些信息(最重要的就是ID和認證信息了),並把service保存到services這個map中,該map的key就是ST了。session

 (2)CAS客戶端在SingleSignOutFilter過濾器中,獲取CAS服務器返回Service Ticket,將爲ST與session創建映射關係,該映射關係將會在單點登出的時候使用。app

    具體的登陸流程,請參考《單點登陸CAS登陸流程》ide

二、單點登出的原理

整個註銷流程大體能夠分爲TGT解碼和ticket銷燬兩個步驟。

2.1 TGT解碼

    整個註銷流程起源於瀏覽器向CAS服務器發起登出請求:http://passport.edu:18080/logout?service=http://jd.edu:9443。

    CAS服務接收請求後,獲取瀏覽器的cookie中的tgc信息,對tgc信息進行解密,解密後將獲取到tgt的ID,而後由CentralAuthenticationServiceImpl 類的 destroyTicketGrantingTicket()方法註銷該TGT。

2.2 ticket銷燬

    因爲CAS服務器和應用服務器都保存了ticket,因此CAS服務器除了本身銷燬ticket外,還須要通知應用服務器銷燬ticket。下面咱們看一下詳細流程。

=========+=======我是分割線,下面是CAS服務器端分析=======================

    看一下 CentralAuthenticationServiceImpl 類的 destroyTicketGrantingTicket()方法。

public List<LogoutRequest> destroyTicketGrantingTicket(@NotNull final String ticketGrantingTicketId) {
	try {
		// 根據tgt ID從ticketRegistry註冊中心中獲取TGT
		final TicketGrantingTicket ticket = getTicket(ticketGrantingTicketId, TicketGrantingTicket.class);
		
		// 備註(1):由LogoutManager 完成註銷 
		final List<LogoutRequest> logoutRequests = logoutManager.performLogout(ticket);
		
		// 備註(2):註冊中心刪除該tgt
		this.ticketRegistry.deleteTicket(ticketGrantingTicketId);

		return logoutRequests;
	} catch (final InvalidTicketException e) {
		logger.debug("TicketGrantingTicket [{}] cannot be found in the ticket registry.", ticketGrantingTicketId);
	}
	return Collections.emptyList();
}

    代碼中的備註(1)完成客戶端的ticket銷燬,備註(2)完成CAS服務器的ticket銷燬。備註(1)的登出管理器的實現類是 LogoutManagerImpl,看一下它的performLogout方法。

@Override
public List<LogoutRequest> performLogout(final TicketGrantingTicket ticket) {
	final Map<String, Service> services = ticket.getServices(); // 獲取註冊在tgt下的service
	final List<LogoutRequest> logoutRequests = new ArrayList<>();
	
	if (!this.singleLogoutCallbacksDisabled) {
		// 遍歷全部的service
		for (final Map.Entry<String, Service> entry : services.entrySet()) {
			// it's a SingleLogoutService, else ignore
			final Service service = entry.getValue();
			if (service instanceof SingleLogoutService) {
				// 對service進行登出操做
				final LogoutRequest logoutRequest = handleLogoutForSloService((SingleLogoutService) service, entry.getKey());
				if (logoutRequest != null) {
					LOGGER.debug("Captured logout request [{}]", logoutRequest);
					logoutRequests.add(logoutRequest);
				}
			}
		}
	}

 繼續看一下handleLogoutForSloService方法

private LogoutRequest handleLogoutForSloService(final SingleLogoutService singleLogoutService, final String ticketId) {
	if (!singleLogoutService.isLoggedOutAlready()) {
		
		// 備註(1):從服務管理器中獲取匹配的已註冊的服務
		final RegisteredService registeredService = servicesManager.findServiceBy(singleLogoutService);
		
		if (serviceSupportsSingleLogout(registeredService)) {
		
			// 決定使用哪一個登出URL,若是registeredService指定了就用它的,否則就用singleLogoutService裏的URL
			// 通常registeredService不會指定
			final URL logoutUrl = determineLogoutUrl(registeredService, singleLogoutService);
			
			// 包裝登出請求
			final DefaultLogoutRequest logoutRequest = new DefaultLogoutRequest(ticketId, singleLogoutService, logoutUrl);
			
			final LogoutType type = registeredService.getLogoutType() == null
					? LogoutType.BACK_CHANNEL : registeredService.getLogoutType();

			switch (type) {
				
				case BACK_CHANNEL:
				
					// 通知應用服務器註銷ticket
					if (performBackChannelLogout(logoutRequest)) {
						logoutRequest.setStatus(LogoutRequestStatus.SUCCESS);
					} else {
						logoutRequest.setStatus(LogoutRequestStatus.FAILURE);
						LOGGER.warn("Logout message not sent to [{}]; Continuing processing...", singleLogoutService.getId());
					}
					break;
				default:
					logoutRequest.setStatus(LogoutRequestStatus.NOT_ATTEMPTED);
					break;
			}
			return logoutRequest;
		}

	}
	return null;
}

    備註(1)中,servicesManager.findServiceBy( ) 該方法將會遍歷在servicesManager註冊的服務,而且查看service是否匹配RegisteredService。RegisteredService是什麼呢?

    RegisteredService是在cas初始化中,加載配置文件後註冊在服務管理器中的服務信息,該信息定義了哪些應用服務器能夠接入CAS,登出的類型是什麼。

    你們是否還記得在CAS服務器的搭建時,是否是修改過 HTTPSandIMAPS-10000001.json 的serviceID呢?這個配置文件就是定義了一個RegisteredService。

清單:HTTPSandIMAPS-10000001.json

{
  "@class" : "org.jasig.cas.services.RegexRegisteredService",
  "serviceId" : "^(https|imaps|http)://.*",
  "name" : "HTTPS and IMAPS",
  "id" : 10000001,
  "description" : "This service definition authorized all application urls that support HTTPS and IMAPS protocols.",
  "proxyPolicy" : {
    "@class" : "org.jasig.cas.services.RefuseRegisteredServiceProxyPolicy"
  },
  "evaluationOrder" : 0,
  "usernameAttributeProvider" : {
    "@class" : "org.jasig.cas.services.DefaultRegisteredServiceUsernameProvider"
  },
  "logoutType" : "BACK_CHANNEL",
  "attributeReleasePolicy" : {
    "@class" : "org.jasig.cas.services.ReturnAllowedAttributeReleasePolicy",
    "principalAttributesRepository" : {
      "@class" : "org.jasig.cas.authentication.principal.DefaultPrincipalAttributesRepository"
    },
    "authorizedToReleaseCredentialPassword" : false,
    "authorizedToReleaseProxyGrantingTicket" : false
  },
  "accessStrategy" : {
    "@class" : "org.jasig.cas.services.DefaultRegisteredServiceAccessStrategy",
    "enabled" : true,
    "ssoEnabled" : true
  }
}

這裏的RegisteredService實現類是 RegexRegisteredService,它經過正則匹配service的url,模式是HTTPSandIMAPS-10000001.json文件中定義的serviceId。

繼續分析它是怎麼通知應用服務器銷燬ticket的。

private boolean performBackChannelLogout(final LogoutRequest request) {
	try {
		// 構建登出的協議報文
		final String logoutRequest = this.logoutMessageBuilder.create(request);
		final SingleLogoutService logoutService = request.getService();
		logoutService.setLoggedOutAlready(true);

		// LogoutHttpMessage封裝了請求的url和報文,url就是應用服務器的url
		final LogoutHttpMessage msg = new LogoutHttpMessage(request.getLogoutUrl(), logoutRequest);
		
		// 調用httpClient,以POST的方式發出報文
		return this.httpClient.sendMessageToEndPoint(msg);
	} catch (final Exception e) {
		LOGGER.error(e.getMessage(), e);
	}
	return false;
}

報文內容以下:

<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="LR-1-VM1PfgJD6VEDtCc4NnIWaVLqFs0PktY6Ej9" Version="2.0" IssueInstant="2017-07-20T10:45:39Z">
	<saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
		@NOT_USED@
	</saml:NameID>
	<samlp:SessionIndex>ST-2-HtrBiWrgRD9DFgL25GI9-passport.edu</samlp:SessionIndex>
</samlp:LogoutRequest>

報文是CAS的協議格式,表示如今發的是logout請求,包含了該service的ST。

至此,CAS服務器遍歷了全部的sercie,給service發出了退出登陸的報文。而後它本身註銷刪除了TGT。

 

=========+=======我是分割線,下面是應用服務器端分析=======================

應用服務器經過一個監聽器和一個過濾器完成登出功能。

<listener>
        <listener-class>org.jasig.cas.client.session.SingleSignOutHttpSessionListener
        </listener-class>
    </listener>

    <filter>
        <filter-name>CAS Single Sign Out Filter</filter-name>
        <filter-class>org.jasig.cas.client.session.SingleSignOutFilter</filter-class>
        <init-param>
            <param-name>casServerUrlPrefix</param-name>
            <param-value>http://passport.edu:18080</param-value>
        </init-param>
    </filter>

    <filter-mapping>
        <filter-name>CAS Single Sign Out Filter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

先看一下 SingleSignOutFilter 的doFilter。

public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse,
            final FilterChain filterChain) throws IOException, ServletException {
        final HttpServletRequest request = (HttpServletRequest) servletRequest;
        final HttpServletResponse response = (HttpServletResponse) servletResponse;

        if (!this.handlerInitialized.getAndSet(true)) {
            HANDLER.init();
        }
        
        // 由HANDLER處理
        if (HANDLER.process(request, response)) {
            filterChain.doFilter(servletRequest, servletResponse);
        }
    }

HANDLE的實現類是SingleSignOutHandler。看一下它的process方法

public boolean process(final HttpServletRequest request, final HttpServletResponse response) {
	if (isTokenRequest(request)) {
		logger.trace("Received a token request");
		recordSession(request);
		return true;

	} else if (isBackChannelLogoutRequest(request)) { //這裏這裏。。。
		logger.trace("Received a back channel logout request");
		destroySession(request);
		return false;

	} else if (isFrontChannelLogoutRequest(request)) {
		logger.trace("Received a front channel logout request");
		destroySession(request);
		// redirection url to the CAS server
		final String redirectionUrl = computeRedirectionToServer(request);
		if (redirectionUrl != null) {
			CommonUtils.sendRedirect(response, redirectionUrl);
		}
		return false;

	} else {
		logger.trace("Ignoring URI for logout: {}", request.getRequestURI());
		return true;
	}
}

process方法將會解析報文,獲取該報文是什麼類型的,前面已經分析過是請求登出報文,咱們進入isBackChannelLogoutRequest(request)分支。這裏調用了destroySession(request)。

private void destroySession(final HttpServletRequest request) {
	final String logoutMessage;
	
	if (isFrontChannelLogoutRequest(request)) {
		// 不要理睬,這裏前臺登出才作的事
		logoutMessage = uncompressLogoutMessage(CommonUtils.safeGetParameter(request,
				this.frontLogoutParameterName));
	} else {
		// 獲取報文的內容
		logoutMessage = CommonUtils.safeGetParameter(request, this.logoutParameterName, this.safeParameters);
	}

	// 獲取ST
	final String token = XmlUtils.getTextForElement(logoutMessage, "SessionIndex");
	
	if (CommonUtils.isNotBlank(token)) {
		// 緩存中刪除ST與sessionId的映射關係,獲取session
		final HttpSession session = this.sessionMappingStorage.removeSessionByMappingId(token);

		if (session != null) {
			final String sessionID = session.getId();

			try {
				session.invalidate(); //銷燬session
			} catch (final IllegalStateException e) {
				logger.debug("Error invalidating session.", e);
			}
			this.logoutStrategy.logout(request); //好像用於強制退出
		}
	}
}

因爲前面是向每一個已經在CAS登陸的應用服務器發送登出報文的,因此每一個應用服務器都會走一次銷燬ticket的流程。至此,應用服務器也銷燬了ticket,而且session也已經銷燬了。

相關文章
相關標籤/搜索