3

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

 2 years ago
source link: https://www.cnblogs.com/notDog/p/5252973.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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);
        }
    }
验证成功后,就可以正常访问了。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK