CAS单点登录原理以及debug跟踪登录流程

  categories:资料  tags:,   author:

CAS 原理和协议

基础模式

基础模式 SSO 访问流程主要有以下步骤:

1. 访问服务: SSO 客户端发送请求访问应用系统提供的服务资源。

2. 定向认证: SSO 客户端会重定向用户请求到 SSO 服务器。

3. 用户认证:用户身份认证。

4. 发放票据: SSO 服务器会产生一个随机的 Service Ticket 。

5. 验证票据: SSO 服务器验证票据 Service Ticket 的合法性,验证通过后,允许客户端访问服务。

6. 传输用户信息: SSO 服务器验证票据通过后,传输用户认证结果信息给客户端。

下面是 CAS 最基本的协议过程:

基础协议图

如 上图: CAS Client 与受保护的客户端应用部署在一起,以 Filter 方式保护 Web 应用的受保护资源,过滤从客户端过来的每一个 Web 请求,同 时, CAS Client 会分析 HTTP 请求中是否包含请求 Service Ticket( ST 上图中的 Ticket) ,如果没有,则说明该用户是没有经过认证的;于是 CAS Client 会重定向用户请求到 CAS Server ( Step 2 ),并传递 Service (要访问的目的资源地址)。 Step 3 是用户认证过程,如果用户提供了正确的 Credentials , CAS Server 随机产生一个相当长度、唯一、不可伪造的 Service Ticket ,并缓存以待将来验证,并且重定向用户到 Service 所在地址(附带刚才产生的 Service Ticket ) , 并为客户端浏览器设置一个 Ticket Granted Cookie ( TGC ) ; CAS Client 在拿到 Service 和新产生的 Ticket 过后,在 Step 5 和 Step6 中与 CAS Server 进行身份核实,以确保 Service Ticket 的合法性。

在该协议中,所有与 CAS Server 的交互均采用 SSL 协议,以确保 ST 和 TGC 的安全性。协议工作过程中会有 2 次重定向 的过程。但是 CAS Client 与 CAS Server 之间进行 Ticket 验证的过程对于用户是透明的(使用 HttpsURLConnection )。

CAS 请求认证时序图如下:

CAS 如何实现 SSO

当用户访问另一个应用的服务再次被重定向到 CAS Server 的时候, CAS Server 会主动获到这个 TGC cookie ,然后做下面的事情:

1) 如果 User 持有 TGC 且其还没失效,那么就走基础协议图的 Step4 ,达到了 SSO 的效果;

2) 如果 TGC 失效,那么用户还是要重新认证 ( 走基础协议图的 Step3) 。

以上是在网络上找到的相关描述,详细请参考:

http://www.open-open.com/lib/view/open1432381488005.html

但是光看文字描述还是不够清晰,不如Debug来看一下。

———————————————————————————-

前提:

有两个web应用

app1.testcas.com

app2.testcas.com

Cas认证中心

demo.testcas.com

第一步:访问目标应用app1

如果想要访问app1的网页

例如:app1.testcas.com/user/doWelcome

这时,该请求将会被事先配置好的CAS Filter所拦截

app1的web.xml配置如下:

<filter>
        <filter-name>CAS Filter</filter-name>
        <filter-class>org.jasig.cas.client.authentication.AuthenticationFilter</filter-class>
        <init-param>
            <param-name>casServerLoginUrl</param-name>
            <param-value>https://demo.testcas.com/cas/login</param-value>
        </init-param>
        <init-param>
            <param-name>serverName</param-name>
            <param-value>http://app1.testcas.com</param-value>
        </init-param>
</filter>
<filter-mapping>
    <filter-name>CAS Filter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>
断点进入该类的doFilter方法

org.jasig.cas.client.authentication.AuthenticationFilter > doFilter
public final void doFilter(final ServletRequest servletRequest,
            final ServletResponse servletResponse, final FilterChain filterChain)
            throws IOException, ServletException
    {
        final HttpServletRequest request = (HttpServletRequest) servletRequest;
        final HttpServletResponse response = (HttpServletResponse) servletResponse;
        final HttpSession session = request.getSession(false);
        
        // 该变量为判断用户是否已经登录的标记,在用户成功登录后会被设置
        final Assertion assertion = session != null ? (Assertion) session.getAttribute(CONST_CAS_ASSERTION)
                : null;
        
        // 判断是否登录过,如果已经登录过,进入if并且退出
        if (assertion != null)
        {
            filterChain.doFilter(request, response);
            return;
        }
        // 如果没有登录过,继续后续处理
        
        // 构造访问的URL,如果该Url包含tikicet参数,则去除参数
        final String serviceUrl = constructServiceUrl(request, response);
        // 如果ticket存在,则获取URL后面的参数ticket
        final String ticket = CommonUtils.safeGetParameter(request,
                getArtifactParameterName());
        // 研究中
        final boolean wasGatewayed = this.gatewayStorage.hasGatewayedAlready(request,
                serviceUrl);
        
        // 如果ticket存在
        if (CommonUtils.isNotBlank(ticket) || wasGatewayed)
        {
            filterChain.doFilter(request, response);
            return;
        }
        
        final String modifiedServiceUrl;
        
        log.debug("no ticket and no assertion found");
        if (this.gateway)
        {
            log.debug("setting gateway attribute in session");
            modifiedServiceUrl = this.gatewayStorage.storeGatewayInformation(request,
                    serviceUrl);
        }
        else
        {
            modifiedServiceUrl = serviceUrl;
        }
        
        if (log.isDebugEnabled())
        {
            log.debug("Constructed service url: " + modifiedServiceUrl);
        }
        
        // 如果用户没有登录过,那么构造重定向的URL
        final String urlToRedirectTo = CommonUtils.constructRedirectUrl(this.casServerLoginUrl,
                getServiceParameterName(),
                modifiedServiceUrl,
                this.renew,
                this.gateway);
        
        if (log.isDebugEnabled())
        {
            log.debug("redirecting to \"" + urlToRedirectTo + "\"");
        }
        
        // 重定向跳转到Cas认证中心
        response.sendRedirect(urlToRedirectTo);
    }

第二步:请求被重定向到CAS服务器端后

根据CAS_Server端的login-webflow.xml配置

 View Code

首先会进入initialFlowSetupAction

 protected Event doExecute(final RequestContext context) throws Exception {
        final HttpServletRequest request = WebUtils.getHttpServletRequest(context);
        if (!this.pathPopulated) {
            final String contextPath = context.getExternalContext().getContextPath();
            final String cookiePath = StringUtils.hasText(contextPath) ? contextPath + "/" : "/";
            logger.info("Setting path for cookies to: "
                + cookiePath);
            this.warnCookieGenerator.setCookiePath(cookiePath);
            this.ticketGrantingTicketCookieGenerator.setCookiePath(cookiePath);
            this.pathPopulated = true;
        }

        // 获取客户端的名为CASTGC的cookie
        context.getFlowScope().put(
            "ticketGrantingTicketId", this.ticketGrantingTicketCookieGenerator.retrieveCookieValue(request));
        context.getFlowScope().put(
            "warnCookieValue",
            Boolean.valueOf(this.warnCookieGenerator.retrieveCookieValue(request)));

        // 获取要访问的服务
        final Service service = WebUtils.getService(this.argumentExtractors,
            context);

        if (service != null && logger.isDebugEnabled()) {
            logger.debug("Placing service in FlowScope: " + service.getId());
        }

        context.getFlowScope().put("service", service);

        return result("success");
    }

之后根据webflow流程,主要有两大分歧

如果TGC并且service存在,则发放ST(service ticket)并重定向回到客户端应用

如果首次访问,TGC不存在,则跳转到CAS-server的登录页面,如下(本登录页面是重新绘制,不是CAS原生登录页)

因为我是首次登录,所以会跳转到该登录页进行认证。

第三步:用户认证

输入用户名、密码、验证码,点击登录

这时再来看login-webflow.xml

用户提交登录后,按流程依次是

1.authenticationViaFormAction.doBind

    <view-state id="viewLoginForm" view="casMyLoginView" model="credentials">
        <binder>
            <binding property="username" />
            <binding property="password" />
            <binding property="imgverifycode" />
        </binder>
        <on-entry>
            <set name="viewScope.commandName" value="'credentials'" />
        </on-entry>
        <transition on="submit" bind="true" validate="true" to="imgverifycodeValidate">
            <evaluate
                expression="authenticationViaFormAction.doBind(flowRequestContext, flowScope.credentials)" />
        </transition>
    </view-state>
=>

2.imgverifycodeValidate(验证码处理为自定义的处理,不是原生逻辑)

    <action-state id="imgverifycodeValidate">
        <evaluate
            expression="authenticationViaFormAction.validatorCode(flowRequestContext, flowScope.credentials, messageContext)" />
        <transition on="error" to="generateLoginTicket" />
        <transition on="success" to="realSubmit" />
    </action-state>

=>

3.realSubmit

<action-state id="realSubmit">
        <evaluate
            expression="authenticationViaFormAction.submit(flowRequestContext, flowScope.credentials, messageContext)" />
        <transition on="warn" to="warn" />
        <transition on="success" to="sendTicketGrantingTicket" />
        <transition on="error" to="generateLoginTicket" />
    </action-state>

realSubmit中执行的是authenticationViaFormAction.submit

 public final String submit(final RequestContext context,
            final Credentials credentials, final MessageContext messageContext)
            throws Exception
    {
        // Validate login ticket
        final String authoritativeLoginTicket = WebUtils.getLoginTicketFromFlowScope(context);
        final String providedLoginTicket = WebUtils.getLoginTicketFromRequest(context);
        if (!authoritativeLoginTicket.equals(providedLoginTicket))
        {
            this.logger.warn("Invalid login ticket " + providedLoginTicket);
            final String code = "INVALID_TICKET";
            messageContext.addMessage(new MessageBuilder().error()
                    .code(code)
                    .arg(providedLoginTicket)
                    .defaultText(code)
                    .build());
            return "error";
        }
        
        // 获取TGT,首次登录的话应该是不存在的,所以直接跳过该分歧
        final String ticketGrantingTicketId = WebUtils.getTicketGrantingTicketId(context);
        final Service service = WebUtils.getService(context);
        if (StringUtils.hasText(context.getRequestParameters().get("renew"))
                && ticketGrantingTicketId != null && service != null)
        {
            
            try
            {
                final String serviceTicketId = this.centralAuthenticationService.grantServiceTicket(ticketGrantingTicketId,
                        service,
                        credentials);
                WebUtils.putServiceTicketInRequestScope(context,
                        serviceTicketId);
                putWarnCookieIfRequestParameterPresent(context);
                return "warn";
            }
            catch (final TicketException e)
            {
                if (e.getCause() != null
                        && AuthenticationException.class.isAssignableFrom(e.getCause()
                                .getClass()))
                {
                    populateErrorsInstance(e, messageContext);
                    return "error";
                }
                this.centralAuthenticationService.destroyTicketGrantingTicket(ticketGrantingTicketId);
                if (logger.isDebugEnabled())
                {
                    logger.debug("Attempted to generate a ServiceTicket using renew=true with different credentials",
                            e);
                }
            }
        }
        
        try
        {
            // 首次登录时,用户输入信息验证成功后,创建一个新的TGT
            WebUtils.putTicketGrantingTicketInRequestScope(context,
                    this.centralAuthenticationService.createTicketGrantingTicket(credentials));
            putWarnCookieIfRequestParameterPresent(context);
            return "success";
        }
        catch (final TicketException e)
        {
            // 如果用户输入信息验证不通过,会抛出异常,并在页面上显示
            populateErrorsInstance(e, messageContext);
            return "error";
        }
    }

=>

4.用户信息认证通过,并且创建了新的TGT后,缓存TGT,并且生成cookie,待后续把cookie写入客户端

    <action-state id="sendTicketGrantingTicket">
        <evaluate expression="sendTicketGrantingTicketAction" />
        <transition to="serviceCheck" />
    </action-state>
sendTicketGrantingTicketAction.doExecute
    protected Event doExecute(final RequestContext context) {
        final String ticketGrantingTicketId = WebUtils.getTicketGrantingTicketId(context); 
        final String ticketGrantingTicketValueFromCookie = (String) context.getFlowScope().get("ticketGrantingTicketId");
        
        if (ticketGrantingTicketId == null) {
            return success();
        }
        
        // 生成Cookie并且写入response,最终在客户端Cookie中保存了本TGT
        this.ticketGrantingTicketCookieGenerator.addCookie(WebUtils.getHttpServletRequest(context), WebUtils
            .getHttpServletResponse(context), ticketGrantingTicketId);

        if (ticketGrantingTicketValueFromCookie != null && !ticketGrantingTicketId.equals(ticketGrantingTicketValueFromCookie)) {
            this.centralAuthenticationService
                .destroyTicketGrantingTicket(ticketGrantingTicketValueFromCookie);
        }

        return success();
    }

=>

5. 然后验证是否存在Service,如果存在,生成ST,重定向用户到 Service 所在地址(附带该ST ) , 并为客户端浏览器设置一个 Ticket Granted Cookie ( TGC ) 

serviceCheck => generateServiceTicket => warn => redirect =>postRedirectDecision

第四步:拿着新产生的ST,到 CAS Server 进行身份核实,以确保 Service Ticket 的合法性。

当cas Service重定向到客户端所在service时,该重定向请求同样会被客户端配置的过滤器所拦截,又进入了第一步处的AuthenticationFilter

但是由于本次请求已经带回了ST(service ticket),所以处理与首次有所不同。

public final void doFilter(final ServletRequest servletRequest,
            final ServletResponse servletResponse, final FilterChain filterChain)
            throws IOException, ServletException
    {
        final HttpServletRequest request = (HttpServletRequest) servletRequest;
        final HttpServletResponse response = (HttpServletResponse) servletResponse;
        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;
        }
        final String serviceUrl = constructServiceUrl(request, response);
        final String ticket = CommonUtils.safeGetParameter(request,
                getArtifactParameterName());
        final boolean wasGatewayed = this.gatewayStorage.hasGatewayedAlready(request,
                serviceUrl);
        
        // 由于本次已经可以取到cas service返回的新的service ticket
        if (CommonUtils.isNotBlank(ticket) || wasGatewayed)
        {
            // 所以直接进入本代码块,然后退出
            filterChain.doFilter(request, response);
            return;
        }
        // 不会再一次被重定向会cas 认证中心

        final String modifiedServiceUrl;
        
        log.debug("no ticket and no assertion found");
        if (this.gateway)
        {
            log.debug("setting gateway attribute in session");
            modifiedServiceUrl = this.gatewayStorage.storeGatewayInformation(request,
                    serviceUrl);
        }
        else
        {
            modifiedServiceUrl = serviceUrl;
        }
        
        if (log.isDebugEnabled())
        {
            log.debug("Constructed service url: " + modifiedServiceUrl);
        }
        
        // 如果用户没有登录过,那么构造重定向的URL
        final String urlToRedirectTo = CommonUtils.constructRedirectUrl(this.casServerLoginUrl,
                getServiceParameterName(),
                modifiedServiceUrl,
                this.renew,
                this.gateway);
        
        if (log.isDebugEnabled())
        {
            log.debug("redirecting to \"" + urlToRedirectTo + "\"");
        }
        
        // 重定向跳转到Cas认证中心
        response.sendRedirect(urlToRedirectTo);
    }

之后,又会被web.xml中的CAS Validation Filter(Cas20ProxyReceivingTicketValidationFilter)所拦截

该拦截器用来与CAS Server 进行身份核实,以确保 Service Ticket 的合法性

由于 Cas20ProxyReceivingTicketValidationFilter 没有重写doFilter方法,所以会进入父类AbstractTicketValidationFilter的doFilter方法

AbstractTicketValidationFilter.doFilter

 public final void doFilter(final ServletRequest servletRequest,
            final ServletResponse servletResponse, final FilterChain filterChain)
            throws IOException, ServletException
    {
        
        if (!preFilter(servletRequest, servletResponse, filterChain))
        {
            return;
        }
        
        final HttpServletRequest request = (HttpServletRequest) servletRequest;
        final HttpServletResponse response = (HttpServletResponse) servletResponse;
        final String ticket = CommonUtils.safeGetParameter(request,
                getArtifactParameterName());
        
        if (CommonUtils.isNotBlank(ticket))
        {
            if (log.isDebugEnabled())
            {
                log.debug("Attempting to validate ticket: " + ticket);
            }
            
            try
            {
                // 构造验证URL,向cas server发起验证请求
                final Assertion assertion = this.ticketValidator.validate(ticket,
                        constructServiceUrl(request, response));
                
                if (log.isDebugEnabled())
                {
                    log.debug("Successfully authenticated user: "
                            + assertion.getPrincipal().getName());
                }
                
                // 如果验证成功,设置assertion,当再一次发起访问请求时,如果发现assertion已经被设置,所以已经通过验证,不过再次重定向会cas认证中心
                request.setAttribute(CONST_CAS_ASSERTION, assertion);
                
                if (this.useSession)
                {
                    request.getSession().setAttribute(CONST_CAS_ASSERTION,
                            assertion);
                }
                onSuccessfulValidation(request, response, assertion);
                
                if (this.redirectAfterValidation)
                {
                    log.debug("Redirecting after successful ticket validation.");
                    response.sendRedirect(constructServiceUrl(request, response));
                    return;
                }
            }
            catch (final TicketValidationException e)
            {
                response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                log.warn(e, e);
                
                onFailedValidation(request, response);
                
                if (this.exceptionOnValidationFailure)
                {
                    throw new ServletException(e);
                }
                
                return;
            }
        }
        
        filterChain.doFilter(request, response);
        
    }
this.ticketValidator.validate(..) 代码如下
 public Assertion validate(final String ticket, final String service)
            throws TicketValidationException
    {
        
        // 生成验证URL,如果你debug会发现,此处会构造一个类似以下的URL,访问的是cas server的serviceValidate方法
        // https://demo.testcas.com/cas/serviceValidate?ticket=ST-31-cioaDNxSpUWIgeYEn4yK-cas&service=http%3A%2F%2Fapp1.testcas.com%2Fb2c-haohai-server%2Fuser%2FcasLogin
        final String validationUrl = constructValidationUrl(ticket, service);
        if (log.isDebugEnabled())
        {
            log.debug("Constructing validation url: " + validationUrl);
        }
        
        try
        {
            log.debug("Retrieving response from server.");
            // 得到cas service响应,验证成功或者失败
            final String serverResponse = retrieveResponseFromServer(new URL(
                    validationUrl), ticket);
            
            if (serverResponse == null)
            {
                throw new TicketValidationException(
                        "The CAS server returned no response.");
            }
            
            if (log.isDebugEnabled())
            {
                log.debug("Server response: " + serverResponse);
            }
            
            return parseResponseFromServer(serverResponse);
        }
        catch (final MalformedURLException e)
        {
            throw new TicketValidationException(e);
        }
    }
可以看一下,cas server侧的serverValidate的具体实现
在cas server的cas-servlet.xml中,可以看到如下配置:
    <bean
        id="handlerMappingC"
        class="org.springframework.web.servlet.handler.SimpleUrlHandlerMapping">
        <property
            name="mappings">
            <props>
                <prop
                    key="/logout">
                    logoutController
                </prop>
                <prop
                    key="/serviceValidate">
                    serviceValidateController
                </prop>
     ...
指向serviceValidateController

ServiceValidateController.handleRequestInternal(...)
 protected final ModelAndView handleRequestInternal(final HttpServletRequest request, final HttpServletResponse response) throws Exception {
        final WebApplicationService service = this.argumentExtractor.extractService(request);
        final String serviceTicketId = service != null ? service.getArtifactId() : null;

        if (service == null || serviceTicketId == null) {
            if (logger.isDebugEnabled()) {
                logger.debug(String.format("Could not process request; Service: %s, Service Ticket Id: %s", service, serviceTicketId));
            }
            return generateErrorView("INVALID_REQUEST", "INVALID_REQUEST", null);
        }

        try {
            final Credentials serviceCredentials = getServiceCredentialsFromRequest(request);
            String proxyGrantingTicketId = null;

            // XXX should be able to validate AND THEN use
            if (serviceCredentials != null) {
                try {
                    proxyGrantingTicketId = this.centralAuthenticationService
                        .delegateTicketGrantingTicket(serviceTicketId,
                            serviceCredentials);
                } catch (final TicketException e) {
                    logger.error("TicketException generating ticket for: "
                        + serviceCredentials, e);
                }
            }

            final Assertion assertion = this.centralAuthenticationService.validateServiceTicket(serviceTicketId, service);

            final ValidationSpecification validationSpecification = this.getCommandClass();
            final ServletRequestDataBinder binder = new ServletRequestDataBinder(validationSpecification, "validationSpecification");
            initBinder(request, binder);
            binder.bind(request);

            if (!validationSpecification.isSatisfiedBy(assertion)) {
                if (logger.isDebugEnabled()) {
                    logger.debug("ServiceTicket [" + serviceTicketId + "] does not satisfy validation specification.");
                }
                return generateErrorView("INVALID_TICKET", "INVALID_TICKET_SPEC", null);
            }

            onSuccessfulValidation(serviceTicketId, assertion);

            final ModelAndView success = new ModelAndView(this.successView);
            success.addObject(MODEL_ASSERTION, assertion);

            if (serviceCredentials != null && proxyGrantingTicketId != null) {
                final String proxyIou = this.proxyHandler.handle(serviceCredentials, proxyGrantingTicketId);
                success.addObject(MODEL_PROXY_GRANTING_TICKET_IOU, proxyIou);
            }

            if (logger.isDebugEnabled()) {
                logger.debug(String.format("Successfully validated service ticket: %s", serviceTicketId));
            }

            return success;
        } catch (final TicketValidationException e) {
            return generateErrorView(e.getCode(), e.getCode(), new Object[] {serviceTicketId, e.getOriginalService().getId(), service.getId()});
        } catch (final TicketException te) {
            return generateErrorView(te.getCode(), te.getCode(),
                new Object[] {serviceTicketId});
        } catch (final UnauthorizedServiceException e) {
            return generateErrorView(e.getMessage(), e.getMessage(), null);
        }
    }
验证成功后,就可以正常访问了。

来源:https://www.cnblogs.com/notDog/p/5252973.html


快乐成长 每天进步一点点      京ICP备18032580号-1