CAS 5.2.x 單點登陸 - 實現原理及源碼淺析

上一篇文章簡單介紹了 CAS 5.2.2 在本地開發環境中搭建服務端和客戶端,對單點登陸過程有了一個直觀的認識以後,本篇將探討 CAS 單點登陸的實現原理。html

1、Session 和 Cookie

HTTP 是無狀態協議,客戶端與服務端之間的每一次通信都是獨立的,而會話機制可讓服務端鑑別每次通信過程當中的客戶端是不是同一個,從而保證業務的關聯性。Session 是服務器使用一種相似於散列表的結構,用來保存用戶會話所須要的信息。Cookie 做爲瀏覽器緩存,存儲 Session ID 以到達會話跟蹤的目的。java

Session和Cookie

因爲 Cookie 的跨域策略限制,Cookie 攜帶的會話標識沒法在域名不一樣的服務端之間共享。
所以引入 CAS 服務端做爲用戶信息鑑別和傳遞中介,達到單點登陸的效果。git

2、CAS 流程圖

官方流程圖,地址:https://apereo.github.io/cas/...github

cas_flow_diagram

瀏覽器與 APP01 服務端web

  1. 瀏覽器第一次訪問受保護的 APP01 服務端,因爲未經受權而被攔截並重定向到 CAS 服務端。
  2. 瀏覽器第一次與 CAS 服務端通信,鑑權成功後由 CAS 服務端建立全局會話 SSO Session,生成全局會話標識 TGT 並存儲在瀏覽器 Cookie 中。
  3. 瀏覽器重定向到 APP01,重寫 URL 地址帶上全局會話標識 TGT。
  4. APP01 拿到全局會話標識 TGT 後向 CAS 服務端請求校驗,若校驗成功,則 APP01 會獲取到已經登陸的用戶信息。
  5. APP01 建立局部會話 Session,並將 SessionID 存儲到瀏覽器 Cookie 中。
  6. 瀏覽器與 APP01 創建會話。

瀏覽器與 APP02 服務端ajax

  1. 瀏覽器第一次訪問受保護的 APP02 服務端,因爲未經受權而被攔截並重定向到 CAS 服務端。
  2. 瀏覽器第二次與 CAS 服務端通信,CAS 校驗 Cookie 中的全局會話標識 TGT。
  3. 瀏覽器重定向到 APP02,重寫 URL 地址帶上全局會話標識 TGT。
  4. APP02 拿到全局會話標識 TGT 後向 CAS 服務端請求校驗,若校驗成功,則 APP02 會獲取到已經登陸的用戶信息。
  5. APP02 建立局部會話 Session,並將 SessionID 存儲到瀏覽器 Cookie 中。
  6. 瀏覽器與 APP02 創建會話。

3、相關源碼

3.1 CAS客戶端

3.1.1 根據是否已登陸進行攔截跳轉

以客戶端攔截器做爲入口,對於用戶請求,若是是已經校驗經過的,直接放行:
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);
    }
}

3.1.2 校驗Ticket

若是請求中帶有 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 中。

Assertion

3.1.3 重寫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();

3.2 CAS服務端

3.2.1 用戶密碼校驗

服務端採用了 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

PolicyBasedAuthenticationManager

通過層層過濾,獲得執行校驗的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);
}

3.2.2 登陸頁Ticket校驗

在 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>)

AbstractCentralAuthenticationService

3.2.3 客戶端Ticket校驗

對於從 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();

最後將用戶信息返回給客戶端。

相關文章
相關標籤/搜索