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();

最后将用户信息返回给客户端。

相关文章
相关标签/搜索