CAS单点登录开源框架解读(九)--CAS单点登录客户端认证之服务端验证票据返回认证信息

时间:2024-03-27 17:24:01

服务端如何返回用户认证信息

上一章节中我们已经知道是通过http请求去到CAS服务端获取信息,根据CAS单点登录客户端的请求地址/serviceValidate,我们再CAS单点登录服务端上通过Springmvc根据url里的/serviceValidate,匹配到@RequestMapping(path="/serviceValidate")

1. ticket校验开始:serviceValidate

/serviceValidate路径所对应的java类为ServiceValidateController.java,在cas-server-webapp-validation模块下的org.jasig.cas.web包中。

*ServiceValidateController.java*

@Component("serviceValidateController")
@Controller
public class ServiceValidateController extends AbstractServiceValidateController {
    /**
     * Handle model and view.
     *
     * @param request the request
     * @param response the response
     * @return the model and view
     * @throws Exception the exception
     */
    @RequestMapping(path="/serviceValidate", method = RequestMethod.GET)
    @Override
    protected ModelAndView handleRequestInternal(final HttpServletRequest request, final HttpServletResponse response)
        throws Exception {
        //交给父类去处理
        return super.handleRequestInternal(request, response);
    }

调用父类的handleRequestInternal()的方法。

2. 调用父类handleRequestInternal()方法

AbstractServiceValidateController.java类在cas-server-webapp-validation模块下的org.jasig.cas.web包中。

*AbstractServiceValidateController.java*

@Component("serviceValidateController")
public abstract class AbstractServiceValidateController extends AbstractDelegateController {

  @Override
    protected ModelAndView handleRequestInternal(final HttpServletRequest request, final HttpServletResponse response)
            throws Exception {
        //CAS单点登录服务端根据请求获取到service
        final WebApplicationService service = this.argumentExtractor.extractService(request);
       //根据服务端获取的service取到serviceId
        final String serviceTicketId = service != null ? service.getArtifactId() : null;
        //如果service为空,或者serviceId为空,返回错误信息给CAS单点登录客户端
        if (service == null || serviceTicketId == null) {
            logger.debug("Could not identify service and/or service ticket for service: [{}]", service);
            return generateErrorView(CasProtocolConstants.ERROR_CODE_INVALID_REQUEST,
                    CasProtocolConstants.ERROR_CODE_INVALID_REQUEST, null, request, service);
        }

        try {
            TicketGrantingTicket proxyGrantingTicketId = null;
            //获取pgturl所对应的认证信息(代理模式下),此时为空
            final Credential serviceCredential = getServiceCredentialsFromRequest(service, request);
            if (serviceCredential != null) {
                proxyGrantingTicketId = handleProxyGrantingTicketDelivery(serviceTicketId, serviceCredential);
                if (proxyGrantingTicketId == null) {
                    return generateErrorView(CasProtocolConstants.ERROR_CODE_INVALID_PROXY_CALLBACK,
                            CasProtocolConstants.ERROR_CODE_INVALID_PROXY_CALLBACK,
                            new Object[]{serviceCredential.getId()}, request, service);
                }
            }
            //通过认证中心校验ticket,并返回认证信息
            final Assertion assertion = this.centralAuthenticationService.validateServiceTicket(serviceTicketId, service);
            //根据认证用户信息和service,如果校验失败返回ticket校验失效信息到CAS单点登录客户端
            if (!validateAssertion(request, serviceTicketId, assertion)) {
                return generateErrorView(CasProtocolConstants.ERROR_CODE_INVALID_TICKET,
                        CasProtocolConstants.ERROR_CODE_INVALID_TICKET, null, request, service);
            }
            //代理模式下的pgtIOU,此时非代理模式下获取为空
            String proxyIou = null;
            if (serviceCredential != null && this.proxyHandler.canHandle(serviceCredential)) {
                proxyIou = this.proxyHandler.handle(serviceCredential, proxyGrantingTicketId);
                if (StringUtils.isEmpty(proxyIou)) {
                    return generateErrorView(CasProtocolConstants.ERROR_CODE_INVALID_PROXY_CALLBACK,
                            CasProtocolConstants.ERROR_CODE_INVALID_PROXY_CALLBACK,
                            new Object[] {serviceCredential.getId()}, request, service);
                }
            }

            onSuccessfulValidation(serviceTicketId, assertion);
            logger.debug("Successfully validated service ticket {} for service [{}]", serviceTicketId, service.getId());
            //返回到CAS服务端成功页面,同时把相关参数信息返回给CAS单点登录客户端
            return generateSuccessView(assertion, proxyIou, service, proxyGrantingTicketId);
        } catch (final AbstractTicketValidationException e) {
            final String code = e.getCode();
            return generateErrorView(code, code,
                    new Object[] {serviceTicketId, e.getOriginalService().getId(), service.getId()}, request, service);
        } catch (final AbstractTicketException te) {
            return generateErrorView(te.getCode(), te.getCode(),
                new Object[] {serviceTicketId}, request, service);
        } catch (final UnauthorizedProxyingException e) {
            return generateErrorView(e.getMessage(), e.getMessage(), new Object[] {service.getId()}, request, service);
        } catch (final UnauthorizedServiceException e) {
            return generateErrorView(e.getMessage(), e.getMessage(), null, request, service);
        }
    }
…………

注意校验成功和失败时所构建的jsp页面是不一样的,当成功时请求跳转的页面为casServiceValidationSuccess.jsp;而当失败时,请求的跳转页面为casServiceValidationFailure.jsp。
调用中心认证服务器校验服务ticket票据:centralAuthenticationService.validateServiceTicket。

3. 调用认证中心centralAuthenticationService

CentralAuthenticationServiceImpl.java此类在cas-server-core模块下的org.jasig.cas包中。

CentralAuthenticationServiceImpl.java

@Component("centralAuthenticationService")
public final class CentralAuthenticationServiceImpl extends AbstractCentralAuthenticationService {
@Audit(
        action="SERVICE_TICKET_VALIDATE",
        actionResolverName="VALIDATE_SERVICE_TICKET_RESOLVER",
        resourceResolverName="VALIDATE_SERVICE_TICKET_RESOURCE_RESOLVER")
    @Timed(name="VALIDATE_SERVICE_TICKET_TIMER")
    @Metered(name="VALIDATE_SERVICE_TICKET_METER")
    @Counted(name="VALIDATE_SERVICE_TICKET_COUNTER", monotonic=true)
    @Override
public Assertion validateServiceTicket(final String serviceTicketId, final Service service) throws AbstractTicketException {
    //根据service获取到cas服务端维护的service地址
        final RegisteredService registeredService = this.servicesManager.findServiceBy(service);
        //校验获取到的注册service是否满足CAS单点登录服务端的service规则
        verifyRegisteredServiceProperties(registeredService, service);
        //根据serviceId获取到serviceTicket
        final ServiceTicket serviceTicket =  this.ticketRegistry.getTicket(serviceTicketId, ServiceTicket.class);

        if (serviceTicket == null) {
            logger.info("Service ticket [{}] does not exist.", serviceTicketId);
            throw new InvalidTicketException(serviceTicketId);
        }
        //校验ticket是否过期,是否有效
        try {
            synchronized (serviceTicket) {
            //注意这里对ticket的判断
                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获取到tgt信息,通过tgt和service获取到当前的认证信息
            final TicketGrantingTicket root = serviceTicket.getGrantingTicket().getRoot();
            final Authentication authentication = getAuthenticationSatisfiedByPolicy(
                    root, new ServiceContext(serviceTicket.getService(), registeredService));
            final Principal principal = authentication.getPrincipal();
            //获取扩展属性信息
            final RegisteredServiceAttributeReleasePolicy attributePolicy = registeredService.getAttributeReleasePolicy();
            logger.debug("Attribute policy [{}] is associated with service [{}]", attributePolicy, registeredService);
            
            @SuppressWarnings("unchecked")
            final Map<String, Object> attributesToRelease = attributePolicy != null
                    ? attributePolicy.getAttributes(principal) : Collections.EMPTY_MAP;
            
            final String principalId = registeredService.getUsernameAttributeProvider().resolveUsername(principal, service);
            final Principal modifiedPrincipal = this.principalFactory.createPrincipal(principalId, attributesToRelease);
            final AuthenticationBuilder builder = DefaultAuthenticationBuilder.newInstance(authentication);
            builder.setPrincipal(modifiedPrincipal);
            //产生认证信息,用于返回给CAS单点登录客户端
            final Assertion assertion = new ImmutableAssertion(
                    builder.build(),
                    serviceTicket.getGrantingTicket().getChainedAuthentications(),
                    serviceTicket.getService(),
                    serviceTicket.isFromNewLogin());
            //触发ticket校验事件
            doPublishEvent(new CasServiceTicketValidatedEvent(this, serviceTicket, assertion));

            return assertion;

        } finally {
            if (serviceTicket.isExpired()) {
                this.ticketRegistry.deleteTicket(serviceTicketId);
            }
        }
    }

通过代码的分析主要过程如下:
1、校验service服务
2、根据serviceTicketId获取ServiceTicket
3、判断serviceTicket是否过期
4、判断serviceTicket是否有效
5、认证,得到用户信息(包括扩展属性)
6、生成断言用户认证信息
下面重点分析一下此过程中相关的代码。

3.1. 检查ticket和tgt是否过期

serviceTicket.isExpired()调用。此类为AbstractTicket.java在cas-server-core-tickets模块下的org.jasig.cas.ticket包中。在校验中是父类ServiceTicketImpl.java中继承了AbstractTicket类。

*AbstractTicket.java*

@MappedSuperclass
public abstract class AbstractTicket implements Ticket, TicketState {
@Override
    public final boolean isExpired() {
        final TicketGrantingTicket tgt = getGrantingTicket();
        return this.expirationPolicy.isExpired(this)
                || (tgt != null && tgt.isExpired())
                || isExpiredInternal();
}
…………
}

expirationPolicy.isExpired调用,通过调用MultiTimeUseOrTimeoutExpirationPolicy类来实现失效策略。在deployerConfigContext.xml中配置了别名。可以自行去文件中查看。

*MultiTimeUseOrTimeoutExpirationPolicy.java*
@Component("multiTimeUseOrTimeoutExpirationPolicy")
public final class MultiTimeUseOrTimeoutExpirationPolicy extends AbstractCasExpirationPolicy {
    /** Serialization support. */
    private static final long serialVersionUID = -5704993954986738308L;
    /** The time to kill in milliseconds. */
    @Value("#{${st.timeToKillInSeconds:10}*1000L}")
    private final long timeToKillInMilliSeconds;
    /** The maximum number of uses before expiration. */
    @Value("${st.numberOfUses:1}")
    private final int numberOfUses;

    @Override
    public boolean isExpired(final TicketState ticketState) {
        if (ticketState == null) {
            LOGGER.debug("Ticket state is null for {}", this.getClass().getSimpleName());
            return true;
        }
        //获取当前在使用的ticket数量
        final long countUses = ticketState.getCountOfUses();
        //如果使用数量大于配置文件中配置的数量,直接返回失效
        if (countUses >= this.numberOfUses) {
            LOGGER.debug("Ticket usage count {} is greater than or equal to {}", countUses, this.numberOfUses);
            return true;
        }

        final long systemTime = System.currentTimeMillis();
        final long lastTimeUsed = ticketState.getLastTimeUsed();
        final long difference = systemTime - lastTimeUsed;
        //判断ticket是否已经失效
        if (difference >= this.timeToKillInMilliSeconds) {
            LOGGER.debug("Ticket has expired because the difference between current time [{}] "
                + "and ticket time [{}] is greater than or equal to [{}]", systemTime, lastTimeUsed,
                this.timeToKillInMilliSeconds);
            return true;
        }
        return false;
    }
}

为调试方便或根据需要,可在cas.properties里重新配置过期时间和使用次数:
st.timeToKillInSeconds=10 过期时间(单位为秒)
st.numberOfUses=1 使用次数

3.2. 检查ticket是否有效

ticket的实现类直接使用的是ServiceTicketImpl.java,此类在cas-server-core-tickets模块下的org.jasig.cas.ticket包中。

ServiceTicketImpl.java

@Entity
@Table(name="SERVICETICKET")
@DiscriminatorColumn(name="TYPE")
@DiscriminatorValue(ServiceTicket.PREFIX)
public class ServiceTicketImpl extends AbstractTicket implements ServiceTicket {

@Override
public boolean isValidFor(final Service serviceToValidate) {
   //更新serviceTicket相关时间和使用次数
        updateState();
        return serviceToValidate.matches(this.service);
}
…………
}

先更新ticket的状态,再进行验证,通过service的match进行校验。
更新状态:

*AbstractTicket.java*
@MappedSuperclass
public abstract class AbstractTicket implements Ticket, TicketState {
    protected final void updateState() {
        this.previousLastTimeUsed = this.lastTimeUsed;
        this.lastTimeUsed = System.currentTimeMillis();
        this.countOfUses++;
    }

更新前一次最后使用时间、最后使用时间和使用次数。
验证,具体的验证在抽象类AbstractWebApplicationService中实现:

AbstractWebApplicationService.java

public abstract class AbstractWebApplicationService implements SingleLogoutService {
@Override
    public boolean matches(final Service service) {
        try {
            //当前的url路径
            final String thisUrl = URLDecoder.decode(this.id, "UTF-8");
//CAS服务端保存的service中的路径
            final String serviceUrl = URLDecoder.decode(service.getId(), "UTF-8");

            logger.trace("Decoded urls and comparing [{}] with [{}]", thisUrl, serviceUrl);
            return thisUrl.equalsIgnoreCase(serviceUrl);
        } catch (final Exception e) {
            logger.error(e.getMessage(), e);
        }
        return false;
    }

比较本次客户端请求的url是否满足CAS服务端配置文件中对于service的要求。

3.3. 认证得到用户信息

获取到认证的用户信息通过authentication =getAuthenticationSatisfiedByPolicy()的调用:

*AbstractCentralAuthenticationService.java*

public abstract class AbstractCentralAuthenticationService implements CentralAuthenticationService, Serializable,
        ApplicationEventPublisherAware {

    protected final Authentication getAuthenticationSatisfiedByPolicy(
            final TicketGrantingTicket ticket, final ServiceContext context) throws AbstractTicketException {

        final ContextualAuthenticationPolicy<ServiceContext> policy =
                serviceContextAuthenticationPolicyFactory.createPolicy(context);
        //如果策略符合tgt中获取到的用户认证信息,就直接放回tgt中的认证信息
        if (policy.isSatisfiedBy(ticket.getAuthentication())) {
            return ticket.getAuthentication();
        }
//不满足,就去获取tgt追加的相关认证信息(注意:getSupplementalAuthentications()方法在4.2.x版本中已经废弃)
        for (final Authentication auth : ticket.getSupplementalAuthentications()) {
            if (policy.isSatisfiedBy(auth)) {
                return auth;
            }
        }
        throw new UnsatisfiedAuthenticationPolicyException(policy);
    }
…… ……
}

通过用户的根tgt,得到用户身份验证信息。

4. 返回到父类handleRequestInternal

AbstractServiceValidateController.java

@Component("serviceValidateController")
public abstract class AbstractServiceValidateController extends AbstractDelegateController {
@Override
    protected ModelAndView handleRequestInternal(final HttpServletRequest request, final HttpServletResponse response)
            throws Exception {
         ......

            final Assertion assertion = this.centralAuthenticationService.validateServiceTicket(serviceTicketId, service);    //上一节执行到这里
            if (!validateAssertion(request, serviceTicketId, assertion)) {
                //校验失败返回
                return generateErrorView(CasProtocolConstants.ERROR_CODE_INVALID_TICKET,
                        CasProtocolConstants.ERROR_CODE_INVALID_TICKET, null, request, service);
            }

            String proxyIou = null;
            if (serviceCredential != null && this.proxyHandler.canHandle(serviceCredential)) {
                proxyIou = this.proxyHandler.handle(serviceCredential, proxyGrantingTicketId);
                if (StringUtils.isEmpty(proxyIou)) {
                    return generateErrorView(CasProtocolConstants.ERROR_CODE_INVALID_PROXY_CALLBACK,
                            CasProtocolConstants.ERROR_CODE_INVALID_PROXY_CALLBACK,
                            new Object[] {serviceCredential.getId()}, request, service);
                }
            }

            onSuccessfulValidation(serviceTicketId, assertion);
            logger.debug("Successfully validated service ticket {} for service [{}]", serviceTicketId, service.getId());
            return generateSuccessView(assertion, proxyIou, service, proxyGrantingTicketId);   //此处校验成功返回
        } catch (final AbstractTicketValidationException e) {
            final String code = e.getCode();
            return generateErrorView(code, code,
                    new Object[] {serviceTicketId, e.getOriginalService().getId(), service.getId()}, request, service);
        } catch (final AbstractTicketException te) {
            return generateErrorView(te.getCode(), te.getCode(),
                new Object[] {serviceTicketId}, request, service);
        } catch (final UnauthorizedProxyingException e) {
            return generateErrorView(e.getMessage(), e.getMessage(), new Object[] {service.getId()}, request, service);
        } catch (final UnauthorizedServiceException e) {
            return generateErrorView(e.getMessage(), e.getMessage(), null, request, service);
        }
    }

generateErrorView 返回的ModelAndView名称为:cas2ServiceFailureView
generateSuccessView返回的ModelAndView名称为:cas2ServiceSuccessView

5. 用户认证信息返回到客户端

需要特别注意的是ticket校验成功后返回的cas2ServiceSuccessView这个视图并不能直接在配置文件中找到对应。这个时候我们可以看看Cas20ResponseView.java这个类是到底如何实现的。

5.1. cas2ServiceFailureView

cas2ServiceFailureView对于失败比较简单直接可以在spring配置文件protocolViewsConfiguration.xml中找到对应的配置。

*protocolViewsConfiguration.xml:*
<bean id="cas2ServiceFailureView" class="org.springframework.web.servlet.view.JstlView"
          c:url="/WEB-INF/view/jsp/protocol/2.0/casServiceValidationFailure.jsp" />

5.2. casServiceValidationFailure.jsp

*casServiceValidationFailure.jsp*

<%@ page session="false" contentType="application/xml; charset=UTF-8" %>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
    <cas:authenticationFailure code='${code}'>
            ${fn:escapeXml(description)}
    </cas:authenticationFailure>
</cas:serviceResponse>

把CAS单点登录服务的错误码和错误描述返回到客户端。

5.3. cas2ServiceSuccessView

Cas20ResponseView.java此类在cas-server-webapp-validation模块下的org.jasig.cas.web.view包中,主要用于最终响应CAS单点登录客户端。

*Cas20ResponseView.java*
public class Cas20ResponseView extends AbstractDelegatingCasView {
    //内部类
    @Component("cas2ServiceSuccessView")
    public static class Success extends Cas20ResponseView {
        /**
         * Instantiates a new Success.
         * @param view the view
         */
        @Autowired
        public Success(@Qualifier("cas2JstlSuccessView")
                       final View view) {
            super(view);
            super.setSuccessResponse(true);
        }
    }

此时是发布了组件cas2ServiceSuccessView,在发布cas2ServiceSuccessView组件的时候,我们注入组件为cas2JstlSuccessView。

5.4. cas2JstlSuccessView

那么我们就要看看casJstlSuccessView具体是怎么配置的。它的配置文件为spring配置文件protocolViewsConfiguration.xml。

*protocolViewsConfiguration.xml:*
<bean id="cas2JstlSuccessView" class="org.springframework.web.servlet.view.JstlView"
          c:url="/WEB-INF/view/jsp/protocol/2.0/casServiceValidationSuccess.jsp" />

从配置文件可知对应视图文件为casServiceValidationSuccess.jsp。

5.5. casServiceValidationSuccess.jsp

casServiceValidationSuccess.jsp

<%@ page session="false" contentType="application/xml; charset=UTF-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/functions" prefix="fn" %>
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
    <cas:authenticationSuccess>
        <cas:user>${fn:escapeXml(principal.id)}</cas:user>
        <c:if test="${not empty pgtIou}">
            <cas:proxyGrantingTicket>${pgtIou}</cas:proxyGrantingTicket>
        </c:if>
        <c:if test="${fn:length(chainedAuthentications) > 0}">
            <cas:proxies>
                <c:forEach var="proxy" items="${chainedAuthentications}" varStatus="loopStatus" begin="0" end="${fn:length(chainedAuthentications)}" step="1">
                    <cas:proxy>${fn:escapeXml(proxy.principal.id)}</cas:proxy>
                </c:forEach>
            </cas:proxies>
        </c:if>
    </cas:authenticationSuccess>
</cas:serviceResponse>

由于我们现在测试的是非代理模式下的登录跳转因此文件执行后,返回给客户端的内容为:

<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
    <cas:authenticationSuccess>
    	//测试情况下返回的就为用户登录名,还可以有其他的用户扩展信息
        <cas:user>casuser</cas:user>
    </cas:authenticationSuccess>
  </cas:serviceResponse>

至此,我们完成了流程图的第6步,后续的操作都将在CAS单点登录客户端进行:
CAS单点登录开源框架解读(九)--CAS单点登录客户端认证之服务端验证票据返回认证信息