CAS 单点登录的实现原理(一)

一,会话和Cookie

HTTP是无状态协议,客户端与服务端之间的每次通信都是独立的,而会话机制可以让服务端鉴别每次通讯过程中的客户端是否是同一个,从而保证业务的关联性。 Session是服务器使用一种类似于散列表的结构,用来保存用户会话所需要的信息.Cookie作为浏览器缓存,存储Session ID以达到会话跟踪的目的。

会话和饼干

由于Cookie的跨域策略限制,Cookie携带的会话标识无法在域名不同的服务端之间共享。
因此引入CAS服务端作为用户信息鉴别和传递中介,达到单点登录的效果。

二,CAS流程图

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

cas_flow_diagram

浏览器与APP01服务端

  1. 浏览器第一次访问受保护的APP01服务端,由于未经授权而被拦截并重定向到CAS服务端。
  2. 浏览器第一次与CAS服务端通讯,鉴权成功后由CAS服务端创建全局会话SSO会话,生成全局会话标识TGT并存在浏览器Cookie中。
  3. 浏览器重定向到APP01,重写URL地址带上全局会话标识TGT。
  4. APP01拿到全局会话标识TGT后向CAS服务端请求校验,若校验成功,则APP01会获取到已经登录的用户信息。
  5. APP01创建局部会话Session,并将SessionID存储到浏览器Cookie中。
  6. 浏览器与APP01建立会话。

浏览器与APP02服务端

  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.1 CAS客户端

3.1.1 根据是否已登录进行拦截跳转

以客户端拦截器作为入口,对于用户请求,如果是已经校验通过的,直接放行:
org.jasig.cas.client.authentication.AuthenticationFilter#doFilter

// 不进行拦截的请求地址
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#doFilter

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 中。

断言

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

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

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值