上一篇文章簡單介紹了 CAS 5.2.2 在本地開發環境中搭建服務端和客戶端,對單點登陸過程有了一個直觀的認識以後,本篇將探討 CAS 單點登陸的實現原理。html
HTTP 是無狀態協議,客戶端與服務端之間的每一次通信都是獨立的,而會話機制可讓服務端鑑別每次通信過程當中的客戶端是不是同一個,從而保證業務的關聯性。Session 是服務器使用一種相似於散列表的結構,用來保存用戶會話所須要的信息。Cookie 做爲瀏覽器緩存,存儲 Session ID 以到達會話跟蹤的目的。java
因爲 Cookie 的跨域策略限制,Cookie 攜帶的會話標識沒法在域名不一樣的服務端之間共享。
所以引入 CAS 服務端做爲用戶信息鑑別和傳遞中介,達到單點登陸的效果。git
官方流程圖,地址:https://apereo.github.io/cas/...github
瀏覽器與 APP01 服務端web
瀏覽器與 APP02 服務端ajax
以客戶端攔截器做爲入口,對於用戶請求,若是是已經校驗經過的,直接放行:
org.jasig.cas.client.authentication.AuthenticationFilter#doFilterexpress
// 不進行攔截的請求地址 if (isRequestUrlExcluded(request)) { logger.debug("Request is ignored."); filterChain.doFilter(request, response); return; } // Session已經登陸 final HttpSession session = request.getSession(false); final Assertion assertion = session != null ? (Assertion) session.getAttribute(CONST_CAS_ASSERTION) : null; if (assertion != null) { filterChain.doFilter(request, response); return; } // 從請求中獲取ticket final String serviceUrl = constructServiceUrl(request, response); final String ticket = retrieveTicketFromRequest(request); final boolean wasGatewayed = this.gateway && this.gatewayStorage.hasGatewayedAlready(request, serviceUrl); if (CommonUtils.isNotBlank(ticket) || wasGatewayed) { filterChain.doFilter(request, response); return; }
不然進行重定向:
org.jasig.cas.client.authentication.AuthenticationFilter#doFiltersegmentfault
this.authenticationRedirectStrategy.redirect(request, response, urlToRedirectTo);
對於Ajax請求和非Ajax請求的重定向,進行分別處理:
org.jasig.cas.client.authentication.FacesCompatibleAuthenticationRedirectStrategy#redirect跨域
public void redirect(final HttpServletRequest request, final HttpServletResponse response, final String potentialRedirectUrl) throws IOException { if (CommonUtils.isNotBlank(request.getParameter(FACES_PARTIAL_AJAX_PARAMETER))) { // this is an ajax request - redirect ajaxly response.setContentType("text/xml"); response.setStatus(200); final PrintWriter writer = response.getWriter(); writer.write("<?xml version='1.0' encoding='UTF-8'?>"); writer.write(String.format("<partial-response><redirect url=\"%s\"></redirect></partial-response>", potentialRedirectUrl)); } else { response.sendRedirect(potentialRedirectUrl); } }
若是請求中帶有 Ticket,則進行校驗,校驗成功返回用戶信息:
org.jasig.cas.client.validation.AbstractTicketValidationFilter#doFilter瀏覽器
final Assertion assertion = this.ticketValidator.validate(ticket, constructServiceUrl(request, response)); logger.debug("Successfully authenticated user: {}", assertion.getPrincipal().getName()); request.setAttribute(CONST_CAS_ASSERTION, assertion);
打斷點得知返回的信息爲 XML 格式字符串:
org.jasig.cas.client.validation.AbstractUrlBasedTicketValidator#validate
logger.debug("Retrieving response from server."); final String serverResponse = retrieveResponseFromServer(new URL(validationUrl), ticket);
XML 文件內容示例:
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'> <cas:authenticationSuccess> <cas:user>casuser</cas:user> <cas:attributes> <cas:credentialType>UsernamePasswordCredential</cas:credentialType> <cas:isFromNewLogin>true</cas:isFromNewLogin> <cas:authenticationDate>2018-03-25T22:09:49.768+08:00[GMT+08:00]</cas:authenticationDate> <cas:authenticationMethod>AcceptUsersAuthenticationHandler</cas:authenticationMethod> <cas:successfulAuthenticationHandlers>AcceptUsersAuthenticationHandler</cas:successfulAuthenticationHandlers> <cas:longTermAuthenticationRequestTokenUsed>false</cas:longTermAuthenticationRequestTokenUsed> </cas:attributes> </cas:authenticationSuccess> </cas:serviceResponse>
最後將 XML 字符串轉換爲對象 org.jasig.cas.client.validation.Assertion,並存儲在 Session 或 Request 中。
定義過濾器:
org.jasig.cas.client.util.HttpServletRequestWrapperFilter#doFilter
其中定義 CasHttpServletRequestWrapper,重寫 HttpServletRequestWrapperFilter:
final class CasHttpServletRequestWrapper extends HttpServletRequestWrapper { private final AttributePrincipal principal; CasHttpServletRequestWrapper(final HttpServletRequest request, final AttributePrincipal principal) { super(request); this.principal = principal; } public Principal getUserPrincipal() { return this.principal; } public String getRemoteUser() { return principal != null ? this.principal.getName() : null; } // 省略其餘代碼
這樣使用如下代碼便可獲取已登陸用戶信息。
AttributePrincipal principal = (AttributePrincipal) request.getUserPrincipal();
服務端採用了 Spirng Web Flow,以 login-webflow.xml 爲入口:
<action-state id="realSubmit"> <evaluate expression="authenticationViaFormAction"/> <transition on="warn" to="warn"/> <transition on="success" to="sendTicketGrantingTicket"/> <transition on="successWithWarnings" to="showAuthenticationWarningMessages"/> <transition on="authenticationFailure" to="handleAuthenticationFailure"/> <transition on="error" to="initializeLoginForm"/> </action-state>
action-state
表明一個流程,其中 id 爲該流程的標識。evaluate expression
爲該流程的實現類。transition
表示對返回結果的處理。
定位到該流程對應的實現類authenticationViaFormAction
,可知在項目啓動時實例化了對象AbstractAuthenticationAction
:
@ConditionalOnMissingBean(name = "authenticationViaFormAction") @Bean @RefreshScope public Action authenticationViaFormAction() { return new InitialAuthenticationAction(initialAuthenticationAttemptWebflowEventResolver, serviceTicketRequestWebflowEventResolver, adaptiveAuthenticationPolicy); }
在頁面上點擊登陸按鈕,進入:
org.apereo.cas.web.flow.actions.AbstractAuthenticationAction#doExecute
org.apereo.cas.authentication.PolicyBasedAuthenticationManager#authenticate
通過層層過濾,獲得執行校驗的AcceptUsersAuthenticationHandler
和待校驗的UsernamePasswordCredential
。
執行校驗,進入
org.apereo.cas.authentication.AcceptUsersAuthenticationHandler#authenticateUsernamePasswordInternal
@Override protected HandlerResult authenticateUsernamePasswordInternal(final UsernamePasswordCredential credential, final String originalPassword) throws GeneralSecurityException { if (this.users == null || this.users.isEmpty()) { throw new FailedLoginException("No user can be accepted because none is defined"); } // 頁面輸入的用戶名 final String username = credential.getUsername(); // 根據用戶名取得緩存中的密碼 final String cachedPassword = this.users.get(username); if (cachedPassword == null) { LOGGER.debug("[{}] was not found in the map.", username); throw new AccountNotFoundException(username + " not found in backing map."); } // 校驗緩存中的密碼和用戶輸入的密碼是否一致 if (!StringUtils.equals(credential.getPassword(), cachedPassword)) { throw new FailedLoginException(); } final List<MessageDescriptor> list = new ArrayList<>(); return createHandlerResult(credential, this.principalFactory.createPrincipal(username), list); }
在 login-webflow.xml 中定義了 Ticket 校驗流程:
<action-state id="ticketGrantingTicketCheck"> <evaluate expression="ticketGrantingTicketCheckAction"/> <transition on="notExists" to="gatewayRequestCheck"/> <transition on="invalid" to="terminateSession"/> <transition on="valid" to="hasServiceCheck"/> </action-state>
org.apereo.cas.web.flow.TicketGrantingTicketCheckAction#doExecute
@Override protected Event doExecute(final RequestContext requestContext) { // 從請求中獲取TicketID final String tgtId = WebUtils.getTicketGrantingTicketId(requestContext); if (!StringUtils.hasText(tgtId)) { return new Event(this, NOT_EXISTS); } String eventId = INVALID; try { // 根據TicketID獲取Tciket對象,校驗是否失效 final Ticket ticket = this.centralAuthenticationService.getTicket(tgtId, Ticket.class); if (ticket != null && !ticket.isExpired()) { eventId = VALID; } } catch (final AbstractTicketException e) { LOGGER.trace("Could not retrieve ticket id [{}] from registry.", e.getMessage()); } return new Event(this, eventId); }
可知 Ticket 存儲在服務端的一個 Map 集合中:
org.apereo.cas.AbstractCentralAuthenticationService#getTicket(java.lang.String, java.lang.Class<T>)
對於從 CAS 客戶端發送過來的 Ticket 校驗請求,則會進入服務端如下代碼:
org.apereo.cas.DefaultCentralAuthenticationService#validateServiceTicket
從 Ticket 倉庫中,根據 TicketID 獲取 Ticket 對象:
final ServiceTicket serviceTicket = this.ticketRegistry.getTicket(serviceTicketId, ServiceTicket.class);
在同步塊中校驗 Ticket 是否失效,以及是否來自合法的客戶端:
synchronized (serviceTicket) { if (serviceTicket.isExpired()) { LOGGER.info("ServiceTicket [{}] has expired.", serviceTicketId); throw new InvalidTicketException(serviceTicketId); } if (!serviceTicket.isValidFor(service)) { LOGGER.error("Service ticket [{}] with service [{}] does not match supplied service [{}]", serviceTicketId, serviceTicket.getService().getId(), service); throw new UnrecognizableServiceForServiceTicketValidationException(serviceTicket.getService()); } }
根據 Ticket 獲取已登陸用戶:
final TicketGrantingTicket root = serviceTicket.getGrantingTicket().getRoot(); final Authentication authentication = getAuthenticationSatisfiedByPolicy(root.getAuthentication(), new ServiceContext(selectedService, registeredService)); final Principal principal = authentication.getPrincipal();
最後將用戶信息返回給客戶端。